diff --git a/platform/db.go b/platform/db.go index b40352b..b9f1476 100644 --- a/platform/db.go +++ b/platform/db.go @@ -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 } diff --git a/platform/platform_test.go b/platform/platform_test.go index 6dafd3d..23be60e 100644 --- a/platform/platform_test.go +++ b/platform/platform_test.go @@ -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"}) diff --git a/platform/queue.go b/platform/queue.go index 3e4378f..2458067 100644 --- a/platform/queue.go +++ b/platform/queue.go @@ -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 } diff --git a/squad/engine.go b/squad/engine.go index 076370f..ad689c1 100644 --- a/squad/engine.go +++ b/squad/engine.go @@ -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)) diff --git a/squad/hitl.go b/squad/hitl.go index a852f1a..e94d602 100644 --- a/squad/hitl.go +++ b/squad/hitl.go @@ -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"` diff --git a/squad/planner_llm.go b/squad/planner_llm.go index cb7d149..4a89744 100644 --- a/squad/planner_llm.go +++ b/squad/planner_llm.go @@ -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,11 +175,15 @@ 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 _, ok := input.Registry.Get(ls.Role); !ok { - return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role) + 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 { @@ -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{ diff --git a/squad/progress.go b/squad/progress.go index 87cfdec..937c538 100644 --- a/squad/progress.go +++ b/squad/progress.go @@ -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() diff --git a/squad/r2_regression_test.go b/squad/r2_regression_test.go new file mode 100644 index 0000000..98aa8ad --- /dev/null +++ b/squad/r2_regression_test.go @@ -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") + } +} diff --git a/squad/workflow.go b/squad/workflow.go index 87af9b0..0d8f015 100644 --- a/squad/workflow.go +++ b/squad/workflow.go @@ -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)) diff --git a/squadintent/catalog.go b/squadintent/catalog.go index 7dabf9d..77a97cb 100644 --- a/squadintent/catalog.go +++ b/squadintent/catalog.go @@ -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) } diff --git a/squadintent/catalog_test.go b/squadintent/catalog_test.go index 65a214e..9f31f42 100644 --- a/squadintent/catalog_test.go +++ b/squadintent/catalog_test.go @@ -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") + } +} diff --git a/squadintent/gate.go b/squadintent/gate.go index dc13234..f5de0c4 100644 --- a/squadintent/gate.go +++ b/squadintent/gate.go @@ -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 + rec intent.Recognizer + 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 } diff --git a/store/eventsink.go b/store/eventsink.go index ddd220d..17ca91f 100644 --- a/store/eventsink.go +++ b/store/eventsink.go @@ -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() diff --git a/store/r2_regression_test.go b/store/r2_regression_test.go new file mode 100644 index 0000000..d442ae7 --- /dev/null +++ b/store/r2_regression_test.go @@ -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) + } +}