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:
@@ -23,7 +23,7 @@ const mockedExecSync = vi.mocked(execSync);
|
||||
import { runCommandAsync } from "./run-command.js";
|
||||
const mockedRunCommandAsync = vi.mocked(runCommandAsync);
|
||||
|
||||
import { TaskStore } from "./store.js";
|
||||
import { TaskStore, TaskHasDependentsError } from "./store.js";
|
||||
import { appendFile, readFile, writeFile, mkdir, rm, readdir, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync } from "node:fs";
|
||||
@@ -3888,6 +3888,50 @@ Task with acceptance criteria
|
||||
expect(logs).toEqual([]);
|
||||
});
|
||||
|
||||
it("deleteTask refuses when another live task depends on this id", async () => {
|
||||
// Regression for the triage-split bug: splitting a parent into children
|
||||
// used to hard-delete the parent even when a child carried the parent id
|
||||
// in its dependencies array, permanently blocking the child because the
|
||||
// scheduler treats missing-dep ids as unmet.
|
||||
const parent = await store.createTask({ description: "Parent to be split" });
|
||||
const child = await store.createTask({
|
||||
description: "Child that accidentally depends on parent",
|
||||
});
|
||||
await store.updateTask(child.id, { dependencies: [parent.id] });
|
||||
|
||||
await expect(store.deleteTask(parent.id)).rejects.toBeInstanceOf(TaskHasDependentsError);
|
||||
|
||||
// Parent must still exist so the dependent isn't stranded.
|
||||
const stillThere = await store.getTask(parent.id);
|
||||
expect(stillThere.id).toBe(parent.id);
|
||||
|
||||
// The error must name the dependent so callers/logs can triage it.
|
||||
try {
|
||||
await store.deleteTask(parent.id);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TaskHasDependentsError);
|
||||
expect((err as TaskHasDependentsError).dependentIds).toContain(child.id);
|
||||
}
|
||||
|
||||
// After the dependent's reference is removed, delete succeeds.
|
||||
await store.updateTask(child.id, { dependencies: [] });
|
||||
await expect(store.deleteTask(parent.id)).resolves.toMatchObject({ id: parent.id });
|
||||
});
|
||||
|
||||
it("deleteTask allows deletion when a similarly-named id contains the target (substring false-positive guard)", async () => {
|
||||
// The LIKE probe uses '%id%'; ensure we don't misidentify e.g. FN-1 as
|
||||
// referencing FN-10 just because the id string appears inside a JSON
|
||||
// array containing "FN-10".
|
||||
const targetTask = await store.createTask({ description: "Target" }); // e.g. FN-001
|
||||
const similarId = `${targetTask.id}X`; // definitely not a real task id
|
||||
const other = await store.createTask({ description: "Other" });
|
||||
await store.updateTask(other.id, { dependencies: [similarId] });
|
||||
|
||||
// Should NOT throw — the LIKE probe's string match is disambiguated by
|
||||
// JSON.parse + array.includes.
|
||||
await expect(store.deleteTask(targetTask.id)).resolves.toMatchObject({ id: targetTask.id });
|
||||
});
|
||||
|
||||
it("deleting a task cascades agent log entry deletion", async () => {
|
||||
const task = await createTestTask();
|
||||
await store.appendAgentLog(task.id, "cascade me", "text");
|
||||
|
||||
@@ -85,6 +85,30 @@ export interface TaskStoreEvents {
|
||||
"agent:log": [entry: AgentLogEntry];
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by {@link TaskStore.deleteTask} when the target task is still
|
||||
* referenced by at least one other live task's `dependencies` array.
|
||||
*
|
||||
* Callers that intend to split a task into children (e.g. triage, the
|
||||
* dashboard subtask-breakdown endpoint) must rewrite or drop those
|
||||
* references *before* deleting the parent — otherwise the dependents
|
||||
* would be permanently blocked by a nonexistent id.
|
||||
*/
|
||||
export class TaskHasDependentsError extends Error {
|
||||
readonly taskId: string;
|
||||
readonly dependentIds: string[];
|
||||
|
||||
constructor(taskId: string, dependentIds: string[]) {
|
||||
super(
|
||||
`Cannot delete task ${taskId}: still referenced as a dependency by ${dependentIds.join(", ")}. ` +
|
||||
`Rewrite or remove these dependencies before deleting.`,
|
||||
);
|
||||
this.name = "TaskHasDependentsError";
|
||||
this.taskId = taskId;
|
||||
this.dependentIds = dependentIds;
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
static async getOrCreateForProject(
|
||||
projectId?: string,
|
||||
@@ -290,6 +314,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
stuckKillCount: row.stuckKillCount ?? undefined,
|
||||
postReviewFixCount: row.postReviewFixCount ?? undefined,
|
||||
recoveryRetryCount: row.recoveryRetryCount ?? undefined,
|
||||
taskDoneRetryCount: row.taskDoneRetryCount ?? undefined,
|
||||
nextRecoveryAt: row.nextRecoveryAt || undefined,
|
||||
error: row.error || undefined,
|
||||
summary: row.summary || undefined,
|
||||
@@ -539,7 +564,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"modelPresetId", "modelProvider", "modelId",
|
||||
"validatorModelProvider", "validatorModelId",
|
||||
"planningModelProvider", "planningModelId",
|
||||
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "nextRecoveryAt",
|
||||
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "nextRecoveryAt",
|
||||
"error", "summary", "thinkingLevel",
|
||||
"createdAt", "updatedAt", "columnMovedAt",
|
||||
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
|
||||
@@ -557,7 +582,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"modelPresetId", "modelProvider", "modelId",
|
||||
"validatorModelProvider", "validatorModelId",
|
||||
"planningModelProvider", "planningModelId",
|
||||
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "nextRecoveryAt",
|
||||
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "nextRecoveryAt",
|
||||
"error", "summary", "thinkingLevel",
|
||||
"createdAt", "updatedAt", "columnMovedAt",
|
||||
"dependencies", "steps", "attachments", "steeringComments",
|
||||
@@ -598,14 +623,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
id, title, description, "column", status, size, reviewLevel, currentStep,
|
||||
worktree, blockedBy, paused, baseBranch, branch, baseCommitSha, modelPresetId, modelProvider,
|
||||
modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, mergeRetries,
|
||||
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, nextRecoveryAt, error,
|
||||
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, nextRecoveryAt, error,
|
||||
summary, thinkingLevel, createdAt, updatedAt, columnMovedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, workflowStepResults, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, checkedOutBy, checkedOutAt
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
@@ -633,6 +658,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
stuckKillCount = excluded.stuckKillCount,
|
||||
postReviewFixCount = excluded.postReviewFixCount,
|
||||
recoveryRetryCount = excluded.recoveryRetryCount,
|
||||
taskDoneRetryCount = excluded.taskDoneRetryCount,
|
||||
nextRecoveryAt = excluded.nextRecoveryAt,
|
||||
error = excluded.error,
|
||||
summary = excluded.summary,
|
||||
@@ -686,6 +712,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.stuckKillCount ?? 0,
|
||||
task.postReviewFixCount ?? 0,
|
||||
task.recoveryRetryCount ?? null,
|
||||
task.taskDoneRetryCount ?? 0,
|
||||
task.nextRecoveryAt ?? null,
|
||||
task.error ?? null,
|
||||
task.summary ?? null,
|
||||
@@ -728,6 +755,33 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
return this.rowToTask(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the ids of live tasks whose `dependencies` array contains `id`.
|
||||
*
|
||||
* Uses a SQL LIKE probe as a cheap pre-filter then parses the JSON column
|
||||
* to rule out false positives (substring matches on similar ids, matches
|
||||
* inside escaped strings, etc.).
|
||||
*/
|
||||
private findLiveDependents(id: string): string[] {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT id, dependencies FROM tasks WHERE dependencies LIKE ? AND id != ?`)
|
||||
.all(`%${id}%`, id) as Array<{ id: string; dependencies: string | null }>;
|
||||
|
||||
const dependents: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.dependencies) continue;
|
||||
try {
|
||||
const deps = JSON.parse(row.dependencies) as unknown;
|
||||
if (Array.isArray(deps) && deps.includes(id)) {
|
||||
dependents.push(row.id);
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON — skip; nothing we can verify.
|
||||
}
|
||||
}
|
||||
return dependents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for activity logging.
|
||||
* Call after init() to record task lifecycle events.
|
||||
@@ -2109,7 +2163,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
async updateTask(
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||
runContext?: RunMutationContext,
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
@@ -2219,6 +2273,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
} else if (updates.recoveryRetryCount !== undefined) {
|
||||
task.recoveryRetryCount = updates.recoveryRetryCount;
|
||||
}
|
||||
if (updates.taskDoneRetryCount === null) {
|
||||
task.taskDoneRetryCount = undefined;
|
||||
} else if (updates.taskDoneRetryCount !== undefined) {
|
||||
task.taskDoneRetryCount = updates.taskDoneRetryCount;
|
||||
}
|
||||
if (updates.nextRecoveryAt === null) {
|
||||
task.nextRecoveryAt = undefined;
|
||||
} else if (updates.nextRecoveryAt !== undefined) {
|
||||
@@ -2789,6 +2848,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
|
||||
// Refuse to delete a task that is still referenced as a dependency
|
||||
// by another live task. Scheduler treats missing-dep ids as unmet,
|
||||
// so silently deleting a task with live dependents would permanently
|
||||
// block them. Callers that want to split/replace a task must rewrite
|
||||
// or drop the incoming references first.
|
||||
const dependentIds = this.findLiveDependents(id);
|
||||
if (dependentIds.length > 0) {
|
||||
throw new TaskHasDependentsError(id, dependentIds);
|
||||
}
|
||||
|
||||
// Clean up the task's branch before deleting from DB
|
||||
const cleanedBranches = await this.cleanupBranchForTask(task);
|
||||
if (cleanedBranches.length > 0) {
|
||||
|
||||
@@ -1434,6 +1434,115 @@ describe("POST /subtasks/*", () => {
|
||||
validatorModelId: undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
it("drops a subtask dependency that references the parent task being split", async () => {
|
||||
// Regression: the AI/UI sometimes emits `dependsOn: ["<parentId>"]` on a
|
||||
// child. Previously the child was created with a reference to the
|
||||
// parent id (via an existing-task lookup), then the parent was deleted,
|
||||
// leaving the child permanently blocked. We now drop parent-id deps and
|
||||
// surface them in the response.
|
||||
const parentTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-PARENT",
|
||||
title: "Parent",
|
||||
column: "triage",
|
||||
};
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(parentTask);
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-CHILD",
|
||||
title: "Child",
|
||||
column: "triage",
|
||||
});
|
||||
(store.deleteTask as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
|
||||
const start = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/subtasks/start-streaming",
|
||||
JSON.stringify({ description: "Break this feature into subtasks" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
const createRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/subtasks/create-tasks",
|
||||
JSON.stringify({
|
||||
sessionId: start.body.sessionId,
|
||||
parentTaskId: "FN-PARENT",
|
||||
subtasks: [
|
||||
{ tempId: "subtask-1", title: "Child", description: "Do it", dependsOn: ["FN-PARENT"] },
|
||||
],
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
// Dependencies must NOT contain the parent id.
|
||||
// updateTask either isn't called for deps, or is called with an empty array.
|
||||
const depUpdateCalls = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls
|
||||
.filter((args: unknown[]) => {
|
||||
const patch = args[1] as { dependencies?: string[] } | undefined;
|
||||
return patch?.dependencies !== undefined;
|
||||
});
|
||||
for (const call of depUpdateCalls) {
|
||||
expect((call[1] as { dependencies: string[] }).dependencies).not.toContain("FN-PARENT");
|
||||
}
|
||||
// The response surfaces the dropped dep instead of silently swallowing it.
|
||||
expect(createRes.body.droppedDependencies).toEqual([
|
||||
{ taskId: "FN-CHILD", dropped: ["FN-PARENT"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces parent close errors when deleteTask refuses due to live dependents", async () => {
|
||||
// If a child still references the parent after the drop step (shouldn't
|
||||
// happen post-fix, but could via race or caller mistake), store.deleteTask
|
||||
// throws. The endpoint must not swallow that silently — parentTaskClosed
|
||||
// is false AND parentTaskCloseError names the reason.
|
||||
const parentTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-STUBBORN",
|
||||
title: "Parent",
|
||||
column: "triage",
|
||||
};
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(parentTask);
|
||||
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "FN-CHILD",
|
||||
title: "Child",
|
||||
column: "triage",
|
||||
});
|
||||
(store.deleteTask as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Cannot delete task FN-STUBBORN: still referenced as a dependency by FN-OTHER."),
|
||||
);
|
||||
|
||||
const start = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/subtasks/start-streaming",
|
||||
JSON.stringify({ description: "Break this feature into subtasks" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
const createRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/subtasks/create-tasks",
|
||||
JSON.stringify({
|
||||
sessionId: start.body.sessionId,
|
||||
parentTaskId: "FN-STUBBORN",
|
||||
subtasks: [
|
||||
{ tempId: "subtask-1", title: "Child", description: "Do it", dependsOn: [] },
|
||||
],
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(createRes.status).toBe(201);
|
||||
expect(createRes.body.parentTaskClosed).toBe(false);
|
||||
expect(createRes.body.parentTaskCloseError).toContain("FN-OTHER");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/retry", () => {
|
||||
|
||||
@@ -8448,33 +8448,80 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve each subtask's dependsOn list:
|
||||
// - map tempIds to the newly-created sibling task ids
|
||||
// - drop any reference to the parent being split (would be a dangling id after delete)
|
||||
// - record dropped ids so the caller can surface them instead of silently losing them
|
||||
const droppedDependencies: Array<{ taskId: string; dropped: string[] }> = [];
|
||||
const normalizedParentId = typeof parentTaskId === "string" ? parentTaskId.trim() : "";
|
||||
|
||||
for (let index = 0; index < subtasks.length; index++) {
|
||||
const item = subtasks[index]!;
|
||||
const created = createdTasks[index]!;
|
||||
const resolvedDependencies = Array.isArray(item.dependsOn)
|
||||
? item.dependsOn.map((dep) => tempIdToTaskId.get(dep)).filter((dep): dep is string => Boolean(dep))
|
||||
: [];
|
||||
const rawDeps = Array.isArray(item.dependsOn) ? item.dependsOn : [];
|
||||
const resolvedDependencies: string[] = [];
|
||||
const dropped: string[] = [];
|
||||
|
||||
for (const dep of rawDeps) {
|
||||
if (typeof dep !== "string" || !dep) continue;
|
||||
if (normalizedParentId && dep === normalizedParentId) {
|
||||
// Parent is about to be deleted — depending on it would permanently
|
||||
// block the dependent.
|
||||
dropped.push(dep);
|
||||
continue;
|
||||
}
|
||||
const siblingId = tempIdToTaskId.get(dep);
|
||||
if (siblingId) {
|
||||
resolvedDependencies.push(siblingId);
|
||||
continue;
|
||||
}
|
||||
// Not a sibling tempId and not the parent — it could be an existing
|
||||
// task id. Keep it only if it resolves to a live task; otherwise drop.
|
||||
try {
|
||||
await scopedStore.getTask(dep);
|
||||
resolvedDependencies.push(dep);
|
||||
} catch {
|
||||
dropped.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedDependencies.length > 0) {
|
||||
const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies });
|
||||
createdTasks[index] = updated;
|
||||
}
|
||||
if (dropped.length > 0) {
|
||||
droppedDependencies.push({ taskId: created.id, dropped });
|
||||
await scopedStore.logEntry(
|
||||
created.id,
|
||||
`Subtask breakdown: dropped invalid dependencies [${dropped.join(", ")}] (parent-id or unknown task id)`,
|
||||
);
|
||||
}
|
||||
|
||||
await scopedStore.logEntry(created.id, "Created via subtask breakdown", `Source: ${session.initialDescription.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
let parentTaskClosed = false;
|
||||
if (typeof parentTaskId === "string" && parentTaskId.trim()) {
|
||||
let parentTaskCloseError: string | undefined;
|
||||
if (normalizedParentId) {
|
||||
try {
|
||||
await scopedStore.deleteTask(parentTaskId);
|
||||
await scopedStore.deleteTask(normalizedParentId);
|
||||
parentTaskClosed = true;
|
||||
} catch {
|
||||
} catch (err: unknown) {
|
||||
// deleteTask refuses when live tasks still reference the parent id.
|
||||
// Keep the parent alive and surface the reason; silently failing here
|
||||
// is what left FN-2164 blocked by the ghost of FN-2163.
|
||||
parentTaskClosed = false;
|
||||
parentTaskCloseError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
cleanupSubtaskSession(sessionId);
|
||||
res.status(201).json({ tasks: createdTasks, parentTaskClosed });
|
||||
res.status(201).json({
|
||||
tasks: createdTasks,
|
||||
parentTaskClosed,
|
||||
parentTaskCloseError,
|
||||
droppedDependencies,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user