package scheduler import ( "context" "encoding/json" "fmt" "strings" "sync" "testing" "github.com/google/uuid" "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" ) // fakeStoryStore is a minimal, in-memory implementation of StoryStore for // unit-testing StoryOrchestrator without a real SQLite database. Mirrors the // fakeStore pattern already used by scheduler_test.go for the Phase 5 // Scheduler. type fakeStoryStore struct { mu sync.Mutex stories map[string]*story.Story tasks map[string]*task.Task events []*event.Event // executions and activeRoleConfigs back the Phase 8 retro stage's // context-gathering (ListExecutions/GetActiveRoleConfig) — keyed the // same way the real storage.DB is (by task ID / role name). executions map[string][]*storage.Execution activeRoleConfigs map[string]*storage.RoleConfigRow } func newFakeStoryStore() *fakeStoryStore { return &fakeStoryStore{ stories: make(map[string]*story.Story), tasks: make(map[string]*task.Task), } } func (f *fakeStoryStore) ListStories(_ storage.StoryFilter) ([]*story.Story, error) { f.mu.Lock() defer f.mu.Unlock() var out []*story.Story for _, s := range f.stories { cp := *s out = append(out, &cp) } return out, nil } func (f *fakeStoryStore) UpdateStory(st *story.Story) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.stories[st.ID]; !ok { return fmt.Errorf("story %q not found", st.ID) } cp := *st f.stories[st.ID] = &cp return nil } func (f *fakeStoryStore) GetTask(id string) (*task.Task, error) { f.mu.Lock() defer f.mu.Unlock() t, ok := f.tasks[id] if !ok { return nil, fmt.Errorf("task %q not found", id) } cp := *t return &cp, nil } func (f *fakeStoryStore) ListDependents(taskID string) ([]*task.Task, error) { f.mu.Lock() defer f.mu.Unlock() var out []*task.Task for _, t := range f.tasks { for _, d := range t.DependsOn { if d == taskID { cp := *t out = append(out, &cp) break } } } return out, nil } func (f *fakeStoryStore) CreateTask(t *task.Task) error { f.mu.Lock() defer f.mu.Unlock() if _, ok := f.tasks[t.ID]; ok { return fmt.Errorf("task %q already exists", t.ID) } cp := *t f.tasks[t.ID] = &cp return nil } func (f *fakeStoryStore) UpdateTaskState(id string, newState task.State) error { f.mu.Lock() defer f.mu.Unlock() t, ok := f.tasks[id] if !ok { return fmt.Errorf("task %q not found", id) } t.State = newState return nil } func (f *fakeStoryStore) CreateEvent(e *event.Event) error { f.mu.Lock() defer f.mu.Unlock() e.ID = uuid.NewString() e.Seq = int64(len(f.events)) + 1 f.events = append(f.events, e) return nil } func (f *fakeStoryStore) ListSubtasks(parentID string) ([]*task.Task, error) { f.mu.Lock() defer f.mu.Unlock() var out []*task.Task for _, t := range f.tasks { if t.ParentTaskID == parentID { cp := *t out = append(out, &cp) } } return out, nil } func (f *fakeStoryStore) ListExecutions(taskID string) ([]*storage.Execution, error) { f.mu.Lock() defer f.mu.Unlock() return f.executions[taskID], nil } func (f *fakeStoryStore) GetActiveRoleConfig(roleName string) (*storage.RoleConfigRow, error) { f.mu.Lock() defer f.mu.Unlock() row, ok := f.activeRoleConfigs[roleName] if !ok { return nil, fmt.Errorf("no active role config for %q", roleName) } return row, nil } func (f *fakeStoryStore) ListEvents(taskID string, sinceSeq int64) ([]*event.Event, error) { f.mu.Lock() defer f.mu.Unlock() var out []*event.Event for _, e := range f.events { if e.TaskID == taskID && e.Seq > sinceSeq { out = append(out, e) } } return out, nil } func (f *fakeStoryStore) setTaskState(id string, s task.State) { f.mu.Lock() defer f.mu.Unlock() if t, ok := f.tasks[id]; ok { t.State = s } } func (f *fakeStoryStore) setTaskSummary(id, summary string) { f.mu.Lock() defer f.mu.Unlock() if t, ok := f.tasks[id]; ok { t.Summary = summary } } func (f *fakeStoryStore) eventsOfKind(k event.Kind) []*event.Event { f.mu.Lock() defer f.mu.Unlock() var out []*event.Event for _, e := range f.events { if e.Kind == k { out = append(out, e) } } return out } func (f *fakeStoryStore) dependentsWithRole(taskID, role string) []*task.Task { deps, _ := f.ListDependents(taskID) var out []*task.Task for _, d := range deps { if d.Agent.Role == role { out = append(out, d) } } return out } func builderTask(id string, state task.State) *task.Task { return &task.Task{ ID: id, Name: "Builder", Agent: task.AgentConfig{Type: "claude", Role: "builder", Instructions: "build it"}, RepositoryURL: "git@example.com:org/repo.git", State: state, } } func newStoryWithRoot(id, rootTaskID, status string) *story.Story { return &story.Story{ID: id, Name: "Test Story", Status: status, RootTaskID: rootTaskID} } // TestStoryOrchestrator_SpawnsEvaluators_WhenBuilderCompletes is verification // item (a): a builder task reaching COMPLETED for a story spawns exactly 4 // evaluator tasks with correct roles/depends_on, moves the story to // VALIDATING. func TestStoryOrchestrator_SpawnsEvaluators_WhenBuilderCompletes(t *testing.T) { store := newFakeStoryStore() root := builderTask("builder-1", task.StateCompleted) store.tasks[root.ID] = root st := newStoryWithRoot("story-1", root.ID, "IN_PROGRESS") store.stories[st.ID] = st pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) deps, err := store.ListDependents(root.ID) if err != nil { t.Fatal(err) } if len(deps) != 4 { t.Fatalf("expected 4 evaluator tasks, got %d: %+v", len(deps), deps) } gotRoles := map[string]bool{} for _, d := range deps { gotRoles[d.Agent.Role] = true if len(d.DependsOn) != 1 || d.DependsOn[0] != root.ID { t.Errorf("evaluator %s: DependsOn = %+v, want [%s]", d.ID, d.DependsOn, root.ID) } if d.ParentTaskID != "" { t.Errorf("evaluator %s: ParentTaskID = %q, want empty (DAG sibling, not subtask)", d.ID, d.ParentTaskID) } if d.State != task.StateQueued { t.Errorf("evaluator %s: State = %v, want QUEUED", d.ID, d.State) } } for _, r := range evaluatorRoles { if !gotRoles[r] { t.Errorf("missing evaluator with role %q", r) } } if pool.submitCount() != 4 { t.Fatalf("expected 4 pool submissions, got %d", pool.submitCount()) } got, err := func() (*story.Story, error) { stories, err := store.ListStories(storage.StoryFilter{}) if err != nil { return nil, err } for _, s := range stories { if s.ID == st.ID { return s, nil } } return nil, fmt.Errorf("not found") }() if err != nil { t.Fatal(err) } if got.Status != "VALIDATING" { t.Errorf("story status: want VALIDATING, got %q", got.Status) } } // TestStoryOrchestrator_DoesNotDuplicateEvaluators is verification item (b): // re-checking the same story after evaluators already exist does not spawn // duplicates. func TestStoryOrchestrator_DoesNotDuplicateEvaluators(t *testing.T) { store := newFakeStoryStore() root := builderTask("builder-1", task.StateCompleted) store.tasks[root.ID] = root st := newStoryWithRoot("story-1", root.ID, "IN_PROGRESS") store.stories[st.ID] = st pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) orch.Tick(context.Background()) orch.Tick(context.Background()) deps, _ := store.ListDependents(root.ID) if len(deps) != 4 { t.Fatalf("expected exactly 4 evaluator tasks after 3 ticks, got %d", len(deps)) } if pool.submitCount() != 4 { t.Fatalf("expected exactly 4 submissions after 3 ticks, got %d", pool.submitCount()) } } // evaluatorTask builds a completed (or not) evaluator task depending on // rootID with the given role. func evaluatorTask(id, rootID, role string, state task.State) *task.Task { return &task.Task{ ID: id, Name: role, Agent: task.AgentConfig{Role: role}, DependsOn: []string{rootID}, State: state, Summary: "looks good", } } // seedStoryWithEvaluators wires up a story whose builder is COMPLETED and // whose 4 evaluators already exist (in the given state), returning the // fakeStoryStore, story, and evaluator tasks (in evaluatorRoles order). func seedStoryWithEvaluators(t *testing.T, evalState task.State) (*fakeStoryStore, *story.Story, []*task.Task) { t.Helper() store := newFakeStoryStore() root := builderTask("builder-1", task.StateCompleted) store.tasks[root.ID] = root st := newStoryWithRoot("story-1", root.ID, "VALIDATING") store.stories[st.ID] = st evaluators := make([]*task.Task, len(evaluatorRoles)) for i, r := range evaluatorRoles { ev := evaluatorTask(fmt.Sprintf("eval-%d", i), root.ID, r, evalState) store.tasks[ev.ID] = ev evaluators[i] = ev } return store, st, evaluators } // TestStoryOrchestrator_SpawnsArbitration_WhenAllEvaluatorsComplete is // verification item (c): all 4 evaluators reaching COMPLETED spawns exactly // 1 arbitration task depending on all 4. func TestStoryOrchestrator_SpawnsArbitration_WhenAllEvaluatorsComplete(t *testing.T) { store, _, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner") if len(arbitrations) != 1 { t.Fatalf("expected exactly 1 arbitration task, got %d: %+v", len(arbitrations), arbitrations) } arb := arbitrations[0] if len(arb.DependsOn) != len(evaluators) { t.Fatalf("arbitration DependsOn = %+v, want all %d evaluator IDs", arb.DependsOn, len(evaluators)) } for _, ev := range evaluators { if !dependsOnAll(arb, []string{ev.ID}) { t.Errorf("arbitration does not depend on evaluator %s", ev.ID) } } if arb.ParentTaskID != "" { t.Errorf("arbitration ParentTaskID = %q, want empty", arb.ParentTaskID) } if arb.State != task.StateQueued { t.Errorf("arbitration State = %v, want QUEUED", arb.State) } // Re-ticking must not spawn a second arbitration task. orch.Tick(context.Background()) orch.Tick(context.Background()) arbitrations = store.dependentsWithRole(evaluators[0].ID, "planner") if len(arbitrations) != 1 { t.Fatalf("expected exactly 1 arbitration task after repeated ticks, got %d", len(arbitrations)) } } // TestStoryOrchestrator_DoesNotSpawnArbitration_UntilAllEvaluatorsComplete // proves the fan-in gate: even with 3 of 4 evaluators COMPLETED, no // arbitration task is created yet. func TestStoryOrchestrator_DoesNotSpawnArbitration_UntilAllEvaluatorsComplete(t *testing.T) { store, _, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) // Knock one evaluator back to RUNNING. store.setTaskState(evaluators[0].ID, task.StateRunning) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner") if len(arbitrations) != 0 { t.Fatalf("expected no arbitration task while an evaluator is incomplete, got %d", len(arbitrations)) } } // TestStoryOrchestrator_EmitsEvalVerdict_OncePerEvaluator proves // maybeEmitVerdict fires exactly once per completed evaluator, attached to // the story's ID, even across repeated ticks. func TestStoryOrchestrator_EmitsEvalVerdict_OncePerEvaluator(t *testing.T) { store, st, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) orch.Tick(context.Background()) orch.Tick(context.Background()) verdicts := store.eventsOfKind(event.KindEvalVerdict) if len(verdicts) != len(evaluators) { t.Fatalf("expected exactly %d eval_verdict events, got %d", len(evaluators), len(verdicts)) } seenTaskIDs := map[string]bool{} for _, e := range verdicts { if e.TaskID != st.ID { t.Errorf("eval_verdict event attached to %q, want story ID %q", e.TaskID, st.ID) } var payload struct { TaskID string `json:"task_id"` Role string `json:"role"` Summary string `json:"summary"` } if err := json.Unmarshal(e.Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } seenTaskIDs[payload.TaskID] = true if payload.Role == "" { t.Errorf("payload missing role: %+v", payload) } if payload.Summary != "looks good" { t.Errorf("payload summary = %q, want %q", payload.Summary, "looks good") } } for _, ev := range evaluators { if !seenTaskIDs[ev.ID] { t.Errorf("no eval_verdict event found for evaluator %s", ev.ID) } } } // TestStoryOrchestrator_ArbitrationCompletes_EmitsDecisionAndReviewReady is // verification item (d): arbitration reaching COMPLETED emits // KindArbitrationDecided and moves the story to REVIEW_READY. func TestStoryOrchestrator_ArbitrationCompletes_EmitsDecisionAndReviewReady(t *testing.T) { store, st, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} // Tick 1: spawns the arbitration task. orch.Tick(context.Background()) arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner") if len(arbitrations) != 1 { t.Fatalf("expected 1 arbitration task, got %d", len(arbitrations)) } arb := arbitrations[0] store.setTaskState(arb.ID, task.StateCompleted) store.setTaskSummary(arb.ID, "ship it") // Tick 2: arbitration is now COMPLETED. orch.Tick(context.Background()) decided := store.eventsOfKind(event.KindArbitrationDecided) if len(decided) != 1 { t.Fatalf("expected exactly 1 arbitration_decided event, got %d", len(decided)) } if decided[0].TaskID != st.ID { t.Errorf("arbitration_decided attached to %q, want story ID %q", decided[0].TaskID, st.ID) } var payload struct { TaskID string `json:"task_id"` Summary string `json:"summary"` } if err := json.Unmarshal(decided[0].Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if payload.TaskID != arb.ID || payload.Summary != "ship it" { t.Errorf("unexpected payload: %+v", payload) } stories, _ := store.ListStories(storage.StoryFilter{}) var got *story.Story for _, s := range stories { if s.ID == st.ID { got = s } } if got == nil { t.Fatal("story not found") } if got.Status != "REVIEW_READY" { t.Errorf("story status: want REVIEW_READY, got %q", got.Status) } // Tick 3+: must not re-emit or re-decide. orch.Tick(context.Background()) orch.Tick(context.Background()) decided = store.eventsOfKind(event.KindArbitrationDecided) if len(decided) != 1 { t.Fatalf("expected still exactly 1 arbitration_decided event after repeated ticks, got %d", len(decided)) } } // TestStoryOrchestrator_ArbitrationRejects_SetsStoryNeedsFix proves that // when the arbitration task reports a structured approved=false verdict // (via a KindVerdictReported event, the same one AgentChannel.ReportVerdict // records), finalizeArbitration routes the story to NEEDS_FIX instead of // unconditionally REVIEW_READY. func TestStoryOrchestrator_ArbitrationRejects_SetsStoryNeedsFix(t *testing.T) { store, st, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) // spawns arbitration arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner") if len(arbitrations) != 1 { t.Fatalf("expected 1 arbitration task, got %d", len(arbitrations)) } arb := arbitrations[0] // Simulate the arbitration agent calling report_verdict with a rejection // before finishing, the same event storeChannel.ReportVerdict records. payload, _ := json.Marshal(struct { Approved bool `json:"approved"` Reasoning string `json:"reasoning"` }{Approved: false, Reasoning: "breaks the running-log panel styling"}) if err := store.CreateEvent(&event.Event{ TaskID: arb.ID, Kind: event.KindVerdictReported, Actor: event.ActorAgent, Payload: payload, }); err != nil { t.Fatalf("seed verdict event: %v", err) } store.setTaskState(arb.ID, task.StateCompleted) store.setTaskSummary(arb.ID, "found a real problem") orch.Tick(context.Background()) // finalizeArbitration runs stories, _ := store.ListStories(storage.StoryFilter{}) var got *story.Story for _, s := range stories { if s.ID == st.ID { got = s } } if got == nil { t.Fatal("story not found") } if got.Status != "NEEDS_FIX" { t.Errorf("story status: want NEEDS_FIX, got %q", got.Status) } } // TestStoryOrchestrator_ArbitrationApproves_SetsReviewReady proves the // mirror case: an explicit approved=true verdict still routes to // REVIEW_READY, same as today's unconditional behavior -- this isn't just // "absence of a verdict defaults to proceed", a real approval also proceeds. func TestStoryOrchestrator_ArbitrationApproves_SetsReviewReady(t *testing.T) { store, st, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner") arb := arbitrations[0] payload, _ := json.Marshal(struct { Approved bool `json:"approved"` Reasoning string `json:"reasoning"` }{Approved: true, Reasoning: "meets all acceptance criteria"}) if err := store.CreateEvent(&event.Event{ TaskID: arb.ID, Kind: event.KindVerdictReported, Actor: event.ActorAgent, Payload: payload, }); err != nil { t.Fatalf("seed verdict event: %v", err) } store.setTaskState(arb.ID, task.StateCompleted) store.setTaskSummary(arb.ID, "ship it") orch.Tick(context.Background()) stories, _ := store.ListStories(storage.StoryFilter{}) var got *story.Story for _, s := range stories { if s.ID == st.ID { got = s } } if got == nil { t.Fatal("story not found") } if got.Status != "REVIEW_READY" { t.Errorf("story status: want REVIEW_READY, got %q", got.Status) } } // TestStoryOrchestrator_DoesNothing_WhenBuilderNotComplete proves the // orchestrator is inert for a story whose builder task hasn't reached // COMPLETED yet and isn't auto-acceptable either (still RUNNING — not // READY, so autoAccept has nothing to do). func TestStoryOrchestrator_DoesNothing_WhenBuilderNotComplete(t *testing.T) { store := newFakeStoryStore() root := builderTask("builder-1", task.StateRunning) store.tasks[root.ID] = root st := newStoryWithRoot("story-1", root.ID, "IN_PROGRESS") store.stories[st.ID] = st pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) deps, _ := store.ListDependents(root.ID) if len(deps) != 0 { t.Fatalf("expected no evaluator tasks while builder is RUNNING, got %d", len(deps)) } if pool.submitCount() != 0 { t.Fatalf("expected no submissions, got %d", pool.submitCount()) } got, err := store.GetTask(root.ID) if err != nil { t.Fatal(err) } if got.State != task.StateRunning { t.Errorf("builder state must be untouched: want RUNNING, got %v", got.State) } } // TestStoryOrchestrator_AutoAcceptsReadyBuilder is the core regression test // for the auto-accept fix: a builder task sitting at READY (execution // succeeded, awaiting what would otherwise be a manual // POST /api/tasks/{id}/accept) is transitioned to COMPLETED by the // orchestrator itself — with no external accept call — and, because that // unblocks Stage 1 in the same tick, the 4 evaluators are spawned // immediately too. func TestStoryOrchestrator_AutoAcceptsReadyBuilder(t *testing.T) { store := newFakeStoryStore() root := builderTask("builder-1", task.StateReady) store.tasks[root.ID] = root st := newStoryWithRoot("story-1", root.ID, "IN_PROGRESS") store.stories[st.ID] = st pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) got, err := store.GetTask(root.ID) if err != nil { t.Fatal(err) } if got.State != task.StateCompleted { t.Fatalf("builder should be auto-accepted to COMPLETED, got %v", got.State) } deps, _ := store.ListDependents(root.ID) if len(deps) != 4 { t.Fatalf("expected 4 evaluator tasks spawned in the same tick the builder auto-accepts, got %d", len(deps)) } } // TestStoryOrchestrator_AutoAcceptsReadyEvaluators proves READY evaluator // tasks are auto-accepted to COMPLETED by the orchestrator, with no external // accept call, and that doing so unblocks arbitration spawning. func TestStoryOrchestrator_AutoAcceptsReadyEvaluators(t *testing.T) { store, _, evaluators := seedStoryWithEvaluators(t, task.StateReady) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) for _, ev := range evaluators { got, err := store.GetTask(ev.ID) if err != nil { t.Fatal(err) } if got.State != task.StateCompleted { t.Errorf("evaluator %s should be auto-accepted to COMPLETED, got %v", ev.ID, got.State) } } arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner") if len(arbitrations) != 1 { t.Fatalf("expected arbitration spawned once all evaluators auto-accept, got %d", len(arbitrations)) } } // TestStoryOrchestrator_AutoAcceptsReadyArbitration proves a READY // arbitration task is auto-accepted to COMPLETED by the orchestrator, with // no external accept call, and that this in turn triggers // finalizeArbitration (KindArbitrationDecided + REVIEW_READY) in the same // tick. func TestStoryOrchestrator_AutoAcceptsReadyArbitration(t *testing.T) { store, st, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} // Tick 1: spawns the arbitration task (starts at QUEUED). orch.Tick(context.Background()) arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner") if len(arbitrations) != 1 { t.Fatalf("expected 1 arbitration task, got %d", len(arbitrations)) } arb := arbitrations[0] // Simulate the arbitration's execution succeeding (RUNNING -> READY), // exactly as executor.Pool.handleRunResult would do for any top-level // task — without ever calling POST /api/tasks/{id}/accept. store.setTaskState(arb.ID, task.StateReady) store.setTaskSummary(arb.ID, "approved") // Tick 2: orchestrator must auto-accept READY -> COMPLETED itself. orch.Tick(context.Background()) got, err := store.GetTask(arb.ID) if err != nil { t.Fatal(err) } if got.State != task.StateCompleted { t.Fatalf("arbitration should be auto-accepted to COMPLETED, got %v", got.State) } decided := store.eventsOfKind(event.KindArbitrationDecided) if len(decided) != 1 { t.Fatalf("expected exactly 1 arbitration_decided event after auto-accept, got %d", len(decided)) } stories, _ := store.ListStories(storage.StoryFilter{}) for _, s := range stories { if s.ID == st.ID && s.Status != "REVIEW_READY" { t.Errorf("story status: want REVIEW_READY after arbitration auto-accepts, got %q", s.Status) } } } // TestStoryOrchestrator_AutoAccept_DoesNotTouchUnrelatedReadyTask proves the // auto-accept behavior is scoped to a story's own pipeline tasks (root task // + its role-matched evaluator/arbitration dependents) and does not sweep up // an unrelated READY task that merely happens to exist in the store. func TestStoryOrchestrator_AutoAccept_DoesNotTouchUnrelatedReadyTask(t *testing.T) { store := newFakeStoryStore() root := builderTask("builder-1", task.StateCompleted) store.tasks[root.ID] = root st := newStoryWithRoot("story-1", root.ID, "IN_PROGRESS") store.stories[st.ID] = st unrelated := &task.Task{ID: "unrelated-1", Name: "unrelated", Agent: task.AgentConfig{Type: "claude"}, State: task.StateReady} store.tasks[unrelated.ID] = unrelated pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) got, err := store.GetTask(unrelated.ID) if err != nil { t.Fatal(err) } if got.State != task.StateReady { t.Errorf("unrelated task must not be auto-accepted: want READY, got %v", got.State) } } // TestStoryOrchestrator_SkipsStoriesWithNoRootTask proves a story with no // root_task_id set is left completely untouched. func TestStoryOrchestrator_SkipsStoriesWithNoRootTask(t *testing.T) { store := newFakeStoryStore() st := &story.Story{ID: "story-1", Name: "no root yet", Status: "DISCOVERY"} store.stories[st.ID] = st pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) // must not panic or error despite no root task existing if pool.submitCount() != 0 { t.Fatalf("expected no submissions, got %d", pool.submitCount()) } } // TestStoryOrchestrator_SkipsTerminalStories proves DONE/CANCELLED stories // are never revisited, even if (hypothetically) their root task is // COMPLETED and evaluators don't yet exist. func TestStoryOrchestrator_SkipsTerminalStories(t *testing.T) { for _, status := range []string{"DONE", "CANCELLED"} { t.Run(status, func(t *testing.T) { store := newFakeStoryStore() root := builderTask("builder-1", task.StateCompleted) store.tasks[root.ID] = root st := newStoryWithRoot("story-1", root.ID, status) store.stories[st.ID] = st pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) deps, _ := store.ListDependents(root.ID) if len(deps) != 0 { t.Fatalf("status %s: expected no evaluator tasks spawned, got %d", status, len(deps)) } }) } } // TestStoryOrchestrator_EndToEnd drives a story through the full chain — // builder complete -> evaluators -> arbitration -> REVIEW_READY — using only // the fake store/pool, proving the whole ceremony holds together end to end // at the orchestrator level (verification item 5, the higher-level test). // // Every task in the chain is driven to READY (never directly to COMPLETED), // mirroring exactly what executor.Pool.handleRunResult does for a real // top-level task whose execution succeeds — proving the orchestrator's own // auto-accept (not an external POST /api/tasks/{id}/accept call, which this // test never makes) is what carries each task the rest of the way to // COMPLETED and advances the story. The only accept call anywhere in this // flow is the story-level one, which isn't part of this test — it's covered // separately in internal/api's story accept-gate tests. func TestStoryOrchestrator_EndToEnd(t *testing.T) { store := newFakeStoryStore() root := builderTask("builder-1", task.StatePending) store.tasks[root.ID] = root st := newStoryWithRoot("story-1", root.ID, "IN_PROGRESS") store.stories[st.ID] = st pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} // Before the builder completes, nothing happens. orch.Tick(context.Background()) if deps, _ := store.ListDependents(root.ID); len(deps) != 0 { t.Fatalf("expected no evaluators before builder completes, got %d", len(deps)) } // Builder's execution succeeds (RUNNING -> READY, exactly like // handleRunResult) — no POST /api/tasks/{id}/accept call here. store.setTaskState(root.ID, task.StateReady) orch.Tick(context.Background()) rootAfter, err := store.GetTask(root.ID) if err != nil { t.Fatal(err) } if rootAfter.State != task.StateCompleted { t.Fatalf("builder should be auto-accepted to COMPLETED without an accept call, got %v", rootAfter.State) } deps, _ := store.ListDependents(root.ID) if len(deps) != 4 { t.Fatalf("expected 4 evaluators, got %d", len(deps)) } statusOf := func() string { stories, _ := store.ListStories(storage.StoryFilter{}) for _, s := range stories { if s.ID == st.ID { return s.Status } } return "" } if statusOf() != "VALIDATING" { t.Fatalf("expected VALIDATING after evaluators spawn, got %q", statusOf()) } // Evaluators' executions succeed one by one (READY, not COMPLETED); // arbitration must not spawn early. for i, d := range deps { store.setTaskState(d.ID, task.StateReady) store.setTaskSummary(d.ID, fmt.Sprintf("verdict %d", i)) orch.Tick(context.Background()) arbs := store.dependentsWithRole(deps[0].ID, "planner") if i < len(deps)-1 && len(arbs) != 0 { t.Fatalf("arbitration spawned too early, after %d/%d evaluators complete", i+1, len(deps)) } } for _, d := range deps { got, err := store.GetTask(d.ID) if err != nil { t.Fatal(err) } if got.State != task.StateCompleted { t.Errorf("evaluator %s should be auto-accepted to COMPLETED, got %v", d.ID, got.State) } } arbs := store.dependentsWithRole(deps[0].ID, "planner") if len(arbs) != 1 { t.Fatalf("expected exactly 1 arbitration task, got %d", len(arbs)) } arb := arbs[0] verdicts := store.eventsOfKind(event.KindEvalVerdict) if len(verdicts) != 4 { t.Fatalf("expected 4 eval_verdict events, got %d", len(verdicts)) } // Arbitration's execution succeeds (READY, not COMPLETED). store.setTaskState(arb.ID, task.StateReady) store.setTaskSummary(arb.ID, "approved") orch.Tick(context.Background()) arbAfter, err := store.GetTask(arb.ID) if err != nil { t.Fatal(err) } if arbAfter.State != task.StateCompleted { t.Fatalf("arbitration should be auto-accepted to COMPLETED, got %v", arbAfter.State) } if statusOf() != "REVIEW_READY" { t.Fatalf("expected REVIEW_READY after arbitration completes, got %q", statusOf()) } decided := store.eventsOfKind(event.KindArbitrationDecided) if len(decided) != 1 { t.Fatalf("expected exactly 1 arbitration_decided event, got %d", len(decided)) } } // seedDoneStory wires up a story whose entire Builder->Evaluators-> // Arbitration pipeline has already completed (mirroring what would actually // be true of any real story by the time it reaches DONE) and whose status // is DONE, returning the fakeStoryStore, story, root, evaluators, and // arbitration task. Used by the Phase 8 retro-stage tests below. func seedDoneStory(t *testing.T) (*fakeStoryStore, *story.Story, *task.Task, []*task.Task, *task.Task) { t.Helper() store, st, evaluators := seedStoryWithEvaluators(t, task.StateCompleted) st.Status = "DONE" ids := make([]string, len(evaluators)) for i, ev := range evaluators { ids[i] = ev.ID } arb := &task.Task{ ID: "arbitration-1", Name: "Arbitration", Agent: task.AgentConfig{Role: arbitrationRole}, DependsOn: ids, State: task.StateCompleted, Summary: "ship it", } store.tasks[arb.ID] = arb root, err := store.GetTask(st.RootTaskID) if err != nil { t.Fatal(err) } return store, st, root, evaluators, arb } // TestStoryOrchestrator_Retro_SpawnsRetroTask_WhenStoryDone is verification // item (a): a story reaching DONE spawns exactly one retro-role task // depending on the arbitration task, whose instructions contain the story's // context (name and ID, at minimum). func TestStoryOrchestrator_Retro_SpawnsRetroTask_WhenStoryDone(t *testing.T) { store, st, _, _, arb := seedDoneStory(t) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) retros := store.dependentsWithRole(arb.ID, retroRole) if len(retros) != 1 { t.Fatalf("expected exactly 1 retro task depending on the arbitration task, got %d", len(retros)) } retro := retros[0] if retro.ParentTaskID != "" { t.Errorf("retro task should be a DAG sibling (no ParentTaskID), got %q", retro.ParentTaskID) } if retro.State != task.StateQueued { t.Errorf("retro task state: want QUEUED, got %v", retro.State) } if !strings.Contains(retro.Agent.Instructions, st.Name) || !strings.Contains(retro.Agent.Instructions, st.ID) { t.Errorf("retro instructions should contain the story's name/ID, got: %s", retro.Agent.Instructions) } } // TestStoryOrchestrator_Retro_DoesNotDuplicate is verification item (b): // checking the same DONE story again does not spawn a second retro task. func TestStoryOrchestrator_Retro_DoesNotDuplicate(t *testing.T) { store, _, _, _, arb := seedDoneStory(t) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) orch.Tick(context.Background()) orch.Tick(context.Background()) retros := store.dependentsWithRole(arb.ID, retroRole) if len(retros) != 1 { t.Fatalf("expected still exactly 1 retro task after repeated ticks, got %d", len(retros)) } } // TestStoryOrchestrator_Retro_DoesNothing_UntilPipelineSettled proves // processRetro never backfills a Builder/Evaluator/Arbitration task itself: // a story marked DONE whose evaluators/arbitration don't actually exist yet // (a hypothetical/malformed case — a real story never reaches DONE any // other way) leaves the task tree untouched, exactly like // TestStoryOrchestrator_SkipsTerminalStories already proves for the // evaluator-spawning side of this. func TestStoryOrchestrator_Retro_DoesNothing_UntilPipelineSettled(t *testing.T) { store := newFakeStoryStore() root := builderTask("builder-1", task.StateCompleted) store.tasks[root.ID] = root st := newStoryWithRoot("story-1", root.ID, "DONE") store.stories[st.ID] = st pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} orch.Tick(context.Background()) deps, _ := store.ListDependents(root.ID) if len(deps) != 0 { t.Fatalf("expected no tasks spawned when the pipeline never actually ran, got %d", len(deps)) } } // TestStoryOrchestrator_Retro_EmitsRetroCaptured_WhenRetroTaskCompletes is // verification item (d): once the retro task reaches COMPLETED (via // auto-accept from READY, exactly like every other task in this pipeline), // KindRetroCaptured is emitted attached to the story's ID, aggregating every // role_config_proposed event the retro task itself recorded plus its // reported summary. func TestStoryOrchestrator_Retro_EmitsRetroCaptured_WhenRetroTaskCompletes(t *testing.T) { store, st, _, _, arb := seedDoneStory(t) pool := &fakePool{} orch := &StoryOrchestrator{Store: store, Pool: pool} // Tick 1: spawns the retro task. orch.Tick(context.Background()) retros := store.dependentsWithRole(arb.ID, retroRole) if len(retros) != 1 { t.Fatalf("expected 1 retro task, got %d", len(retros)) } retro := retros[0] // Simulate the retro agent having called propose_role_config twice // (recording KindRoleConfigProposed events attached to its own task ID, // exactly as storeChannel.ProposeRoleConfig does — see // internal/executor/channel.go) before finishing with a summary. mustMarshal := func(v any) json.RawMessage { b, err := json.Marshal(v) if err != nil { t.Fatal(err) } return b } store.CreateEvent(&event.Event{ TaskID: retro.ID, Kind: event.KindRoleConfigProposed, Actor: event.ActorAgent, Payload: mustMarshal(struct { Role string `json:"role"` Version int `json:"version"` }{Role: "builder", Version: 2}), }) store.CreateEvent(&event.Event{ TaskID: retro.ID, Kind: event.KindRoleConfigProposed, Actor: event.ActorAgent, Payload: mustMarshal(struct { Role string `json:"role"` Version int `json:"version"` }{Role: "evaluator_quality", Version: 1}), }) store.setTaskState(retro.ID, task.StateReady) store.setTaskSummary(retro.ID, "The builder over-escalated twice; tightening its system prompt should help.") // Tick 2: retro task auto-accepted to COMPLETED, retro_captured emitted. orch.Tick(context.Background()) retroAfter, err := store.GetTask(retro.ID) if err != nil { t.Fatal(err) } if retroAfter.State != task.StateCompleted { t.Fatalf("retro task should be auto-accepted to COMPLETED, got %v", retroAfter.State) } captured := store.eventsOfKind(event.KindRetroCaptured) if len(captured) != 1 { t.Fatalf("expected exactly 1 retro_captured event, got %d", len(captured)) } if captured[0].TaskID != st.ID { t.Errorf("retro_captured should be attached to the story ID %q, got %q", st.ID, captured[0].TaskID) } var payload struct { TaskID string `json:"task_id"` Proposals []struct { Role string `json:"role"` Version int `json:"version"` } `json:"proposals"` Summary string `json:"summary"` } if err := json.Unmarshal(captured[0].Payload, &payload); err != nil { t.Fatalf("unmarshal payload: %v", err) } if payload.TaskID != retro.ID { t.Errorf("payload.task_id: want %q, got %q", retro.ID, payload.TaskID) } if len(payload.Proposals) != 2 { t.Fatalf("expected 2 proposals in payload, got %d: %+v", len(payload.Proposals), payload.Proposals) } if payload.Proposals[0].Role != "builder" || payload.Proposals[0].Version != 2 { t.Errorf("proposal[0] mismatch: %+v", payload.Proposals[0]) } if payload.Proposals[1].Role != "evaluator_quality" || payload.Proposals[1].Version != 1 { t.Errorf("proposal[1] mismatch: %+v", payload.Proposals[1]) } if !strings.Contains(payload.Summary, "over-escalated") { t.Errorf("expected the retro's summary in the payload, got %q", payload.Summary) } // Tick 3+: must not re-emit. orch.Tick(context.Background()) orch.Tick(context.Background()) captured = store.eventsOfKind(event.KindRetroCaptured) if len(captured) != 1 { t.Fatalf("expected still exactly 1 retro_captured event after repeated ticks, got %d", len(captured)) } }