summaryrefslogtreecommitdiff
path: root/internal/storage
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 23:22:42 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 23:22:42 +0000
commit997cd8b56bc086a02b9c7c006dd62b07b9fcd2f3 (patch)
tree94351bb0aebeb92cb4c0f63f2e0a2636e06c4f1e /internal/storage
parent787b7fb1aed92c2b701724a7741576053b93cccb (diff)
feat(executor): add DAG auto-cascade-fail + role-typed subtask spawning (Phase 6)
Two prerequisites for safe parallel evaluator fan-out (later phase): 1. Auto-cascade-fail: previously, a failed task's dependents just sat PENDING/QUEUED forever (or until something eventually tried to dispatch them and discovered the dependency was dead). Pool.cascadeFail now fires right after a task lands in a terminal failure state (FAILED/TIMED_OUT/ CANCELLED/BUDGET_EXCEEDED, from handleRunResult, the budget-gate reject path, and the checkDepsReady dependency-failure path), recursively cancelling every not-yet-run dependent (PENDING/QUEUED only -- RUNNING and terminal states are left alone) with a message referencing the upstream failure. A visited-set guards recursion, which turned out to be load-bearing rather than defense-in-depth: task creation does not prevent dependency cycles anywhere in this codebase. Correction to an earlier assumption: internal/executor's waitForDependencies is dead code, never called. The live mechanism is checkDepsReady, invoked synchronously in execute() with a self-requeue via time.AfterFunc. Added a freshness re-check (GetTask, bail if no longer QUEUED) at the top of that block so a task cascade-cancelled while sitting in the requeue loop stops silently instead of hitting an invalid CANCELLED->CANCELLED transition or, worse, still getting dispatched. 2. storeChannel.SpawnSubtask hardcoded Agent.Type: "claude" on every spawned child regardless of what role it should play -- a hard blocker for a Planner/Builder task spawning role-typed evaluator subtasks. SubtaskSpec (internal/agentchannel) gains a Role field; when set, the child task gets Agent.Role instead of a hardcoded Type, so Phase 5's role-resolution picks provider/model from that role's escalation ladder. spec.Role == "" (every existing caller) preserves today's exact behavior byte-for-byte -- proven by an explicit regression test, not just new-feature coverage. Threaded the new `role` parameter through both spawn_subtask transports: the native tool-use loop (internal/agentloop/tools.go) and the MCP tool exposed to ContainerRunner-driven claude/gemini agents (internal/executor/agentmcp.go). go build/vet/test -race -count=1 all pass, full suite (20 packages). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/storage')
-rw-r--r--internal/storage/db.go30
-rw-r--r--internal/storage/db_test.go76
2 files changed, 106 insertions, 0 deletions
diff --git a/internal/storage/db.go b/internal/storage/db.go
index a16ad4e..d571c2e 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -287,6 +287,36 @@ func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) {
return tasks, rows.Err()
}
+// ListDependents returns tasks that directly depend on taskID — i.e. those
+// whose depends_on_json contains taskID. depends_on_json has no reverse
+// index, so this is a full-table scan; the codebase already accepts this
+// tradeoff for other JSON-blob columns at self-hosted scale (see the
+// "Additive migration strategy is fragile" design-debt note in CLAUDE.md).
+// Only direct dependents are returned — callers that need the full
+// transitive downstream subtree (e.g. cascade-cancellation) must recurse.
+func (s *DB) ListDependents(taskID string) ([]*task.Task, error) {
+ rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, project, repository_url, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var dependents []*task.Task
+ for rows.Next() {
+ t, err := scanTaskRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ for _, depID := range t.DependsOn {
+ if depID == taskID {
+ dependents = append(dependents, t)
+ break
+ }
+ }
+ }
+ return dependents, rows.Err()
+}
+
// UpdateTaskState atomically updates a task's state, enforcing valid
// transitions. The transition is attributed to the system actor; callers
// acting on behalf of a user should use UpdateTaskStateBy.
diff --git a/internal/storage/db_test.go b/internal/storage/db_test.go
index 09bbdfc..b53234a 100644
--- a/internal/storage/db_test.go
+++ b/internal/storage/db_test.go
@@ -1256,4 +1256,80 @@ func TestUpdateProject(t *testing.T) {
}
}
+func makeDepTask(id string, dependsOn []string) *task.Task {
+ now := time.Now().UTC()
+ if dependsOn == nil {
+ dependsOn = []string{}
+ }
+ return &task.Task{
+ ID: id, Name: "Task " + id,
+ Agent: task.AgentConfig{Type: "claude", Instructions: "x"},
+ Priority: task.PriorityNormal,
+ Retry: task.RetryConfig{MaxAttempts: 1, Backoff: "linear"},
+ Tags: []string{},
+ DependsOn: dependsOn,
+ State: task.StatePending,
+ CreatedAt: now, UpdatedAt: now,
+ }
+}
+
+// TestListDependents verifies ListDependents returns exactly the tasks that
+// directly depend on the given ID, not transitive dependents and not
+// unrelated tasks.
+func TestListDependents(t *testing.T) {
+ db := testDB(t)
+
+ // A has no deps.
+ // B and C both depend directly on A.
+ // D depends on B (transitive on A, not direct).
+ // E has no relation to A at all.
+ a := makeDepTask("ld-a", nil)
+ b := makeDepTask("ld-b", []string{"ld-a"})
+ c := makeDepTask("ld-c", []string{"ld-a"})
+ d := makeDepTask("ld-d", []string{"ld-b"})
+ e := makeDepTask("ld-e", nil)
+
+ for _, tk := range []*task.Task{a, b, c, d, e} {
+ if err := db.CreateTask(tk); err != nil {
+ t.Fatalf("CreateTask(%s): %v", tk.ID, err)
+ }
+ }
+
+ deps, err := db.ListDependents("ld-a")
+ if err != nil {
+ t.Fatalf("ListDependents: %v", err)
+ }
+ if len(deps) != 2 {
+ t.Fatalf("want 2 direct dependents of ld-a, got %d: %+v", len(deps), deps)
+ }
+ got := map[string]bool{}
+ for _, dep := range deps {
+ got[dep.ID] = true
+ }
+ if !got["ld-b"] || !got["ld-c"] {
+ t.Errorf("expected dependents ld-b and ld-c, got %v", got)
+ }
+ if got["ld-d"] || got["ld-e"] {
+ t.Errorf("ListDependents must not include transitive (ld-d) or unrelated (ld-e) tasks: %v", got)
+ }
+
+ // ld-d depends only on ld-b.
+ depsOfB, err := db.ListDependents("ld-b")
+ if err != nil {
+ t.Fatalf("ListDependents(ld-b): %v", err)
+ }
+ if len(depsOfB) != 1 || depsOfB[0].ID != "ld-d" {
+ t.Errorf("want [ld-d] as dependents of ld-b, got %+v", depsOfB)
+ }
+
+ // A task with no dependents returns an empty slice, not an error.
+ depsOfE, err := db.ListDependents("ld-e")
+ if err != nil {
+ t.Fatalf("ListDependents(ld-e): %v", err)
+ }
+ if len(depsOfE) != 0 {
+ t.Errorf("want no dependents of ld-e, got %+v", depsOfE)
+ }
+}
+