fix(triage): prevent orphaned deps when splitting tasks + detect worktree drift

Root cause: during a triage split the AI could set a child task's
`dependencies` to the parent id. The parent is hard-deleted after the split,
and the scheduler's dep check treats a missing id as unmet — permanently
blocking the dependent. This stranded FN-2164 behind the deleted FN-2163.

- core/store.deleteTask: refuse to delete when any live task still has the id
  in its `dependencies` array. Throws TaskHasDependentsError listing dependents
  so callers can rewrite or recover. Covers the triage-split path and any
  future caller.
- engine/triage task_create: validate each proposed dependency before creating
  a child — reject the parent id, reject unknown task ids, allow siblings
  created earlier in the same split or pre-existing tasks.
- engine/triage split cleanup: wrap the parent deleteTask in try/catch that
  keeps the parent alive (safer than stranding dependents) and logs the reason.
- engine/triage prompts: both the mandatory-split and proactive-split prompts
  now explicitly state that subtask deps must never reference the parent.
- dashboard/routes /subtasks/create-tasks: reject parent-id deps, drop unknown
  deps with an audit log entry, surface parentTaskCloseError + droppedDependencies
  in the response instead of silently swallowing them.
- engine/executor: on execute entry, detect the drift state (in-progress task
  with no worktree) and emit a loud log + task log entry; the existing
  fresh-worktree path then recovers. Prevents silent "operating without a
  worktree" behavior that we saw on FN-2152.

Tests:
  core:      2907/2907 pass (+5 new, incl. deleteTask guard regression)
  engine:    2554/2554 pass (+17 new, incl. task_create dep validation)
  dashboard: 9064/9064 pass (+2 new for /subtasks/create-tasks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-19 20:45:36 -07:00
parent 0b9bf62a2a
commit f530a69955
7 changed files with 529 additions and 21 deletions

View File

@@ -1096,6 +1096,28 @@ export class TaskExecutor {
}
}
// Drift detection: a task that is already in-progress (i.e. we're not
// dispatching it fresh from todo) should always carry a `worktree`. If it
// doesn't, some prior update — most likely a partial pause/abort sequence
// where updateTask({ worktree: null }) succeeded but the subsequent
// moveTask()/status write failed — left the row in a half-state. The
// executor can still recover by falling through to the fresh-worktree
// path below, but we emit a loud audit record so these states stop being
// silent.
if (task.column === "in-progress" && !task.worktree) {
executorLog.error(
`${task.id}: drift detected — task is in-progress with no worktree. ` +
`Recovering by creating a fresh worktree. This usually indicates a partial ` +
`updateTask/moveTask sequence failed somewhere upstream.`,
);
await this.store.logEntry(
task.id,
"Drift detected: in-progress with no worktree — creating fresh worktree to recover",
undefined,
this.currentRunContext,
);
}
// Hoist worktreePath so it's accessible in the catch block for dep-abort cleanup
// Determine worktree name based on settings
let worktreePath: string;
@@ -1410,8 +1432,8 @@ export class TaskExecutor {
return;
}
// Reset workflowStepRetries counter on success
await this.store.updateTask(task.id, { workflowStepRetries: undefined });
// Reset retry counters on success
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
await this.store.moveTask(task.id, "in-review");
// Audit trail: record task move (FN-1404)
@@ -1880,8 +1902,8 @@ export class TaskExecutor {
return;
}
// Reset workflowStepRetries counter on success
await this.store.updateTask(task.id, { workflowStepRetries: undefined });
// Reset retry counters on success
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
await this.store.moveTask(task.id, "in-review");
executorLog.log(`${task.id} completed → in-review`);
@@ -1980,6 +2002,9 @@ export class TaskExecutor {
return;
}
// Reset retry counters on success
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null });
await this.store.moveTask(task.id, "in-review");
executorLog.log(`${task.id} completed on retry → in-review`);
this.options.onComplete?.(task);

View File

