fix(FN-8277): preserve planning task lineage (#2324)

## Summary

Planning breakdowns now preserve their creating task as durable lineage
and reuse only siblings from that same parent. Identical wording under a
different parent creates a distinct child instead of silently linking
the wrong lineage.

The dashboard planning path now uses the same duplicate-safe creation
contract as agent tools, leaves reused canonical tasks untouched, and
exposes API-created parent links in task details.

Related: FN-8277

## Validation

- Core duplicate guard: 12 tests passed
- Engine task creation: 32 tests passed
- Dashboard planning routes: 4 focused tests passed
- Dashboard task detail provenance: 2 focused tests passed
- Core, engine, dashboard, and CLI typechecks passed
- Lint and strict changeset validation passed


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Preserved parent-task lineage for subtasks created through planning
breakdowns and API workflows.
- Improved duplicate detection so identical tasks from different parent
tasks can coexist safely.
  - Added parent-task links to API-created task provenance details.
  - Reused existing duplicates only within the same parent-task context.

- **Bug Fixes**
- Prevented duplicate handling from incorrectly archiving or skipping
tasks belonging to other parents.
  - Improved dependency handling when creating planned subtasks.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-18 15:35:26 -07:00
committed by GitHub
parent 569abcc258
commit 4a4f231ef4
9 changed files with 248 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Preserve parent lineage and reuse duplicate tasks created from planning breakdowns.
category: feature
dev: Scopes deterministic task reuse by `sourceParentTaskId` and links API-created children to their parent in task details.

View File

@@ -100,6 +100,38 @@ describe("runDeterministicDuplicateGuard", () => {
result.releaseLock(); result.releaseLock();
}); });
it("scopes exact duplicates to the creating parent task", async () => {
const foreignSibling = mkTask({
id: "FN-1",
title: INPUT.title,
description: INPUT.description,
column: "todo",
sourceParentTaskId: "FN-PARENT-A",
source: {
sourceType: "api",
sourceParentTaskId: "FN-PARENT-A",
sourceMetadata: { contentFingerprint: "fp" },
},
});
const { store } = makeStore([foreignSibling]);
vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValue([foreignSibling]);
const otherParent = await runDeterministicDuplicateGuard(store, INPUT, {
lockScope: "p-1",
sourceParentTaskId: "FN-PARENT-B",
});
expect(otherParent.action).toBe("proceed");
otherParent.releaseLock();
const sameParent = await runDeterministicDuplicateGuard(store, INPUT, {
lockScope: "p-1",
sourceParentTaskId: "FN-PARENT-A",
});
expect(sameParent.action).toBe("duplicate");
expect(sameParent.existing?.id).toBe("FN-1");
sameParent.releaseLock();
});
it("serializes concurrent calls with same lock scope", async () => { it("serializes concurrent calls with same lock scope", async () => {
const { store, tasks } = makeStore(); const { store, tasks } = makeStore();
const first = runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" }); const first = runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" });
@@ -188,6 +220,42 @@ describe("runDeterministicDuplicateGuard", () => {
}); });
describe("reconcileDeterministicDuplicate", () => { describe("reconcileDeterministicDuplicate", () => {
it("does not archive an identical task created by a different parent", async () => {
const canonicalTs = new Date(Date.now() - 2_000).toISOString();
const createdTs = new Date().toISOString();
const foreignSibling = mkTask({
id: "FN-1",
title: INPUT.title,
description: INPUT.description,
column: "todo",
createdAt: canonicalTs,
updatedAt: canonicalTs,
sourceParentTaskId: "FN-PARENT-A",
source: { sourceType: "api", sourceParentTaskId: "FN-PARENT-A", sourceMetadata: { contentFingerprint: "fp" } },
});
const created = mkTask({
id: "FN-2",
title: INPUT.title,
description: INPUT.description,
column: "todo",
createdAt: createdTs,
updatedAt: createdTs,
sourceParentTaskId: "FN-PARENT-B",
source: { sourceType: "api", sourceParentTaskId: "FN-PARENT-B", sourceMetadata: { contentFingerprint: "fp" } },
});
const { store } = makeStore([foreignSibling, created]);
vi.spyOn(store, "findRecentTasksByContentFingerprint").mockResolvedValueOnce([foreignSibling, created]);
const result = await reconcileDeterministicDuplicate(store, {
createdTask: created,
fingerprint: "fp",
sourceParentTaskId: "FN-PARENT-B",
});
expect(result).toEqual({ outcome: "kept", canonical: created });
expect(store.moveTask).not.toHaveBeenCalled();
});
it("archives late-race loser and records activity metadata", async () => { it("archives late-race loser and records activity metadata", async () => {
const canonicalTs = new Date(Date.now() - 2_000).toISOString(); const canonicalTs = new Date(Date.now() - 2_000).toISOString();
const createdTs = new Date().toISOString(); const createdTs = new Date().toISOString();

View File

@@ -17,6 +17,8 @@ export interface DeterministicGuardOptions {
logger?: { warn(msg: string, data?: Record<string, unknown>): void }; logger?: { warn(msg: string, data?: Record<string, unknown>): void };
/** Serialize related creates even when their exact-content fingerprints differ. */ /** Serialize related creates even when their exact-content fingerprints differ. */
serializationKey?: string; serializationKey?: string;
/** When set, only tasks created by this parent can satisfy the duplicate check. */
sourceParentTaskId?: string | null;
} }
export interface DeterministicGuardOutcome { export interface DeterministicGuardOutcome {
@@ -37,6 +39,10 @@ function clampWindowMs(windowMs?: number): number {
function noop(): void {} function noop(): void {}
function matchesParentScope(task: Task, sourceParentTaskId?: string | null): boolean {
return !sourceParentTaskId || task.sourceParentTaskId === sourceParentTaskId;
}
export async function runDeterministicDuplicateGuard( export async function runDeterministicDuplicateGuard(
store: TaskStore, store: TaskStore,
input: { title?: string | null; description: string }, input: { title?: string | null; description: string },
@@ -56,7 +62,9 @@ export async function runDeterministicDuplicateGuard(
windowMs, windowMs,
includeArchived: false, includeArchived: false,
}); });
const deterministicConflict = deterministicMatches.find((match) => !acknowledged.has(match.id)); const deterministicConflict = deterministicMatches.find((match) =>
matchesParentScope(match, opts?.sourceParentTaskId) && !acknowledged.has(match.id),
);
if (deterministicConflict) { if (deterministicConflict) {
return { action: "duplicate", fingerprint, existing: deterministicConflict, releaseLock: noop }; return { action: "duplicate", fingerprint, existing: deterministicConflict, releaseLock: noop };
} }
@@ -69,7 +77,7 @@ export async function runDeterministicDuplicateGuard(
return { action: "proceed", fingerprint, releaseLock: noop }; return { action: "proceed", fingerprint, releaseLock: noop };
} }
const lockKey = `${opts.lockScope}:${opts.serializationKey ?? fingerprint}`; const lockKey = `${opts.lockScope}:${opts.sourceParentTaskId ?? "*"}:${opts.serializationKey ?? fingerprint}`;
const existingLock = deterministicGuardLocks.get(lockKey); const existingLock = deterministicGuardLocks.get(lockKey);
let releaseCalled = false; let releaseCalled = false;
let resolveGate: (() => void) | undefined; let resolveGate: (() => void) | undefined;
@@ -106,7 +114,9 @@ export async function runDeterministicDuplicateGuard(
windowMs, windowMs,
includeArchived: false, includeArchived: false,
}); });
const deterministicConflict = deterministicMatches.find((match) => !acknowledged.has(match.id)); const deterministicConflict = deterministicMatches.find((match) =>
matchesParentScope(match, opts.sourceParentTaskId) && !acknowledged.has(match.id),
);
if (deterministicConflict) { if (deterministicConflict) {
return { action: "duplicate", fingerprint, existing: deterministicConflict, releaseLock }; return { action: "duplicate", fingerprint, existing: deterministicConflict, releaseLock };
} }
@@ -127,6 +137,7 @@ export async function reconcileDeterministicDuplicate(
createdTask: Task; createdTask: Task;
fingerprint: string | null; fingerprint: string | null;
windowMs?: number; windowMs?: number;
sourceParentTaskId?: string | null;
logger?: { warn(msg: string, data?: Record<string, unknown>): void }; logger?: { warn(msg: string, data?: Record<string, unknown>): void };
}, },
): Promise<{ outcome: "kept" | "archived"; canonical: Task }> { ): Promise<{ outcome: "kept" | "archived"; canonical: Task }> {
@@ -140,7 +151,11 @@ export async function reconcileDeterministicDuplicate(
includeArchived: false, includeArchived: false,
}); });
const olderSibling = siblings.find((sibling) => sibling.id !== args.createdTask.id && sibling.createdAt < args.createdTask.createdAt); const olderSibling = siblings.find((sibling) =>
sibling.id !== args.createdTask.id
&& sibling.createdAt < args.createdTask.createdAt
&& matchesParentScope(sibling, args.sourceParentTaskId),
);
if (!olderSibling) { if (!olderSibling) {
return { outcome: "kept", canonical: args.createdTask }; return { outcome: "kept", canonical: args.createdTask };
} }

View File

@@ -619,7 +619,10 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt
case "cli": case "cli":
return { label: tr ? tr("taskDetail.provenance.cli", "CLI") : "CLI" }; return { label: tr ? tr("taskDetail.provenance.cli", "CLI") : "CLI" };
case "api": case "api":
return { label: tr ? tr("taskDetail.provenance.api", "API") : "API" }; return {
label: tr ? tr("taskDetail.provenance.api", "API") : "API",
parentTaskId: task.sourceParentTaskId,
};
case "recovery": case "recovery":
return { label: tr ? tr("taskDetail.provenance.recovery", "Recovery") : "Recovery" }; return { label: tr ? tr("taskDetail.provenance.recovery", "Recovery") : "Recovery" };
case "unknown": case "unknown":

View File

@@ -343,6 +343,26 @@ describe("TaskDetailModal", () => {
}); });
}); });
it("renders parent task link for API-created planning tasks", async () => {
render(
<TaskDetailModal
initialTab="definition"
task={makeTask({ sourceType: "api", sourceParentTaskId: "FN-PLANNER" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(/Created via API/)).toBeInTheDocument();
const link = screen.getByRole("button", { name: "FN-PLANNER" });
await userEvent.click(link);
await waitFor(() => expect(noopOpenDetail).toHaveBeenCalled());
});
it("renders compact github issue link for github import provenance", () => { it("renders compact github issue link for github import provenance", () => {
render( render(
<TaskDetailModal <TaskDetailModal

View File

@@ -150,6 +150,20 @@ vi.mock("@fusion/engine", async () => {
Route tests mock @fusion/engine wholesale, but planning/subtask helpers now resolve MCP servers before creating read-only AI sessions. Keep the default MCP result shaped so unrelated route assertions do not fail on the fallback vi.fn() returning undefined. Route tests mock @fusion/engine wholesale, but planning/subtask helpers now resolve MCP servers before creating read-only AI sessions. Keep the default MCP result shaped so unrelated route assertions do not fail on the fallback vi.fn() returning undefined.
*/ */
resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }), resolveMcpServersForStore: vi.fn().mockResolvedValue({ servers: [], errors: [] }),
createAgentTask: vi.fn(async (
taskStore: TaskStore,
input: Parameters<TaskStore["createTask"]>[0],
options?: { sourceTaskId?: string },
) => ({
task: await taskStore.createTask({
...input,
source: input.source ?? {
sourceType: "api",
sourceParentTaskId: options?.sourceTaskId,
},
}),
wasDuplicate: false,
})),
AgentReflectionService: class MockAgentReflectionService { AgentReflectionService: class MockAgentReflectionService {
async generateReflection(): Promise<import("@fusion/core").AgentReflection | null> { async generateReflection(): Promise<import("@fusion/core").AgentReflection | null> {
throw new Error("Reflection service unavailable in route tests"); throw new Error("Reflection service unavailable in route tests");
@@ -163,7 +177,7 @@ vi.mock("@fusion/engine", async () => {
}); });
import { AgentStore, Database, RoutineStore, isGhAvailable, isGhAuthenticated } from "@fusion/core"; import { AgentStore, Database, RoutineStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { createFnAgent } from "@fusion/engine"; import { createAgentTask, createFnAgent } from "@fusion/engine";
const mockIsGhAvailable = vi.mocked(isGhAvailable); const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated); const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
@@ -2165,6 +2179,66 @@ describe("POST /subtasks/*", () => {
expect(store.updateTask).toHaveBeenCalledWith("FN-102", { dependencies: ["FN-101"] }); expect(store.updateTask).toHaveBeenCalledWith("FN-102", { dependencies: ["FN-101"] });
}); });
it("checks for a parent-scoped duplicate before persisting a planned task", async () => {
const existing = {
...FAKE_TASK_DETAIL,
id: "FN-EXISTING",
title: "Existing child",
column: "triage",
sourceParentTaskId: "FN-PARENT",
};
vi.mocked(createAgentTask).mockResolvedValueOnce({ task: existing, wasDuplicate: true });
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-PARENT" });
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: "Existing child", description: "Do existing work", size: "L" }],
}), { "Content-Type": "application/json" });
expect(createRes.status).toBe(201);
expect(createRes.body.tasks[0].id).toBe("FN-EXISTING");
expect(createAgentTask).toHaveBeenCalledWith(store, expect.objectContaining({
source: expect.objectContaining({ sourceParentTaskId: "FN-PARENT" }),
}), expect.objectContaining({ sourceTaskId: "FN-PARENT" }));
expect(store.createTask).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalled();
});
it("keeps updates for the created sibling when a later input reuses it", async () => {
const canonical = {
...FAKE_TASK_DETAIL,
id: "FN-CANONICAL",
title: "Canonical child",
column: "triage",
sourceParentTaskId: "FN-PARENT",
};
vi.mocked(createAgentTask)
.mockResolvedValueOnce({ task: canonical, wasDuplicate: false })
.mockResolvedValueOnce({ task: canonical, wasDuplicate: true });
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-PARENT" });
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...canonical, size: "S" });
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: "Canonical child", description: "Do the work", size: "S" },
{ tempId: "subtask-2", title: "Canonical child rewritten", description: "Do the same work", size: "L" },
],
}), { "Content-Type": "application/json" });
expect(createRes.status).toBe(201);
expect(store.updateTask).toHaveBeenCalledTimes(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-CANONICAL", { size: "S" });
expect(store.logEntry).toHaveBeenCalledTimes(1);
});
it("subtask batch creation succeeds without explicit tracking issue creation", async () => { it("subtask batch creation succeeds without explicit tracking issue creation", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({ const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
owner: "task", owner: "task",
@@ -2536,9 +2610,9 @@ describe("POST /subtasks/*", () => {
"/api/subtasks/create-tasks", "/api/subtasks/create-tasks",
JSON.stringify({ JSON.stringify({
sessionId: start.body.sessionId, sessionId: start.body.sessionId,
parentTaskId: "FN-PARENT", parentTaskId: "fn-parent",
subtasks: [ subtasks: [
{ tempId: "subtask-1", title: "Child", description: "Do it", dependsOn: ["FN-PARENT"] }, { tempId: "subtask-1", title: "Child", description: "Do it", dependsOn: ["fn-parent"] },
], ],
}), }),
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
@@ -2554,10 +2628,11 @@ describe("POST /subtasks/*", () => {
}); });
for (const call of depUpdateCalls) { for (const call of depUpdateCalls) {
expect((call[1] as { dependencies: string[] }).dependencies).not.toContain("FN-PARENT"); expect((call[1] as { dependencies: string[] }).dependencies).not.toContain("FN-PARENT");
expect((call[1] as { dependencies: string[] }).dependencies).not.toContain("fn-parent");
} }
// The response surfaces the dropped dep instead of silently swallowing it. // The response surfaces the dropped dep instead of silently swallowing it.
expect(createRes.body.droppedDependencies).toEqual([ expect(createRes.body.droppedDependencies).toEqual([
{ taskId: "FN-CHILD", dropped: ["FN-PARENT"] }, { taskId: "FN-CHILD", dropped: ["fn-parent"] },
]); ]);
}); });

View File

@@ -9,6 +9,7 @@ import {
type TaskStore, type TaskStore,
type ThinkingLevel, type ThinkingLevel,
} from "@fusion/core"; } from "@fusion/core";
import { createAgentTask } from "@fusion/engine";
import { normalizePlanningSummaryPayload } from "../planning.js"; import { normalizePlanningSummaryPayload } from "../planning.js";
import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js"; import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js";
import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js"; import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js";
@@ -296,7 +297,9 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
inheritedBaseBranch: resolvedBaseBranch, inheritedBaseBranch: resolvedBaseBranch,
}; };
const normalizedParentId = typeof parentTaskId === "string" ? parentTaskId.trim().toUpperCase() : "";
const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[]; const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
const wasDuplicateByIndex: boolean[] = [];
const tempIdToTaskId = new Map<string, string>(); const tempIdToTaskId = new Map<string, string>();
for (const item of subtasks) { for (const item of subtasks) {
@@ -317,7 +320,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
column resolution. Omitting `column` lets the store resolve intake for the column resolution. Omitting `column` lets the store resolve intake for the
selected-or-default workflow (byte-identical "triage" for builtin:coding). selected-or-default workflow (byte-identical "triage" for builtin:coding).
*/ */
const task = await scopedStore.createTask({ const { task, wasDuplicate } = await createAgentTask(scopedStore, {
title: item.title.trim(), title: item.title.trim(),
description: typeof item.description === "string" ? item.description.trim() : item.title.trim(), description: typeof item.description === "string" ? item.description.trim() : item.title.trim(),
dependencies: undefined, dependencies: undefined,
@@ -326,7 +329,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
modelId: parentTask?.modelId, modelId: parentTask?.modelId,
validatorModelProvider: parentTask?.validatorModelProvider, validatorModelProvider: parentTask?.validatorModelProvider,
validatorModelId: parentTask?.validatorModelId, validatorModelId: parentTask?.validatorModelId,
source: { sourceType: "api", sourceParentTaskId: typeof parentTaskId === "string" ? parentTaskId : undefined }, source: { sourceType: "api", sourceParentTaskId: normalizedParentId || undefined },
branch: taskBranch, branch: taskBranch,
baseBranch: resolvedBaseBranch, baseBranch: resolvedBaseBranch,
branchContext: planningBranchContext, branchContext: planningBranchContext,
@@ -335,12 +338,16 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
Tasks created from a workflow lane via subtask breakdown must stay on that active workflow instead of falling back to the project default board. Tasks created from a workflow lane via subtask breakdown must stay on that active workflow instead of falling back to the project default board.
*/ */
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}), ...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
}, {
rootDir: scopedStore.getRootDir(),
sourceTaskId: normalizedParentId || undefined,
}); });
tempIdToTaskId.set(item.tempId, task.id); tempIdToTaskId.set(item.tempId, task.id);
createdTasks.push(task); createdTasks.push(task);
wasDuplicateByIndex.push(wasDuplicate);
if (item.size === "S" || item.size === "M" || item.size === "L") { if (!wasDuplicate && (item.size === "S" || item.size === "M" || item.size === "L")) {
await scopedStore.updateTask(task.id, { size: item.size }); await scopedStore.updateTask(task.id, { size: item.size });
} }
} }
@@ -350,18 +357,17 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
// - drop any reference to the parent being split (would be a dangling id after delete) // - 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 // - record dropped ids so the caller can surface them instead of silently losing them
const droppedDependencies: Array<{ taskId: string; dropped: string[] }> = []; const droppedDependencies: Array<{ taskId: string; dropped: string[] }> = [];
const normalizedParentId = typeof parentTaskId === "string" ? parentTaskId.trim() : "";
for (let index = 0; index < subtasks.length; index++) { for (let index = 0; index < subtasks.length; index++) {
const item = subtasks[index]!; const item = subtasks[index]!;
const created = createdTasks[index]!; const created = createdTasks[index]!;
if (wasDuplicateByIndex[index]) continue;
const rawDeps = Array.isArray(item.dependsOn) ? item.dependsOn : []; const rawDeps = Array.isArray(item.dependsOn) ? item.dependsOn : [];
const resolvedDependencies: string[] = []; const resolvedDependencies: string[] = [];
const dropped: string[] = []; const dropped: string[] = [];
for (const dep of rawDeps) { for (const dep of rawDeps) {
if (typeof dep !== "string" || !dep) continue; if (typeof dep !== "string" || !dep) continue;
if (normalizedParentId && dep === normalizedParentId) { if (normalizedParentId && dep.trim().toUpperCase() === normalizedParentId) {
// Parent is about to be deleted — depending on it would permanently // Parent is about to be deleted — depending on it would permanently
// block the dependent. // block the dependent.
dropped.push(dep); dropped.push(dep);
@@ -369,7 +375,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
} }
const siblingId = tempIdToTaskId.get(dep); const siblingId = tempIdToTaskId.get(dep);
if (siblingId) { if (siblingId) {
resolvedDependencies.push(siblingId); if (siblingId !== created.id) resolvedDependencies.push(siblingId);
continue; continue;
} }
// Not a sibling tempId and not the parent — it could be an existing // Not a sibling tempId and not the parent — it could be an existing

View File

@@ -332,6 +332,42 @@ describe("createDelegateTaskTool", () => {
expect(tasks).toHaveLength(3); expect(tasks).toHaveLength(3);
}); });
it("keeps identical follow-ups from different parent tasks separate", async () => {
const foreign = {
id: "FN-A",
title: "",
description: "Write the regression test",
dependencies: [],
column: "triage" as const,
sourceParentTaskId: "FN-PARENT-A",
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as Task;
const created = {
...foreign,
id: "FN-B",
sourceParentTaskId: "FN-PARENT-B",
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
} as Task;
vi.mocked(taskStore.findRecentTasksByContentFingerprint)
.mockResolvedValueOnce([foreign])
.mockResolvedValueOnce([foreign, created]);
vi.mocked(taskStore.findRecentTasksBySourceParentTaskId).mockResolvedValue([]);
vi.mocked(taskStore.createTask).mockResolvedValue(created);
const result = await createAgentTask(taskStore, {
description: "Write the regression test",
}, { sourceTaskId: "FN-PARENT-B" });
expect(result).toEqual({ task: created, wasDuplicate: false });
expect(taskStore.createTask).toHaveBeenCalled();
expect(taskStore.moveTask).not.toHaveBeenCalled();
});
it("persists option-based parent provenance on the step-session fn_task_create surface", async () => { it("persists option-based parent provenance on the step-session fn_task_create surface", async () => {
const tool = createTaskCreateTool(taskStore, undefined, { sourceTaskId: "FN-PARENT", sourceAgentId: "agent-worker" }); const tool = createTaskCreateTool(taskStore, undefined, { sourceTaskId: "FN-PARENT", sourceAgentId: "agent-worker" });
await tool.execute("call-1", { description: "Capture optional report screenshots" }, undefined as any, undefined as any, undefined as any); await tool.execute("call-1", { description: "Capture optional report screenshots" }, undefined as any, undefined as any, undefined as any);

View File

@@ -991,6 +991,7 @@ export async function createAgentTask(
bypass: options?.bypassDuplicateCheck === true, bypass: options?.bypassDuplicateCheck === true,
acknowledgedDuplicates: options?.acknowledgedDuplicates, acknowledgedDuplicates: options?.acknowledgedDuplicates,
serializationKey: sourceParentTaskId ? `parent:${sourceParentTaskId}` : undefined, serializationKey: sourceParentTaskId ? `parent:${sourceParentTaskId}` : undefined,
sourceParentTaskId,
logger: log, logger: log,
}); });
@@ -1083,6 +1084,7 @@ export async function createAgentTask(
const reconcile = await reconcileDeterministicDuplicate(store, { const reconcile = await reconcileDeterministicDuplicate(store, {
createdTask, createdTask,
fingerprint: guard.fingerprint, fingerprint: guard.fingerprint,
sourceParentTaskId,
logger: log, logger: log,
}); });