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();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface DeterministicGuardOptions {
|
||||
logger?: { warn(msg: string, data?: Record<string, unknown>): 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<string, unknown>): 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 };
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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", () => {
|
||||
render(
|
||||
<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.
|
||||
*/
|
||||
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 {
|
||||
async generateReflection(): Promise<import("@fusion/core").AgentReflection | null> {
|
||||
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<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 () => {
|
||||
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"] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ReturnType<TaskStore["createTask"]>>[];
|
||||
const wasDuplicateByIndex: boolean[] = [];
|
||||
const tempIdToTaskId = new Map<string, string>();
|
||||
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user