package executor import ( "context" "errors" "log/slog" "os" "strings" "testing" "time" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) // depTask returns a task with the given DependsOn and state, using the same // shape makeTask() produces (see executor_test.go) so it satisfies // task.Validate-adjacent expectations (non-nil Tags/DependsOn slices etc). func depTask(id string, dependsOn []string, state task.State) *task.Task { tk := makeTask(id) if dependsOn == nil { dependsOn = []string{} } tk.DependsOn = dependsOn tk.State = state return tk } func cascadeTestPool(t *testing.T, store Store) *Pool { t.Helper() logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) return NewPool(2, map[string]Runner{"claude": &mockRunner{}}, store, logger) } // TestPool_CascadeFail_SingleLevel: A fails; B depends on A and is PENDING. // cascadeFail must mark B CANCELLED with a message referencing A. func TestPool_CascadeFail_SingleLevel(t *testing.T) { store := testStore(t) a := depTask("cf-a", nil, task.StateFailed) b := depTask("cf-b", []string{"cf-a"}, task.StatePending) if err := store.CreateTask(a); err != nil { t.Fatal(err) } if err := store.CreateTask(b); err != nil { t.Fatal(err) } pool := cascadeTestPool(t, store) pool.cascadeFail("cf-a", "build failed") got, err := store.GetTask("cf-b") if err != nil { t.Fatal(err) } if got.State != task.StateCancelled { t.Fatalf("cf-b state: want CANCELLED, got %s", got.State) } execs, err := store.ListExecutions("cf-b") if err != nil { t.Fatal(err) } if len(execs) != 1 { t.Fatalf("expected 1 execution recorded for cf-b, got %d", len(execs)) } if execs[0].Status != "CANCELLED" { t.Errorf("execution status: want CANCELLED, got %q", execs[0].Status) } if !strings.Contains(execs[0].ErrorMsg, "cf-a") { t.Errorf("execution ErrorMsg should reference upstream task cf-a, got %q", execs[0].ErrorMsg) } } // TestPool_CascadeFail_MultiLevelRecursion: A fails, B depends on A, C // depends on B. Both B and C must end up CANCELLED — cascadeFail must // recurse through the whole subtree, not just cancel direct dependents. func TestPool_CascadeFail_MultiLevelRecursion(t *testing.T) { store := testStore(t) a := depTask("cfm-a", nil, task.StateFailed) b := depTask("cfm-b", []string{"cfm-a"}, task.StatePending) c := depTask("cfm-c", []string{"cfm-b"}, task.StatePending) for _, tk := range []*task.Task{a, b, c} { if err := store.CreateTask(tk); err != nil { t.Fatal(err) } } pool := cascadeTestPool(t, store) pool.cascadeFail("cfm-a", "build failed") gotB, err := store.GetTask("cfm-b") if err != nil { t.Fatal(err) } gotC, err := store.GetTask("cfm-c") if err != nil { t.Fatal(err) } if gotB.State != task.StateCancelled { t.Errorf("cfm-b state: want CANCELLED, got %s", gotB.State) } if gotC.State != task.StateCancelled { t.Errorf("cfm-c state: want CANCELLED, got %s", gotC.State) } } // TestPool_CascadeFail_LeavesRunningAndCompletedAlone: dependents that are // already RUNNING or already terminal (COMPLETED) when the cascade fires // must be left untouched, not force-cancelled. func TestPool_CascadeFail_LeavesRunningAndCompletedAlone(t *testing.T) { store := testStore(t) a := depTask("cfr-a", nil, task.StateFailed) running := depTask("cfr-running", []string{"cfr-a"}, task.StateRunning) completed := depTask("cfr-completed", []string{"cfr-a"}, task.StateCompleted) for _, tk := range []*task.Task{a, running, completed} { if err := store.CreateTask(tk); err != nil { t.Fatal(err) } } pool := cascadeTestPool(t, store) pool.cascadeFail("cfr-a", "build failed") gotRunning, err := store.GetTask("cfr-running") if err != nil { t.Fatal(err) } if gotRunning.State != task.StateRunning { t.Errorf("cfr-running state: want unchanged RUNNING, got %s", gotRunning.State) } gotCompleted, err := store.GetTask("cfr-completed") if err != nil { t.Fatal(err) } if gotCompleted.State != task.StateCompleted { t.Errorf("cfr-completed state: want unchanged COMPLETED, got %s", gotCompleted.State) } // No execution records should have been created for either — cascadeFail // should not have touched them at all. if execs, _ := store.ListExecutions("cfr-running"); len(execs) != 0 { t.Errorf("expected no executions recorded for cfr-running, got %d", len(execs)) } if execs, _ := store.ListExecutions("cfr-completed"); len(execs) != 0 { t.Errorf("expected no executions recorded for cfr-completed, got %d", len(execs)) } } // TestPool_CascadeFail_CycleGuard proves cascadeFail terminates on a // dependency cycle instead of recursing forever. task.Validate and // storage.CreateTask do not reject dependency cycles today (there is no // cycle-detection anywhere in task creation), so this guard is load-bearing, // not just defense-in-depth. func TestPool_CascadeFail_CycleGuard(t *testing.T) { store := testStore(t) a := depTask("cyc-a", []string{"cyc-b"}, task.StatePending) b := depTask("cyc-b", []string{"cyc-a"}, task.StatePending) if err := store.CreateTask(a); err != nil { t.Fatal(err) } if err := store.CreateTask(b); err != nil { t.Fatal(err) } pool := cascadeTestPool(t, store) done := make(chan struct{}) go func() { pool.cascadeFail("cyc-a", "boom") close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("cascadeFail did not terminate on a dependency cycle — infinite recursion?") } gotA, err := store.GetTask("cyc-a") if err != nil { t.Fatal(err) } gotB, err := store.GetTask("cyc-b") if err != nil { t.Fatal(err) } if gotA.State != task.StateCancelled { t.Errorf("cyc-a state: want CANCELLED, got %s", gotA.State) } if gotB.State != task.StateCancelled { t.Errorf("cyc-b state: want CANCELLED, got %s", gotB.State) } } // TestPool_CascadeFail_WiredIntoHandleRunResult verifies cascadeFail is // actually invoked when a task's run terminates in a failure state via the // normal handleRunResult path (not just when called directly), using a real // store so ListDependents sees real data. func TestPool_CascadeFail_WiredIntoHandleRunResult(t *testing.T) { store := testStore(t) a := depTask("hrr-cascade-a", nil, task.StateRunning) b := depTask("hrr-cascade-b", []string{"hrr-cascade-a"}, task.StatePending) if err := store.CreateTask(a); err != nil { t.Fatal(err) } if err := store.CreateTask(b); err != nil { t.Fatal(err) } pool := cascadeTestPool(t, store) execRec := &storage.Execution{ID: "hrr-cascade-exec", TaskID: a.ID, Status: "RUNNING"} pool.handleRunResult(context.Background(), a, execRec, errors.New("boom"), "claude") <-pool.resultCh gotB, err := store.GetTask("hrr-cascade-b") if err != nil { t.Fatal(err) } if gotB.State != task.StateCancelled { t.Errorf("hrr-cascade-b state: want CANCELLED after upstream FAILED, got %s", gotB.State) } }