summaryrefslogtreecommitdiff
path: root/internal/scheduler/story_orchestrator_test.go
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 05:05:36 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-04 05:05:36 +0000
commit8cb291ad7cdb317ff80947278ee055b1a4925b41 (patch)
treed9719ae8982a9d7cc3482a71a87d003cdb7e0dfd /internal/scheduler/story_orchestrator_test.go
parent04b6e7eef473cb6eb69e345a4ea08243a8713077 (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.go254
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))
+ }
+}