From 4a4f231ef44b6425a75846687500ebc9e41354ae Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 15:35:26 -0700 Subject: [PATCH] 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 ## 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. --- .changeset/track-task-planning-lineage.md | 7 ++ .../src/__tests__/duplicate-guard.test.ts | 68 +++++++++++++++ packages/core/src/duplicate-guard.ts | 23 ++++- .../app/components/TaskDetailModal.tsx | 5 +- .../TaskDetailModal.rendering.test.tsx | 20 +++++ .../src/__tests__/routes-tasks.test.ts | 83 ++++++++++++++++++- .../register-planning-subtask-routes.ts | 20 +++-- .../__tests__/agent-tools-delegation.test.ts | 36 ++++++++ packages/engine/src/agent-tools.ts | 2 + 9 files changed, 248 insertions(+), 16 deletions(-) create mode 100644 .changeset/track-task-planning-lineage.md diff --git a/.changeset/track-task-planning-lineage.md b/.changeset/track-task-planning-lineage.md new file mode 100644 index 0000000000..ef9f6ae0c0 --- /dev/null +++ b/.changeset/track-task-planning-lineage.md @@ -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. diff --git a/packages/core/src/__tests__/duplicate-guard.test.ts b/packages/core/src/__tests__/duplicate-guard.test.ts index 319319aca7..761775fd5d 100644 --- a/packages/core/src/__tests__/duplicate-guard.test.ts +++ b/packages/core/src/__tests__/duplicate-guard.test.ts @@ -100,6 +100,38 @@ describe("runDeterministicDuplicateGuard", () => { 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 () => { const { store, tasks } = makeStore(); const first = runDeterministicDuplicateGuard(store, INPUT, { lockScope: "p-1" }); @@ -188,6 +220,42 @@ describe("runDeterministicDuplicateGuard", () => { }); 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 () => { const canonicalTs = new Date(Date.now() - 2_000).toISOString(); const createdTs = new Date().toISOString(); diff --git a/packages/core/src/duplicate-guard.ts b/packages/core/src/duplicate-guard.ts index d836440e83..1422eed293 100644 --- a/packages/core/src/duplicate-guard.ts +++ b/packages/core/src/duplicate-guard.ts @@ -17,6 +17,8 @@ export interface DeterministicGuardOptions { logger?: { warn(msg: string, data?: Record): void }; /** Serialize related creates even when their exact-content fingerprints differ. */ serializationKey?: string; + /** When set, only tasks created by this parent can satisfy the duplicate check. */ + sourceParentTaskId?: string | null; } export interface DeterministicGuardOutcome { @@ -37,6 +39,10 @@ function clampWindowMs(windowMs?: number): number { function noop(): void {} +function matchesParentScope(task: Task, sourceParentTaskId?: string | null): boolean { + return !sourceParentTaskId || task.sourceParentTaskId === sourceParentTaskId; +} + export async function runDeterministicDuplicateGuard( store: TaskStore, input: { title?: string | null; description: string }, @@ -56,7 +62,9 @@ export async function runDeterministicDuplicateGuard( windowMs, 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) { return { action: "duplicate", fingerprint, existing: deterministicConflict, releaseLock: noop }; } @@ -69,7 +77,7 @@ export async function runDeterministicDuplicateGuard( 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); let releaseCalled = false; let resolveGate: (() => void) | undefined; @@ -106,7 +114,9 @@ export async function runDeterministicDuplicateGuard( windowMs, 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) { return { action: "duplicate", fingerprint, existing: deterministicConflict, releaseLock }; } @@ -127,6 +137,7 @@ export async function reconcileDeterministicDuplicate( createdTask: Task; fingerprint: string | null; windowMs?: number; + sourceParentTaskId?: string | null; logger?: { warn(msg: string, data?: Record): void }; }, ): Promise<{ outcome: "kept" | "archived"; canonical: Task }> { @@ -140,7 +151,11 @@ export async function reconcileDeterministicDuplicate( 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) { return { outcome: "kept", canonical: args.createdTask }; } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 8c1c6f35ca..fd911c2349 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -619,7 +619,10 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt case "cli": return { label: tr ? tr("taskDetail.provenance.cli", "CLI") : "CLI" }; 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": return { label: tr ? tr("taskDetail.provenance.recovery", "Recovery") : "Recovery" }; case "unknown": diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index b0fd7998ea..5af7c10c51 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -343,6 +343,26 @@ describe("TaskDetailModal", () => { }); }); + it("renders parent task link for API-created planning tasks", async () => { + render( + , + ); + + 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", () => { render( { 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: [] }), + createAgentTask: vi.fn(async ( + taskStore: TaskStore, + input: Parameters[0], + options?: { sourceTaskId?: string }, + ) => ({ + task: await taskStore.createTask({ + ...input, + source: input.source ?? { + sourceType: "api", + sourceParentTaskId: options?.sourceTaskId, + }, + }), + wasDuplicate: false, + })), AgentReflectionService: class MockAgentReflectionService { async generateReflection(): Promise { 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 { createFnAgent } from "@fusion/engine"; +import { createAgentTask, createFnAgent } from "@fusion/engine"; const mockIsGhAvailable = vi.mocked(isGhAvailable); const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated); @@ -2165,6 +2179,66 @@ describe("POST /subtasks/*", () => { 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).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).mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-PARENT" }); + (store.updateTask as ReturnType).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 () => { const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({ owner: "task", @@ -2536,9 +2610,9 @@ describe("POST /subtasks/*", () => { "/api/subtasks/create-tasks", JSON.stringify({ sessionId: start.body.sessionId, - parentTaskId: "FN-PARENT", + parentTaskId: "fn-parent", 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" }, @@ -2554,10 +2628,11 @@ describe("POST /subtasks/*", () => { }); 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"); } // The response surfaces the dropped dep instead of silently swallowing it. expect(createRes.body.droppedDependencies).toEqual([ - { taskId: "FN-CHILD", dropped: ["FN-PARENT"] }, + { taskId: "FN-CHILD", dropped: ["fn-parent"] }, ]); }); diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index 048cd82696..3c3f7d434f 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -9,6 +9,7 @@ import { type TaskStore, type ThinkingLevel, } from "@fusion/core"; +import { createAgentTask } from "@fusion/engine"; import { normalizePlanningSummaryPayload } from "../planning.js"; import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js"; import { writeSSEEvent, type SessionBufferedEvent } from "../sse-buffer.js"; @@ -296,7 +297,9 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann inheritedBaseBranch: resolvedBaseBranch, }; + const normalizedParentId = typeof parentTaskId === "string" ? parentTaskId.trim().toUpperCase() : ""; const createdTasks = [] as Awaited>[]; + const wasDuplicateByIndex: boolean[] = []; const tempIdToTaskId = new Map(); 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 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(), description: typeof item.description === "string" ? item.description.trim() : item.title.trim(), dependencies: undefined, @@ -326,7 +329,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann modelId: parentTask?.modelId, validatorModelProvider: parentTask?.validatorModelProvider, validatorModelId: parentTask?.validatorModelId, - source: { sourceType: "api", sourceParentTaskId: typeof parentTaskId === "string" ? parentTaskId : undefined }, + source: { sourceType: "api", sourceParentTaskId: normalizedParentId || undefined }, branch: taskBranch, baseBranch: resolvedBaseBranch, 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. */ ...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}), + }, { + rootDir: scopedStore.getRootDir(), + sourceTaskId: normalizedParentId || undefined, }); tempIdToTaskId.set(item.tempId, task.id); 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 }); } } @@ -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) // - 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]!; + if (wasDuplicateByIndex[index]) continue; 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) { + if (normalizedParentId && dep.trim().toUpperCase() === normalizedParentId) { // Parent is about to be deleted — depending on it would permanently // block the dependent. dropped.push(dep); @@ -369,7 +375,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann } const siblingId = tempIdToTaskId.get(dep); if (siblingId) { - resolvedDependencies.push(siblingId); + if (siblingId !== created.id) resolvedDependencies.push(siblingId); continue; } // Not a sibling tempId and not the parent — it could be an existing diff --git a/packages/engine/src/__tests__/agent-tools-delegation.test.ts b/packages/engine/src/__tests__/agent-tools-delegation.test.ts index 02a31cd0d7..135aa5566e 100644 --- a/packages/engine/src/__tests__/agent-tools-delegation.test.ts +++ b/packages/engine/src/__tests__/agent-tools-delegation.test.ts @@ -332,6 +332,42 @@ describe("createDelegateTaskTool", () => { 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 () => { 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); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 3913bb2858..45eb514e1e 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -991,6 +991,7 @@ export async function createAgentTask( bypass: options?.bypassDuplicateCheck === true, acknowledgedDuplicates: options?.acknowledgedDuplicates, serializationKey: sourceParentTaskId ? `parent:${sourceParentTaskId}` : undefined, + sourceParentTaskId, logger: log, }); @@ -1083,6 +1084,7 @@ export async function createAgentTask( const reconcile = await reconcileDeterministicDuplicate(store, { createdTask, fingerprint: guard.fingerprint, + sourceParentTaskId, logger: log, });