@@ -1275,6 +1275,146 @@ describe("taskCreate tool model inheritance", () => {
}));
});
it("task_create rejects a dependency on the parent task being split", async () => {
// Regression: triage used to accept any id in `dependencies`. If the AI
// named the parent, the parent got deleted after the split and the child
// was blocked forever by a nonexistent dep (FN-2163/FN-2164 incident).
const parentTask: Task = {
id: "FN-600",
description: "Parent about to be split",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(parentTask),
createTask: vi.fn(),
});
const processor = new TriageProcessor(store, "/test/root");
const createdSubtasksRef = { current: [] };
const tools = (processor as any).createTriageTools({
parentTaskId: "FN-600",
allowTaskCreate: true,
createdSubtasksRef,
});
const taskCreateTool = tools.find((t: any) => t.name === "task_create");
const result = await taskCreateTool.execute("call-1", {
description: "Child that tries to wait for the parent",
dependencies: ["FN-600"],
});
const text = result.content[0].text;
expect(text).toContain("ERROR");
expect(text).toContain("FN-600");
expect(text).toContain("parent task is deleted after splitting");
// Must not create the child — the caller has to fix the deps and retry.
expect(store.createTask).not.toHaveBeenCalled();
expect(createdSubtasksRef.current).toEqual([]);
});
it("task_create accepts dependencies on sibling subtasks created earlier in the same split", async () => {
// The valid case: two siblings where the second depends on the first.
const parentTask: Task = {
id: "FN-700",
description: "Parent to split",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const sibling1: Task = { ...parentTask, id: "FN-701", description: "Sibling 1" };
const sibling2: Task = { ...parentTask, id: "FN-702", description: "Sibling 2" };
const createTaskMock = vi
.fn()
.mockResolvedValueOnce(sibling1)
.mockResolvedValueOnce(sibling2);
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(parentTask),
createTask: createTaskMock,
});
const processor = new TriageProcessor(store, "/test/root");
const createdSubtasksRef = { current: [] };
const tools = (processor as any).createTriageTools({
parentTaskId: "FN-700",
allowTaskCreate: true,
createdSubtasksRef,
});
const taskCreateTool = tools.find((t: any) => t.name === "task_create");
const firstRes = await taskCreateTool.execute("c1", {
description: "Sibling 1",
dependencies: [],
});
expect(firstRes.content[0].text).toContain("Created child task FN-701");
const secondRes = await taskCreateTool.execute("c2", {
description: "Sibling 2 depending on sibling 1",
dependencies: ["FN-701"],
});
expect(secondRes.content[0].text).toContain("Created child task FN-702");
expect(secondRes.content[0].text).not.toContain("ERROR");
// The second createTask call should have the resolved sibling id preserved.
expect(createTaskMock).toHaveBeenLastCalledWith(
expect.objectContaining({ dependencies: ["FN-701"] }),
);
expect(createdSubtasksRef.current).toEqual(["FN-701", "FN-702"]);
});
it("task_create rejects an unknown dependency id that is neither sibling nor existing task", async () => {
const parentTask: Task = {
id: "FN-800",
description: "Parent",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
// getTask returns the parent when asked, but throws for unknown ids.
const store = createMockStore({
getTask: vi.fn(async (id: string) => {
if (id === "FN-800") return parentTask;
throw new Error(`Task ${id} not found`);
}) as unknown as TaskStore["getTask"],
createTask: vi.fn(),
});
const processor = new TriageProcessor(store, "/test/root");
const createdSubtasksRef = { current: [] };
const tools = (processor as any).createTriageTools({
parentTaskId: "FN-800",
allowTaskCreate: true,
createdSubtasksRef,
});
const taskCreateTool = tools.find((t: any) => t.name === "task_create");
const result = await taskCreateTool.execute("c1", {
description: "Child naming a nonexistent dep",
dependencies: ["FN-9999"],
});
expect(result.content[0].text).toContain("ERROR");
expect(result.content[0].text).toContain("FN-9999");
expect(result.content[0].text).toContain("task not found");
expect(store.createTask).not.toHaveBeenCalled();
});
it("closes parent after proactive split even when breakIntoSubtasks is undefined", async () => {
// Test that the post-session closure path doesn't gate on breakIntoSubtasks.
// Strategy: capture the customTools from createFnAgent, then have

View File

@@ -187,6 +187,7 @@ When the task includes \`breakIntoSubtasks: true\`, first decide whether it shou
- Split only when the work is meaningfully decomposable into 2-5 independently executable child tasks.
- If splitting: use the \`task_create\` tool to create child tasks in triage, include clear descriptions and dependencies between them, then stop. Do NOT write a PROMPT.md for the parent task.
- **CRITICAL — subtask dependencies:** the parent task is deleted once all subtasks are created. \`dependencies\` on a new subtask may ONLY reference sibling subtasks you have created earlier in this same split (or unrelated existing tasks). **Never depend on the parent task's id.** If a child conceptually "waits for the parent's remaining work", create a sibling subtask that does that work and depend on the sibling instead. The \`task_create\` tool will reject parent-id dependencies with an error.
- If not splitting: proceed with a normal PROMPT.md specification.
## Proactive Subtask Breakdown for M/L Tasks
@@ -835,8 +836,24 @@ export class TriageProcessor {
task.id,
`Converted into subtasks: ${childTaskIds}`,
);
await this.store.deleteTask(task.id);
triageLog.log(`${task.id} split into subtasks (${childTaskIds}) and closed`);
try {
await this.store.deleteTask(task.id);
triageLog.log(`${task.id} split into subtasks (${childTaskIds}) and closed`);
} catch (err: unknown) {
// deleteTask refuses when live tasks still depend on this id.
// If task_create's validation worked correctly this branch is
// unreachable, but we keep it as defense-in-depth: leaving the
// parent alive is always safer than stranding dependents.
const msg = err instanceof Error ? err.message : String(err);
triageLog.error(
`${task.id}: cannot close parent after split (${msg}). ` +
`Parent kept alive to avoid orphaning dependents; subtasks were still created.`,
);
await this.store.logEntry(
task.id,
`Split-close aborted: ${msg}. Subtasks created but parent kept alive to avoid orphaning dependents.`,
);
}
return;
}
@@ -1220,7 +1237,10 @@ export class TriageProcessor {
"Use this when the work can be split into 2-5 independently executable tasks, " +
"either because the user requested subtask breakdown or because the task is " +
"oversized (8+ steps, 3+ packages, multiple independent deliverables). " +
"The created task will be a child of the current task being triaged.",
"The created task will be a child of the current task being triaged. " +
"IMPORTANT: `dependencies` may ONLY reference other subtasks you have created " +
"in this same triage session. Never depend on the parent task — the parent is " +
"deleted after splitting, and stale dependency ids permanently block the dependent.",
parameters: taskCreateParams,
execute: async (
_callId: string,
@@ -1229,6 +1249,57 @@ export class TriageProcessor {
// task_create is always available during triage to support both
// explicit breakIntoSubtasks and proactive splitting of oversized tasks.
try {
// Validate dependencies before creating the child:
// 1. Cannot depend on the parent (it's about to be deleted).
// 2. Each id must either (a) already exist in the store, or
// (b) reference a sibling created earlier in this split.
// This is the load-bearing guard that prevents the AI from stranding
// children behind a never-to-exist parent id.
const requestedDeps = params.dependencies || [];
const siblings = new Set(options.createdSubtasksRef.current);
const validDeps: string[] = [];
const rejected: Array<{ id: string; reason: string }> = [];
for (const depId of requestedDeps) {
if (depId === options.parentTaskId) {
rejected.push({
id: depId,
reason: "parent task is deleted after splitting; depend on a sibling child task instead",
});
continue;
}
if (siblings.has(depId)) {
validDeps.push(depId);
continue;
}
try {
await store.getTask(depId);
validDeps.push(depId);
} catch {
rejected.push({
id: depId,
reason: "task not found (only existing tasks or siblings created earlier in this split are allowed)",
});
}
}
if (rejected.length > 0) {
const summary = rejected
.map((r) => ` - ${r.id}: ${r.reason}`)
.join("\n");
return {
content: [
{
type: "text" as const,
text:
`ERROR: task_create rejected. Invalid dependencies:\n${summary}\n\n` +
`Remove or replace these ids and call task_create again.`,
},
],
details: { rejectedDependencies: rejected },
};
}
// Fetch parent task to inherit model settings
let parentTask: Awaited<ReturnType<typeof store.getTask>> | undefined;
try {
@@ -1243,7 +1314,7 @@ export class TriageProcessor {
const newTask = await store.createTask({
title: params.title,
description: params.description,
dependencies: params.dependencies || [],
dependencies: validDeps,
column: "triage",
// Inherit parent's model settings if available
modelProvider: parentTask?.modelProvider,
@@ -1798,6 +1869,8 @@ The user has requested that this task be broken into smaller subtasks if it is c
4. After creating all subtasks, stop — do NOT write a PROMPT.md for the parent task
5. If NOT splitting: proceed with a normal PROMPT.md specification for this task
**Subtask dependencies rule:** \`dependencies\` on a child may only reference **sibling subtasks created earlier in this same split** or **pre-existing tasks in the store**. They must NEVER reference the parent task being split — the parent is deleted after the split completes, and a dependency on a deleted task permanently blocks the dependent. If a child "needs the rest of the parent's work to finish first", create another sibling subtask for that remaining work and depend on the sibling. The \`task_create\` tool rejects parent-id dependencies.
**Important:** If you create subtasks, this parent task will be closed and replaced by the children. Make sure each child is a complete, executable task.`;
} else {
subtaskSection = `
@@ -1823,6 +1896,7 @@ The user did not explicitly request subtask breakdown, so you should first asses
**How to decide:**
- If you choose to split: use the \\\`task_create\\\` tool to create the child tasks, set dependencies where needed, and then stop without writing a PROMPT.md for the parent task.
- **Subtask dependencies must only reference sibling subtasks created earlier in this same split, or pre-existing tasks. NEVER depend on the parent task being split — the parent is deleted after splitting, and the tool will reject parent-id dependencies.**
- If the work appears to be Size S, or if an M/L task genuinely has 5 or fewer focused steps with a clear scope, proceed with a normal PROMPT.md specification.
- If size is uncertain at first, make a quick assessment from the available context before deciding.`;
}