1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
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.
|