feat(FN-4045): surface and handle delegate task collision errors

Added delegate collision detection and error surfacing across the task creation and delegation pipeline. The core store now throws a typed `DelegateCollisionError` when `createTask` encounters a duplicate task ID, the engine's `delegate_task` tool propagates these errors with context, and the CLI ex

Fusion-Task-Id: FN-4045
This commit is contained in:
Fusion
2026-05-11 22:58:23 -07:00
committed by gsxdsm
parent c6edf5f49f
commit 7b5ec3d063
7 changed files with 194 additions and 91 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Surface task ID collision errors for task creation and delegation tools.

View File

@@ -884,6 +884,7 @@ Create a new task and assign it to a specific agent for execution. The task goes
- `"ERROR: Agent {agent_id} not found"` - `"ERROR: Agent {agent_id} not found"`
- `"ERROR: Cannot delegate to ephemeral/runtime agent {agent_id}"` - `"ERROR: Cannot delegate to ephemeral/runtime agent {agent_id}"`
- `"ERROR: Agent {agent_id} has role \"...\"; implementation task <new> requires an \"executor\"-role agent by default, with durable \"engineer\" supported only for explicit routing. Pass override=true to bypass."` - `"ERROR: Agent {agent_id} has role \"...\"; implementation task <new> requires an \"executor\"-role agent by default, with durable \"engineer\" supported only for explicit routing. Pass override=true to bypass."`
- `"ERROR: Task ID already exists: {id}"` (allocator collision; request fails without mutating the existing task)
### `agent_create` ### `agent_create`

View File

@@ -245,4 +245,23 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr
expect(rejected.isError).toBe(true); expect(rejected.isError).toBe(true);
expect(rejected.content[0].text).toContain("ephemeral/runtime agent"); expect(rejected.content[0].text).toContain("ephemeral/runtime agent");
}); });
it("returns explicit error when fn_delegate_task hits task-id collision", async () => {
const agent = await seedAgent(tmpDir, { name: "release-agent" });
const delegateTool = api.tools.get("fn_delegate_task")!;
const createSpy = vi.spyOn(TaskStore.prototype, "createTask").mockRejectedValueOnce(new Error("Task ID already exists: FN-001"));
const result = await delegateTool.execute(
"delegate-collision",
{ agent_id: agent.id, description: "collision task" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Task ID already exists: FN-001");
expect(result.details.error).toContain("Task ID already exists: FN-001");
createSpy.mockRestore();
});
}); });

View File

