diff options
| author | Claudomator Agent <agent@claudomator> | 2026-07-09 03:53:51 +0000 |
|---|---|---|
| committer | Claudomator Agent <agent@claudomator> | 2026-07-09 03:53:51 +0000 |
| commit | ad00e1865c19afd40c8d159dbb55668ed6b51b12 (patch) | |
| tree | 940ac37f50a48a6867472c6aaafa58976f3f3270 /internal/executor/executor_test.go | |
| parent | 17027fbbd8eba2f1b6a8dad9c4749173f2533159 (diff) | |
fix(executor): support nested subtask decomposition (subtask-with-own-subtasks correctly blocks, cascades, and recovers)
Diffstat (limited to 'internal/executor/executor_test.go')
| -rw-r--r-- | internal/executor/executor_test.go | 138 |
1 files changed, 138 insertions, 0 deletions
diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 12d052c..d89a9eb 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -1031,6 +1031,144 @@ func TestPool_Submit_NotLastSubtask_ParentStaysBlocked(t *testing.T) { } } +// TestPool_Submit_SubtaskWithOwnSubtasks_GoesBlocked proves the fix for a +// real pre-existing bug: a subtask (ParentTaskID != "") that itself spawns +// subtasks must go BLOCKED when its own agent turn ends, not COMPLETED -- +// mirroring exactly how a top-level task with subtasks already behaves +// (TestPool_Submit_TopLevel_WithSubtasks_GoesBlocked). Before this fix, +// handleRunResult only checked for pending subtasks when ParentTaskID == "", +// so a decomposing subtask was marked COMPLETED the instant its own turn +// ended, ignoring the children it just spawned. +func TestPool_Submit_SubtaskWithOwnSubtasks_GoesBlocked(t *testing.T) { + store := testStore(t) + runner := &mockRunner{} + runners := map[string]Runner{"claude": runner} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + middle := makeTask("middle-with-own-subtasks") + middle.ParentTaskID = "grandparent-1" // middle is itself a subtask + store.CreateTask(middle) + + // middle spawned its own child, but that child hasn't been submitted yet. + grandchild := makeTask("grandchild-of-middle") + grandchild.ParentTaskID = middle.ID + store.CreateTask(grandchild) + + if err := pool.Submit(context.Background(), middle); err != nil { + t.Fatalf("submit: %v", err) + } + + result := <-pool.Results() + if result.Err != nil { + t.Errorf("expected no error, got: %v", result.Err) + } + if result.Execution.Status != "BLOCKED" { + t.Errorf("status: want BLOCKED, got %q", result.Execution.Status) + } + got, _ := store.GetTask(middle.ID) + if got.State != task.StateBlocked { + t.Errorf("task state: want BLOCKED, got %v (this is the bug: a decomposing subtask must not skip straight to COMPLETED)", got.State) + } +} + +// TestPool_Submit_GrandchildCompletion_CascadesThroughNestedParents proves +// the full 3-level recursive propagation: when the deepest leaf completes, +// its immediate parent (itself a subtask) goes straight to COMPLETED -- +// not READY, since subtasks never need a human accept -- which then +// recursively unblocks the true top-level root to READY. +func TestPool_Submit_GrandchildCompletion_CascadesThroughNestedParents(t *testing.T) { + store := testStore(t) + runner := &mockRunner{} + runners := map[string]Runner{"claude": runner} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + root := makeTask("nested-root") + root.State = task.StateBlocked // already ran, delegated to middle + store.CreateTask(root) + + middle := makeTask("nested-middle") + middle.ParentTaskID = root.ID + middle.State = task.StateBlocked // already ran, delegated to grandchild + store.CreateTask(middle) + + grandchild := makeTask("nested-grandchild") + grandchild.ParentTaskID = middle.ID + store.CreateTask(grandchild) // fresh, about to be submitted + + if err := pool.Submit(context.Background(), grandchild); err != nil { + t.Fatalf("submit: %v", err) + } + + result := <-pool.Results() + if result.Err != nil { + t.Errorf("expected no error, got: %v", result.Err) + } + if result.Execution.Status != "COMPLETED" { + t.Errorf("grandchild status: want COMPLETED, got %q", result.Execution.Status) + } + + gotMiddle, err := store.GetTask(middle.ID) + if err != nil { + t.Fatalf("get middle: %v", err) + } + if gotMiddle.State != task.StateCompleted { + t.Errorf("middle state: want COMPLETED (it's a subtask, no accept needed), got %v", gotMiddle.State) + } + + gotRoot, err := store.GetTask(root.ID) + if err != nil { + t.Fatalf("get root: %v", err) + } + if gotRoot.State != task.StateReady { + t.Errorf("root state: want READY (top-level, awaiting accept), got %v", gotRoot.State) + } +} + +// TestPool_RecoverStaleBlocked_PromotesNestedSubtaskParent proves +// RecoverStaleBlocked no longer skips BLOCKED subtask-parents -- before +// this fix, its "only promote actual parents" filter (ParentTaskID == "") +// meant a restarted server could never recover a BLOCKED subtask-parent +// once its own children finished, leaving it stuck forever. +func TestPool_RecoverStaleBlocked_PromotesNestedSubtaskParent(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) + + root := makeTask("recover-nested-root") + root.State = task.StateBlocked + store.CreateTask(root) + + middle := makeTask("recover-nested-middle") + middle.ParentTaskID = root.ID + middle.State = task.StateBlocked + store.CreateTask(middle) + + grandchild := makeTask("recover-nested-grandchild") + grandchild.ParentTaskID = middle.ID + grandchild.State = task.StateCompleted + store.CreateTask(grandchild) + + pool.RecoverStaleBlocked() + + gotMiddle, err := store.GetTask(middle.ID) + if err != nil { + t.Fatalf("get middle: %v", err) + } + if gotMiddle.State != task.StateCompleted { + t.Errorf("middle state: want COMPLETED, got %v", gotMiddle.State) + } + + gotRoot, err := store.GetTask(root.ID) + if err != nil { + t.Fatalf("get root: %v", err) + } + if gotRoot.State != task.StateReady { + t.Errorf("root state: want READY, got %v", gotRoot.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) { |
