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
|
# Current Attempt Resolution 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:** Build the `CurrentAttempt` resolution primitive `docs/superpowers/specs/2026-07-09-recursive-arbitrated-review-design.md` calls for, and wire `internal/executor.Pool.maybeUnblockParent` to resolve each of a roll-up's subtasks through it before checking completion. This is piece 2b of that design's implementation order.
**Architecture:** Every position in a task tree is identified by an anchor task ID, set once. "The task currently representing that position" is *resolved*, not stored — `CurrentAttempt` walks forward through a chain of fix-attempt `DependsOn` edges (a fix-attempt is structurally: a task sharing the same `ParentTaskID` as the task it supersedes, whose sole `DependsOn` entry is that task's own ID). This is the exact shape `internal/scheduler.StoryOrchestrator.ensureFixAttempt` already creates for a story's root today (`ParentTaskID == ""`), and the exact shape any future subtask-level fix-attempt spawning will create too (`ParentTaskID` set to the rejected subtask's own parent). One function, one rule, works identically at any depth.
`CurrentAttempt` lives in `internal/task` — the lowest-level package both `internal/executor` and `internal/scheduler` already import — behind a minimal `TaskLookup` interface (`GetTask`, `ListDependents`), which both `executor.Store` and `scheduler.StoryStore` already satisfy structurally. This avoids duplicating the walk logic once for each caller; `internal/executor.Pool.maybeUnblockParent` becomes the first real caller in this plan, and piece 3 (migrating the story-level fix loop off `RootTaskID` re-pointing) will become the second, later.
**Tech Stack:** Go. No schema/storage changes.
## Global Constraints
- **This plan does not change *when* arbitrated review runs, or make `maybeUnblockParent` aware of pending/rejected verdicts.** Today, nothing spawns a subtask-level fix-attempt yet (that's piece 4, generalizing `ensureEvaluators`/`ensureArbitration` to recurse into subtask trees — deliberately not this plan). This plan only makes the *counting/completion-gate* logic correct for whenever such a fix-attempt chain *does* exist: it resolves each subtask through `CurrentAttempt` so a superseded original doesn't get counted as "the" position's final state, and so the gate doesn't wait on a stale task forever. Whether a roll-up should be allowed to unblock *before* arbitration has even had a chance to reject one of its children is a separate, open question that belongs to piece 4, which is what will actually introduce that race — this plan does not attempt to resolve it.
- **Do not touch `ensureFixAttempt`, `story.RootTaskID`, or anything in `internal/scheduler`.** Migrating the story-level fix loop onto `CurrentAttempt` is piece 3, sequenced after this one so `CurrentAttempt` has one proven, isolated caller (`maybeUnblockParent`) before a second one is layered on.
- **Do not touch `internal/api` or `handleRunResult`/`RecoverStaleBlocked`.** This plan only touches `maybeUnblockParent`'s subtask-counting loop, plus the new `internal/task/currentattempt.go`.
---
## Task 1: `CurrentAttempt` resolution + `maybeUnblockParent` generalization
**Files:**
- Create: `internal/task/currentattempt.go`
- Test: `internal/task/currentattempt_test.go`
- Modify: `internal/executor/executor.go` (`maybeUnblockParent`'s subtask-completion loop)
- Test: `internal/executor/executor_test.go` (append)
**Interfaces:**
- Produces: `task.TaskLookup` interface, `task.CurrentAttempt(store TaskLookup, anchorID string) (*Task, error)`. Piece 3 (story-level fix-loop migration) will call this directly with `StoryOrchestrator.Store`, exactly as this plan calls it with `executor.Pool`'s own `store` field.
- [ ] **Step 1: Write the failing `CurrentAttempt` tests**
Create `internal/task/currentattempt_test.go`:
```go
package task
import (
"errors"
"testing"
)
type fakeLookup struct {
tasks map[string]*Task
}
func newFakeLookup() *fakeLookup {
return &fakeLookup{tasks: make(map[string]*Task)}
}
func (f *fakeLookup) GetTask(id string) (*Task, error) {
t, ok := f.tasks[id]
if !ok {
return nil, errors.New("not found")
}
return t, nil
}
func (f *fakeLookup) ListDependents(taskID string) ([]*Task, error) {
var out []*Task
for _, t := range f.tasks {
for _, d := range t.DependsOn {
if d == taskID {
out = append(out, t)
break
}
}
}
return out, nil
}
func TestCurrentAttempt_NoFixAttempt_ReturnsAnchorItself(t *testing.T) {
lookup := newFakeLookup()
lookup.tasks["a"] = &Task{ID: "a"}
got, err := CurrentAttempt(lookup, "a")
if err != nil {
t.Fatalf("CurrentAttempt: %v", err)
}
if got.ID != "a" {
t.Errorf("got %q, want a", got.ID)
}
}
func TestCurrentAttempt_OneFixAttempt_ResolvesToIt(t *testing.T) {
lookup := newFakeLookup()
lookup.tasks["a"] = &Task{ID: "a", ParentTaskID: "parent-1"}
lookup.tasks["a-fix1"] = &Task{ID: "a-fix1", ParentTaskID: "parent-1", DependsOn: []string{"a"}}
got, err := CurrentAttempt(lookup, "a")
if err != nil {
t.Fatalf("CurrentAttempt: %v", err)
}
if got.ID != "a-fix1" {
t.Errorf("got %q, want a-fix1", got.ID)
}
}
func TestCurrentAttempt_ChainOfFixAttempts_ResolvesToTip(t *testing.T) {
lookup := newFakeLookup()
lookup.tasks["a"] = &Task{ID: "a", ParentTaskID: "parent-1"}
lookup.tasks["a-fix1"] = &Task{ID: "a-fix1", ParentTaskID: "parent-1", DependsOn: []string{"a"}}
lookup.tasks["a-fix2"] = &Task{ID: "a-fix2", ParentTaskID: "parent-1", DependsOn: []string{"a-fix1"}}
got, err := CurrentAttempt(lookup, "a")
if err != nil {
t.Fatalf("CurrentAttempt: %v", err)
}
if got.ID != "a-fix2" {
t.Errorf("got %q, want a-fix2 (tip of the chain)", got.ID)
}
// Resolving from an intermediate link in the chain also lands on the tip.
got, err = CurrentAttempt(lookup, "a-fix1")
if err != nil {
t.Fatalf("CurrentAttempt: %v", err)
}
if got.ID != "a-fix2" {
t.Errorf("resolving from a-fix1: got %q, want a-fix2", got.ID)
}
}
// TestCurrentAttempt_DependentWithDifferentParent_NotTreatedAsFixAttempt
// proves a task that depends on "a" but has a DIFFERENT ParentTaskID (e.g.
// an unrelated Evaluator task depending on a Builder for scheduling reasons)
// must not be mistaken for a's fix attempt.
func TestCurrentAttempt_DependentWithDifferentParent_NotTreatedAsFixAttempt(t *testing.T) {
lookup := newFakeLookup()
lookup.tasks["a"] = &Task{ID: "a", ParentTaskID: "parent-1"}
lookup.tasks["unrelated"] = &Task{ID: "unrelated", ParentTaskID: "", DependsOn: []string{"a"}}
got, err := CurrentAttempt(lookup, "a")
if err != nil {
t.Fatalf("CurrentAttempt: %v", err)
}
if got.ID != "a" {
t.Errorf("got %q, want a (unrelated dependent must not be treated as a fix attempt)", got.ID)
}
}
// TestCurrentAttempt_DependentWithMultipleDependsOn_NotTreatedAsFixAttempt
// proves a fix attempt has exactly one DependsOn entry (the task it
// supersedes); a task depending on "a" among several dependencies (e.g. an
// Arbitration task depending on all 4 Evaluators) must not be mistaken for
// a's fix attempt.
func TestCurrentAttempt_DependentWithMultipleDependsOn_NotTreatedAsFixAttempt(t *testing.T) {
lookup := newFakeLookup()
lookup.tasks["a"] = &Task{ID: "a", ParentTaskID: "parent-1"}
lookup.tasks["multi-dep"] = &Task{ID: "multi-dep", ParentTaskID: "parent-1", DependsOn: []string{"a", "b"}}
got, err := CurrentAttempt(lookup, "a")
if err != nil {
t.Fatalf("CurrentAttempt: %v", err)
}
if got.ID != "a" {
t.Errorf("got %q, want a", got.ID)
}
}
func TestCurrentAttempt_UnknownAnchor_ReturnsError(t *testing.T) {
lookup := newFakeLookup()
if _, err := CurrentAttempt(lookup, "does-not-exist"); err == nil {
t.Error("expected an error for an unknown anchor ID")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./internal/task/ -run TestCurrentAttempt -v`
Expected: compile failure — `CurrentAttempt`/`TaskLookup` don't exist yet. Paste the actual output.
- [ ] **Step 3: Implement `CurrentAttempt`**
Create `internal/task/currentattempt.go`:
```go
package task
// TaskLookup is the minimal interface CurrentAttempt needs: resolving a
// task by ID and finding tasks that depend on it. Both
// internal/executor.Store and internal/scheduler.StoryStore already satisfy
// this structurally, so CurrentAttempt lives here (in the lowest-level
// package both already import) rather than being duplicated in each.
type TaskLookup interface {
GetTask(id string) (*Task, error)
ListDependents(taskID string) ([]*Task, error)
}
// maxAttemptChainWalk bounds CurrentAttempt's forward walk as a defense
// against a pathological/cyclic DependsOn chain (task creation doesn't
// reject cycles) -- in practice, no caller creates chains anywhere near
// this long; a much smaller, intentional policy limit (e.g.
// internal/scheduler's own maxFixAttempts) is what actually bounds real
// fix-attempt chains in normal operation.
const maxAttemptChainWalk = 100
// CurrentAttempt resolves the task that currently represents the logical
// position anchored at anchorID: anchorID itself, or -- if it was rejected
// and a fix attempt exists -- the fix attempt, walking forward through
// however many rejections have occurred. A fix-attempt task is identified
// structurally: a task with the same ParentTaskID as the task it supersedes,
// whose sole DependsOn entry is that task's own ID -- the exact shape a
// fix-attempt is spawned with, whether at a story's root (ParentTaskID ==
// "") or at an arbitrary subtask position (ParentTaskID set). This is the
// one place "which task is current" is ever decided; callers resolve
// through this before checking a position's own completion/verdict state,
// never operating on a raw anchor ID directly once a fix-attempt might
// exist. See docs/superpowers/specs/2026-07-09-recursive-arbitrated-review-design.md.
func CurrentAttempt(store TaskLookup, anchorID string) (*Task, error) {
current, err := store.GetTask(anchorID)
if err != nil {
return nil, err
}
for i := 0; i < maxAttemptChainWalk; i++ {
dependents, err := store.ListDependents(current.ID)
if err != nil {
return nil, err
}
var next *Task
for _, d := range dependents {
if d.ParentTaskID == current.ParentTaskID && len(d.DependsOn) == 1 && d.DependsOn[0] == current.ID {
next = d
break
}
}
if next == nil {
return current, nil
}
current = next
}
return current, nil
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `go test ./internal/task/ -run TestCurrentAttempt -v`
Expected: PASS, all 6 tests. Paste the actual output.
- [ ] **Step 5: Write the failing `maybeUnblockParent` generalization test**
Append to `internal/executor/executor_test.go`, directly after `TestPool_RecoverStaleBlocked_PromotesNestedSubtaskParent` (the last test added by the previous plan):
```go
// TestPool_MaybeUnblockParent_ResolvesSubtaskThroughCurrentAttempt proves
// the parent's "are all subtasks done" gate resolves each subtask through
// task.CurrentAttempt first -- so a subtask that was superseded by a
// fix-attempt sibling (same ParentTaskID, DependsOn=[original]) correctly
// waits for the FIX ATTEMPT to complete, not just the stale original that
// already reached COMPLETED before being superseded.
func TestPool_MaybeUnblockParent_ResolvesSubtaskThroughCurrentAttempt(t *testing.T) {
store := testStore(t)
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
pool := NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger)
parent := makeTask("resolve-parent")
parent.State = task.StateBlocked
store.CreateTask(parent)
original := makeTask("resolve-original")
original.ParentTaskID = parent.ID
original.State = task.StateCompleted // rejected, but its own execution finished
store.CreateTask(original)
fixAttempt := makeTask("resolve-fix-attempt")
fixAttempt.ParentTaskID = parent.ID
fixAttempt.DependsOn = []string{original.ID}
fixAttempt.State = task.StateRunning // still in progress
store.CreateTask(fixAttempt)
pool.maybeUnblockParent(parent.ID)
got, err := store.GetTask(parent.ID)
if err != nil {
t.Fatalf("get parent: %v", err)
}
if got.State != task.StateBlocked {
t.Errorf("parent state: want still BLOCKED (fix attempt not done yet), got %v", got.State)
}
// Now the fix attempt finishes.
if err := store.UpdateTaskState(fixAttempt.ID, task.StateCompleted); err != nil {
t.Fatalf("complete fix attempt: %v", err)
}
pool.maybeUnblockParent(parent.ID)
got, err = store.GetTask(parent.ID)
if err != nil {
t.Fatalf("get parent: %v", err)
}
if got.State != task.StateReady {
t.Errorf("parent state: want READY (fix attempt now done), got %v", got.State)
}
}
```
- [ ] **Step 6: Run the test to verify it fails**
Run: `go test ./internal/executor/ -run TestPool_MaybeUnblockParent_ResolvesSubtaskThroughCurrentAttempt -v`
Expected: FAILS at the first assertion — today's `maybeUnblockParent` checks `original.State` directly (already `COMPLETED`) and ignores that it's been superseded by the still-running `fixAttempt`, so it incorrectly promotes `parent` to `READY` immediately instead of waiting. Paste the actual output.
- [ ] **Step 7: Generalize `maybeUnblockParent`'s subtask-completion loop**
In `internal/executor/executor.go`, find the loop inside `maybeUnblockParent`:
```go
for _, sub := range subtasks {
if sub.State != task.StateCompleted {
return
}
}
```
Replace it with:
```go
for _, sub := range subtasks {
current, err := task.CurrentAttempt(p.store, sub.ID)
if err != nil {
p.logger.Error("maybeUnblockParent: resolve current attempt", "parentID", parentID, "subtaskID", sub.ID, "error", err)
return
}
if current.State != task.StateCompleted {
return
}
}
```
- [ ] **Step 8: Run tests to verify they pass**
Run: `go test ./internal/executor/ -run 'TestPool_(MaybeUnblockParent|Submit_|RecoverStaleBlocked)' -v`
Expected: PASS, all of them — the new test, plus every pre-existing `TestPool_Submit_*`/`TestPool_RecoverStaleBlocked_*` test from this and the previous plan, unmodified. A subtask with no fix-attempt resolves to itself via `CurrentAttempt` (Step 3's `TestCurrentAttempt_NoFixAttempt_ReturnsAnchorItself` already proves this in isolation), so every existing test's behavior is unchanged. Paste the actual output.
- [ ] **Step 9: Run the full package suite, then the full repo suite**
Run: `go test ./internal/task/... ./internal/executor/...`
Expected: PASS for everything.
Then run: `go test ./...` (the entire repo). This must also 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. If it fails a second time in a way connected to your actual changes, stop and call ask_user.
Also run `gofmt -l internal/task/currentattempt.go internal/task/currentattempt_test.go internal/executor/executor.go internal/executor/executor_test.go` before committing — this repo has had gofmt regressions slip through in prior tasks this session by copying plan markdown's own formatting verbatim; run `gofmt -w` on any file it flags that wasn't already gofmt-dirty before your changes (check with `git show HEAD:<path> | gofmt -l -` if unsure whether dirtiness is pre-existing).
- [ ] **Step 10: Commit and push to `main` directly, matching this repo's existing workflow**
```bash
git add internal/task/currentattempt.go internal/task/currentattempt_test.go internal/executor/executor.go internal/executor/executor_test.go
git commit -m "feat(task,executor): add CurrentAttempt resolution, wire maybeUnblockParent to resolve through it"
```
## 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 "CurrentAttempt" internal/` to visually confirm the File Map was actually touched (should be `internal/task/currentattempt.go`, `internal/task/currentattempt_test.go`, and `internal/executor/executor.go` only — nothing in `internal/scheduler` yet, since that's piece 3).
|