summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-10 06:27:24 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-10 06:27:24 +0000
commit98123778f4b3774d54441d31b68cb607ccedcbe8 (patch)
tree728166241f4fbacc5a12c159f95ea4ef08a6c61e
parent063a6c1661a91370e2f70d7fab670acd09561619 (diff)
docs: add piece 4b-3 plan (tree-walk arbitrated review)
-rw-r--r--docs/superpowers/plans/2026-07-10-tree-walk-arbitrated-review.md536
1 files changed, 536 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-10-tree-walk-arbitrated-review.md b/docs/superpowers/plans/2026-07-10-tree-walk-arbitrated-review.md
new file mode 100644
index 0000000..7b64bf2
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-tree-walk-arbitrated-review.md
@@ -0,0 +1,536 @@
+# Tree-Walk Arbitrated Review 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:** `StoryOrchestrator.processStory` discovers and drives *every* `READY` `builder`-role node in a story's task tree — root or nested, at any depth — through the Evaluators → Arbitration → approve-or-fix cycle each tick, not just the story's root. This is piece 4b-3, the final generalization step of `docs/superpowers/specs/2026-07-09-recursive-arbitrated-review-design.md`'s piece 4.
+
+**Architecture:** `processStory`'s existing per-root body (Evaluators → Arbitration → finalize) is extracted, unchanged in substance, into a new `processBuilderNode(ctx, st, node)` helper. `processStory` itself is reduced to: handle the root's `NEEDS_FIX` fix-loop (unchanged), resolve the root's current attempt (unchanged), then walk `o.taskTree(root.ID)` (already exists, already does the right BFS) and call `processBuilderNode` for every node found with `Agent.Role == "builder" && State == StateReady`. `ensureEvaluators` gains the same `isRoot`-gated `story.Status` write `finalizeArbitration` already has (piece 4b-2) — today it unconditionally sets `st.Status = "VALIDATING"` whenever it spawns evaluators, which was harmless while it was only ever called with the true root, but must be gated now that it's called for nested nodes too.
+
+**Tech Stack:** Go, `internal/scheduler` package only.
+
+## Global Constraints
+
+- This is piece 4b-3 (the final slice of piece 4) of `docs/superpowers/specs/2026-07-09-recursive-arbitrated-review-design.md`, following piece 4a (root only becomes `COMPLETED` after arbitration approves it), piece 4b-1 (a nested `builder`-role task goes `READY` instead of `COMPLETED` in `internal/executor`), and piece 4b-2 (`ensureEvaluators`/`ensureArbitration`/`finalizeArbitration` generalized to operate on any builder-role node, not just root). This plan is the piece that actually *triggers* that generalized machinery for nested nodes — 4b-2 built the mechanism, this plan wires it up.
+- Critical ordering fact this design depends on (verified by reading `internal/executor/executor.go`'s `maybeUnblockParent` and `internal/task/currentattempt.go` directly, not from memory): `maybeUnblockParent` already resolves each of a parent's subtasks through `task.CurrentAttempt` and requires the *current attempt* to be `COMPLETED` (not merely `READY`) before promoting the parent out of `BLOCKED`. This means a root can never reach `READY` while any nested descendant still sits unevaluated at `READY` — so this orchestrator must discover and drive nested `READY` builder nodes independently of whether the root itself has reached `READY`, not only after. `processStory` must NOT gate the tree walk on `root.State == StateReady` (removing that gate, which existed pre-4b-3, is the central behavior change of this plan).
+- No changes to `ensureArbitration`, `finalizeArbitration`, `spawnNestedFixAttempt`, `ensureFixAttempt`, `fixAttemptDepth`, `rejectionReasoning`, `findEvaluators`, `findArbitration`, `taskTree`, `processRetro`, `autoAccept`, `maybeEmitVerdict`, `arbitrationVerdict`, `dependsOnAll`, `formatAcceptanceCriteria`, `spawnRoleTask` — verify by running the full existing test suite and confirming these functions' own tests are unaffected.
+- `ensureEvaluators`'s parameter is renamed from `root` to `node` (matching `ensureArbitration`/`finalizeArbitration`'s existing naming from piece 4b-2) — a pure rename plus the new `isRoot` gate, no other behavior change to that function's evaluator-spawning/idempotency logic.
+
+---
+
+### Task 1: Generalize `ensureEvaluators`'s status write and restructure `processStory` into a tree walk
+
+**Files:**
+- Modify: `internal/scheduler/story_orchestrator.go` (`ensureEvaluators`, `processStory`; add `processBuilderNode`)
+- Modify: `internal/scheduler/story_orchestrator_test.go` (3 new tests)
+
+**Interfaces:**
+- Consumes: `task.CurrentAttempt`, `o.taskTree`, `o.ensureEvaluators`, `o.ensureArbitration`, `o.autoAccept`, `o.maybeEmitVerdict`, `o.finalizeArbitration`, `o.ensureFixAttempt` (all unchanged in behavior except `ensureEvaluators`'s status-write gating below).
+- Produces: `ensureEvaluators(ctx, st, node *task.Task) ([]*task.Task, bool)` — parameter renamed from `root` to `node`, no signature-shape change (still 3rd positional arg, same types). `processBuilderNode(ctx context.Context, st *story.Story, node *task.Task)` — new, unexported, no return value.
+
+- [ ] **Step 1: Rename `ensureEvaluators`'s parameter to `node` and gate its `story.Status` write on `isRoot`**
+
+In `internal/scheduler/story_orchestrator.go`, 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,
+// 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.
+// Returns ok=false if any missing evaluator couldn't be created this tick
+// (transient store error); the caller retries on the next tick.
+//
+// node is the builder-role task being evaluated -- root or nested. st.Status
+// only ever moves to VALIDATING here when node IS the story's root
+// (node.ParentTaskID == ""), matching finalizeArbitration's own isRoot gate
+// (piece 4b-2): a nested node spawning its own evaluators has no claim on
+// the story-level status field.
+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, "nodeID", node.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 2: Restructure `processStory` into a tree walk and extract `processBuilderNode`**
+
+In the same file, 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 (+ story -> VALIDATING).
+ 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 drives every READY builder-role node in the story's tree
+// through up to one stage of Evaluators -> Arbitration -> approve-or-fix
+// each tick (see processBuilderNode) -- not just the story's root. This is
+// piece 4b-3 of
+// docs/superpowers/specs/2026-07-09-recursive-arbitrated-review-design.md: a
+// nested builder subtask reaches READY (not COMPLETED -- see
+// internal/executor.Pool.promoteNestedTask) the moment its own execution
+// finishes, but internal/executor.Pool.maybeUnblockParent will not promote
+// ITS parent out of BLOCKED until that nested position's *current attempt*
+// (task.CurrentAttempt-resolved) reaches COMPLETED -- which only happens
+// once this orchestrator's own arbitration cycle approves it. So the root
+// itself can never reach READY while a nested descendant sits unevaluated:
+// this orchestrator must discover and drive nested READY builder nodes
+// independently of whether the root has reached READY yet, not only once it
+// has. taskTree's BFS (ParentTaskID children + DependsOn edges, seed node
+// included) is exactly the walk needed to find every qualifying node, at
+// any depth, in one pass.
+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
+ }
+
+ for _, node := range o.taskTree(root.ID) {
+ if node.Agent.Role != "builder" || node.State != task.StateReady {
+ continue
+ }
+ o.processBuilderNode(ctx, st, node)
+ }
+}
+
+// processBuilderNode advances one READY builder-role node -- root or nested
+// -- through up to one stage of Evaluators -> Arbitration -> approve-or-fix
+// (it returns as soon as it finds a stage that isn't ready to progress yet;
+// the next tick's tree walk in processStory picks this node back up if it's
+// still READY). This is processStory's entire pre-4b-3 per-root body,
+// unchanged in substance, generalized from "root" to any qualifying node.
+//
+// node is NOT auto-accepted here. Unlike evaluator/arbitration tasks, a
+// builder-role node's 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: node -> Evaluators (+ story -> VALIDATING, root only).
+ 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; root also:
+ // story -> REVIEW_READY) or reject (root: story -> NEEDS_FIX, handled
+ // next tick by ensureFixAttempt; nested: fix attempt spawned directly via
+ // finalizeArbitration's own spawnNestedFixAttempt call) -- see
+ // finalizeArbitration's doc comment for the full isRoot split.
+ if arbitration.State == task.StateCompleted {
+ o.finalizeArbitration(ctx, st, node, arbitration)
+ }
+}
+```
+
+- [ ] **Step 3: Add the 3 new tests**
+
+In `internal/scheduler/story_orchestrator_test.go`, add these 3 tests anywhere after `builderTask`/`newStoryWithRoot`'s definitions (e.g., at the end of the file — placement doesn't matter, they're self-contained):
+
+```go
+// TestStoryOrchestrator_NestedBuilderReady_SpawnsEvaluators_RootStaysBlockedStoryStatusUnchanged
+// proves piece 4b-3's core behavior: the tree walk in processStory discovers
+// a nested READY builder subtask and drives it through ensureEvaluators even
+// while the root itself is still BLOCKED (has not, and per
+// maybeUnblockParent's currentAttempt-resolved gate, cannot yet reach READY
+// until this nested position reaches COMPLETED). story.Status must NOT move
+// to VALIDATING for a nested node's own evaluators -- that's reserved for
+// the actual root position.
+func TestStoryOrchestrator_NestedBuilderReady_SpawnsEvaluators_RootStaysBlockedStoryStatusUnchanged(t *testing.T) {
+ store := newFakeStoryStore()
+ root := builderTask("nested-tw-root", task.StateBlocked)
+ store.tasks[root.ID] = root
+ st := newStoryWithRoot("nested-tw-story", root.ID, "IN_PROGRESS")
+ store.stories[st.ID] = st
+
+ nested := builderTask("nested-tw-node", task.StateReady)
+ nested.ParentTaskID = root.ID
+ store.tasks[nested.ID] = nested
+
+ pool := &fakePool{}
+ orch := &StoryOrchestrator{Store: store, Pool: pool}
+ orch.Tick(context.Background())
+
+ deps, _ := store.ListDependents(nested.ID)
+ if len(deps) != 4 {
+ t.Fatalf("expected 4 evaluator tasks spawned for the nested node, got %d", len(deps))
+ }
+ rootDeps, _ := store.ListDependents(root.ID)
+ if len(rootDeps) != 0 {
+ t.Fatalf("expected no evaluator tasks spawned for the still-BLOCKED root, got %d", len(rootDeps))
+ }
+
+ gotRoot, err := store.GetTask(root.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gotRoot.State != task.StateBlocked {
+ t.Errorf("root State = %v, want unchanged BLOCKED", gotRoot.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 (a nested node's evaluators must not touch story.Status)", gotStory.Status)
+ }
+}
+
+// TestStoryOrchestrator_NestedBuilderNode_FullyApprovedAcrossMultipleTicks_RootRemainsBlocked
+// drives a nested builder node all the way through Evaluators -> Arbitration
+// -> COMPLETED across several Tick calls (this fake store never runs real
+// executions, so each stage's tasks are driven to READY manually between
+// ticks, mirroring how a real execution's completion would land them
+// there), proving the tree-walk-driven cycle for a nested position produces
+// the same terminal outcome (COMPLETED, verified by arbitration) the root's
+// own cycle already produced pre-4b-3 -- while the root itself, having no
+// story-level bookkeeping tied to a nested node's progress, is left
+// completely untouched throughout. Root unblocking itself is
+// executor.Pool.maybeUnblockParent's job, not this orchestrator's -- already
+// covered by internal/executor's own piece 4b-1 tests -- so this test
+// deliberately stops at "nested node reaches COMPLETED, root still BLOCKED".
+func TestStoryOrchestrator_NestedBuilderNode_FullyApprovedAcrossMultipleTicks_RootRemainsBlocked(t *testing.T) {
+ store := newFakeStoryStore()
+ root := builderTask("nested-full-root", task.StateBlocked)
+ store.tasks[root.ID] = root
+ st := newStoryWithRoot("nested-full-story", root.ID, "IN_PROGRESS")
+ store.stories[st.ID] = st
+
+ nested := builderTask("nested-full-node", task.StateReady)
+ nested.ParentTaskID = root.ID
+ store.tasks[nested.ID] = nested
+
+ pool := &fakePool{}
+ orch := &StoryOrchestrator{Store: store, Pool: pool}
+
+ orch.Tick(context.Background()) // spawns 4 evaluators for nested
+
+ var evaluators []*task.Task
+ for _, r := range evaluatorRoles {
+ evaluators = append(evaluators, store.dependentsWithRole(nested.ID, r)...)
+ }
+ if len(evaluators) != 4 {
+ t.Fatalf("expected 4 evaluators, got %d", len(evaluators))
+ }
+ for _, ev := range evaluators {
+ store.setTaskState(ev.ID, task.StateReady)
+ }
+
+ orch.Tick(context.Background()) // auto-accepts evaluators, spawns arbitration
+
+ arbs := store.dependentsWithRole(evaluators[0].ID, arbitrationRole)
+ if len(arbs) != 1 {
+ t.Fatalf("expected 1 arbitration task, got %d", len(arbs))
+ }
+ arb := arbs[0]
+ store.setTaskState(arb.ID, task.StateReady)
+ payload, _ := json.Marshal(struct {
+ Approved bool `json:"approved"`
+ Reasoning string `json:"reasoning"`
+ }{Approved: true, Reasoning: "meets 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)
+ }
+
+ orch.Tick(context.Background()) // auto-accepts arbitration, finalizes
+
+ gotNested, err := store.GetTask(nested.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gotNested.State != task.StateCompleted {
+ t.Errorf("nested node State = %v, want COMPLETED", gotNested.State)
+ }
+
+ gotRoot, err := store.GetTask(root.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gotRoot.State != task.StateBlocked {
+ t.Errorf("root State = %v, want unchanged BLOCKED (this orchestrator never unblocks a parent -- that's executor.Pool.maybeUnblockParent's job)", gotRoot.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", gotStory.Status)
+ }
+}
+
+// TestStoryOrchestrator_TreeWalk_ProcessesSiblingNestedBuilderNodesInSameTick
+// proves the tree walk drives every qualifying READY builder node it finds
+// within a single Tick call, not just one node per tick -- necessary once a
+// root decomposes into multiple sibling subtasks that reach READY together.
+func TestStoryOrchestrator_TreeWalk_ProcessesSiblingNestedBuilderNodesInSameTick(t *testing.T) {
+ store := newFakeStoryStore()
+ root := builderTask("sib-root", task.StateBlocked)
+ store.tasks[root.ID] = root
+ st := newStoryWithRoot("sib-story", root.ID, "IN_PROGRESS")
+ store.stories[st.ID] = st
+
+ child1 := builderTask("sib-child-1", task.StateReady)
+ child1.ParentTaskID = root.ID
+ store.tasks[child1.ID] = child1
+
+ child2 := builderTask("sib-child-2", task.StateReady)
+ child2.ParentTaskID = root.ID
+ store.tasks[child2.ID] = child2
+
+ pool := &fakePool{}
+ orch := &StoryOrchestrator{Store: store, Pool: pool}
+ orch.Tick(context.Background())
+
+ deps1, _ := store.ListDependents(child1.ID)
+ if len(deps1) != 4 {
+ t.Errorf("expected 4 evaluator tasks for child1, got %d", len(deps1))
+ }
+ deps2, _ := store.ListDependents(child2.ID)
+ if len(deps2) != 4 {
+ t.Errorf("expected 4 evaluator tasks for child2, got %d", len(deps2))
+ }
+ if pool.submitCount() != 8 {
+ t.Fatalf("expected 8 pool submissions (4 evaluators x 2 siblings) in the same tick, got %d", pool.submitCount())
+ }
+}
+```
+
+- [ ] **Step 4: Commit locally NOW (before further verification)**
+
+Run these exact commands, in order:
+
+```bash
+git add -A
+git commit -m "refactor(scheduler): restructure processStory into a tree walk driving every READY builder-role node through arbitrated review"
+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 3 new ones above. Every pre-existing test must ALSO pass completely unchanged, in particular: `TestStoryOrchestrator_SpawnsEvaluators_WhenBuilderReady` (confirms the ROOT's own `VALIDATING` transition still fires — `root.ParentTaskID == ""` still satisfies the new `isRoot` gate), `TestStoryOrchestrator_DoesNotDuplicateEvaluators`, `TestStoryOrchestrator_SpawnsArbitration_WhenAllEvaluatorsComplete`, `TestStoryOrchestrator_DoesNotSpawnArbitration_UntilAllEvaluatorsComplete`, `TestStoryOrchestrator_EmitsEvalVerdict_OncePerEvaluator`, all 5 of piece 4b-2's `TestStoryOrchestrator_FinalizeArbitration_*` tests, `TestStoryOrchestrator_ArbitrationCompletes_EmitsDecisionAndReviewReady`, `TestStoryOrchestrator_ArbitrationRejects_SetsStoryNeedsFix`, `TestStoryOrchestrator_ArbitrationApproves_SetsReviewReady`, `TestStoryOrchestrator_ArbitrationRejects_LeavesRootReadyNotCompleted`, the 3 fix-loop tests, `TestStoryOrchestrator_DoesNothing_WhenBuilderNotComplete` (root RUNNING, no subtasks — confirms the tree walk correctly does nothing when nothing qualifies), `TestStoryOrchestrator_ReadyBuilder_SpawnsEvaluators_StaysReadyUntilApproved`, `TestStoryOrchestrator_AutoAccept_DoesNotTouchUnrelatedReadyTask`, `TestStoryOrchestrator_SkipsTerminalStories`, `TestStoryOrchestrator_EndToEnd`, and the full retro test cluster. 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.