summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/executor/executor.go7
-rw-r--r--internal/executor/executor_test.go51
-rw-r--r--internal/task/currentattempt.go56
-rw-r--r--internal/task/currentattempt_test.go130
4 files changed, 243 insertions, 1 deletions
diff --git a/internal/executor/executor.go b/internal/executor/executor.go
index 88a5783..03b368e 100644
--- a/internal/executor/executor.go
+++ b/internal/executor/executor.go
@@ -1259,7 +1259,12 @@ func (p *Pool) maybeUnblockParent(parentID string) {
return
}
for _, sub := range subtasks {
- if sub.State != task.StateCompleted {
+ 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
}
}
diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go
index e2b9b44..d505a3a 100644
--- a/internal/executor/executor_test.go
+++ b/internal/executor/executor_test.go
@@ -1169,6 +1169,57 @@ func TestPool_RecoverStaleBlocked_PromotesNestedSubtaskParent(t *testing.T) {
}
}
+// 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)
+ }
+}
+
// TestPool_Submit_ParentNotBlocked_NoTransition verifies that completing a subtask
// does not change the parent's state when the parent is not BLOCKED.
func TestPool_Submit_ParentNotBlocked_NoTransition(t *testing.T) {
diff --git a/internal/task/currentattempt.go b/internal/task/currentattempt.go
new file mode 100644
index 0000000..24eed10
--- /dev/null
+++ b/internal/task/currentattempt.go
@@ -0,0 +1,56 @@
+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
+}
diff --git a/internal/task/currentattempt_test.go b/internal/task/currentattempt_test.go
new file mode 100644
index 0000000..76661d8
--- /dev/null
+++ b/internal/task/currentattempt_test.go
@@ -0,0 +1,130 @@
+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")
+ }
+}