diff options
Diffstat (limited to 'internal/executor/cascade_test.go')
| -rw-r--r-- | internal/executor/cascade_test.go | 222 |
1 files changed, 222 insertions, 0 deletions
diff --git a/internal/executor/cascade_test.go b/internal/executor/cascade_test.go new file mode 100644 index 0000000..5e5f923 --- /dev/null +++ b/internal/executor/cascade_test.go @@ -0,0 +1,222 @@ +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) + } +} |
