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:
7
.changeset/track-task-planning-lineage.md
Normal file
7
.changeset/track-task-planning-lineage.md
Normal 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.
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"] },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user