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
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
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.
|