summaryrefslogtreecommitdiff
path: root/internal/executor/executor_test.go
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-07-09 03:53:51 +0000
committerClaudomator Agent <agent@claudomator>2026-07-09 03:53:51 +0000
commitad00e1865c19afd40c8d159dbb55668ed6b51b12 (patch)
tree940ac37f50a48a6867472c6aaafa58976f3f3270 /internal/executor/executor_test.go
parent17027fbbd8eba2f1b6a8dad9c4749173f2533159 (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.go138
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) {