From eb7d223a03e5782bc0b82cc42a9d527300649d8c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 10:53:19 -0700 Subject: [PATCH] FN-8207: add task reassignment for delegated work Correct delegation routing for duplicate tasks and enable explicit task owner reassignment. - Preserve requested assignee and todo routing on duplicate canonical tasks - Add governed fn_task_assign to engine, heartbeat, triage, workflow, and chat sessions - Cover assignment validation, truthful delegation responses, and tool availability Files changed: .changeset/fn-8207-delegate-assign.md | 7 ++ docs/agents.md | 6 ++ packages/core/src/types.ts | 1 + packages/core/src/usage-events.ts | 2 +- .../dashboard/src/__tests__/chat-manager.test.ts | 2 + packages/dashboard/src/__tests__/chat.test.ts | 2 + packages/dashboard/src/chat.ts | 2 + .../engine/src/__tests__/agent-action-gate.test.ts | 2 + .../src/__tests__/agent-tools-delegation.test.ts | 99 ++++++++++++++++++- .../src/__tests__/agent-tools-task-assign.test.ts | 75 +++++++++++++++ .../src/__tests__/gating-classifications.test.ts | 1 + .../src/__tests__/heartbeat-executor.test.ts | 8 +- .../src/__tests__/permanent-agent-gating.test.ts | 2 + .../src/__tests__/step-session-executor.test.ts | 4 +- packages/engine/src/__tests__/triage.test.ts | 5 +- packages/engine/src/agent-heartbeat.ts | 4 +- packages/engine/src/agent-tools.ts | 105 ++++++++++++++++++++- packages/engine/src/executor.ts | 3 + packages/engine/src/gating-classifications.ts | 1 + packages/engine/src/index.ts | 2 + packages/engine/src/step-session-executor.ts | 2 + packages/engine/src/triage.ts | 2 + 22 files changed, 324 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-8207 Fusion-Task-Lineage: db6f3876-bdc8-4279-b726-29344d9acdb9 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8207-delegate-assign.md | 7 ++ docs/agents.md | 6 + packages/core/src/types.ts | 1 + packages/core/src/usage-events.ts | 2 +- .../src/__tests__/chat-manager.test.ts | 2 + packages/dashboard/src/__tests__/chat.test.ts | 2 + packages/dashboard/src/chat.ts | 2 + .../src/__tests__/agent-action-gate.test.ts | 2 + .../__tests__/agent-tools-delegation.test.ts | 99 ++++++++++++++++- .../__tests__/agent-tools-task-assign.test.ts | 75 +++++++++++++ .../__tests__/gating-classifications.test.ts | 1 + .../src/__tests__/heartbeat-executor.test.ts | 8 +- .../__tests__/permanent-agent-gating.test.ts | 2 + .../__tests__/step-session-executor.test.ts | 4 +- packages/engine/src/__tests__/triage.test.ts | 5 +- packages/engine/src/agent-heartbeat.ts | 4 +- packages/engine/src/agent-tools.ts | 105 +++++++++++++++++- packages/engine/src/executor.ts | 3 + packages/engine/src/gating-classifications.ts | 1 + packages/engine/src/index.ts | 2 + packages/engine/src/step-session-executor.ts | 2 + packages/engine/src/triage.ts | 2 + 22 files changed, 324 insertions(+), 13 deletions(-) create mode 100644 .changeset/fn-8207-delegate-assign.md create mode 100644 packages/engine/src/__tests__/agent-tools-task-assign.test.ts diff --git a/.changeset/fn-8207-delegate-assign.md b/.changeset/fn-8207-delegate-assign.md new file mode 100644 index 0000000000..f7b2f1d8bb --- /dev/null +++ b/.changeset/fn-8207-delegate-assign.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Correct duplicate delegation ownership and add engine task reassignment. +category: fix +dev: Engine sessions now expose fn_task_assign; CLI reassignment remains fn_task_update(agentId). diff --git a/docs/agents.md b/docs/agents.md index e04f4f9440..0f88b73125 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1226,6 +1226,12 @@ Delete a non-ephemeral direct-report agent. Deletion is blocked when the target - `"ERROR: Cannot delete ephemeral/runtime agent {agent_id}"` - Underlying store errors (for example, an active checkout lease) are returned as `"ERROR: {message}"`; provide `force: true` to bypass lease-related blocking. +### Engine-session task reassignment + +Engine-managed agent sessions (executor, heartbeat, triage, workflow-step, and dashboard chat) expose `fn_task_assign(task_id, agent_id, override?)` to retarget an existing task by ID. It uses the same durable-agent and role/assignment-policy checks as `fn_delegate_task`; ephemeral agents and `assignmentPolicy: "none"` cannot be selected, and `override` only bypasses the role check. + +`fn_task_update` in engine sessions remains lifecycle-only. In the CLI/pi extension, use the existing `fn_task_update(id, agentId)` reassignment field instead; there is no redundant CLI `fn_task_assign` alias. If engine `fn_delegate_task` finds a deterministic duplicate, it updates the canonical task's requested owner and column before returning and reports the actual owner rather than promising an incorrect heartbeat pickup. + ### Role-based assignment policy Implementation-task routing distinguishes explicit specialist assignment from generic backlog pickup: diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5109ca5405..ef5cb88d67 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -5831,6 +5831,7 @@ export const AGENT_PERMISSION_POLICY_CATEGORY_TOOL_EXAMPLES: Record< "fn_spawn_agent", "fn_update_agent_config", "fn_task_update", + "fn_task_assign", "fn_workflow_create", "fn_workflow_update", "fn_workflow_delete", diff --git a/packages/core/src/usage-events.ts b/packages/core/src/usage-events.ts index b8ca5ea0a4..ba0e554819 100644 --- a/packages/core/src/usage-events.ts +++ b/packages/core/src/usage-events.ts @@ -120,7 +120,7 @@ export function categorizeToolName(toolName: string | null | undefined): string if (name.startsWith("fn_skills_")) return "skills"; if (name.startsWith("fn_memory_")) return "memory"; if (name === "fn_list_agents" || name === "fn_agent_org_chart") return "read"; - if (name.startsWith("fn_agent_") || name === "fn_delegate_task") return "agents"; + if (name.startsWith("fn_agent_") || name === "fn_delegate_task" || name === "fn_task_assign") return "agents"; if ( name.startsWith("fn_mission_") || name.startsWith("fn_milestone_") || diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index feeba655a7..079869ef45 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -880,6 +880,7 @@ describe("ChatManager.sendMessage", () => { "fn_task_search", "fn_task_create", "fn_delegate_task", + "fn_task_assign", "fn_list_agents", "fn_get_agent_config", "fn_web_fetch", @@ -3642,6 +3643,7 @@ describe("ChatManager generation isolation", () => { "fn_task_search", "fn_task_create", "fn_delegate_task", + "fn_task_assign", "fn_list_agents", "fn_get_agent_config", "fn_web_fetch", diff --git a/packages/dashboard/src/__tests__/chat.test.ts b/packages/dashboard/src/__tests__/chat.test.ts index 633d9d99b9..6ddfeda9c1 100644 --- a/packages/dashboard/src/__tests__/chat.test.ts +++ b/packages/dashboard/src/__tests__/chat.test.ts @@ -67,6 +67,7 @@ vi.mock("@fusion/engine", () => ({ createTaskSearchTool: vi.fn(), createListAgentsTool: vi.fn(), createDelegateTaskTool: vi.fn(), + createTaskAssignTool: vi.fn(), createGetAgentConfigTool: vi.fn(), createWebFetchTool: vi.fn(), createGoalRetrievalTools: vi.fn(() => []), @@ -88,6 +89,7 @@ vi.mock("@fusion/engine", () => ({ createTaskSearchTool: vi.fn(() => ({})), createListAgentsTool: vi.fn(() => ({})), createDelegateTaskTool: vi.fn(() => ({})), + createTaskAssignTool: vi.fn(() => ({})), createGetAgentConfigTool: vi.fn(() => ({})), createWebFetchTool: vi.fn(() => ({})), createGoalRetrievalTools: vi.fn(() => []), diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 8f5f865ed4..c142865d9b 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -64,6 +64,7 @@ import { createTaskSearchTool, createListAgentsTool, createDelegateTaskTool, + createTaskAssignTool, createGetAgentConfigTool, createWebFetchTool, createGoalRetrievalTools, @@ -378,6 +379,7 @@ export async function createChatFusionToolset(options: ChatFusionToolsetOptions) tools.push(createListAgentsTool(agentStore)); if (taskStore) { tools.push(createDelegateTaskTool(agentStore, taskStore, { rootDir })); + tools.push(createTaskAssignTool(agentStore, taskStore)); } if (agentId) { tools.push(createGetAgentConfigTool(agentStore, agentId)); diff --git a/packages/engine/src/__tests__/agent-action-gate.test.ts b/packages/engine/src/__tests__/agent-action-gate.test.ts index 88c3c3e4e7..5e13bca762 100644 --- a/packages/engine/src/__tests__/agent-action-gate.test.ts +++ b/packages/engine/src/__tests__/agent-action-gate.test.ts @@ -207,6 +207,7 @@ describe("agent-action-gate", () => { expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_create", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_add_dep", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_delegate_task", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); + expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_assign", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_update_agent_config", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); expect(evaluateAgentActionGate({ agentId: "a1", toolName: "fn_task_import_github_issue", args: {}, permissionPolicy: unrestrictedPolicy }).category).toBe("task_agent_mutation"); @@ -527,6 +528,7 @@ describe("agent-action-gate", () => { it.each([ "fn_task_create", "fn_delegate_task", + "fn_task_assign", "fn_task_import_github", "fn_task_import_github_issue", "fn_task_import_gitlab_project_issues", diff --git a/packages/engine/src/__tests__/agent-tools-delegation.test.ts b/packages/engine/src/__tests__/agent-tools-delegation.test.ts index 4e39be955b..46af001526 100644 --- a/packages/engine/src/__tests__/agent-tools-delegation.test.ts +++ b/packages/engine/src/__tests__/agent-tools-delegation.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Agent, AgentStore, TaskStore, Task } from "@fusion/core"; -import { createListAgentsTool, createDelegateTaskTool } from "../agent-tools.js"; +import { createAgentTask, createListAgentsTool, createDelegateTaskTool } from "../agent-tools.js"; function createMockAgentStore(overrides: Partial = {}): AgentStore { return { @@ -14,6 +14,9 @@ function createMockAgentStore(overrides: Partial = {}): AgentStore { function createMockTaskStore(overrides: Partial = {}): TaskStore { return { getSettings: vi.fn().mockResolvedValue({ autoSummarizeTitles: false }), + findRecentTasksByContentFingerprint: vi.fn().mockResolvedValue([]), + updateTask: vi.fn(), + moveTask: vi.fn(), createTask: vi.fn().mockResolvedValue({ id: "FN-001", description: "", @@ -239,6 +242,100 @@ describe("createDelegateTaskTool", () => { expect(text).toContain("picked up by Bob on their next heartbeat cycle"); }); + it("reassigns and moves a duplicate canonical task before reporting delegation", async () => { + const agent = createAgent({ id: "agent-002", name: "Rita" }); + const existing = { + id: "FN-duplicate", + description: "Write tests", + dependencies: [], + column: "triage" as const, + assignedAgentId: "agent-001", + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const reassigned = { ...existing, assignedAgentId: "agent-002" }; + const moved = { ...reassigned, column: "todo" as const }; + vi.mocked(agentStore.getAgent).mockResolvedValue(agent); + vi.mocked(taskStore.findRecentTasksByContentFingerprint).mockResolvedValue([existing]); + vi.mocked(taskStore.updateTask).mockResolvedValue(reassigned); + vi.mocked(taskStore.moveTask).mockResolvedValue(moved); + + const result = await createDelegateTaskTool(agentStore, taskStore).execute("session-1", { + agent_id: "agent-002", + description: "Write tests", + }, undefined as any, undefined as any, undefined as any); + + expect(taskStore.updateTask).toHaveBeenCalledWith("FN-duplicate", { assignedAgentId: "agent-002" }); + expect(taskStore.moveTask).toHaveBeenCalledWith("FN-duplicate", "todo"); + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("Delegated to Rita (agent-002): Linked existing FN-duplicate"); + expect(text).toContain("picked up by Rita on their next heartbeat cycle"); + }); + + it("does not mutate a same-owner duplicate canonical task", async () => { + const existing = { + id: "FN-duplicate", + description: "Write tests", + dependencies: [], + column: "todo" as const, + assignedAgentId: "agent-001", + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + vi.mocked(taskStore.findRecentTasksByContentFingerprint).mockResolvedValue([existing]); + + const result = await createAgentTask(taskStore, { + description: "Write tests", + column: "todo", + assignedAgentId: "agent-001", + }); + + expect(result.task).toBe(existing); + expect(taskStore.updateTask).not.toHaveBeenCalled(); + expect(taskStore.moveTask).not.toHaveBeenCalled(); + }); + + it("carries delegation routing onto the reconcile canonical task", async () => { + const created = { + id: "FN-new", + description: "Write tests", + dependencies: [], + column: "todo" as const, + steps: [], currentStep: 0, log: [], + createdAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z", + }; + const canonical = { ...created, id: "FN-old", assignedAgentId: "agent-old", column: "triage" as const, createdAt: "2026-01-01T00:00:00.000Z" }; + const reassigned = { ...canonical, assignedAgentId: "agent-002" }; + const moved = { ...reassigned, column: "todo" as const }; + vi.mocked(taskStore.createTask).mockResolvedValue(created); + vi.mocked(taskStore.findRecentTasksByContentFingerprint) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([canonical, created]); + vi.mocked(taskStore.updateTask).mockImplementation(async (id, updates) => + id === "FN-old" ? reassigned : { ...created, ...updates }, + ); + vi.mocked(taskStore.moveTask).mockImplementation(async (id, column) => + id === "FN-old" ? moved : { ...created, id, column }, + ); + + const result = await createAgentTask(taskStore, { + description: "Write tests", + column: "todo", + assignedAgentId: "agent-002", + }); + + expect(result.wasDuplicate).toBe(true); + expect(result.task).toBe(moved); + expect(taskStore.updateTask).toHaveBeenCalledWith("FN-old", { assignedAgentId: "agent-002" }); + expect(taskStore.moveTask).toHaveBeenCalledWith("FN-old", "todo"); + }); + it("returns success message with task ID and agent name", async () => { const agent = createAgent({ id: "agent-001", name: "Bob" }); vi.mocked(agentStore.getAgent).mockResolvedValue(agent); diff --git a/packages/engine/src/__tests__/agent-tools-task-assign.test.ts b/packages/engine/src/__tests__/agent-tools-task-assign.test.ts new file mode 100644 index 0000000000..9db23a0c5a --- /dev/null +++ b/packages/engine/src/__tests__/agent-tools-task-assign.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Agent, AgentStore, Task, TaskStore } from "@fusion/core"; +import { createTaskAssignTool } from "../agent-tools.js"; + +function agent(overrides: Partial = {}): Agent { + const now = "2026-07-29T00:00:00.000Z"; + return { + id: "agent-001", name: "Ari", role: "executor", state: "idle", + createdAt: now, updatedAt: now, metadata: {}, ...overrides, + }; +} + +function task(overrides: Partial = {}): Task { + return { + id: "FN-001", description: "Implement fix", dependencies: [], column: "todo", + steps: [], currentStep: 0, log: [], createdAt: "2026-07-29T00:00:00.000Z", + updatedAt: "2026-07-29T00:00:00.000Z", ...overrides, + }; +} + +describe("createTaskAssignTool", () => { + let agentStore: AgentStore; + let taskStore: TaskStore; + + beforeEach(() => { + agentStore = { getAgent: vi.fn().mockResolvedValue(null) } as unknown as AgentStore; + taskStore = { + getTask: vi.fn().mockResolvedValue(task()), + updateTask: vi.fn().mockImplementation(async (id, updates) => task({ id, ...updates })), + } as unknown as TaskStore; + }); + + it("assigns an existing task and truthfully confirms its owner", async () => { + vi.mocked(agentStore.getAgent).mockResolvedValue(agent({ id: "agent-002", name: "Bea" })); + const result = await createTaskAssignTool(agentStore, taskStore).execute("run", { + task_id: "FN-001", agent_id: "agent-002", + }, undefined as never, undefined as never, undefined as never); + + expect(taskStore.updateTask).toHaveBeenCalledWith("FN-001", { assignedAgentId: "agent-002" }); + expect((result.content[0] as { text: string }).text).toBe("Assigned FN-001 to Bea (agent-002)."); + }); + + it("reports missing tasks and agents", async () => { + const tool = createTaskAssignTool(agentStore, taskStore); + let result = await tool.execute("run", { task_id: "FN-missing", agent_id: "agent-missing" }, undefined as never, undefined as never, undefined as never); + expect((result.content[0] as { text: string }).text).toContain("Agent agent-missing not found"); + + vi.mocked(agentStore.getAgent).mockResolvedValue(agent()); + vi.mocked(taskStore.getTask).mockRejectedValue(new Error("missing")); + result = await tool.execute("run", { task_id: "FN-missing", agent_id: "agent-001" }, undefined as never, undefined as never, undefined as never); + expect((result.content[0] as { text: string }).text).toContain("Task FN-missing not found"); + }); + + it("rejects ephemeral and assignmentPolicy none agents", async () => { + const tool = createTaskAssignTool(agentStore, taskStore); + vi.mocked(agentStore.getAgent).mockResolvedValue(agent({ id: "executor-FN-1", metadata: { agentKind: "task-worker" } })); + let result = await tool.execute("run", { task_id: "FN-001", agent_id: "executor-FN-1" }, undefined as never, undefined as never, undefined as never); + expect((result.content[0] as { text: string }).text).toContain("Cannot assign to ephemeral/runtime agent"); + + vi.mocked(agentStore.getAgent).mockResolvedValue(agent({ runtimeConfig: { assignmentPolicy: "none" } })); + result = await tool.execute("run", { task_id: "FN-001", agent_id: "agent-001", override: true }, undefined as never, undefined as never, undefined as never); + expect((result.content[0] as { text: string }).text).toContain('assignmentPolicy "none"'); + expect(taskStore.updateTask).not.toHaveBeenCalled(); + }); + + it("requires override for a reviewer target", async () => { + vi.mocked(agentStore.getAgent).mockResolvedValue(agent({ role: "reviewer" })); + const tool = createTaskAssignTool(agentStore, taskStore); + let result = await tool.execute("run", { task_id: "FN-001", agent_id: "agent-001" }, undefined as never, undefined as never, undefined as never); + expect((result.content[0] as { text: string }).text).toContain("Pass override=true"); + + result = await tool.execute("run", { task_id: "FN-001", agent_id: "agent-001", override: true }, undefined as never, undefined as never, undefined as never); + expect((result.content[0] as { text: string }).text).toContain("Assigned FN-001"); + }); +}); diff --git a/packages/engine/src/__tests__/gating-classifications.test.ts b/packages/engine/src/__tests__/gating-classifications.test.ts index 8ebff527ae..f696bef45c 100644 --- a/packages/engine/src/__tests__/gating-classifications.test.ts +++ b/packages/engine/src/__tests__/gating-classifications.test.ts @@ -63,6 +63,7 @@ const FN_7111_GOVERNED_TOOLS = [ ["fn_workflow_delete", "task_agent_mutation"], ["fn_workflow_settings", "task_agent_mutation"], ["fn_task_update", "task_agent_mutation"], + ["fn_task_assign", "task_agent_mutation"], ["fn_task_promote", "task_agent_mutation"], ["fn_task_refine", "task_agent_mutation"], ["fn_run_verification", "command_execution"], diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 1e0ece5206..07e3b6ec7f 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -1616,7 +1616,7 @@ describe("executeHeartbeat", () => { expect(result.resultJson).toEqual({ reason: "no_assignment" }); }); - it("identity agent without task receives correct tools (fn_task_create, fn_list_agents, fn_delegate_task, fn_heartbeat_done)", async () => { + it("identity agent without task receives delegation and task assignment tools", async () => { const store = createStoreWithAgentForExec({ taskId: undefined, soul: "I am a coordinator" }); const mockSession = createMockAgentSession(); mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any }); @@ -1630,10 +1630,11 @@ describe("executeHeartbeat", () => { expect(callArgs.tools).toBe("coding"); const toolNames = callArgs.customTools!.map((tool: any) => tool.name); - // Should have fn_task_create, fn_list_agents, fn_delegate_task + // Delegation and direct reassignment must remain available together. expect(toolNames).toContain("fn_task_create"); expect(toolNames).toContain("fn_list_agents"); expect(toolNames).toContain("fn_delegate_task"); + expect(toolNames).toContain("fn_task_assign"); // Should have fn_heartbeat_done expect(toolNames).toContain("fn_heartbeat_done"); // Should have memory tools @@ -3274,7 +3275,7 @@ describe("executeHeartbeat", () => { */ // fn_artifact_register/list/view, agent config/provisioning, goals/evaluations/identity, // task read discovery (incl. logs_read), workflow discovery/authoring, task promotion, bounded research, clarification, web fetch, memory, and fn_heartbeat_done. - expect(callArgs.customTools).toHaveLength(42); + expect(callArgs.customTools).toHaveLength(43); expect(callArgs.customTools!.map((tool) => tool.name)).toEqual([ "fn_task_create", "fn_task_log", @@ -3286,6 +3287,7 @@ describe("executeHeartbeat", () => { "fn_artifact_view", "fn_list_agents", "fn_delegate_task", + "fn_task_assign", "fn_get_agent_config", "fn_update_agent_config", "fn_agent_create", diff --git a/packages/engine/src/__tests__/permanent-agent-gating.test.ts b/packages/engine/src/__tests__/permanent-agent-gating.test.ts index e28997ff85..1ed64ced29 100644 --- a/packages/engine/src/__tests__/permanent-agent-gating.test.ts +++ b/packages/engine/src/__tests__/permanent-agent-gating.test.ts @@ -52,6 +52,7 @@ const FN_7111_GOVERNED_TOOLS = [ ["fn_workflow_delete", "task_agent_mutation"], ["fn_workflow_settings", "task_agent_mutation"], ["fn_task_update", "task_agent_mutation"], + ["fn_task_assign", "task_agent_mutation"], ["fn_task_promote", "task_agent_mutation"], ["fn_task_refine", "task_agent_mutation"], ["fn_run_verification", "command_execution"], @@ -109,6 +110,7 @@ describe("permanent-agent-gating", () => { expect(classifyPermanentAgentToolCall("fn_research_cancel").category).toBe("network_api"); expect(classifyPermanentAgentToolCall("worktrunk_install").category).toBe("network_api"); expect(classifyPermanentAgentToolCall("fn_task_update").category).toBe("task_agent_mutation"); + expect(classifyPermanentAgentToolCall("fn_task_assign").category).toBe("task_agent_mutation"); expect(classifyPermanentAgentToolCall("fn_task_show").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_research_get").category).toBe("none"); expect(classifyPermanentAgentToolCall("fn_heartbeat_done")).toEqual({ category: "none", recognized: true }); diff --git a/packages/engine/src/__tests__/step-session-executor.test.ts b/packages/engine/src/__tests__/step-session-executor.test.ts index af56f20fdf..b81446769c 100644 --- a/packages/engine/src/__tests__/step-session-executor.test.ts +++ b/packages/engine/src/__tests__/step-session-executor.test.ts @@ -3062,7 +3062,7 @@ describe("StepSessionExecutor tool availability", () => { return captured; } - it("includes fn_list_agents and fn_delegate_task when agentStore is available", async () => { + it("includes fn_list_agents, fn_delegate_task, and fn_task_assign when agentStore is available", async () => { const mockAgentStore = { listAgents: vi.fn().mockResolvedValue([]), getAgent: vi.fn().mockResolvedValue(null), @@ -3075,6 +3075,7 @@ describe("StepSessionExecutor tool availability", () => { const toolNames = tools.map((t: any) => t.name); expect(toolNames).toContain("fn_list_agents"); expect(toolNames).toContain("fn_delegate_task"); + expect(toolNames).toContain("fn_task_assign"); }); it("excludes delegation tools when agentStore is not provided", async () => { @@ -3083,6 +3084,7 @@ describe("StepSessionExecutor tool availability", () => { const toolNames = tools.map((t: any) => t.name); expect(toolNames).not.toContain("fn_list_agents"); expect(toolNames).not.toContain("fn_delegate_task"); + expect(toolNames).not.toContain("fn_task_assign"); }); it("includes fn_send_message and fn_read_messages when messageStore and assignedAgentId are available", async () => { diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 5c52ae432b..31e8f9d966 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -6840,7 +6840,7 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => { } describe("skillSelection context propagation", () => { - it("passes skillSelection to createFnAgent with correct projectRootDir", async () => { + it("passes skillSelection and delegation reassignment tools to createFnAgent", async () => { const args = await captureCreateFnAgentArgs({ assignedAgentId: "agent-001", assignedAgentSkills: ["triage"], @@ -6849,6 +6849,9 @@ describe("TriageProcessor skillSelection regression (FN-1511)", () => { expect(args).not.toBeNull(); expect(args).toHaveProperty("skillSelection"); expect(args.skillSelection.projectRootDir).toBe(projectRoot); + const toolNames = args.customTools.map((tool: { name: string }) => tool.name); + expect(toolNames).toContain("fn_delegate_task"); + expect(toolNames).toContain("fn_task_assign"); }); it("uses 'triage' as sessionPurpose for triage sessions", async () => { diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index fa434c38d5..0f319bbe66 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -38,7 +38,7 @@ import { import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "@earendil-works/pi-ai"; import { createHash } from "node:crypto"; -import { createTaskCreateTool, createTaskLogToolWithContext, createTaskLogsReadTool, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js"; +import { createTaskCreateTool, createTaskLogToolWithContext, createTaskLogsReadTool, createTaskDocumentWriteTool, createTaskDocumentReadTool, createTaskReadTools, createArtifactRegisterTool, createArtifactListTool, createArtifactViewTool, createListAgentsTool, createDelegateTaskTool, createTaskAssignTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createAgentCreateTool, createAgentDeleteTool, createSendMessageTool, createReadMessagesTool, createPostRoomMessageTool, createMemoryTools, createGoalRetrievalTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, createWebFetchTool, createWorkflowListTool, createWorkflowGetTool, createWorkflowValidateTool, createWorkflowSelectTool, createTaskPromoteTool, createWorkflowCreateTool, createWorkflowUpdateTool, createWorkflowDeleteTool, createWorkflowSettingsTool, createTraitListTool, createAskQuestionTool, createResearchTools, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js"; import { AgentLogger } from "./agent-logger.js"; import { resolveAgentInstructionsWithRatings, @@ -2496,6 +2496,7 @@ export class HeartbeatMonitor { // Agent delegation tools heartbeatTools.push(createListAgentsTool(this.store)); heartbeatTools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir })); + heartbeatTools.push(createTaskAssignTool(this.store, taskStore)); heartbeatTools.push(createGetAgentConfigTool(this.store, agentId)); heartbeatTools.push(createUpdateAgentConfigTool(this.store, agentId)); heartbeatTools.push(createAgentCreateTool(this.store, agentId)); @@ -3757,6 +3758,7 @@ export class HeartbeatMonitor { // Agent delegation tools — discover and delegate work to other agents tools.push(createListAgentsTool(this.store)); tools.push(createDelegateTaskTool(this.store, taskStore, { rootDir: this.rootDir })); + tools.push(createTaskAssignTool(this.store, taskStore)); tools.push(createGetAgentConfigTool(this.store, agentId)); tools.push(createUpdateAgentConfigTool(this.store, agentId)); tools.push(createAgentCreateTool(this.store, agentId)); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index b5924d9687..40d4195469 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -13,7 +13,7 @@ import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import * as fusionCore from "@fusion/core"; -import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode } from "@fusion/core"; +import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, Task, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode } from "@fusion/core"; import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core"; import { promoteHeldTask } from "./hold-release.js"; import { DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core"; @@ -387,6 +387,12 @@ export const delegateTaskParams = Type.Object({ override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })), }); +export const taskAssignParams = Type.Object({ + task_id: Type.String({ description: "Task ID to assign (e.g. FN-001)" }), + agent_id: Type.String({ description: "Durable agent ID to assign to the task" }), + override: Type.Optional(Type.Boolean({ description: "Set true to bypass executor-role assignment policy" })), +}); + export const getAgentConfigParams = Type.Object({ agent_id: Type.String({ description: "The agent ID to read configuration for" }), }); @@ -933,6 +939,28 @@ type AgentTaskCreationOptions = { callerIsEphemeral?: boolean; }; +/* +FNXC:AgentRouting 2026-07-29-00:00: +FN-8207 requires deterministic-duplicate canonical tasks to honor an explicit delegate's owner and todo-column request. Carry both mutations in the engine task-creation seam so every canonical return path is truthful without changing the shared core duplicate-guard API. +*/ +async function carryCanonicalTaskRouting( + store: TaskStore, + canonical: Task, + input: TaskCreateInput, +): Promise { + // Task creation without an explicit assignee must not mutate an existing duplicate. + if (input.assignedAgentId === undefined) return canonical; + + let task = canonical; + if (input.assignedAgentId !== canonical.assignedAgentId) { + task = await store.updateTask(canonical.id, { assignedAgentId: input.assignedAgentId }); + } + if (input.column !== undefined && input.column !== task.column) { + task = await store.moveTask(task.id, input.column); + } + return task; +} + export async function createAgentTask( store: TaskStore, input: TaskCreateInput, @@ -954,7 +982,10 @@ export async function createAgentTask( try { if (guard.action === "duplicate" && guard.existing) { - return { task: guard.existing, wasDuplicate: true }; + return { + task: await carryCanonicalTaskRouting(store, guard.existing, input), + wasDuplicate: true, + }; } const sourceMetadata = { @@ -1003,7 +1034,12 @@ export async function createAgentTask( logger: log, }); - return { task: reconcile.canonical, wasDuplicate: reconcile.outcome === "archived" }; + return { + task: reconcile.outcome === "archived" + ? await carryCanonicalTaskRouting(store, reconcile.canonical, input) + : reconcile.canonical, + wasDuplicate: reconcile.outcome === "archived", + }; } finally { guard.releaseLock(); } @@ -4122,11 +4158,24 @@ export function createDelegateTaskTool( const deps = task.dependencies.length ? ` (depends on: ${task.dependencies.join(", ")})` : ""; const workflow = workflowId ? ` (workflow: ${workflowId})` : ""; + /* + FNXC:AgentRouting 2026-07-29-00:00: + FN-8207 requires delegation confirmation to reflect the canonical task's actual owner. Never promise a heartbeat pickup by the requested agent when a duplicate canonical task remains owned by someone else. + */ + const assignedToRequestedAgent = !wasDuplicate || task.assignedAgentId === agent.id; + const actualOwner = task.assignedAgentId ? `agent ${task.assignedAgentId}` : "no agent"; + const action = wasDuplicate + ? assignedToRequestedAgent + ? `Linked existing ${task.id} and assigned it to ${agent.name}` + : `Linked existing ${task.id}; it remains assigned to ${actualOwner}` + : `Created ${task.id}`; + const pickup = assignedToRequestedAgent + ? ` The task will be picked up by ${agent.name} on their next heartbeat cycle.` + : ""; return { content: [{ type: "text" as const, - text: `Delegated to ${agent.name} (${agent.id}): ${wasDuplicate ? "Linked existing" : "Created"} ${task.id}${deps}${workflow}. ` + - `The task will be picked up by ${agent.name} on their next heartbeat cycle.`, + text: `${assignedToRequestedAgent ? `Delegated to ${agent.name} (${agent.id})` : "Delegation linked"}: ${action}${deps}${workflow}.${pickup}`, }], details: { taskId: task.id, agentId: agent.id, agentName: agent.name }, }; @@ -4144,6 +4193,52 @@ export function createDelegateTaskTool( }; } +/* +FNXC:AgentRouting 2026-07-29-00:00: +FN-8207 adds an engine-session reassignment tool because executor fn_task_update is lifecycle-only. Bind checks match delegation: ephemeral agents and assignmentPolicy "none" are never assignable, while override bypasses only role eligibility. +*/ +export function createTaskAssignTool( + agentStore: AgentStore, + taskStore: TaskStore, +): ToolDefinition { + return { + name: "fn_task_assign", + label: "Assign Task", + description: "Assign an existing task to a durable agent by task ID. Use this to correct or change task ownership.", + parameters: taskAssignParams, + execute: async (_id: string, params: Static) => { + const agent = await agentStore.getAgent(params.agent_id); + if (!agent) { + return { content: [{ type: "text" as const, text: `ERROR: Agent ${params.agent_id} not found` }], details: {} }; + } + if (isEphemeralAgent(agent)) { + return { content: [{ type: "text" as const, text: `ERROR: Cannot assign to ephemeral/runtime agent ${params.agent_id}` }], details: {} }; + } + + let task: Task; + try { + task = await taskStore.getTask(params.task_id); + } catch { + return { content: [{ type: "text" as const, text: `ERROR: Task ${params.task_id} not found` }], details: {} }; + } + + const verdict = evaluateImplementationTaskBind(agent, task, { + explicitRouting: true, + executorRoleOverride: params.override === true, + }); + if (!verdict.allowed) { + return { content: [{ type: "text" as const, text: `ERROR: ${verdict.reason}` }], details: {} }; + } + + const assigned = await taskStore.updateTask(task.id, { assignedAgentId: agent.id }); + return { + content: [{ type: "text" as const, text: `Assigned ${assigned.id} to ${agent.name} (${agent.id}).` }], + details: { taskId: assigned.id, agentId: agent.id, agentName: agent.name }, + }; + }, + }; +} + type AskQuestionInput = Static; function askQuestionError(message: string) { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 9d024b6de3..fe909f6930 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -227,6 +227,7 @@ import { createAgentCreateTool, createAgentDeleteTool, createDelegateTaskTool, + createTaskAssignTool, createGetAgentConfigTool, createListAgentsTool, createMemoryTools, @@ -278,6 +279,7 @@ export { createAgentCreateTool, createAgentDeleteTool, createDelegateTaskTool, + createTaskAssignTool, createGetAgentConfigTool, createListAgentsTool, createReadMessagesTool, @@ -11665,6 +11667,7 @@ export class TaskExecutor { ...(this.options.agentStore ? [ createListAgentsTool(this.options.agentStore), createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }), + createTaskAssignTool(this.options.agentStore, this.store), ...(assignedAgentId ? [ createGetAgentConfigTool(this.options.agentStore, assignedAgentId), createUpdateAgentConfigTool(this.options.agentStore, assignedAgentId), diff --git a/packages/engine/src/gating-classifications.ts b/packages/engine/src/gating-classifications.ts index 4ed2fc431b..c7b99e99ea 100644 --- a/packages/engine/src/gating-classifications.ts +++ b/packages/engine/src/gating-classifications.ts @@ -11,6 +11,7 @@ export const FILE_WRITE_BUILTIN_TOOLS: ReadonlySet = new Set(["write", " const SHARED_TASK_AGENT_TOOLS = [ "fn_task_add_dep", "fn_task_update", + "fn_task_assign", "fn_spawn_agent", "fn_update_agent_config", "fn_agent_create", diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 5451c7298b..7f817fbbd5 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -27,6 +27,7 @@ export { createTaskReadTools, createListAgentsTool, createDelegateTaskTool, + createTaskAssignTool, createGetAgentConfigTool, createWebFetchTool, createGoalRetrievalTools, @@ -84,6 +85,7 @@ export { traitListParams, listAgentsParams, delegateTaskParams, + taskAssignParams, getAgentConfigParams, webFetchParams, memorySearchParams, diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index c14cea0b30..6c03aebbb2 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -44,6 +44,7 @@ import { isContextLimitError } from "./context-limit-detector.js"; import { checkSessionError } from "./usage-limit-detector.js"; import { createDelegateTaskTool, + createTaskAssignTool, createListAgentsTool, createMemoryTools, createWebFetchTool, @@ -1314,6 +1315,7 @@ export class StepSessionExecutor { ? [ createListAgentsTool(this.options.agentStore), createDelegateTaskTool(this.options.agentStore, this.options.store!, { rootDir: this.options.rootDir }), + createTaskAssignTool(this.options.agentStore, this.options.store!), ] : []; diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index add010af88..68ffc58da0 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -160,6 +160,7 @@ import { promisify } from "node:util"; import { createAgentTask, createDelegateTaskTool, + createTaskAssignTool, createListAgentsTool, createMemoryTools, createGoalRetrievalTools, @@ -1110,6 +1111,7 @@ export class TriageProcessor { ...(this.options.agentStore ? [ createListAgentsTool(this.options.agentStore), createDelegateTaskTool(this.options.agentStore, this.store, { rootDir: this.rootDir }), + createTaskAssignTool(this.options.agentStore, this.store), ] : []), ];