diff options
| author | Claudomator Agent <agent@claudomator.local> | 2026-07-09 04:39:05 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator.local> | 2026-07-09 04:39:05 +0000 |
| commit | 4ba335c8eacb3f9e20bccce1643b2e5320d5239d (patch) | |
| tree | 397433d1db8564b3818f6b73744567662c15c9d0 | |
| parent | 884da0ba74f622dc5c41cde25710718762321ae8 (diff) | |
feat(task,executor): add CurrentAttempt resolution, wire maybeUnblockParent to resolve through it
| -rw-r--r-- | internal/executor/executor.go | 7 | ||||
| -rw-r--r-- | internal/executor/executor_test.go | 51 | ||||
| -rw-r--r-- | internal/task/currentattempt.go | 56 | ||||
| -rw-r--r-- | internal/task/currentattempt_test.go | 130 |
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") + } +} |
