summaryrefslogtreecommitdiff
path: root/internal/scheduler/story_orchestrator.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.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.go')
-rw-r--r--internal/scheduler/story_orchestrator.go343
1 files changed, 342 insertions, 1 deletions
diff --git a/internal/scheduler/story_orchestrator.go b/internal/scheduler/story_orchestrator.go
index 6ad0f86..b8e1b4c 100644
--- a/internal/scheduler/story_orchestrator.go
+++ b/internal/scheduler/story_orchestrator.go
@@ -47,6 +47,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
+ "strings"
"time"
"github.com/google/uuid"
@@ -75,6 +76,17 @@ var evaluatorRoles = []string{
// its summary for an approve/reject verdict.
const arbitrationRole = "planner"
+// retroRole is the role assigned to the single task spawned once a story
+// reaches DONE (see processRetro below) to reflect on the whole story and
+// propose role_configs improvements — the final stage in the harness's
+// self-improvement loop (Phase 8): retro produces draft role_configs
+// versions, a human reviews and activates them via the existing
+// POST /api/roles/{role}/activate, and future dispatches use the improved
+// config. Just another role in the system, resolved via the same
+// escalation-ladder machinery as builder/evaluator/planner — not a special
+// case.
+const retroRole = "retro"
+
// StoryStore is the subset of storage.DB methods StoryOrchestrator needs.
// Defining it as an interface (mirroring executor.Store/scheduler.Store)
// allows tests to supply an in-memory fake with no real SQLite database.
@@ -86,6 +98,19 @@ type StoryStore interface {
CreateTask(t *task.Task) error
UpdateTaskState(id string, newState task.State) error
CreateEvent(e *event.Event) error
+ // ListSubtasks, ListExecutions, GetActiveRoleConfig, and ListEvents back
+ // the Phase 8 retro stage (processRetro/buildRetroInstructions/
+ // finalizeRetro below): assembling a story's full task tree (subtasks +
+ // dependents, mirroring GET /api/stories/{id}/task-tree's walk in
+ // internal/api/stories.go), each task's cost/escalation history, the
+ // active config for each role involved, and the story's own event
+ // stream (evaluator verdicts, arbitration decision) into the retro
+ // task's instructions, then reading the retro task's own event stream
+ // back once it completes to see what it proposed.
+ ListSubtasks(parentID string) ([]*task.Task, error)
+ ListExecutions(taskID string) ([]*storage.Execution, error)
+ GetActiveRoleConfig(roleName string) (*storage.RoleConfigRow, error)
+ ListEvents(taskID string, sinceSeq int64) ([]*event.Event, error)
}
// StoryOrchestrator polls stories with a root_task_id set and drives them
@@ -119,6 +144,16 @@ type StoryOrchestrator struct {
// creation and duplicate story-status transitions — the two things that
// would matter if repeated forever.
handledVerdicts map[string]bool
+
+ // handledRetro dedupes KindRetroCaptured emission within a single
+ // running process, keyed by retro task ID — the same in-memory,
+ // per-process guard handledVerdicts is for eval_verdict, and for the
+ // same reason: the structural idempotency check that actually matters
+ // (processRetro's "does a retro-role task already depend on the
+ // arbitration task" check, mirroring ensureEvaluators/ensureArbitration)
+ // prevents ever spawning a second retro task; this only prevents
+ // re-emitting the summary event every tick while the story sits DONE.
+ handledRetro map[string]bool
}
// DefaultStoryPollInterval is used by Run when pollInterval <= 0.
@@ -154,9 +189,20 @@ func (o *StoryOrchestrator) Tick(ctx context.Context) {
if st.RootTaskID == "" {
continue // no execution tree yet — nothing for this orchestrator to do
}
- if st.Status == "DONE" || st.Status == "CANCELLED" {
+ if st.Status == "CANCELLED" {
continue // terminal; this orchestrator never revives a story from here
}
+ if st.Status == "DONE" {
+ // The Builder->Evaluators->Arbitration->REVIEW_READY chain is
+ // long done by the time a story reaches DONE; the only thing
+ // left for this orchestrator to do is the Phase 8 retro stage —
+ // a sibling stage to processStory below, not a continuation of
+ // it (see processRetro's own doc comment for why it re-derives
+ // the pipeline's tasks via ensureEvaluators/ensureArbitration
+ // rather than assuming processStory has already run recently).
+ o.processRetro(ctx, st)
+ continue
+ }
o.processStory(ctx, st)
}
}
@@ -380,6 +426,301 @@ func (o *StoryOrchestrator) finalizeArbitration(st *story.Story, arbitration *ta
}
}
+// processRetro is the Phase 8 retro stage: it runs for every story the Tick
+// loop has already observed at status DONE (see Tick above). Unlike
+// processStory's stages, it never spawns or advances a Builder/Evaluator/
+// Arbitration task itself — it only *looks up* the already-completed
+// Evaluators/Arbitration (via the read-only findEvaluators/findArbitration
+// below, not the spawning ensureEvaluators/ensureArbitration processStory
+// uses) to find the Arbitration task the retro task should depend on. A real
+// story never reaches DONE without that pipeline already having completed
+// (DONE only follows REVIEW_READY, which only follows a completed
+// Arbitration), so this is normally an immediate hit; if it's ever not
+// found — e.g. this exact story/task shape was constructed directly at DONE
+// without ever running the pipeline — processRetro simply has nothing to do
+// yet and retries next tick, the same as any other transient "not ready"
+// return in this file. This keeps the retro stage a strict sibling to the
+// existing chain: it can never backfill or mutate Builder/Evaluator/
+// Arbitration state, only react to it once it's already settled.
+//
+// Once the Arbitration task is found, this checks, structurally, whether a
+// retro-role task already depends on it (mirroring ensureArbitration's own
+// "does a role-matched dependent already exist" idempotency check) —
+// spawning one only if not — and once that retro task reaches COMPLETED
+// (auto-accepted from READY exactly like every other task in this pipeline),
+// hands off to finalizeRetro to emit KindRetroCaptured.
+func (o *StoryOrchestrator) processRetro(ctx context.Context, st *story.Story) {
+ root, err := o.Store.GetTask(st.RootTaskID)
+ if err != nil {
+ o.logf("story orchestrator: retro: get root task", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
+ return
+ }
+
+ evaluators, ok := o.findEvaluators(root.ID)
+ if !ok {
+ return // pipeline not (yet) fully settled; nothing for retro to attach to
+ }
+ arbitration, ok := o.findArbitration(evaluators)
+ if !ok {
+ return
+ }
+
+ dependents, err := o.Store.ListDependents(arbitration.ID)
+ if err != nil {
+ o.logf("story orchestrator: retro: list arbitration dependents", "storyID", st.ID, "error", err)
+ return
+ }
+ var retro *task.Task
+ for _, d := range dependents {
+ if d.Agent.Role == retroRole {
+ retro = d
+ break
+ }
+ }
+ if retro == nil {
+ instructions := o.buildRetroInstructions(st, root, arbitration)
+ nt, err := o.spawnRoleTask(ctx, "Retro: "+st.Name, retroRole, []string{arbitration.ID}, root, instructions)
+ if err != nil {
+ o.logf("story orchestrator: retro: spawn", "storyID", st.ID, "error", err)
+ return
+ }
+ retro = nt
+ }
+
+ retro = o.autoAccept(st, retro)
+ if retro.State != task.StateCompleted {
+ return // still running, or not yet READY to auto-accept
+ }
+ o.finalizeRetro(st, retro)
+}
+
+// findEvaluators returns root's 4 evaluator-role dependents if all of them
+// already exist — read-only, unlike ensureEvaluators, which spawns any
+// missing ones. processRetro deliberately never spawns Builder-pipeline
+// tasks itself (see that function's doc comment); if the 4 evaluators
+// aren't all found, ok is false and processRetro has nothing to do this
+// tick.
+func (o *StoryOrchestrator) findEvaluators(rootID string) ([]*task.Task, bool) {
+ dependents, err := o.Store.ListDependents(rootID)
+ if err != nil {
+ return nil, false
+ }
+ found := make(map[string]*task.Task, len(evaluatorRoles))
+ for _, d := range dependents {
+ for _, r := range evaluatorRoles {
+ if d.Agent.Role == r {
+ found[r] = d
+ }
+ }
+ }
+ if len(found) != len(evaluatorRoles) {
+ return nil, false
+ }
+ ordered := make([]*task.Task, len(evaluatorRoles))
+ for i, r := range evaluatorRoles {
+ ordered[i] = found[r]
+ }
+ return ordered, true
+}
+
+// findArbitration returns the "planner"-role task depending on every
+// evaluator in evaluators, if one exists — the read-only counterpart to
+// ensureArbitration, for the same reason findEvaluators is read-only (see
+// processRetro's doc comment).
+func (o *StoryOrchestrator) findArbitration(evaluators []*task.Task) (*task.Task, bool) {
+ ids := make([]string, len(evaluators))
+ for i, ev := range evaluators {
+ ids[i] = ev.ID
+ }
+ dependents, err := o.Store.ListDependents(evaluators[0].ID)
+ if err != nil {
+ return nil, false
+ }
+ for _, d := range dependents {
+ if d.Agent.Role == arbitrationRole && dependsOnAll(d, ids) {
+ return d, true
+ }
+ }
+ return nil, false
+}
+
+// buildRetroInstructions assembles the retro task's Instructions from the
+// story's full task tree (subtasks + DAG dependents, the same walk
+// GET /api/stories/{id}/task-tree does in internal/api/stories.go), each
+// task's accumulated cost and highest escalation rung, the currently active
+// role_configs for every role encountered, and the story's own event stream
+// (evaluator verdicts, the arbitration decision) — everything a retro needs
+// to reflect on what happened and judge whether any role's config is worth
+// revising. Kept to one story at a time, no cross-story/epic aggregation
+// (that's explicitly out of scope for this phase).
+func (o *StoryOrchestrator) buildRetroInstructions(st *story.Story, root, arbitration *task.Task) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "You are running a retrospective for story %q (id %s), which just reached DONE.\n\n", st.Name, st.ID)
+ fmt.Fprintf(&b, "Story spec:\n%s\n\n", st.Spec)
+ fmt.Fprintf(&b, "Acceptance criteria:\n%s\n", formatAcceptanceCriteria(st.AcceptanceCriteria))
+
+ nodes := o.taskTree(root.ID)
+ b.WriteString("\nTask tree (name, role, state, provider/model, accumulated cost, highest escalation rung):\n")
+ totalCost := 0.0
+ rolesSeen := map[string]bool{}
+ for _, t := range nodes {
+ execs, err := o.Store.ListExecutions(t.ID)
+ if err != nil {
+ o.logf("story orchestrator: retro: list executions", "storyID", st.ID, "taskID", t.ID, "error", err)
+ }
+ cost := 0.0
+ maxRung := 0
+ for _, e := range execs {
+ cost += e.CostUSD
+ if e.EscalationRung > maxRung {
+ maxRung = e.EscalationRung
+ }
+ }
+ totalCost += cost
+ fmt.Fprintf(&b, "- %s (role=%q, state=%s, agent=%s/%s, cost=$%.4f, max_escalation_rung=%d)\n",
+ t.Name, t.Agent.Role, t.State, t.Agent.Type, t.Agent.Model, cost, maxRung)
+ if t.Agent.Role != "" {
+ rolesSeen[t.Agent.Role] = true
+ }
+ }
+ fmt.Fprintf(&b, "\nTotal estimated cost across the story's task tree: $%.4f\n", totalCost)
+
+ b.WriteString("\nActive role configs for roles involved in this story:\n")
+ for r := range rolesSeen {
+ row, err := o.Store.GetActiveRoleConfig(r)
+ if err != nil {
+ fmt.Fprintf(&b, "- %s: no active role config\n", r)
+ continue
+ }
+ fmt.Fprintf(&b, "- %s (active version %d):\n%s\n", r, row.Version, row.ConfigJSON)
+ }
+
+ if events, err := o.Store.ListEvents(st.ID, 0); err != nil {
+ o.logf("story orchestrator: retro: list story events", "storyID", st.ID, "error", err)
+ } else if len(events) > 0 {
+ b.WriteString("\nStory events (evaluator verdicts, arbitration decision, human accept):\n")
+ for _, e := range events {
+ fmt.Fprintf(&b, "- [%s] %s\n", e.Kind, string(e.Payload))
+ }
+ }
+
+ b.WriteString("\nYour job: reflect on what happened across this story -- the roles/configs involved, " +
+ "escalations, cost, and any evaluator/arbitration feedback -- and propose concrete improvements. For each " +
+ "role whose config you judge worth revising, call propose_role_config with a complete, improved role " +
+ "config (role name, and whichever of system_prompt/escalation_ladder/tools/sandbox_kind/" +
+ "default_budget_usd you're changing). Only propose changes you can justify from what actually happened " +
+ "in this story; it is fine to propose zero changes if nothing stands out. Before finishing, call " +
+ "report_summary with the qualitative lessons learned (what worked, what didn't, and why) -- that is this " +
+ "retro's most important output even when you propose no config changes.")
+
+ return b.String()
+}
+
+// taskTree walks the task graph realizing a story starting at rootID,
+// following both parent_task_id children (ListSubtasks) and depends_on edges
+// in either direction (ListDependents) — the identical BFS
+// GET /api/stories/{id}/task-tree performs in internal/api/stories.go,
+// reimplemented here against StoryStore so the retro stage can assemble the
+// same view without an HTTP round-trip to its own server (per this phase's
+// guidance to use Store methods directly). Errors from either listing call
+// are logged and treated as "no further edges from this node" rather than
+// aborting the whole walk, matching the API handler's tolerance for a
+// dangling reference.
+func (o *StoryOrchestrator) taskTree(rootID string) []*task.Task {
+ visited := map[string]bool{}
+ queue := []string{rootID}
+ var out []*task.Task
+ for len(queue) > 0 {
+ id := queue[0]
+ queue = queue[1:]
+ if visited[id] {
+ continue
+ }
+ visited[id] = true
+
+ t, err := o.Store.GetTask(id)
+ if err != nil {
+ continue // referenced task no longer resolves; skip it, don't abort the walk
+ }
+ out = append(out, t)
+
+ if children, err := o.Store.ListSubtasks(id); err == nil {
+ for _, c := range children {
+ if !visited[c.ID] {
+ queue = append(queue, c.ID)
+ }
+ }
+ }
+ if deps, err := o.Store.ListDependents(id); err == nil {
+ for _, d := range deps {
+ if !visited[d.ID] {
+ queue = append(queue, d.ID)
+ }
+ }
+ }
+ }
+ return out
+}
+
+// finalizeRetro emits event.KindRetroCaptured, attached to the story's ID
+// (matching this orchestrator's convention for planning-layer ceremony
+// events — KindEvalVerdict/KindArbitrationDecided are attached the same
+// way), once the retro task reaches COMPLETED. The payload aggregates every
+// event.KindRoleConfigProposed the retro task itself recorded (one per
+// propose_role_config call — see storeChannel.ProposeRoleConfig in
+// internal/executor/channel.go) into a single list of {role, version}, plus
+// the retro task's own reported summary (the "capturing lessons" half of
+// this phase's brief) — this is the final mechanism in the harness's
+// self-improvement loop: retro produces drafts here, a human activates them
+// via the unchanged POST /api/roles/{role}/activate, and future dispatches
+// use the improved config.
+//
+// Guarded by handledRetro (in-memory, per-process — see that field's doc
+// comment) so a story sitting at DONE forever doesn't re-emit this event on
+// every subsequent tick.
+func (o *StoryOrchestrator) finalizeRetro(st *story.Story, retro *task.Task) {
+ if o.handledRetro == nil {
+ o.handledRetro = make(map[string]bool)
+ }
+ if o.handledRetro[retro.ID] {
+ return
+ }
+ o.handledRetro[retro.ID] = true
+
+ type proposedRoleConfig struct {
+ Role string `json:"role"`
+ Version int `json:"version"`
+ }
+ var proposals []proposedRoleConfig
+ events, err := o.Store.ListEvents(retro.ID, 0)
+ if err != nil {
+ o.logf("story orchestrator: retro: list retro task events", "storyID", st.ID, "taskID", retro.ID, "error", err)
+ }
+ for _, e := range events {
+ if e.Kind != event.KindRoleConfigProposed {
+ continue
+ }
+ var p proposedRoleConfig
+ if json.Unmarshal(e.Payload, &p) == nil {
+ proposals = append(proposals, p)
+ }
+ }
+
+ payload, _ := json.Marshal(struct {
+ TaskID string `json:"task_id"`
+ Proposals []proposedRoleConfig `json:"proposals"`
+ Summary string `json:"summary"`
+ }{TaskID: retro.ID, Proposals: proposals, Summary: retro.Summary})
+ if err := o.Store.CreateEvent(&event.Event{
+ TaskID: st.ID,
+ Kind: event.KindRetroCaptured,
+ Actor: event.ActorSystem,
+ Payload: payload,
+ }); err != nil {
+ o.logf("story orchestrator: retro: emit retro_captured", "storyID", st.ID, "error", err)
+ }
+}
+
// maybeEmitVerdict records a KindEvalVerdict event, attached to the story's
// ID (not the evaluator task's ID), the first time a given evaluator task is
// observed COMPLETED. Attaching to the story ID — the same choice