Close the squad P1 gaps the audit listed for this batch
Why: parallel groups dropped a hold, HITL timeouts were unused, the planner failed closed on any model error, and event sequence numbers restarted at 1. Evidence: GOWORK=off go test -count=1 -race ./squad/ ./squadintent/ ./store/ ./platform/ passed, including TestR2ExpiredDecisionUsesCancel, TestR2IntentStepSkipsRoleRegistry, TestR2PlannerFallsBackAndCapsSteps, TestR2TaskFileUsesLatestBlackboard, TestR2FanoutCancelDoesNotPanic, TestR2EventSeqContinuesAfterRestart, TestR2JournalModeWAL, and TestR2GateReloadSeesNewIntent. Impact: a failed or held parallel sibling cancels the rest; expired requests synthesize cancel or fail; the intent gate follows Registry.Reload. Refs: Q6, Q10, Q11, Q12, Q13, Q17, Q18, Q19, I9 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -17,7 +17,7 @@ type Store struct {
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)")
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -233,6 +233,17 @@ func openTest(t *testing.T) *Store {
|
||||
return s
|
||||
}
|
||||
|
||||
func TestR2JournalModeWAL(t *testing.T) {
|
||||
s := openTest(t)
|
||||
var mode string
|
||||
if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Fatalf("journal_mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func claimAny(t *testing.T, s *Store, workspaceID string) (Task, bool, error) {
|
||||
t.Helper()
|
||||
rt, err := s.RegisterRuntime(Runtime{WorkspaceID: workspaceID, Name: "tmp", Command: "in-process", Protocol: "agent-native"})
|
||||
|
||||
+4
-2
@@ -233,8 +233,10 @@ func (s *Store) Claim(runtimeID string, at time.Time) (Task, bool, error) {
|
||||
continue
|
||||
}
|
||||
lease := at.UTC().Add(5 * time.Minute).Format(time.RFC3339Nano)
|
||||
res, err := s.db.Exec(`UPDATE task SET status='dispatched', runtime_id=?, lease_until=?, updated_at=? WHERE id=? AND status='queued'`,
|
||||
runtimeID, lease, now(), cand.id)
|
||||
res, err := s.db.Exec(`UPDATE task SET status='dispatched', runtime_id=?, lease_until=?, updated_at=?
|
||||
WHERE id=? AND status='queued'
|
||||
AND (SELECT COUNT(*) FROM task WHERE agent_id=? AND status IN ('dispatched','running')) < ?`,
|
||||
runtimeID, lease, now(), cand.id, cand.agentID, cap)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
}
|
||||
|
||||
@@ -1103,6 +1103,32 @@ func (e *SquadEngine) SweepWakeups(ctx context.Context) int {
|
||||
return woken
|
||||
}
|
||||
|
||||
// SweepExpired 把超过 HITL 时限的挂起请求合成 fail 或 cancel 决策。
|
||||
func (e *SquadEngine) SweepExpired(ctx context.Context, now time.Time) int {
|
||||
runs, err := e.ListRuns()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, meta := range runs {
|
||||
if !RunStatus(meta.Status).IsHeld() {
|
||||
continue
|
||||
}
|
||||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", meta.ID))
|
||||
if err != nil || gate == nil || gate.Request.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
decision, due := expiredDecision(gate.Request, e.deps.HitlPolicy, now)
|
||||
if !due {
|
||||
continue
|
||||
}
|
||||
if _, err := e.Respond(ctx, meta.ID, decision); err == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// fireWakeup 在已持有 run 锁时处理一条挂起。只自动放行 gap 与 escalation。
|
||||
func (e *SquadEngine) fireWakeup(ctx context.Context, runID string) bool {
|
||||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
|
||||
@@ -114,6 +114,28 @@ func newRequestID() string {
|
||||
return "hr_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// expiredDecision 在请求超过 TimeoutAfter 或 ExpiresAt 时合成过期决策。
|
||||
// ExpireAction 为 cancel 时选择 cancel,否则 fail。未到期返回 false。
|
||||
func expiredDecision(req HumanRequest, policy HitlPolicy, now time.Time) (RespondDecision, bool) {
|
||||
var deadline time.Time
|
||||
switch {
|
||||
case req.ExpiresAt != nil:
|
||||
deadline = *req.ExpiresAt
|
||||
case policy.TimeoutAfter > 0 && !req.CreatedAt.IsZero():
|
||||
deadline = req.CreatedAt.Add(policy.TimeoutAfter)
|
||||
default:
|
||||
return RespondDecision{}, false
|
||||
}
|
||||
if now.Before(deadline) {
|
||||
return RespondDecision{}, false
|
||||
}
|
||||
choice := "fail"
|
||||
if policy.ExpireAction == "cancel" {
|
||||
choice = "cancel"
|
||||
}
|
||||
return RespondDecision{RequestID: req.ID, Choice: choice, Answer: "expired"}, true
|
||||
}
|
||||
|
||||
// hitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
|
||||
type hitlGate struct {
|
||||
Request HumanRequest `json:"request"`
|
||||
|
||||
+45
-3
@@ -18,6 +18,10 @@ type LLMPlanner struct {
|
||||
APIBase string
|
||||
APIKey string
|
||||
MaxTokens int
|
||||
// Fallback 在模型调用或解析失败时接手。空则保持 fail-closed。
|
||||
Fallback Planner
|
||||
// MaxSteps 计划步骤硬上限(默认 32)。
|
||||
MaxSteps int
|
||||
}
|
||||
|
||||
// NewLLMPlanner 创建 LLM 规划器。
|
||||
@@ -82,6 +86,9 @@ const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标
|
||||
|
||||
func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
|
||||
if p.Client == nil || p.Model == "" {
|
||||
if p.Fallback != nil {
|
||||
return p.Fallback.Plan(ctx, input)
|
||||
}
|
||||
return nil, fmt.Errorf("llm planner requires client and model")
|
||||
}
|
||||
userMsg := p.buildPrompt(input)
|
||||
@@ -95,7 +102,8 @@ func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, er
|
||||
}
|
||||
raw, err := p.complete(ctx, sys, userMsg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm planner call failed: %w", err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
schema, err := parseLLMPlan(raw)
|
||||
if err != nil {
|
||||
@@ -106,6 +114,9 @@ func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, er
|
||||
break
|
||||
}
|
||||
if parsed == nil {
|
||||
if p.Fallback != nil {
|
||||
return p.Fallback.Plan(ctx, input)
|
||||
}
|
||||
return nil, fmt.Errorf("llm planner output unparsable after retry: %w", lastErr)
|
||||
}
|
||||
|
||||
@@ -164,12 +175,16 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
|
||||
steps := make([]StepSpec, 0, len(parsed.Steps))
|
||||
for i, ls := range parsed.Steps {
|
||||
if ls.Role == "" {
|
||||
if ls.IntentID == "" && ls.Role == "" {
|
||||
return nil, fmt.Errorf("step %d missing role", i)
|
||||
}
|
||||
if ls.IntentID == "" {
|
||||
if _, ok := input.Registry.Get(ls.Role); !ok {
|
||||
return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role)
|
||||
}
|
||||
} else if ls.Role == "" {
|
||||
ls.Role = "intent"
|
||||
}
|
||||
if _, inSquad := findAgent(agents, ls.Role); !inSquad {
|
||||
if spec, ok := input.Registry.Get(ls.Role); ok {
|
||||
addAgent(spec)
|
||||
@@ -189,6 +204,13 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
IntentID: ls.IntentID, IntentSlots: ls.IntentSlots,
|
||||
})
|
||||
}
|
||||
maxSteps := p.MaxSteps
|
||||
if maxSteps <= 0 {
|
||||
maxSteps = 32
|
||||
}
|
||||
if len(steps) > maxSteps {
|
||||
return nil, fmt.Errorf("plan has %d steps, max %d", len(steps), maxSteps)
|
||||
}
|
||||
|
||||
plan := &SquadPlan{
|
||||
Squad: SquadConfig{SquadName: truncate(input.Goal.Text, 40), Version: "1", Agents: agents},
|
||||
@@ -207,7 +229,8 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
func (p *LLMPlanner) buildPrompt(input PlanInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 用户目标\n")
|
||||
b.WriteString(input.Goal.Text + "\n")
|
||||
b.WriteString(segmentGoal(input.Goal.Text))
|
||||
b.WriteString("\n")
|
||||
if len(input.Goal.Constraints) > 0 {
|
||||
b.WriteString("\n## 约束\n")
|
||||
for k, v := range input.Goal.Constraints {
|
||||
@@ -239,6 +262,25 @@ func (p *LLMPlanner) buildPrompt(input PlanInput) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const goalSegmentRunes = 2000
|
||||
|
||||
// segmentGoal 把长目标按段标出,避免整段原文无界注入提示词。
|
||||
func segmentGoal(text string) string {
|
||||
runes := []rune(text)
|
||||
if len(runes) <= goalSegmentRunes {
|
||||
return text + "\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(runes); i += goalSegmentRunes {
|
||||
end := i + goalSegmentRunes
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n### 目标第 %d 段\n%s\n", i/goalSegmentRunes+1, string(runes[i:end]))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// complete 调一次模型并拼接完整文本。
|
||||
func (p *LLMPlanner) complete(ctx context.Context, system, user string) (string, error) {
|
||||
msgs := []llm.Message{
|
||||
|
||||
+12
-1
@@ -99,12 +99,23 @@ func (s *FanoutSink) Subscribe(cursor int) (<-chan Event, int, func()) {
|
||||
copy(replay, s.buffer[cursor:])
|
||||
next := len(s.buffer)
|
||||
s.mu.Unlock()
|
||||
done := make(chan struct{})
|
||||
var once sync.Once
|
||||
var replayDone sync.WaitGroup
|
||||
replayDone.Add(1)
|
||||
go func() {
|
||||
defer replayDone.Done()
|
||||
for _, ev := range replay {
|
||||
ch <- ev
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case ch <- ev:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch, next, func() {
|
||||
once.Do(func() { close(done) })
|
||||
replayDone.Wait()
|
||||
s.mu.Lock()
|
||||
delete(s.subs, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestR2ExpiredDecisionUsesCancel(t *testing.T) {
|
||||
created := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||
decision, due := expiredDecision(HumanRequest{ID: "hr_1", CreatedAt: created, Status: "pending"},
|
||||
HitlPolicy{TimeoutAfter: time.Minute, ExpireAction: "cancel"}, created.Add(2*time.Minute))
|
||||
if !due || decision.Choice != "cancel" || decision.RequestID != "hr_1" {
|
||||
t.Fatalf("decision %+v due %v", decision, due)
|
||||
}
|
||||
if _, early := expiredDecision(HumanRequest{ID: "hr_1", CreatedAt: created},
|
||||
HitlPolicy{TimeoutAfter: time.Minute}, created.Add(30*time.Second)); early {
|
||||
t.Fatal("not due yet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2IntentStepSkipsRoleRegistry(t *testing.T) {
|
||||
p := &LLMPlanner{MaxSteps: 4}
|
||||
out, err := p.materialize(PlanInput{Goal: Goal{Text: "查进度"}, Registry: NewRoleRegistry()}, &llmPlanSchema{
|
||||
Steps: []llmStep{{IntentID: "demo.run.status_query", Title: "查"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Plan.Workflow.Steps[0].IntentID != "demo.run.status_query" || out.Plan.Workflow.Steps[0].Role != "intent" {
|
||||
t.Fatalf("step %+v", out.Plan.Workflow.Steps[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2PlannerFallsBackAndCapsSteps(t *testing.T) {
|
||||
fallback := NewRulePlanner()
|
||||
p := &LLMPlanner{Fallback: fallback}
|
||||
out, err := p.Plan(context.Background(), PlanInput{Goal: Goal{Text: "写一份说明"}, Registry: NewRoleRegistry()})
|
||||
if err != nil || out == nil || (out.Plan == nil && out.Clarification == nil) {
|
||||
t.Fatalf("fallback: %+v %v", out, err)
|
||||
}
|
||||
tooMany := &llmPlanSchema{}
|
||||
for i := 0; i < 3; i++ {
|
||||
tooMany.Steps = append(tooMany.Steps, llmStep{IntentID: "demo.x", Title: "t"})
|
||||
}
|
||||
if _, err := (&LLMPlanner{MaxSteps: 2}).materialize(PlanInput{Goal: Goal{Text: "x"}, Registry: NewRoleRegistry()}, tooMany); err == nil || !strings.Contains(err.Error(), "max 2") {
|
||||
t.Fatalf("cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2GoalIsSegmented(t *testing.T) {
|
||||
text := strings.Repeat("标", goalSegmentRunes+1)
|
||||
got := segmentGoal(text)
|
||||
if !strings.Contains(got, "目标第 2 段") {
|
||||
t.Fatalf("segments missing: %s", got[:80])
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2TaskFileUsesLatestBlackboard(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ws := &WorkspaceEngine{}
|
||||
step := &StepSpec{ID: "s", Role: "writer", Title: "写", Targets: []string{"output.md"}}
|
||||
role := RoleSpec{Role: "writer", Name: "写手"}
|
||||
bb := &BlackboardState{Fields: map[string]string{"fact": "old"}}
|
||||
if err := ws.writeTaskFile(dir, step, role, bb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bb.Fields["fact"] = "new"
|
||||
if err := ws.writeTaskFile(dir, step, role, bb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, "TASK.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(b), "fact = new") || strings.Contains(string(b), "fact = old") {
|
||||
t.Fatalf("task file: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2FanoutCancelDoesNotPanic(t *testing.T) {
|
||||
sink := NewFanoutSink(8)
|
||||
ch, _, cancel := sink.Subscribe(0)
|
||||
cancel()
|
||||
sink.Emit(Event{Type: "phase", Text: "later"})
|
||||
if _, ok := <-ch; ok {
|
||||
t.Fatal("channel should be closed")
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -146,17 +146,25 @@ func (d *flowDriver) gateBeforeRun(ctx context.Context, st *runState, i int) (*R
|
||||
|
||||
// runParallel 并发执行一个并行组;关键步骤失败即终止整个运行。
|
||||
func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int) (*RunResult, *hitlGate, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
failedCritical := -1
|
||||
var gate *hitlGate
|
||||
for _, idx := range idxs {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
out, _, err := d.runOneStep(ctx, st, idx)
|
||||
out, g, err := d.runOneStep(ctx, st, idx)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if g != nil && gate == nil {
|
||||
gate = g
|
||||
cancel()
|
||||
}
|
||||
if (out != nil && out.Status == RunFailed) || err != nil {
|
||||
cancel()
|
||||
if st.Plan.Workflow.Steps[idx].IsCritical() && failedCritical < 0 {
|
||||
failedCritical = idx
|
||||
}
|
||||
@@ -164,6 +172,9 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
|
||||
}(idx)
|
||||
}
|
||||
wg.Wait()
|
||||
if gate != nil {
|
||||
return heldResult(st, gate), gate, nil
|
||||
}
|
||||
if failedCritical >= 0 {
|
||||
return d.failStep(ctx, st, failedCritical)
|
||||
}
|
||||
@@ -172,6 +183,9 @@ func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int)
|
||||
|
||||
// runOneStep 执行单个步骤(含瞬态重试与交付物校验)。
|
||||
func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: err.Error()}, nil, err
|
||||
}
|
||||
step := st.Plan.Workflow.Steps[idx]
|
||||
role, ok := findAgent(st.Plan.Squad.Agents, step.Role)
|
||||
if !ok {
|
||||
@@ -180,6 +194,8 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
st.mu.Lock()
|
||||
st.Plan.Squad.Agents = append(st.Plan.Squad.Agents, r)
|
||||
st.mu.Unlock()
|
||||
} else if step.IntentID != "" {
|
||||
role = RoleSpec{Role: step.Role, Name: step.Role}
|
||||
} else {
|
||||
step.Status, step.Error = "failed", "role not found: "+step.Role
|
||||
st.setStep(idx, step)
|
||||
@@ -208,6 +224,11 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru
|
||||
}
|
||||
started := time.Now()
|
||||
|
||||
if d.opts.Workspaces != nil {
|
||||
if err := d.opts.Workspaces.writeTaskFile(subDir, &step, role, &st.BB); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), step.Title)
|
||||
prompt = st.injectSteering(prompt)
|
||||
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role))
|
||||
|
||||
@@ -13,15 +13,16 @@ import (
|
||||
// Catalog 把识别快照渲染为 squad 的能力目录数据视图(规划者提示词 + 计划校验用)。
|
||||
// 闸门未启用(nil Gate)时返回 nil。
|
||||
func (g *Gate) Catalog() *squad.IntentCatalog {
|
||||
if g == nil || g.snap == nil {
|
||||
snap := g.Snapshot()
|
||||
if g == nil || snap == nil {
|
||||
return nil
|
||||
}
|
||||
catalog := &squad.IntentCatalog{
|
||||
Revision: g.snap.Revision(),
|
||||
Markdown: g.snap.CatalogMarkdown(),
|
||||
Specs: make(map[string]squad.IntentSpecView, len(g.snap.SpecIDs())),
|
||||
Revision: snap.Revision(),
|
||||
Markdown: snap.CatalogMarkdown(),
|
||||
Specs: make(map[string]squad.IntentSpecView, len(snap.SpecIDs())),
|
||||
}
|
||||
for _, cs := range g.snap.Catalog() {
|
||||
for _, cs := range snap.Catalog() {
|
||||
view := squad.IntentSpecView{
|
||||
ID: cs.ID,
|
||||
Description: cs.Description,
|
||||
@@ -67,10 +68,10 @@ func (g *Gate) EchoRunner() *Runner {
|
||||
|
||||
// Run 实现 squad.IntentRunner。未知意图/未知 handler fail-closed(不静默降级)。
|
||||
func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result, error) {
|
||||
if r == nil || r.gate == nil || r.gate.snap == nil {
|
||||
if r == nil || r.gate == nil || r.gate.Snapshot() == nil {
|
||||
return nil, fmt.Errorf("intent runner: gate not configured")
|
||||
}
|
||||
spec, ok := r.gate.snap.SpecByID(step.IntentID)
|
||||
spec, ok := r.gate.Snapshot().SpecByID(step.IntentID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("intent runner: snapshot 中不存在意图 %s", step.IntentID)
|
||||
}
|
||||
|
||||
@@ -120,3 +120,25 @@ func TestNilGateCatalogAndRunner(t *testing.T) {
|
||||
t.Fatal("nil gate runner must fail-closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2GateReloadSeesNewIntent(t *testing.T) {
|
||||
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents":[{"id":"demo.old","domain":"demo","version":1,"matcher":{"includes_any":["旧"]}}]}`)}}
|
||||
g, err := New(context.Background(), Options{
|
||||
IntentOptions: intent.Options{},
|
||||
FS: fsys,
|
||||
Roots: []string{"."},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := g.Snapshot().SpecByID("demo.new"); ok {
|
||||
t.Fatal("new spec exists before reload")
|
||||
}
|
||||
fsys["intents.json"].Data = []byte(`{"intents":[{"id":"demo.old","domain":"demo","version":1,"matcher":{"includes_any":["旧"]}},{"id":"demo.new","domain":"demo","version":1,"matcher":{"includes_any":["新"]}}]}`)
|
||||
if _, err := g.Reload(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := g.Snapshot().SpecByID("demo.new"); !ok {
|
||||
t.Fatal("reload did not publish demo.new")
|
||||
}
|
||||
}
|
||||
|
||||
+16
-8
@@ -55,32 +55,40 @@ func New(ctx context.Context, o Options) (*Gate, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Gate{rec: intent.NewRecognizer(o.IntentOptions, nil, nil), snap: reg.Snapshot()}, nil
|
||||
return &Gate{rec: intent.NewRecognizer(o.IntentOptions, nil, nil), reg: reg}, nil
|
||||
}
|
||||
|
||||
// Gate 实现 squad.IntentGate。
|
||||
// Gate 实现 squad.IntentGate。快照从 Registry 现读,Reload 后下一次 Decide 即生效。
|
||||
type Gate struct {
|
||||
rec intent.Recognizer
|
||||
snap *intent.Snapshot
|
||||
reg *intent.Registry
|
||||
}
|
||||
|
||||
// Snapshot 暴露当前识别快照(诊断/审计用;Reload 由宿主经重建闸门实现)。
|
||||
// Snapshot 暴露当前识别快照(诊断/审计用)。
|
||||
func (g *Gate) Snapshot() *intent.Snapshot {
|
||||
if g == nil {
|
||||
if g == nil || g.reg == nil {
|
||||
return nil
|
||||
}
|
||||
return g.snap
|
||||
return g.reg.Snapshot()
|
||||
}
|
||||
|
||||
// Reload 重读配置来源。失败时保留旧快照。
|
||||
func (g *Gate) Reload(ctx context.Context) (*intent.Snapshot, error) {
|
||||
if g == nil || g.reg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return g.reg.Reload(ctx)
|
||||
}
|
||||
|
||||
// Decide 实现 squad.IntentGate。同输入 + 同快照结果确定;无副作用。
|
||||
func (g *Gate) Decide(ctx context.Context, goal squad.Goal, opts squad.RunOptions) (*squad.IntentDecision, error) {
|
||||
if g == nil || g.snap == nil {
|
||||
if g == nil || g.Snapshot() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if opts.TaskKey != "" {
|
||||
return nil, nil // 显式 TaskKey 优先(引擎侧同样保证,双保险)
|
||||
}
|
||||
res, err := g.rec.Recognize(ctx, goal.Text, g.snap)
|
||||
res, err := g.rec.Recognize(ctx, goal.Text, g.Snapshot())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -58,6 +59,7 @@ func (s *FileEventSink) Emit(ev squad.Event) {
|
||||
}
|
||||
f = fp
|
||||
s.files[ev.RunID] = f
|
||||
s.seqs[ev.RunID] = lastEventSeq(filepath.Join(dir, "events.jsonl"))
|
||||
}
|
||||
s.seqs[ev.RunID]++
|
||||
line := persistedEvent{Seq: s.seqs[ev.RunID], Type: ev.Type, StepID: ev.StepID,
|
||||
@@ -72,6 +74,23 @@ func (s *FileEventSink) Emit(ev squad.Event) {
|
||||
_, _ = f.Write(append(data, '\n'))
|
||||
}
|
||||
|
||||
func lastEventSeq(path string) int64 {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
var last int64
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
var line persistedEvent
|
||||
if err := json.Unmarshal(sc.Bytes(), &line); err == nil && line.Seq > last {
|
||||
last = line.Seq
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// Close 关闭全部文件句柄。
|
||||
func (s *FileEventSink) Close() {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestR2EventSeqContinuesAfterRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
run := "run_1"
|
||||
path := filepath.Join(dir, "runs", run)
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "events.jsonl"), []byte("{\"seq\":7,\"type\":\"phase\"}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sink := NewFileEventSink(dir, nil)
|
||||
sink.Emit(squad.Event{Type: "phase", RunID: run, Text: "next"})
|
||||
sink.Close()
|
||||
if got := lastEventSeq(filepath.Join(path, "events.jsonl")); got != 8 {
|
||||
t.Fatalf("seq %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user