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:
5
.changeset/fn-4045-collision-errors.md
Normal file
5
.changeset/fn-4045-collision-errors.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Surface task ID collision errors for task creation and delegation tools.
|
||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user