summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/plans/2026-07-10-recurse-processstory-tree-walk.md626
1 files changed, 626 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-10-recurse-processstory-tree-walk.md b/docs/superpowers/plans/2026-07-10-recurse-processstory-tree-walk.md
new file mode 100644
index 0000000..5f898d7
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-recurse-processstory-tree-walk.md
@@ -0,0 +1,626 @@
+# Recurse ProcessStory Tree Walk Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `processStory` drives *every* currently-READY `builder`-role node in a story's tree — root or nested, any depth — through Evaluators → Arbitration → approve/reject each tick, not just the story's root. This closes a real deadlock the single-root version has: a nested subtask reaching READY while its parent is still BLOCKED would never get evaluated, since `internal/executor.Pool.maybeUnblockParent` requires the nested position's `task.CurrentAttempt` to resolve to COMPLETED before promoting the parent — and only this orchestrator's Evaluators/Arbitration/`finalizeArbitration` cycle can ever produce that COMPLETED.
+
+**Architecture:** Reuses the existing `taskTree` BFS (root's own `ParentTaskID` children + `DependsOn` edges, already used by `processRetro`/`buildRetroInstructions`) to enumerate every task reachable from the story's current root position. `processStory`'s former single-root body is extracted verbatim into a new `processBuilderNode(ctx, st, node)` helper; `processStory` itself becomes: handle `NEEDS_FIX` exactly as before (root-only, unchanged), resolve the current root position via `task.CurrentAttempt`, short-circuit if it's already `COMPLETED` (nothing left to walk — see the invariant below), otherwise walk the tree and call `processBuilderNode` for every node with `Agent.Role == "builder" && State == StateReady`. `ensureEvaluators`'s `story.Status = "VALIDATING"` write is gated to `node.ParentTaskID == ""` (mirroring `finalizeArbitration`'s existing `isRoot` gate), since it's now called for nested nodes too and must not touch story-level bookkeeping that only the root position has any claim to.
+
+**Tech Stack:** Go, `internal/scheduler` package only.
+
+## Global Constraints
+
+- This is piece 4b-3 of `docs/superpowers/specs/2026-07-09-recursive-arbitrated-review-design.md`'s piece 4 — the final piece-4 subpiece. Quote from the spec: *"Today `StoryOrchestrator.processStory` only ever evaluates `story.RootTaskID` (now: `currentAttempt(story.RootTaskID)`). This design extends it to recurse: once a `builder`-role task's own work is done (leaf `READY`, or roll-up `READY` via the generalized `maybeUnblockParent`), it is auto-accepted and put through `ensureEvaluators`/`ensureArbitration` **exactly like the story root is today** ... A tree walk (reusing the existing `taskTree`/`GET .../task-tree` BFS shape, following both `ParentTaskID` children and `DependsOn` edges) finds every `builder`-role node in a story's tree once its immediate prerequisites are satisfied, and drives each one through the identical Builder→Evaluators→Arbitration→approve-or-fix cycle."*
+- Piece 4b-2 (already shipped, commit `063a6c1`) generalized `ensureEvaluators`/`ensureArbitration`/`finalizeArbitration` themselves to accept any node. This plan only generalizes the *trigger* (`processStory`), reusing that machinery unchanged except for `ensureEvaluators`'s `story.Status` gate described above.
+- **Load-bearing invariant this plan depends on (already shipped, not touched here):** `internal/executor.Pool.maybeUnblockParent` already requires every subtask to resolve (via `task.CurrentAttempt`) to `COMPLETED` before promoting a `BLOCKED` parent to `READY`. This is *why* the tree walk must not gate on root's own `READY` state: a nested node can be `READY` (awaiting arbitration) while root itself is still `BLOCKED`, precisely *because* that nested node hasn't been arbitrated (and thus turned `COMPLETED`) yet. Do not add any gate that skips the tree walk while root is `BLOCKED`/`RUNNING` — that would silently reintroduce the deadlock this plan exists to fix.
+- Do not modify `ensureArbitration`, `finalizeArbitration`, `spawnNestedFixAttempt`, `ensureFixAttempt`, `fixAttemptDepth`, `rejectionReasoning`, `findEvaluators`, `findArbitration`, `taskTree`, `processRetro`, `autoAccept`, `maybeEmitVerdict`, `arbitrationVerdict`, `dependsOnAll`, `formatAcceptanceCriteria`, `spawnRoleTask` — verify via the full existing test suite passing unchanged.
+- The `NEEDS_FIX` early-return at the top of `processStory` is unchanged: root's own rejection handling still polls `story.Status`, per piece 4b-2's established root-vs-nested plumbing asymmetry (not a special case in the review mechanism itself — see that plan's Global Constraints for the full reasoning, unchanged here).
+
+---
+
+### Task 1: Generalize `processStory` into a tree walk; extract `processBuilderNode`; gate `ensureEvaluators`'s `VALIDATING` write to root
+
+**Files:**
+- Modify: `internal/scheduler/story_orchestrator.go` (`processStory`, `ensureEvaluators`; add `processBuilderNode`)
+- Modify: `internal/scheduler/story_orchestrator_test.go` (4 new tests)
+
+**Interfaces:**
+- Consumes: `task.CurrentAttempt`, `o.taskTree`, `o.ensureEvaluators`, `o.ensureArbitration`, `o.finalizeArbitration`, `o.autoAccept`, `o.maybeEmitVerdict`, `o.ensureFixAttempt` (all unchanged in behavior; `ensureEvaluators`'s signature is unchanged — same 3 params — only its 3rd param is now read generically and its `VALIDATING` write gated).
+- Produces: `processBuilderNode(ctx context.Context, st *story.Story, node *task.Task)` — new, unexported.
+
+- [ ] **Step 1: Rewrite `processStory`, extract `processBuilderNode`**
+
+In `internal/scheduler/story_orchestrator.go`, find the full function (including its doc comment):
+
+```go
+// processStory advances a single story by at most one stage per tick (it
+// returns as soon as it finds a stage that isn't ready to progress yet — the
+// next tick picks up where this one left off).
+func (o *StoryOrchestrator) processStory(ctx context.Context, st *story.Story) {
+ if st.Status == "NEEDS_FIX" {
+ o.ensureFixAttempt(ctx, st)
+ return
+ }
+
+ root, err := task.CurrentAttempt(o.Store, st.RootTaskID)
+ if err != nil {
+ o.logf("story orchestrator: resolve current root attempt", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
+ return
+ }
+ // root is NOT auto-accepted here. Unlike evaluator/arbitration tasks,
+ // root is builder-role: its own completion must mean "verified by
+ // arbitration", not merely "the agent finished" -- so it stays READY
+ // through the whole Evaluators/Arbitration cycle below, and only
+ // finalizeArbitration's approval branch ever promotes it to COMPLETED.
+ // READY already satisfies a dependent's DependsOn (see
+ // executor.depDoneStates), so evaluators/arbitration can depend on a
+ // still-READY root without issue.
+ if root.State != task.StateReady {
+ return // not yet finished (still running/blocked); or already COMPLETED, meaning a prior tick's finalizeArbitration already approved it -- nothing further to do
+ }
+
+ // Stage 1: Builder -> Evaluators.
+ evaluators, ok := o.ensureEvaluators(ctx, st, root)
+ if !ok {
+ return // not all 4 could be found/created yet; retry next tick
+ }
+
+ // Auto-accept each evaluator that's reached READY, emit per-evaluator
+ // verdicts, and check whether all 4 are done.
+ allDone := true
+ for i, ev := range evaluators {
+ ev = o.autoAccept(st, ev)
+ evaluators[i] = ev
+ o.maybeEmitVerdict(st, ev)
+ if ev.State != task.StateCompleted {
+ allDone = false
+ }
+ }
+ if !allDone {
+ return
+ }
+
+ // Stage 2: Evaluators -> Arbitration.
+ arbitration, ok := o.ensureArbitration(ctx, st, root, evaluators)
+ if !ok {
+ return
+ }
+ arbitration = o.autoAccept(st, arbitration)
+
+ // Stage 3: Arbitration -> approve (root: READY -> COMPLETED, story ->
+ // REVIEW_READY) or reject (story -> NEEDS_FIX; a fix attempt is spawned
+ // next tick by ensureFixAttempt, depending on the still-READY root --
+ // task.CurrentAttempt resolves future lookups of this position forward
+ // to it).
+ if arbitration.State == task.StateCompleted {
+ o.finalizeArbitration(ctx, st, root, arbitration)
+ }
+}
+```
+
+Replace with:
+
+```go
+// processStory advances every currently-READY builder-role node in a
+// story's tree -- root or nested, any depth -- by up to one stage per tick
+// (each node's own progression through Evaluators -> Arbitration ->
+// approve/reject is handled by processBuilderNode, below). This no longer
+// stops at the first node that isn't ready yet: a story's tree can have
+// several builder nodes progressing independently and concurrently. In
+// particular, a still-BLOCKED root can have a nested subtask that has
+// already reached READY and needs its own arbitration before it can turn
+// COMPLETED -- and internal/executor.Pool.maybeUnblockParent requires every
+// subtask's task.CurrentAttempt to resolve to COMPLETED before promoting a
+// BLOCKED parent. Stopping at root the way the single-root version did would
+// deadlock any story with nested subtasks: root can never become READY
+// while the very node that would produce that COMPLETED sits unprocessed,
+// waiting for a tick that never reaches past root's own not-yet-READY
+// state.
+func (o *StoryOrchestrator) processStory(ctx context.Context, st *story.Story) {
+ if st.Status == "NEEDS_FIX" {
+ o.ensureFixAttempt(ctx, st)
+ return
+ }
+
+ root, err := task.CurrentAttempt(o.Store, st.RootTaskID)
+ if err != nil {
+ o.logf("story orchestrator: resolve current root attempt", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
+ return
+ }
+ if root.State == task.StateCompleted {
+ // The whole tree is already fully resolved: root only ever reaches
+ // COMPLETED via finalizeArbitration's approval branch, which --
+ // per the maybeUnblockParent invariant above -- can't happen until
+ // every nested builder node beneath it is COMPLETED too. Nothing
+ // left to walk.
+ return
+ }
+
+ for _, node := range o.taskTree(root.ID) {
+ if node.Agent.Role != "builder" || node.State != task.StateReady {
+ continue
+ }
+ o.processBuilderNode(ctx, st, node)
+ }
+}
+
+// processBuilderNode drives one READY builder-role node -- root or nested --
+// through Evaluators -> Arbitration -> approve/reject, advancing it by at
+// most one stage per tick (the next tick picks up where this one left off).
+// This is the exact per-root body processStory ran directly before this
+// design recursed into nested subtask trees; extracting it into its own
+// function changed nothing about what it does, only how many nodes call it
+// per tick.
+//
+// node is NOT auto-accepted here. Unlike evaluator/arbitration tasks, node
+// is builder-role: its own completion must mean "verified by arbitration",
+// not merely "the agent finished" -- so it stays READY through the whole
+// Evaluators/Arbitration cycle below, and only finalizeArbitration's
+// approval branch ever promotes it to COMPLETED. READY already satisfies a
+// dependent's DependsOn (see executor.depDoneStates), so evaluators/
+// arbitration can depend on a still-READY node without issue.
+func (o *StoryOrchestrator) processBuilderNode(ctx context.Context, st *story.Story, node *task.Task) {
+ // Stage 1: Builder -> Evaluators.
+ evaluators, ok := o.ensureEvaluators(ctx, st, node)
+ if !ok {
+ return // not all 4 could be found/created yet; retry next tick
+ }
+
+ // Auto-accept each evaluator that's reached READY, emit per-evaluator
+ // verdicts, and check whether all 4 are done.
+ allDone := true
+ for i, ev := range evaluators {
+ ev = o.autoAccept(st, ev)
+ evaluators[i] = ev
+ o.maybeEmitVerdict(st, ev)
+ if ev.State != task.StateCompleted {
+ allDone = false
+ }
+ }
+ if !allDone {
+ return
+ }
+
+ // Stage 2: Evaluators -> Arbitration.
+ arbitration, ok := o.ensureArbitration(ctx, st, node, evaluators)
+ if !ok {
+ return
+ }
+ arbitration = o.autoAccept(st, arbitration)
+
+ // Stage 3: Arbitration -> approve (node: READY -> COMPLETED, and if node
+ // is the story's root, story -> REVIEW_READY) or reject (if node is the
+ // root, story -> NEEDS_FIX and a fix attempt is spawned next tick by
+ // ensureFixAttempt; otherwise finalizeArbitration spawns the nested
+ // fix-attempt directly). task.CurrentAttempt resolves future lookups of
+ // this position forward to whatever fix-attempt gets spawned.
+ if arbitration.State == task.StateCompleted {
+ o.finalizeArbitration(ctx, st, node, arbitration)
+ }
+}
+```
+
+- [ ] **Step 2: Generalize `ensureEvaluators` — rename `root` to `node`, gate the `VALIDATING` write to root**
+
+In the same file, find the full function (including its doc comment):
+
+```go
+// ensureEvaluators returns the 4 Evaluator tasks fanned out from root,
+// spawning any missing ones. Idempotency is structural, not a marker on the
+// story: it looks at root's actual dependents and checks which of
+// evaluatorRoles are already represented, so calling this repeatedly for the
+// same story never spawns duplicates (test (b) in the phase description) —
+// even across a process restart, unlike a purely in-memory guard would be.
+// Returns ok=false if any missing evaluator couldn't be created this tick
+// (transient store error); the caller retries on the next tick.
+func (o *StoryOrchestrator) ensureEvaluators(ctx context.Context, st *story.Story, root *task.Task) ([]*task.Task, bool) {
+ dependents, err := o.Store.ListDependents(root.ID)
+ if err != nil {
+ o.logf("story orchestrator: list root dependents", "storyID", st.ID, "error", err)
+ 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
+ }
+ }
+ }
+
+ criteria := root.AcceptanceCriteria
+ if len(criteria) == 0 {
+ criteria = st.AcceptanceCriteria
+ }
+
+ spawnedAny := false
+ for _, r := range evaluatorRoles {
+ if _, ok := found[r]; ok {
+ continue
+ }
+ nt, err := o.spawnRoleTask(ctx, fmt.Sprintf("%s: %s", r, st.Name), r, []string{root.ID}, "", root,
+ fmt.Sprintf("Evaluate the changes made by task %s against the %q dimension for story %q.\n\nStory spec:\n%s\n\nAcceptance criteria:\n%s", root.ID, r, st.Name, st.Spec, formatAcceptanceCriteria(criteria)))
+ if err != nil {
+ o.logf("story orchestrator: spawn evaluator", "storyID", st.ID, "role", r, "error", err)
+ continue
+ }
+ found[r] = nt
+ spawnedAny = true
+ }
+
+ if spawnedAny {
+ st.Status = "VALIDATING"
+ if err := o.Store.UpdateStory(st); err != nil {
+ o.logf("story orchestrator: update story to VALIDATING", "storyID", st.ID, "error", err)
+ }
+ }
+
+ 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
+}
+```
+
+Replace with:
+
+```go
+// ensureEvaluators returns the 4 Evaluator tasks fanned out from node (root
+// or nested -- any builder-role task processStory's tree walk has found
+// READY), spawning any missing ones. Idempotency is structural, not a marker
+// on the story: it looks at node's actual dependents and checks which of
+// evaluatorRoles are already represented, so calling this repeatedly for the
+// same node never spawns duplicates (test (b) in the phase description) —
+// even across a process restart, unlike a purely in-memory guard would be.
+// story.Status is only ever set to VALIDATING here when node IS the story's
+// root (node.ParentTaskID == "") -- mirroring finalizeArbitration's own
+// isRoot gate on story.Status writes; a nested node's evaluators spawning
+// must not touch story-level bookkeeping that only the root position has
+// any claim to.
+// Returns ok=false if any missing evaluator couldn't be created this tick
+// (transient store error); the caller retries on the next tick.
+func (o *StoryOrchestrator) ensureEvaluators(ctx context.Context, st *story.Story, node *task.Task) ([]*task.Task, bool) {
+ dependents, err := o.Store.ListDependents(node.ID)
+ if err != nil {
+ o.logf("story orchestrator: list node dependents", "storyID", st.ID, "error", err)
+ 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
+ }
+ }
+ }
+
+ criteria := node.AcceptanceCriteria
+ if len(criteria) == 0 {
+ criteria = st.AcceptanceCriteria
+ }
+
+ spawnedAny := false
+ for _, r := range evaluatorRoles {
+ if _, ok := found[r]; ok {
+ continue
+ }
+ nt, err := o.spawnRoleTask(ctx, fmt.Sprintf("%s: %s", r, st.Name), r, []string{node.ID}, "", node,
+ fmt.Sprintf("Evaluate the changes made by task %s against the %q dimension for story %q.\n\nStory spec:\n%s\n\nAcceptance criteria:\n%s", node.ID, r, st.Name, st.Spec, formatAcceptanceCriteria(criteria)))
+ if err != nil {
+ o.logf("story orchestrator: spawn evaluator", "storyID", st.ID, "role", r, "error", err)
+ continue
+ }
+ found[r] = nt
+ spawnedAny = true
+ }
+
+ if spawnedAny && node.ParentTaskID == "" {
+ st.Status = "VALIDATING"
+ if err := o.Store.UpdateStory(st); err != nil {
+ o.logf("story orchestrator: update story to VALIDATING", "storyID", st.ID, "error", err)
+ }
+ }
+
+ 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
+}
+```
+
+- [ ] **Step 3: Add the 4 new tests**
+
+In `internal/scheduler/story_orchestrator_test.go`, add these 4 tests anywhere after `newStoryWithRoot`'s definition (e.g. immediately before `TestStoryOrchestrator_SpawnsEvaluators_WhenBuilderReady`, or at the end of the file — placement doesn't matter, they're self-contained):
+
+```go
+// TestStoryOrchestrator_ProcessesNestedReadyBuilderNode_WhileRootStillBlocked
+// proves the deadlock the single-root version of processStory had: a nested
+// builder subtask reaching READY while its parent (root) is still BLOCKED
+// must still get its own Evaluators spawned this tick -- root can only ever
+// become READY once this nested node's own arbitration approves it (see
+// internal/executor.Pool.maybeUnblockParent, which requires every
+// subtask's task.CurrentAttempt to resolve to COMPLETED before promoting a
+// BLOCKED parent). Root itself, still BLOCKED, gets nothing spawned for it.
+func TestStoryOrchestrator_ProcessesNestedReadyBuilderNode_WhileRootStillBlocked(t *testing.T) {
+ store := newFakeStoryStore()
+ root := builderTask("deadlock-root", task.StateBlocked)
+ store.tasks[root.ID] = root
+ st := newStoryWithRoot("deadlock-story", root.ID, "IN_PROGRESS")
+ store.stories[st.ID] = st
+
+ nested := builderTask("deadlock-nested", task.StateReady)
+ nested.ParentTaskID = root.ID
+ store.tasks[nested.ID] = nested
+
+ pool := &fakePool{}
+ orch := &StoryOrchestrator{Store: store, Pool: pool}
+ orch.Tick(context.Background())
+
+ deps, err := store.ListDependents(nested.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(deps) != 4 {
+ t.Fatalf("expected 4 evaluator tasks spawned for the nested READY node even though root is still BLOCKED, got %d", len(deps))
+ }
+
+ rootDeps, _ := store.ListDependents(root.ID)
+ if len(rootDeps) != 0 {
+ t.Fatalf("root itself must not get evaluators spawned while still BLOCKED, got %d dependents", len(rootDeps))
+ }
+ rootAfter, err := store.GetTask(root.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rootAfter.State != task.StateBlocked {
+ t.Errorf("root state must be untouched: want BLOCKED, got %v", rootAfter.State)
+ }
+
+ stories, _ := store.ListStories(storage.StoryFilter{})
+ var gotStory *story.Story
+ for _, s := range stories {
+ if s.ID == st.ID {
+ gotStory = s
+ }
+ }
+ if gotStory.Status != "IN_PROGRESS" {
+ t.Errorf("story Status = %q, want unchanged IN_PROGRESS (nested evaluator spawn must not touch story-level status)", gotStory.Status)
+ }
+}
+
+// TestStoryOrchestrator_ProcessStory_MultipleSiblingNestedNodes_BothProcessedSameTick
+// proves the tree walk processes every qualifying node in a single tick, not
+// just the first one it finds -- a real behavioral change from the old
+// single-root processStory, which returned as soon as it found one node not
+// ready to progress.
+func TestStoryOrchestrator_ProcessStory_MultipleSiblingNestedNodes_BothProcessedSameTick(t *testing.T) {
+ store := newFakeStoryStore()
+ root := builderTask("multi-root", task.StateBlocked)
+ store.tasks[root.ID] = root
+ st := newStoryWithRoot("multi-story", root.ID, "IN_PROGRESS")
+ store.stories[st.ID] = st
+
+ nested1 := builderTask("multi-nested-1", task.StateReady)
+ nested1.ParentTaskID = root.ID
+ store.tasks[nested1.ID] = nested1
+
+ nested2 := builderTask("multi-nested-2", task.StateReady)
+ nested2.ParentTaskID = root.ID
+ store.tasks[nested2.ID] = nested2
+
+ pool := &fakePool{}
+ orch := &StoryOrchestrator{Store: store, Pool: pool}
+ orch.Tick(context.Background())
+
+ deps1, _ := store.ListDependents(nested1.ID)
+ deps2, _ := store.ListDependents(nested2.ID)
+ if len(deps1) != 4 {
+ t.Errorf("nested1: expected 4 evaluator tasks, got %d", len(deps1))
+ }
+ if len(deps2) != 4 {
+ t.Errorf("nested2: expected 4 evaluator tasks, got %d", len(deps2))
+ }
+ if pool.submitCount() != 8 {
+ t.Fatalf("expected 8 pool submissions (4 evaluators x 2 sibling nodes) in a single tick, got %d", pool.submitCount())
+ }
+}
+
+// TestStoryOrchestrator_ProcessStory_RootCompleted_NoOp proves the
+// short-circuit added for a fully-resolved tree: once root itself is
+// COMPLETED (only possible after finalizeArbitration approved it, which
+// itself requires every nested node beneath it to already be COMPLETED --
+// see maybeUnblockParent), processStory does nothing further, not even a
+// tree walk.
+func TestStoryOrchestrator_ProcessStory_RootCompleted_NoOp(t *testing.T) {
+ store := newFakeStoryStore()
+ root := builderTask("done-root", task.StateCompleted)
+ store.tasks[root.ID] = root
+ st := newStoryWithRoot("done-story", root.ID, "REVIEW_READY")
+ store.stories[st.ID] = st
+
+ pool := &fakePool{}
+ orch := &StoryOrchestrator{Store: store, Pool: pool}
+ orch.Tick(context.Background())
+
+ if pool.submitCount() != 0 {
+ t.Fatalf("expected no submissions once root is COMPLETED, got %d", pool.submitCount())
+ }
+}
+
+// TestStoryOrchestrator_EndToEnd_NestedSubtask exercises the full recursive
+// loop this piece adds: a nested builder subtask reaches READY while root is
+// still BLOCKED, gets its own Evaluators -> Arbitration -> approval cycle
+// entirely independent of root, reaches COMPLETED -- at which point (in a
+// real deployment) internal/executor.Pool.maybeUnblockParent would see every
+// one of root's subtasks resolve to COMPLETED via task.CurrentAttempt and
+// promote root BLOCKED -> READY. That promotion is simulated directly here
+// (store.setTaskState) since maybeUnblockParent lives in a different
+// package/component this fake doesn't exercise. Root then goes through the
+// exact same cycle for itself, ending at COMPLETED with the story at
+// REVIEW_READY -- proving the two levels compose without any special-casing
+// between them.
+func TestStoryOrchestrator_EndToEnd_NestedSubtask(t *testing.T) {
+ store := newFakeStoryStore()
+ root := builderTask("e2e-nested-root", task.StateBlocked)
+ store.tasks[root.ID] = root
+ st := newStoryWithRoot("e2e-nested-story", root.ID, "IN_PROGRESS")
+ store.stories[st.ID] = st
+
+ nested := builderTask("e2e-nested-child", task.StateReady)
+ nested.ParentTaskID = root.ID
+ store.tasks[nested.ID] = nested
+
+ pool := &fakePool{}
+ orch := &StoryOrchestrator{Store: store, Pool: pool}
+
+ // Tick 1: nested is READY -> its 4 evaluators spawn. Root, still
+ // BLOCKED, gets nothing.
+ orch.Tick(context.Background())
+ nestedDeps, _ := store.ListDependents(nested.ID)
+ if len(nestedDeps) != 4 {
+ t.Fatalf("expected 4 evaluators for nested, got %d", len(nestedDeps))
+ }
+
+ // Evaluators' executions succeed one by one (READY, not COMPLETED).
+ for i, d := range nestedDeps {
+ store.setTaskState(d.ID, task.StateReady)
+ store.setTaskSummary(d.ID, fmt.Sprintf("nested verdict %d", i))
+ orch.Tick(context.Background())
+ }
+ for _, d := range nestedDeps {
+ got, err := store.GetTask(d.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.State != task.StateCompleted {
+ t.Fatalf("nested evaluator %s should be auto-accepted to COMPLETED, got %v", d.ID, got.State)
+ }
+ }
+
+ nestedArbs := store.dependentsWithRole(nestedDeps[0].ID, "planner")
+ if len(nestedArbs) != 1 {
+ t.Fatalf("expected exactly 1 arbitration task for nested, got %d", len(nestedArbs))
+ }
+ nestedArb := nestedArbs[0]
+
+ // Arbitration approves nested.
+ store.setTaskState(nestedArb.ID, task.StateReady)
+ store.setTaskSummary(nestedArb.ID, "approved")
+ payload, _ := json.Marshal(struct {
+ Approved bool `json:"approved"`
+ Reasoning string `json:"reasoning"`
+ }{Approved: true, Reasoning: "meets criteria"})
+ if err := store.CreateEvent(&event.Event{TaskID: nestedArb.ID, Kind: event.KindVerdictReported, Actor: event.ActorAgent, Payload: payload}); err != nil {
+ t.Fatalf("seed nested verdict event: %v", err)
+ }
+ orch.Tick(context.Background())
+
+ nestedAfter, err := store.GetTask(nested.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if nestedAfter.State != task.StateCompleted {
+ t.Fatalf("nested should be promoted to COMPLETED now that its arbitration approved it, got %v", nestedAfter.State)
+ }
+
+ // Story status must still be untouched by the nested node's own
+ // approval -- only root's own approval may set REVIEW_READY.
+ statusOf := func() string {
+ stories, _ := store.ListStories(storage.StoryFilter{})
+ for _, s := range stories {
+ if s.ID == st.ID {
+ return s.Status
+ }
+ }
+ return ""
+ }
+ if statusOf() != "IN_PROGRESS" {
+ t.Fatalf("story status must remain IN_PROGRESS after only the nested node's approval, got %q", statusOf())
+ }
+
+ // Simulate what internal/executor.Pool.maybeUnblockParent would do in a
+ // real deployment now that nested's task.CurrentAttempt resolves to
+ // COMPLETED: promote root BLOCKED -> READY.
+ store.setTaskState(root.ID, task.StateReady)
+
+ // Tick: root is now READY -> its own 4 evaluators spawn.
+ orch.Tick(context.Background())
+ rootDeps, _ := store.ListDependents(root.ID)
+ if len(rootDeps) != 4 {
+ t.Fatalf("expected 4 evaluators for root, got %d", len(rootDeps))
+ }
+ if statusOf() != "VALIDATING" {
+ t.Fatalf("expected VALIDATING once root's own evaluators spawn, got %q", statusOf())
+ }
+
+ for i, d := range rootDeps {
+ store.setTaskState(d.ID, task.StateReady)
+ store.setTaskSummary(d.ID, fmt.Sprintf("root verdict %d", i))
+ orch.Tick(context.Background())
+ }
+
+ rootArbs := store.dependentsWithRole(rootDeps[0].ID, "planner")
+ if len(rootArbs) != 1 {
+ t.Fatalf("expected exactly 1 arbitration task for root, got %d", len(rootArbs))
+ }
+ rootArb := rootArbs[0]
+
+ store.setTaskState(rootArb.ID, task.StateReady)
+ store.setTaskSummary(rootArb.ID, "approved")
+ rootPayload, _ := json.Marshal(struct {
+ Approved bool `json:"approved"`
+ Reasoning string `json:"reasoning"`
+ }{Approved: true, Reasoning: "meets criteria"})
+ if err := store.CreateEvent(&event.Event{TaskID: rootArb.ID, Kind: event.KindVerdictReported, Actor: event.ActorAgent, Payload: rootPayload}); err != nil {
+ t.Fatalf("seed root verdict event: %v", err)
+ }
+ orch.Tick(context.Background())
+
+ rootAfter, err := store.GetTask(root.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rootAfter.State != task.StateCompleted {
+ t.Fatalf("root should be promoted to COMPLETED now that its own arbitration approved it, got %v", rootAfter.State)
+ }
+ if statusOf() != "REVIEW_READY" {
+ t.Fatalf("expected REVIEW_READY once root's own arbitration approves it, got %q", statusOf())
+ }
+}
+```
+
+- [ ] **Step 4: Commit locally NOW (before further verification)**
+
+Run these exact commands, in order:
+
+```bash
+git add -A
+git commit -m "refactor(scheduler): recurse processStory into a tree walk over every READY builder-role node"
+git status
+git log --oneline -1
+```
+
+Do NOT run `git push`. This repo's own bare git remote is not mounted inside dispatch containers. Per this repo's own CLAUDE.md ("After a successful run, new commits are pushed from the workspace"), claudomator's ContainerRunner pushes your commits externally once your run finishes successfully with a clean working tree — your only job is a clean local commit and a clean working tree at teardown. Do this as soon as all edits (Steps 1-3) are done, before spending time on the verification steps below.
+
+- [ ] **Step 5: Run the full scheduler package test suite**
+
+Run: `go test ./internal/scheduler/... -v -run 'TestStoryOrchestrator'`
+Expected: PASS for every test in the file, including the 4 new ones above. Every pre-existing test must ALSO pass completely unchanged — in particular `TestStoryOrchestrator_DoesNothing_WhenBuilderNotComplete` (root RUNNING, no subtasks — the tree walk must still find nothing to do), `TestStoryOrchestrator_SpawnsEvaluators_WhenBuilderReady` (root's own VALIDATING write must still fire), `TestStoryOrchestrator_EndToEnd` (the pre-existing single-root lifecycle), and the full retro test cluster (`processRetro` is untouched but depends on `taskTree`, which this plan also doesn't touch). Paste the actual output. If anything fails, fix it, then repeat Step 4's commit (a new commit is fine, don't amend).
+
+- [ ] **Step 6: Run the full repo test suite**
+
+Run: `go build ./...` then `go test ./...` (the entire repo). Both must pass — paste the actual output. If `internal/api` flakes under the race detector on a one-off "sql: database is closed" teardown error unrelated to this task's actual changes, rerun once before treating it as real.
+
+Also run `gofmt -l internal/scheduler/story_orchestrator.go internal/scheduler/story_orchestrator_test.go` and `gofmt -w` any file it flags that wasn't already gofmt-dirty before your changes (check with `git show HEAD~1:<path> | gofmt -l -` if unsure). If you make any further fixes here, commit them too (again, no push).
+
+## Mandatory verification disclosure
+
+When you call report_summary, paste the actual terminal output of every command above — literal pass/fail counts, not a claim of success — including the full-repo `go test ./...` run and the final `git status`/`git log` confirmation showing a clean tree with your commit(s) present. If anything here is ambiguous or conflicts with what you find in the actual repo, call ask_user and describe the specific conflict — don't guess or silently decide.