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
|
# Story Fix-and-Re-Evaluate Loop 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:** Close the recursive-story-decomposition design spec's "fix-and-re-evaluate loop has to become real" gap. Today, when `finalizeArbitration` sets a story to `NEEDS_FIX`, nothing further happens automatically — a human must notice and manually intervene. This plan makes `StoryOrchestrator` automatically spawn a new builder-role fix attempt, re-point the story at it, and let the existing Builder → Evaluators → Arbitration chain run again from scratch against the new attempt — with no new pipeline, just the existing one re-entered against a new root.
**Architecture:** A story at status `NEEDS_FIX` is handled by a new `ensureFixAttempt` stage, checked first thing in `processStory` before the existing Builder/Evaluators/Arbitration flow. It spawns one new top-level `builder`-role task (`DependsOn: [oldRootID]`, purely for structural discoverability/audit trail — the rejected root is already `COMPLETED` so this dependency is immediately satisfied), whose instructions carry the original story spec/acceptance criteria plus the arbitration's structured rejection reasoning (read via `AgentChannel.ReportVerdict`'s `KindVerdictReported` event, the same mechanism `finalizeArbitration` already reads). It then re-points `story.RootTaskID` at the new task and resets `story.Status` to `IN_PROGRESS`. On the *next* tick, `processStory` finds the new root via the identical unmodified path every other root already takes — `ensureEvaluators`/`ensureArbitration`/`finalizeArbitration` all reused completely unchanged. Idempotency is structural (mirrors every other stage in this file): `ensureFixAttempt` looks for an existing `builder`-role dependent of the rejected root before spawning a new one, safe to call on every tick and safe across a process restart. A hardcoded `maxFixAttempts = 3` safety cap (a plain, deterministic counter — not real cost/escalation tiering, which the design spec's Non-Goals explicitly deferred) stops automatic spawning once a chain of that many consecutive fix attempts has been tried, leaving the story at `NEEDS_FIX` for a human exactly as it behaves today.
**Tech Stack:** Go, existing `internal/scheduler`/`internal/story`/`internal/task`/`internal/event` packages — no new packages, no schema changes (`story.Status` already lists `IN_PROGRESS` as a valid value; `story.RootTaskID` is already an unvalidated plain string field).
## Global Constraints
- The mechanism to re-point a story at a new attempt is **re-pointing `story.RootTaskID`** (not spawning the fix task as a dependent of the original root while leaving `RootTaskID` fixed forever) — this was an explicit design decision, made after comparing both approaches: re-pointing keeps every existing root-task-id-keyed structural idempotency check (`ensureEvaluators`, `ensureArbitration`) working unmodified against the new attempt, with no "find the latest attempt in the chain" logic needed anywhere.
- `maxFixAttempts = 3` is a **hardcoded safety net, not a designed cost/escalation policy** — the design spec's Non-Goals section explicitly deferred real depth/cost bounds to future work tied to cheap-model tiering. This plan's cap exists only to prevent a literal unbounded spend loop; it is not meant to be a tuned value.
- Do not touch `internal/api` (chatbot/REST MCP surface), the `builder` role's system prompt, or anything about per-node arbitrated review at depths below a story's root. Those remain separately deferred pieces of the recursive-story-decomposition design.
---
## Task 1: Automatic fix-attempt spawning on `NEEDS_FIX`
**Files:**
- Modify: `internal/scheduler/story_orchestrator.go` (`processStory`, `finalizeArbitration`, `arbitrationVerdict`; new `ensureFixAttempt`, `rejectionReasoning`, `fixAttemptDepth`, `maxFixAttempts`)
- Test: `internal/scheduler/story_orchestrator_test.go` (append)
**Interfaces:**
- Consumes: `event.KindVerdictReported` (existing), `StoryStore.ListEvents`/`ListDependents`/`CreateTask`/`UpdateTaskState`/`UpdateStory` (all existing, already used elsewhere in this file), `spawnRoleTask`/`findEvaluators`/`findArbitration` (existing helpers in this same file).
- Produces: nothing new for later work — this closes the last "documented simplification" this plan set out to close.
- [ ] **Step 1: Write the failing tests**
Append to `internal/scheduler/story_orchestrator_test.go`, directly after `TestStoryOrchestrator_ArbitrationApproves_SetsReviewReady` (the last test before `TestStoryOrchestrator_DoesNothing_WhenBuilderNotComplete`):
```go
// seedNeedsFixStory drives a story through the full Builder -> Evaluators ->
// Arbitration chain to a rejected verdict (mirrors
// TestStoryOrchestrator_ArbitrationRejects_SetsStoryNeedsFix's own setup),
// leaving it at status NEEDS_FIX with its original (rejected) root task
// still in the store -- the starting point ensureFixAttempt operates on.
// Returns the store, the story (as currently persisted), the rejected root
// task, the evaluators, and the arbitration task.
func seedNeedsFixStory(t *testing.T) (*fakeStoryStore, *story.Story, *task.Task, []*task.Task, *task.Task) {
t.Helper()
store, st, evaluators := seedStoryWithEvaluators(t, task.StateCompleted)
oldRoot, err := store.GetTask(st.RootTaskID)
if err != nil {
t.Fatalf("get root: %v", err)
}
pool := &fakePool{}
orch := &StoryOrchestrator{Store: store, Pool: pool}
orch.Tick(context.Background()) // spawns arbitration
arbitrations := store.dependentsWithRole(evaluators[0].ID, "planner")
if len(arbitrations) != 1 {
t.Fatalf("expected 1 arbitration task, got %d", len(arbitrations))
}
arb := arbitrations[0]
payload, _ := json.Marshal(struct {
Approved bool `json:"approved"`
Reasoning string `json:"reasoning"`
}{Approved: false, Reasoning: "breaks the running-log panel styling"})
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)
}
store.setTaskState(arb.ID, task.StateCompleted)
store.setTaskSummary(arb.ID, "found a real problem")
orch.Tick(context.Background()) // finalizeArbitration runs, sets NEEDS_FIX
stories, _ := store.ListStories(storage.StoryFilter{})
var got *story.Story
for _, s := range stories {
if s.ID == st.ID {
got = s
}
}
if got == nil || got.Status != "NEEDS_FIX" {
t.Fatalf("setup failed: story = %+v, want status NEEDS_FIX", got)
}
return store, got, oldRoot, evaluators, arb
}
// TestStoryOrchestrator_NeedsFix_SpawnsFixAttemptAndRepointsRoot proves the
// core mechanism: a story at NEEDS_FIX gets a new builder-role fix-attempt
// task spawned (depending on the rejected root), and the story is re-pointed
// at it (RootTaskID updated, Status reset to IN_PROGRESS) so the very next
// tick re-enters the normal Builder->Evaluators->Arbitration flow against
// the new attempt.
func TestStoryOrchestrator_NeedsFix_SpawnsFixAttemptAndRepointsRoot(t *testing.T) {
store, st, oldRoot, _, _ := seedNeedsFixStory(t)
pool := &fakePool{}
orch := &StoryOrchestrator{Store: store, Pool: pool}
orch.Tick(context.Background())
fixAttempts := store.dependentsWithRole(oldRoot.ID, "builder")
if len(fixAttempts) != 1 {
t.Fatalf("expected exactly 1 fix-attempt task depending on the rejected root, got %d", len(fixAttempts))
}
fix := fixAttempts[0]
if fix.State != task.StateQueued {
t.Errorf("fix attempt State = %v, want QUEUED", fix.State)
}
if fix.ParentTaskID != "" {
t.Errorf("fix attempt ParentTaskID = %q, want empty (top-level, DAG sibling not delegated subtask)", fix.ParentTaskID)
}
stories, _ := store.ListStories(storage.StoryFilter{})
var got *story.Story
for _, s := range stories {
if s.ID == st.ID {
got = s
}
}
if got == nil {
t.Fatal("story not found")
}
if got.RootTaskID != fix.ID {
t.Errorf("story RootTaskID = %q, want the new fix-attempt task %q", got.RootTaskID, fix.ID)
}
if got.Status != "IN_PROGRESS" {
t.Errorf("story Status = %q, want IN_PROGRESS", got.Status)
}
}
// TestStoryOrchestrator_NeedsFix_FixAttemptInstructionsIncludeRejectionReasoning
// proves the fix attempt's instructions carry the arbitration's structured
// rejection reasoning (read via the KindVerdictReported event), not just a
// generic "try again" -- the whole point of automating this loop is that the
// next attempt has something concrete to act on.
func TestStoryOrchestrator_NeedsFix_FixAttemptInstructionsIncludeRejectionReasoning(t *testing.T) {
store, _, oldRoot, _, _ := seedNeedsFixStory(t)
pool := &fakePool{}
orch := &StoryOrchestrator{Store: store, Pool: pool}
orch.Tick(context.Background())
fixAttempts := store.dependentsWithRole(oldRoot.ID, "builder")
if len(fixAttempts) != 1 {
t.Fatalf("expected exactly 1 fix-attempt task, got %d", len(fixAttempts))
}
instructions := fixAttempts[0].Agent.Instructions
if !strings.Contains(instructions, "breaks the running-log panel styling") {
t.Errorf("fix-attempt instructions do not include the arbitration's rejection reasoning:\n%s", instructions)
}
if !strings.Contains(instructions, "Test Story") {
t.Errorf("fix-attempt instructions do not include the story name:\n%s", instructions)
}
}
// TestStoryOrchestrator_NeedsFix_IdempotentAcrossTicks proves repeated ticks
// don't spawn more than one fix-attempt task -- after the first tick the
// story's status flips to IN_PROGRESS, so the NEEDS_FIX branch simply isn't
// re-entered on subsequent ticks (the normal Builder-not-complete-yet path
// takes over instead, since the fix attempt is freshly QUEUED).
func TestStoryOrchestrator_NeedsFix_IdempotentAcrossTicks(t *testing.T) {
store, _, oldRoot, _, _ := seedNeedsFixStory(t)
pool := &fakePool{}
orch := &StoryOrchestrator{Store: store, Pool: pool}
orch.Tick(context.Background())
orch.Tick(context.Background())
orch.Tick(context.Background())
fixAttempts := store.dependentsWithRole(oldRoot.ID, "builder")
if len(fixAttempts) != 1 {
t.Fatalf("expected exactly 1 fix-attempt task after repeated ticks, got %d", len(fixAttempts))
}
}
// TestStoryOrchestrator_EnsureFixAttempt_FindsExistingSpawnedAttempt proves
// ensureFixAttempt's own structural idempotency: simulating a restart
// between "fix task spawned" and "story RootTaskID/Status updated to point
// at it" -- calling ensureFixAttempt again must find the already-spawned
// builder-role dependent rather than spawning a second one, and still
// complete the re-pointing.
func TestStoryOrchestrator_EnsureFixAttempt_FindsExistingSpawnedAttempt(t *testing.T) {
store, st, oldRoot, _, _ := seedNeedsFixStory(t)
pool := &fakePool{}
orch := &StoryOrchestrator{Store: store, Pool: pool}
preexisting, err := orch.spawnRoleTask(context.Background(), "Fix attempt: pre-existing", "builder", []string{oldRoot.ID}, oldRoot, "fix it")
if err != nil {
t.Fatalf("seed pre-existing fix attempt: %v", err)
}
orch.ensureFixAttempt(context.Background(), st)
fixAttempts := store.dependentsWithRole(oldRoot.ID, "builder")
if len(fixAttempts) != 1 {
t.Fatalf("expected exactly 1 fix-attempt task (the pre-existing one, not a new one), got %d", len(fixAttempts))
}
if fixAttempts[0].ID != preexisting.ID {
t.Errorf("expected ensureFixAttempt to reuse the pre-existing task %s, got a different one %s", preexisting.ID, fixAttempts[0].ID)
}
if st.RootTaskID != preexisting.ID {
t.Errorf("story RootTaskID = %q, want the pre-existing fix attempt %q", st.RootTaskID, preexisting.ID)
}
if st.Status != "IN_PROGRESS" {
t.Errorf("story Status = %q, want IN_PROGRESS", st.Status)
}
}
// TestStoryOrchestrator_NeedsFix_CapsAtMaxFixAttempts proves the safety net:
// once a chain of maxFixAttempts consecutive fix attempts already exists,
// ensureFixAttempt stops spawning new ones and leaves the story at
// NEEDS_FIX, exactly like today's fully-manual behavior.
func TestStoryOrchestrator_NeedsFix_CapsAtMaxFixAttempts(t *testing.T) {
store, st, oldRoot, _, _ := seedNeedsFixStory(t)
pool := &fakePool{}
orch := &StoryOrchestrator{Store: store, Pool: pool}
// Build a chain of maxFixAttempts prior fix-attempt tasks, each
// depending on the one before it, ending at oldRoot -- simulating a
// story that has already exhausted its automatic fix attempts.
prev := oldRoot
for i := 0; i < maxFixAttempts; i++ {
nt, err := orch.spawnRoleTask(context.Background(), fmt.Sprintf("Fix attempt %d", i), "builder", []string{prev.ID}, oldRoot, "fix it")
if err != nil {
t.Fatalf("seed fix-attempt chain: %v", err)
}
prev = nt
}
st.RootTaskID = prev.ID
if err := store.UpdateStory(st); err != nil {
t.Fatalf("persist chained root: %v", err)
}
orch.Tick(context.Background())
fixAttempts := store.dependentsWithRole(prev.ID, "builder")
if len(fixAttempts) != 0 {
t.Fatalf("expected no new fix-attempt task once maxFixAttempts is reached, got %d", len(fixAttempts))
}
stories, _ := store.ListStories(storage.StoryFilter{})
var got *story.Story
for _, s := range stories {
if s.ID == st.ID {
got = s
}
}
if got.Status != "NEEDS_FIX" {
t.Errorf("story Status = %q, want NEEDS_FIX (cap reached, no further automation)", got.Status)
}
}
```
`internal/scheduler/story_orchestrator_test.go` already imports `"strings"` (used elsewhere in this file) — no import changes are needed for this task.
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./internal/scheduler/ -run 'TestStoryOrchestrator_(NeedsFix|EnsureFixAttempt)' -v`
Expected: compile failure — `ensureFixAttempt`, `fixAttemptDepth`, and `maxFixAttempts` don't exist yet. Paste the actual output.
- [ ] **Step 3: Extend `arbitrationVerdict` to also return the rejection reasoning**
In `internal/scheduler/story_orchestrator.go`, find `arbitrationVerdict`:
```go
// arbitrationVerdict reads the arbitration task's own event stream for a
// KindVerdictReported event (AgentChannel.ReportVerdict) and returns its
// approved value. hasVerdict is false if no such event was ever recorded —
// callers must treat that as "no structured verdict was reported", not as
// an implicit rejection.
func (o *StoryOrchestrator) arbitrationVerdict(st *story.Story, arbitration *task.Task) (approved bool, hasVerdict bool) {
events, err := o.Store.ListEvents(arbitration.ID, 0)
if err != nil {
o.logf("story orchestrator: list arbitration task events", "storyID", st.ID, "taskID", arbitration.ID, "error", err)
return false, false
}
for _, e := range events {
if e.Kind != event.KindVerdictReported {
continue
}
var payload struct {
Approved bool `json:"approved"`
}
if json.Unmarshal(e.Payload, &payload) == nil {
approved = payload.Approved
hasVerdict = true
}
}
return approved, hasVerdict
}
```
Replace it with:
```go
// arbitrationVerdict reads the arbitration task's own event stream for a
// KindVerdictReported event (AgentChannel.ReportVerdict) and returns its
// approved value and reasoning text. hasVerdict is false if no such event was
// ever recorded — callers must treat that as "no structured verdict was
// reported", not as an implicit rejection. reasoning feeds ensureFixAttempt's
// automatic fix-attempt instructions; finalizeArbitration itself only needs
// approved/hasVerdict and discards reasoning.
func (o *StoryOrchestrator) arbitrationVerdict(st *story.Story, arbitration *task.Task) (approved bool, reasoning string, hasVerdict bool) {
events, err := o.Store.ListEvents(arbitration.ID, 0)
if err != nil {
o.logf("story orchestrator: list arbitration task events", "storyID", st.ID, "taskID", arbitration.ID, "error", err)
return false, "", false
}
for _, e := range events {
if e.Kind != event.KindVerdictReported {
continue
}
var payload struct {
Approved bool `json:"approved"`
Reasoning string `json:"reasoning"`
}
if json.Unmarshal(e.Payload, &payload) == nil {
approved = payload.Approved
reasoning = payload.Reasoning
hasVerdict = true
}
}
return approved, reasoning, hasVerdict
}
```
- [ ] **Step 4: Update `finalizeArbitration`'s call site**
In the same file, find this line inside `finalizeArbitration`:
```go
approved, hasVerdict := o.arbitrationVerdict(st, arbitration)
```
Replace it with:
```go
approved, _, hasVerdict := o.arbitrationVerdict(st, arbitration)
```
- [ ] **Step 5: Add the `NEEDS_FIX` branch to `processStory`**
In the same file, find the start of `processStory`:
```go
func (o *StoryOrchestrator) processStory(ctx context.Context, st *story.Story) {
root, err := o.Store.GetTask(st.RootTaskID)
```
Replace it with:
```go
func (o *StoryOrchestrator) processStory(ctx context.Context, st *story.Story) {
if st.Status == "NEEDS_FIX" {
o.ensureFixAttempt(ctx, st)
return
}
root, err := o.Store.GetTask(st.RootTaskID)
```
- [ ] **Step 6: Add `maxFixAttempts`, `ensureFixAttempt`, `rejectionReasoning`, and `fixAttemptDepth`**
In the same file, find the `optionalBool` function (it ends right before `processRetro`'s doc comment):
```go
// optionalBool returns a pointer to v if present is true, nil otherwise —
// used so KindArbitrationDecided's payload omits "approved" entirely
// (rather than encoding a misleading false) when no structured verdict was
// ever reported.
func optionalBool(v bool, present bool) *bool {
if !present {
return nil
}
return &v
}
```
Directly after it (still before `processRetro`'s doc comment), add:
```go
// maxFixAttempts caps automatic fix-attempt spawning (see ensureFixAttempt)
// to prevent an unbounded loop if arbitration keeps rejecting a story's
// work. This is a simple, hardcoded safety net, not real cost/escalation
// tiering — the design spec's Non-Goals explicitly deferred "no depth or
// cost bound in this design... left as a distinct future piece of work".
// Once hit, the story stays at NEEDS_FIX exactly like it does today, for a
// human to intervene manually via PUT /api/stories/{id}.
const maxFixAttempts = 3
// ensureFixAttempt handles a story sitting at NEEDS_FIX: it spawns one new
// top-level builder-role task depending on the rejected root (purely for
// structural discoverability/audit trail — the rejected root is already
// COMPLETED, so this dependency is immediately satisfied), whose
// instructions carry the original story spec/acceptance criteria plus the
// arbitration's rejection reasoning, then re-points st.RootTaskID at the new
// task and resets st.Status to IN_PROGRESS. The very next tick re-enters
// processStory's normal Builder->Evaluators->Arbitration flow against the
// new root, completely unchanged — no new pipeline, the existing one
// re-entered.
//
// Idempotency is structural, mirroring every other stage in this file: it
// looks for an existing builder-role dependent of the rejected root before
// spawning a new one, so calling this repeatedly (or after a restart between
// "task spawned" and "story updated") never spawns duplicates. The
// maxFixAttempts cap is only checked in the "spawn a new one" branch — a
// dependent that was already committed to being spawned still gets
// re-pointed to, regardless of the cap, since refusing to do so would leave
// a task dangling with nothing tracking it.
func (o *StoryOrchestrator) ensureFixAttempt(ctx context.Context, st *story.Story) {
oldRoot, err := o.Store.GetTask(st.RootTaskID)
if err != nil {
o.logf("story orchestrator: fix attempt: get rejected root task", "storyID", st.ID, "rootTaskID", st.RootTaskID, "error", err)
return
}
dependents, err := o.Store.ListDependents(oldRoot.ID)
if err != nil {
o.logf("story orchestrator: fix attempt: list root dependents", "storyID", st.ID, "error", err)
return
}
var fix *task.Task
for _, d := range dependents {
if d.Agent.Role == "builder" {
fix = d
break
}
}
if fix == nil {
if depth := o.fixAttemptDepth(oldRoot); depth >= maxFixAttempts {
o.logf("story orchestrator: fix attempt: max fix attempts reached, leaving story at NEEDS_FIX for a human", "storyID", st.ID, "depth", depth)
return
}
reasoning := o.rejectionReasoning(st, oldRoot)
instructions := fmt.Sprintf(
"A previous attempt at story %q (task %s) was rejected by arbitration. Fix the issues and resubmit.\n\n"+
"Story spec:\n%s\n\nAcceptance criteria:\n%s\n\nArbitration's rejection reasoning:\n%s",
st.Name, oldRoot.ID, st.Spec, formatAcceptanceCriteria(st.AcceptanceCriteria), reasoning)
nt, err := o.spawnRoleTask(ctx, "Fix attempt: "+st.Name, "builder", []string{oldRoot.ID}, oldRoot, instructions)
if err != nil {
o.logf("story orchestrator: fix attempt: spawn", "storyID", st.ID, "error", err)
return
}
fix = nt
}
st.RootTaskID = fix.ID
st.Status = "IN_PROGRESS"
if err := o.Store.UpdateStory(st); err != nil {
o.logf("story orchestrator: fix attempt: repoint story root", "storyID", st.ID, "error", err)
}
}
// rejectionReasoning finds the rejected root's evaluator/arbitration chain
// (read-only, via findEvaluators/findArbitration — the same lookups
// processRetro uses) and returns the arbitration task's structured
// KindVerdictReported reasoning, falling back to its plain-text Summary if no
// structured verdict was reported, or a placeholder if neither is available.
func (o *StoryOrchestrator) rejectionReasoning(st *story.Story, oldRoot *task.Task) string {
evaluators, ok := o.findEvaluators(oldRoot.ID)
if !ok {
return "(no arbitration reasoning found)"
}
arbitration, ok := o.findArbitration(evaluators)
if !ok {
return "(no arbitration reasoning found)"
}
_, reasoning, hasVerdict := o.arbitrationVerdict(st, arbitration)
if hasVerdict && reasoning != "" {
return reasoning
}
if arbitration.Summary != "" {
return arbitration.Summary
}
return "(no arbitration reasoning found)"
}
// fixAttemptDepth walks backward through builder-role tasks connected by a
// single DependsOn edge — the exact shape ensureFixAttempt creates — counting
// how many fix attempts precede root. Stops as soon as it finds a task with
// anything other than exactly one DependsOn entry (the original, non-fix-
// attempt root, which was created by whatever process first submitted the
// story). Bounded by maxFixAttempts+1 iterations since callers only care
// whether the cap is hit, not the exact depth beyond that.
func (o *StoryOrchestrator) fixAttemptDepth(root *task.Task) int {
depth := 0
current := root
for depth <= maxFixAttempts {
if len(current.DependsOn) != 1 {
break
}
prev, err := o.Store.GetTask(current.DependsOn[0])
if err != nil {
break
}
depth++
current = prev
}
return depth
}
```
- [ ] **Step 7: Run tests to verify they pass**
Run: `go test ./internal/scheduler/ -run 'TestStoryOrchestrator_(NeedsFix|EnsureFixAttempt|Arbitration)' -v`
Expected: PASS, all of them — the new tests, plus the pre-existing `TestStoryOrchestrator_ArbitrationRejects_SetsStoryNeedsFix`, `TestStoryOrchestrator_ArbitrationApproves_SetsReviewReady`, and `TestStoryOrchestrator_ArbitrationCompletes_EmitsDecisionAndReviewReady` (unmodified, must still pass — they only exercise `arbitrationVerdict` indirectly through `finalizeArbitration`, which still compiles and behaves the same after Step 4's one-line signature-adjustment). Paste the actual output.
- [ ] **Step 8: Run the full package suite, then the full repo suite**
Run: `go test ./internal/scheduler/...`
Expected: PASS for everything.
Then run: `go test ./...` (the entire repo). This must also pass — a prior plan in this repo found that task-scoped tests passing is not sufficient on its own; grep the repo for any other caller of `arbitrationVerdict` before assuming none exists (`grep -rn "arbitrationVerdict" internal/`) — it should only be called from within `story_orchestrator.go` itself (by `finalizeArbitration` and the new `rejectionReasoning`), so Step 4's signature change should not require touching any other file. Paste the actual full-repo test output.
Note: if you see a transient one-off failure in `internal/api` under the race detector mentioning "sql: database is closed" during an unrelated webhook/websocket test's teardown, that is a known pre-existing flake unrelated to this change — simply rerun `go test ./...` once to confirm it doesn't reproduce, and proceed if it passes clean on rerun. If it fails a second time in a way connected to your actual changes, stop and call ask_user.
- [ ] **Step 9: Commit and push to `main` directly, matching this repo's existing workflow**
```bash
git add internal/scheduler/story_orchestrator.go internal/scheduler/story_orchestrator_test.go
git commit -m "feat(scheduler): automatically spawn and re-point a fix attempt when a story is rejected"
```
## Mandatory verification disclosure
When you call report_summary, paste the actual terminal output of every test command above — literal pass/fail counts, not a claim of success, including the full-repo `go test ./...` run. 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.
---
## Final Verification
- [ ] Run `go build ./...` — passes.
- [ ] Run `go test ./...` — passes, full repo.
- [ ] Run `grep -rn "ensureFixAttempt\|fixAttemptDepth\|rejectionReasoning\|maxFixAttempts" internal/` to visually confirm the File Map was actually touched (should all be in `internal/scheduler/story_orchestrator.go` and its test file only).
|