diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 05:05:36 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-04 05:05:36 +0000 |
| commit | 8cb291ad7cdb317ff80947278ee055b1a4925b41 (patch) | |
| tree | d9719ae8982a9d7cc3482a71a87d003cdb7e0dfd /internal/scheduler/story_orchestrator_test.go | |
| parent | 04b6e7eef473cb6eb69e345a4ea08243a8713077 (diff) | |
feat(story,role): add retro ceremony -- closes the self-improvement loop (Phase 8)
The final mechanism the versioned role-config model (Phase 5) was built for:
when a story reaches DONE, StoryOrchestrator spawns a retro-role task that
reflects on the story's full history and proposes draft role_configs
versions for a human to review and activate via the existing (unchanged)
POST /api/roles/{role}/activate.
- AgentChannel gains a 6th method, ProposeRoleConfig(ctx, role.RoleConfig)
(version, err), following ProposeEpic's precedent (Phase 7c): a structured
tool call, not summary-parsing. storeChannel.ProposeRoleConfig calls the
same Store.CreateRoleConfig the human-facing POST /api/roles/{role}/versions
endpoint already uses (proposed_by: "retro"), landing a new draft row
without touching whatever's currently active. Wired through both
transports exactly like ProposeEpic: internal/agentloop/tools.go (native
loop) and internal/executor/agentmcp.go (MCP).
- StoryOrchestrator.Tick now routes a story at status DONE to a new
processRetro stage instead of processStory -- a sibling stage, not a
continuation, since the Builder->Evaluators->Arbitration chain is long
settled by then. processRetro only *reads* that settled pipeline
(read-only findEvaluators/findArbitration counterparts to
ensureEvaluators/ensureArbitration -- it never spawns/mutates
Builder-pipeline tasks) to locate the Arbitration task the retro task
depends on, then spawns (idempotently -- checks for an existing
retro-role dependent first) one retro-role task with instructions
assembled from the story's spec/acceptance-criteria, full task tree, per-
task cost/escalation history, active role_configs per role encountered,
and the story's own event stream (evaluator verdicts, arbitration
decision).
- event.KindRetroCaptured (attached to the story's ID, matching
KindEvalVerdict/KindArbitrationDecided's convention) fires once the retro
task completes (auto-accepted like every other pipeline task), aggregating
every event.KindRoleConfigProposed the retro task recorded (one per
propose_role_config call) into {task_id, proposals: [{role, version}],
summary} -- the summary is the "capturing lessons" half of this ceremony,
the proposals are the versioned-config half.
- Human activation is completely untouched: drafts land through the
identical CreateRoleConfig/config_json path Phase 5's endpoints already
handle, confirmed via existing role-endpoint tests passing unmodified.
go build/vet/test -race -count=1 all pass, full suite (20 packages) -- one
run hit a known, pre-existing, intermittent flake under full-suite load
(unrelated to this phase's files) that did not reproduce on two immediate
reruns, both in isolation and full-suite.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/scheduler/story_orchestrator_test.go')
| -rw-r--r-- | internal/scheduler/story_orchestrator_test.go | 254 |
1 files changed, 254 insertions, 0 deletions
diff --git a/internal/scheduler/story_orchestrator_test.go b/internal/scheduler/story_orchestrator_test.go index bee326d..b811206 100644 --- a/internal/scheduler/story_orchestrator_test.go +++ b/internal/scheduler/story_orchestrator_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "sync" "testing" @@ -23,6 +24,12 @@ type fakeStoryStore struct { 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 { @@ -107,10 +114,52 @@ 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() @@ -767,3 +816,208 @@ func TestStoryOrchestrator_EndToEnd(t *testing.T) { 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)) + } +} |