@@ -1474,6 +1474,24 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content[0].text).toContain(ephemeralId); expect(result.content[0].text).toContain(ephemeralId);
}); });
it("returns explicit collision error when fn_task_create hits an existing task id", async () => {
const createSpy = vi.spyOn(TaskStore.prototype, "createTask").mockRejectedValueOnce(new Error("Task ID already exists: FN-001"));
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-collision",
{ description: "collision task" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Task ID already exists: FN-001");
expect(result.details.error).toContain("Task ID already exists: FN-001");
createSpy.mockRestore();
});
it("fn_task_create allows durable engineer assignment for implementation tasks", async () => { it("fn_task_create allows durable engineer assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") }); const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init(); await agentStore.init();

View File

@@ -425,41 +425,52 @@ export default function kbExtension(pi: ExtensionAPI) {
} }
} }
const task = await store.createTask({ try {
description: params.description.trim(), const task = await store.createTask({
dependencies: params.depends, description: params.description.trim(),
assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId, dependencies: params.depends,
source: { sourceType: "api" }, assignedAgentId: normalizedAgentId === null ? undefined : normalizedAgentId,
}); source: { sourceType: "api" },
});
const label = const label =
task.description.length > 80 task.description.length > 80
? task.description.slice(0, 80) + "…" ? task.description.slice(0, 80) + "…"
: task.description; : task.description;
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: text:
`Created ${task.id}: ${label}\n` + `Created ${task.id}: ${label}\n` +
`Column: triage\n` + `Column: triage\n` +
(task.dependencies.length (task.dependencies.length
? `Dependencies: ${task.dependencies.join(", ")}\n` ? `Dependencies: ${task.dependencies.join(", ")}\n`
: "") + : "") +
(task.assignedAgentId (task.assignedAgentId
? `Assigned to: ${task.assignedAgentId}\n` ? `Assigned to: ${task.assignedAgentId}\n`
: "") + : "") +
`Path: .fusion/tasks/${task.id}/`, `Path: .fusion/tasks/${task.id}/`,
},
],
details: {
taskId: task.id,
column: task.column,
dependencies: task.dependencies,
assignedAgentId: task.assignedAgentId,
}, },
], };
details: { } catch (error) {
taskId: task.id, if (error instanceof Error && error.message.startsWith("Task ID already exists:")) {
column: task.column, return {
dependencies: task.dependencies, content: [{ type: "text", text: `ERROR: ${error.message}` }],
assignedAgentId: task.assignedAgentId, isError: true,
}, details: { error: error.message },
}; };
}
throw error;
}
}, },
}); });
@@ -2763,28 +2774,39 @@ export default function kbExtension(pi: ExtensionAPI) {
await agentStore.init(); await agentStore.init();
const agent = await agentStore.getAgent(params.agent_id); const agent = await agentStore.getAgent(params.agent_id);
// Create task assigned to the target agent try {
const store = await getStore(ctx.cwd); // Create task assigned to the target agent
const task = await store.createTask({ const store = await getStore(ctx.cwd);
description: params.description, const task = await store.createTask({
dependencies: params.dependencies, description: params.description,
column: "todo", dependencies: params.dependencies,
assignedAgentId: params.agent_id, column: "todo",
source: { assignedAgentId: params.agent_id,
sourceType: "api", source: {
...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}), sourceType: "api",
}, ...(params.override === true ? { sourceMetadata: { executorRoleOverride: true } } : {}),
}); },
});
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
return { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,
text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}. ` + text: `Delegated to ${agent!.name} (${agent!.id}): Created ${task.id}${deps}. ` +
`The task will be picked up by ${agent!.name} on their next heartbeat cycle.`, `The task will be picked up by ${agent!.name} on their next heartbeat cycle.`,
}], }],
details: { taskId: task.id, agentId: agent!.id, agentName: agent!.name }, details: { taskId: task.id, agentId: agent!.id, agentName: agent!.name },
}; };
} catch (error) {
if (error instanceof Error && error.message.startsWith("Task ID already exists:")) {
return {
content: [{ type: "text", text: `ERROR: ${error.message}` }],
isError: true,
details: { error: error.message },
};
}
throw error;
}
}, },
}); });

View File

@@ -256,6 +256,22 @@ describe("createDelegateTaskTool", () => {
expect(taskStore.createTask).not.toHaveBeenCalled(); expect(taskStore.createTask).not.toHaveBeenCalled();
}); });
it("returns explicit collision error when delegated createTask hits existing id", async () => {
const agent = createAgent({ id: "agent-001", name: "Bob" });
vi.mocked(agentStore.getAgent).mockResolvedValue(agent);
vi.mocked(taskStore.createTask).mockRejectedValue(new Error("Task ID already exists: FN-050"));
const tool = createDelegateTaskTool(agentStore, taskStore);
const result = await tool.execute("session-1", {
agent_id: "agent-001",
description: "Write tests",
}, undefined as any, undefined as any, undefined as any);
expect((result as { isError?: boolean }).isError).toBe(true);
expect((result.content[0] as { text: string }).text).toBe("ERROR: Task ID already exists: FN-050");
expect(result.details).toEqual({});
});
it("allows durable engineer target without override", async () => { it("allows durable engineer target without override", async () => {
const engineer = createAgent({ id: "agent-009", name: "Eli", role: "engineer" }); const engineer = createAgent({ id: "agent-009", name: "Eli", role: "engineer" });
vi.mocked(agentStore.getAgent).mockResolvedValue(engineer); vi.mocked(agentStore.getAgent).mockResolvedValue(engineer);

View File

@@ -623,24 +623,35 @@ export function createTaskCreateTool(
"or the current task should wait for the new one).", "or the current task should wait for the new one).",
parameters: taskCreateParams, parameters: taskCreateParams,
execute: async (_id: string, params: Static<typeof taskCreateParams>) => { execute: async (_id: string, params: Static<typeof taskCreateParams>) => {
const task = await createAgentTask(store, { try {
description: params.description, const task = await createAgentTask(store, {
dependencies: params.dependencies, description: params.description,
column: "triage", dependencies: params.dependencies,
source: provenance ? { column: "triage",
sourceType: provenance.sourceType, source: provenance ? {
sourceAgentId: provenance.sourceAgentId, sourceType: provenance.sourceType,
sourceRunId: provenance.sourceRunId, sourceAgentId: provenance.sourceAgentId,
} : undefined, sourceRunId: provenance.sourceRunId,
}, options); } : undefined,
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; }, options);
return { const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
content: [{ return {
type: "text" as const, content: [{
text: `Created ${task.id}: ${params.description}${deps}`, type: "text" as const,
}], text: `Created ${task.id}: ${params.description}${deps}`,
details: { taskId: task.id }, }],
}; details: { taskId: task.id },
};
} catch (err) {
if (err instanceof Error && err.message.startsWith("Task ID already exists:")) {
return {
content: [{ type: "text" as const, text: `ERROR: ${err.message}` }],
details: {},
isError: true,
};
}
throw err;
}
}, },
}; };
} }
@@ -1761,27 +1772,38 @@ export function createDelegateTaskTool(
}; };
} }
// Create task assigned to the target agent try {
const task = await createAgentTask(taskStore, { // Create task assigned to the target agent
description: params.description, const task = await createAgentTask(taskStore, {
dependencies: params.dependencies, description: params.description,
column: "todo", dependencies: params.dependencies,
assignedAgentId: params.agent_id, column: "todo",
source: { assignedAgentId: params.agent_id,
sourceType: "api", source: {
...(override ? { sourceMetadata: { executorRoleOverride: true } } : {}), sourceType: "api",
}, ...(override ? { sourceMetadata: { executorRoleOverride: true } } : {}),
}, options); },
}, options);
const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : "";
return { return {
content: [{ content: [{
type: "text" as const, type: "text" as const,
text: `Delegated to ${agent.name} (${agent.id}): Created ${task.id}${deps}. ` + text: `Delegated to ${agent.name} (${agent.id}): Created ${task.id}${deps}. ` +
`The task will be picked up by ${agent.name} on their next heartbeat cycle.`, `The task will be picked up by ${agent.name} on their next heartbeat cycle.`,
}], }],
details: { taskId: task.id, agentId: agent.id, agentName: agent.name }, details: { taskId: task.id, agentId: agent.id, agentName: agent.name },
}; };
} catch (err) {
if (err instanceof Error && err.message.startsWith("Task ID already exists:")) {
return {
content: [{ type: "text" as const, text: `ERROR: ${err.message}` }],
details: {},
isError: true,
};
}
throw err;
}
}, },
}; };
} }