diff --git a/.changeset/agent-assignment-policy-routing-guard.md b/.changeset/agent-assignment-policy-routing-guard.md new file mode 100644 index 0000000000..4b740c4047 --- /dev/null +++ b/.changeset/agent-assignment-policy-routing-guard.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add per-agent Assignment Policy; guard every task-routing path so liaison agents can never receive product tasks. +category: feature +dev: New `runtimeConfig.assignmentPolicy` ("auto" | "explicit-only" | "none") enforced via shared `evaluateImplementationTaskBind` at `claimTaskForAgent`, the previously unguarded `checkoutTask`/`assignTask` primitives, `selectNextTaskForAgent` (including the in-progress re-selection branch), scheduler auto-assign pool, heartbeat auto-claim, `fn_delegate_task`, CLI agent-id validation, and dashboard assign/checkout routes. "none" is not bypassable by `override=true`/`executorRoleOverride`. Fixes Runfusion/Fusion#2015. diff --git a/docs/agents.md b/docs/agents.md index c5e11ba045..3b322c373d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -599,6 +599,7 @@ The `runtimeConfig` field on agents supports the following options: | `heartbeatIntervalMs` | `number` | — | How often the agent should wake up for heartbeat checks (ms) | | `autoClaimRelevantTasks` | `boolean` | `true` | During no-task heartbeats, opportunistically claim unowned relevant todo tasks that align with the agent's role/soul | | `engineerBacklogAutoClaim` | `boolean` | inherits project (`false`) | Opt this engineer-role agent into no-task backlog auto-claim for implementation tasks. Executor-role agents remain eligible by default; explicit routing/delegation is unchanged. | +| `assignmentPolicy` | `"auto" \| "explicit-only" \| "none"` | `"auto"` | Per-agent task-routing eligibility (issue #2015). `auto` keeps default behavior. `explicit-only` removes the agent from the scheduler auto-assign pool and backlog auto-claim but still accepts direct assignment/delegation. `none` guarantees the agent can never be bound to an implementation task by ANY path — scheduler, auto-claim, delegation, checkout, or `override=true` — use it for liaison/observer agents whose mandate excludes product work. Enforced at every binding primitive (`claimTaskForAgent`, `checkoutTask`, `assignTask`, inbox selection, `fn_delegate_task`, dashboard assign/checkout routes). | | `autoClaimCandidatesInPrompt` | `number` | `5` | Per-agent override for no-task candidate lines rendered in prompts. Integer `0-10`; `0` suppresses candidate injection. | | `heartbeatTimeoutMs` | `number` | — | Time without heartbeat before agent is considered unresponsive (ms) | | `maxConcurrentRuns` | `number` | `1` | Max concurrent heartbeat runs for this agent | diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 733b4452e1..be6c941e01 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -24,8 +24,6 @@ import { isResearchExperimentalEnabled, isEphemeralAgent, resolveResearchSettings, - canAgentTakeImplementationTaskForExplicitRouting, - formatRoleMismatchReason, getTaskDuplicateLineage, resolveAgentProvisioningPolicy, TASK_PRIORITIES, @@ -229,7 +227,7 @@ async function validateAssignableAgentId( task?: Pick | null, override = false, ): Promise { - const { AgentStore, isEphemeralAgent } = await import("@fusion/core"); + const { AgentStore, isEphemeralAgent, evaluateImplementationTaskBind } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: getFusionDir(cwd) }); await agentStore.init(); const agent = await agentStore.getAgent(agentId); @@ -239,8 +237,15 @@ async function validateAssignableAgentId( if (isEphemeralAgent(agent)) { return `Cannot assign task to ephemeral/runtime agent ${agentId}`; } - if (task && !override && !canAgentTakeImplementationTaskForExplicitRouting(agent, task)) { - return formatRoleMismatchReason(agent, task); + if (task) { + // FNXC:AgentRouting 2026-07-12-12:30: issue #2015 — shared bind evaluator; override bypasses role only, never assignmentPolicy "none". + const verdict = evaluateImplementationTaskBind(agent, task, { + explicitRouting: true, + executorRoleOverride: override, + }); + if (!verdict.allowed) { + return verdict.reason; + } } return null; } diff --git a/packages/core/src/__tests__/agent-role-policy.test.ts b/packages/core/src/__tests__/agent-role-policy.test.ts index 2dea943f95..80b4e82aa4 100644 --- a/packages/core/src/__tests__/agent-role-policy.test.ts +++ b/packages/core/src/__tests__/agent-role-policy.test.ts @@ -1,9 +1,15 @@ import { describe, expect, it } from "vitest"; import { + AgentTaskRoutingPolicyError, + assertImplementationTaskBindAllowed, + canAgentReceiveImplementationTasks, canAgentTakeImplementationTask, canAgentTakeImplementationTaskForBacklogPickup, canAgentTakeImplementationTaskForExplicitRouting, + evaluateImplementationTaskBind, formatRoleMismatchReason, + getAgentAssignmentPolicy, + isAgentAutoAssignable, isEngineerRoleAgent, isExecutorRoleAgent, isImplementationTask, @@ -94,3 +100,68 @@ describe("agent-role-policy", () => { expect(reason).toContain("durable \"engineer\" supported only for explicit routing"); }); }); + +/* +FNXC:AgentRouting 2026-07-12-12:40: +Issue #2015 regression matrix: an executor-ROLE liaison agent must be excludable from every routing path via +runtimeConfig.assignmentPolicy, and "none" must not be defeatable by executorRoleOverride. +*/ +describe("agent assignment policy (issue #2015)", () => { + const executor = { id: "a-exec", role: "executor" as const }; + const liaisonNone = { id: "a-liaison", role: "executor" as const, runtimeConfig: { assignmentPolicy: "none" } }; + const explicitOnly = { id: "a-explicit", role: "executor" as const, runtimeConfig: { assignmentPolicy: "explicit-only" } }; + const todoTask = { id: "FN-1", column: "todo" as const }; + const doneTask = { id: "FN-2", column: "done" as const }; + + it("defaults to auto and parses configured values", () => { + expect(getAgentAssignmentPolicy(executor)).toBe("auto"); + expect(getAgentAssignmentPolicy({ runtimeConfig: {} })).toBe("auto"); + expect(getAgentAssignmentPolicy({ runtimeConfig: { assignmentPolicy: "bogus" } })).toBe("auto"); + expect(getAgentAssignmentPolicy(liaisonNone)).toBe("none"); + expect(getAgentAssignmentPolicy(explicitOnly)).toBe("explicit-only"); + expect(isAgentAutoAssignable(executor)).toBe(true); + expect(isAgentAutoAssignable(explicitOnly)).toBe(false); + expect(isAgentAutoAssignable(liaisonNone)).toBe(false); + expect(canAgentReceiveImplementationTasks(executor)).toBe(true); + expect(canAgentReceiveImplementationTasks(explicitOnly)).toBe(true); + expect(canAgentReceiveImplementationTasks(liaisonNone)).toBe(false); + }); + + it("policy 'none' blocks implementation tasks on every path, including overrides", () => { + expect(canAgentTakeImplementationTaskForExplicitRouting(liaisonNone, todoTask)).toBe(false); + expect(canAgentTakeImplementationTask(liaisonNone, todoTask)).toBe(false); + expect(evaluateImplementationTaskBind(liaisonNone, todoTask, { explicitRouting: true }).allowed).toBe(false); + expect(evaluateImplementationTaskBind(liaisonNone, todoTask, { explicitRouting: true, executorRoleOverride: true }).allowed).toBe(false); + expect(evaluateImplementationTaskBind(liaisonNone, todoTask, { executorRoleOverride: true }).allowed).toBe(false); + expect(() => assertImplementationTaskBindAllowed(liaisonNone, todoTask, { explicitRouting: true, executorRoleOverride: true })) + .toThrow(AgentTaskRoutingPolicyError); + }); + + it("policy 'explicit-only' blocks automatic routing but allows explicit routing", () => { + expect(canAgentTakeImplementationTask(explicitOnly, todoTask)).toBe(false); + expect(canAgentTakeImplementationTaskForBacklogPickup(explicitOnly, todoTask, { allowEngineer: true })).toBe(false); + expect(evaluateImplementationTaskBind(explicitOnly, todoTask, {}).allowed).toBe(false); + expect(canAgentTakeImplementationTaskForExplicitRouting(explicitOnly, todoTask)).toBe(true); + expect(evaluateImplementationTaskBind(explicitOnly, todoTask, { explicitRouting: true }).allowed).toBe(true); + }); + + it("policy never gates non-implementation columns", () => { + expect(evaluateImplementationTaskBind(liaisonNone, doneTask, {}).allowed).toBe(true); + expect(canAgentTakeImplementationTask(liaisonNone, doneTask)).toBe(true); + }); + + it("evaluator preserves role semantics for auto-policy agents", () => { + expect(evaluateImplementationTaskBind(executor, todoTask, {}).allowed).toBe(true); + expect(evaluateImplementationTaskBind({ id: "a-cust", role: "custom" }, todoTask, { explicitRouting: true }).allowed).toBe(false); + expect(evaluateImplementationTaskBind({ id: "a-cust", role: "custom" }, todoTask, { explicitRouting: true, executorRoleOverride: true }).allowed).toBe(true); + expect(evaluateImplementationTaskBind({ id: "a-eng", role: "engineer" }, todoTask, { explicitRouting: true }).allowed).toBe(true); + expect(evaluateImplementationTaskBind({ id: "a-eng", role: "engineer" }, todoTask, {}).allowed).toBe(false); + expect(evaluateImplementationTaskBind({ id: "a-eng", role: "engineer" }, todoTask, { allowEngineer: true }).allowed).toBe(true); + }); + + it("mismatch reason names the policy when it is the blocker", () => { + const reason = formatRoleMismatchReason(liaisonNone, todoTask); + expect(reason).toContain("assignmentPolicy \"none\""); + expect(formatRoleMismatchReason(explicitOnly, todoTask)).toContain("explicit routing only"); + }); +}); diff --git a/packages/core/src/__tests__/agent-store-routing-policy.test.ts b/packages/core/src/__tests__/agent-store-routing-policy.test.ts new file mode 100644 index 0000000000..0f178c6b39 --- /dev/null +++ b/packages/core/src/__tests__/agent-store-routing-policy.test.ts @@ -0,0 +1,315 @@ +/** + * FNXC:AgentRouting 2026-07-12-13:00: + * Regression suite for GitHub issue Runfusion/Fusion#2015 (FN-7851): product-code implementation tasks were + * repeatedly bound to a liaison-only agent. Two invariants are locked here across ALL binding primitives: + * 1. Role guard — the previously UNGUARDED primitives (AgentStore.checkoutTask, AgentStore.assignTask) and + * the inbox selector's in-progress branch enforce the same executor-role policy as claimTaskForAgent. + * 2. Assignment policy — an agent with runtimeConfig.assignmentPolicy "explicit-only" is excluded from + * automatic routing, and "none" can NEVER be bound to an implementation task, even with + * executorRoleOverride (the liaison guarantee). + * Plus project isolation: an agent registered in another project's store can never be bound to this + * project's tasks through any binding primitive. + */ +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; +import { AgentStore } from "../agent-store.js"; +import { TaskStore } from "../store.js"; +import { AgentTaskRoutingPolicyError } from "../agent-role-policy.js"; +import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./store-test-helpers.js"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "fn-agent-routing-policy-test-")); +} + +beforeAll(() => installInMemoryDbSnapshot()); +afterAll(() => clearInMemoryDbSnapshot()); + +describe("task→agent routing policy (issue #2015)", () => { + let rootDir: string; + let taskStore: TaskStore; + let agentStore: AgentStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"), { inMemoryDb: true }); + await taskStore.init(); + agentStore = new AgentStore({ rootDir, inMemoryDb: true, taskStore }); + await agentStore.init(); + }); + + afterEach(async () => { + agentStore.close(); + taskStore.close(); + await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + }); + + describe("checkoutTask guard (previously unguarded)", () => { + it("rejects a fresh checkout by a role-incompatible agent", async () => { + const liaison = await agentStore.createAgent({ name: "Liaison", role: "custom" }); + const task = await taskStore.createTask({ description: "product-code work" }); + + await expect(agentStore.checkoutTask(liaison.id, task.id)).rejects.toBeInstanceOf(AgentTaskRoutingPolicyError); + const after = await taskStore.getTask(task.id); + expect(after?.checkedOutBy).toBeUndefined(); + }); + + it("rejects a fresh checkout by an executor-ROLE agent with assignmentPolicy 'none' (liaison case)", async () => { + const liaison = await agentStore.createAgent({ + name: "Platform Liaison", + role: "executor", + runtimeConfig: { assignmentPolicy: "none" }, + }); + const task = await taskStore.createTask({ description: "backend healthcheck fix" }); + + await expect(agentStore.checkoutTask(liaison.id, task.id)).rejects.toBeInstanceOf(AgentTaskRoutingPolicyError); + }); + + it("rejects an automatic (unassigned) checkout by an 'explicit-only' executor but allows it when explicitly assigned", async () => { + const explicitOnly = await agentStore.createAgent({ + name: "Explicit Only", + role: "executor", + runtimeConfig: { assignmentPolicy: "explicit-only" }, + }); + const task = await taskStore.createTask({ description: "implementation work" }); + + await expect(agentStore.checkoutTask(explicitOnly.id, task.id)).rejects.toBeInstanceOf(AgentTaskRoutingPolicyError); + + await taskStore.updateTask(task.id, { assignedAgentId: explicitOnly.id }); + const updated = await agentStore.checkoutTask(explicitOnly.id, task.id); + expect(updated.checkedOutBy).toBe(explicitOnly.id); + }); + + it("still allows lease renewal by the existing holder", async () => { + const executor = await agentStore.createAgent({ name: "Exec", role: "executor" }); + const task = await taskStore.createTask({ description: "work" }); + await agentStore.checkoutTask(executor.id, task.id, { nodeId: "node-a", runId: "run-1", leaseEpoch: 0 }); + + // Simulate policy tightened AFTER the hold was acquired — renewal must not strand the run. + await agentStore.updateAgent(executor.id, { runtimeConfig: { assignmentPolicy: "none" } }); + const held = await taskStore.getTask(task.id); + const renewed = await agentStore.checkoutTask(executor.id, task.id, { + nodeId: "node-a", + runId: "run-2", + leaseEpoch: held?.checkoutLeaseEpoch ?? 0, + }); + expect(renewed.checkedOutBy).toBe(executor.id); + }); + + it("honors executorRoleOverride for explicitly assigned tasks but never for policy 'none'", async () => { + const custom = await agentStore.createAgent({ name: "Custom Override", role: "custom" }); + const task = await taskStore.createTask({ + description: "override-delegated work", + source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } }, + }); + await taskStore.updateTask(task.id, { assignedAgentId: custom.id }); + const updated = await agentStore.checkoutTask(custom.id, task.id); + expect(updated.checkedOutBy).toBe(custom.id); + + const liaison = await agentStore.createAgent({ + name: "Liaison None", + role: "executor", + runtimeConfig: { assignmentPolicy: "none" }, + }); + const overrideTask = await taskStore.createTask({ + description: "override-delegated liaison work", + source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } }, + }); + await taskStore.updateTask(overrideTask.id, { assignedAgentId: liaison.id }); + await expect(agentStore.checkoutTask(liaison.id, overrideTask.id)).rejects.toBeInstanceOf(AgentTaskRoutingPolicyError); + }); + }); + + describe("assignTask guard (previously unguarded)", () => { + it("rejects binding an implementation task to a role-incompatible agent", async () => { + const reviewer = await agentStore.createAgent({ name: "Reviewer", role: "reviewer" }); + const task = await taskStore.createTask({ description: "implementation work" }); + + await expect(agentStore.assignTask(reviewer.id, task.id)).rejects.toBeInstanceOf(AgentTaskRoutingPolicyError); + const after = await agentStore.getAgent(reviewer.id); + expect(after?.taskId).toBeUndefined(); + }); + + it("rejects binding to a policy-'none' executor even when the task carries executorRoleOverride", async () => { + const liaison = await agentStore.createAgent({ + name: "Liaison", + role: "executor", + runtimeConfig: { assignmentPolicy: "none" }, + }); + const task = await taskStore.createTask({ + description: "work", + source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } }, + }); + + await expect(agentStore.assignTask(liaison.id, task.id)).rejects.toBeInstanceOf(AgentTaskRoutingPolicyError); + }); + + it("allows executors, explicit-only executors, clears, and unresolvable ids", async () => { + const executor = await agentStore.createAgent({ name: "Exec", role: "executor" }); + const explicitOnly = await agentStore.createAgent({ + name: "Explicit Only", + role: "executor", + runtimeConfig: { assignmentPolicy: "explicit-only" }, + }); + const task = await taskStore.createTask({ description: "work" }); + + await expect(agentStore.assignTask(executor.id, task.id)).resolves.toMatchObject({ taskId: task.id }); + await agentStore.assignTask(executor.id, undefined); + // assignTask IS explicit routing — explicit-only agents accept it. + await expect(agentStore.assignTask(explicitOnly.id, task.id)).resolves.toMatchObject({ taskId: task.id }); + + const liaison = await agentStore.createAgent({ + name: "Liaison", + role: "executor", + runtimeConfig: { assignmentPolicy: "none" }, + }); + // Hosts WITHOUT a TaskStore stay fail-open (display-only linkage; cannot resolve the column). + const bareStore = new AgentStore({ rootDir, inMemoryDb: true }); + await bareStore.init(); + try { + const bareLiaison = await bareStore.createAgent({ + name: "Bare Liaison", + role: "executor", + runtimeConfig: { assignmentPolicy: "none" }, + }); + await expect(bareStore.assignTask(bareLiaison.id, "KB-unresolvable")).resolves.toMatchObject({ taskId: "KB-unresolvable" }); + } finally { + bareStore.close(); + } + }); + }); + + describe("claimTaskForAgent policy", () => { + it("refuses auto-claim for explicit-only and none policies, allows explicit claim for explicit-only", async () => { + const explicitOnly = await agentStore.createAgent({ + name: "Explicit Only", + role: "executor", + runtimeConfig: { assignmentPolicy: "explicit-only" }, + }); + const liaison = await agentStore.createAgent({ + name: "Liaison", + role: "executor", + runtimeConfig: { assignmentPolicy: "none" }, + }); + const unassigned = await taskStore.createTask({ description: "backlog work" }); + + const autoClaim = await agentStore.claimTaskForAgent(explicitOnly.id, unassigned.id); + expect(autoClaim.ok).toBe(false); + + const liaisonClaim = await agentStore.claimTaskForAgent(liaison.id, unassigned.id); + expect(liaisonClaim.ok).toBe(false); + + const assigned = await taskStore.createTask({ description: "assigned work" }); + await taskStore.updateTask(assigned.id, { assignedAgentId: explicitOnly.id }); + const explicitClaim = await agentStore.claimTaskForAgent(explicitOnly.id, assigned.id); + expect(explicitClaim.ok).toBe(true); + }); + + it("refuses explicit claim for policy 'none' even with executorRoleOverride", async () => { + const liaison = await agentStore.createAgent({ + name: "Liaison", + role: "executor", + runtimeConfig: { assignmentPolicy: "none" }, + }); + const task = await taskStore.createTask({ + description: "override work", + source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } }, + }); + await taskStore.updateTask(task.id, { assignedAgentId: liaison.id }); + + const claim = await agentStore.claimTaskForAgent(liaison.id, task.id); + expect(claim.ok).toBe(false); + if (!claim.ok) { + expect(claim.reason).toContain("assignmentPolicy \"none\""); + } + }); + }); + + describe("selectNextTaskForAgent bind compatibility", () => { + it("does not re-select a mis-bound in-progress implementation task for a role-incompatible agent", async () => { + const liaison = await agentStore.createAgent({ name: "Liaison", role: "custom" }); + const task = await taskStore.createTask({ description: "mis-bound work" }); + await taskStore.updateTask(task.id, { assignedAgentId: liaison.id }); + await taskStore.moveTask(task.id, "todo"); + await taskStore.moveTask(task.id, "in-progress"); + + const selection = await taskStore.selectNextTaskForAgent(liaison.id, { id: liaison.id, role: liaison.role }); + expect(selection).toBeNull(); + }); + + it("does not re-select an in-progress task for a policy-'none' executor even with executorRoleOverride", async () => { + const liaison = await agentStore.createAgent({ + name: "Liaison", + role: "executor", + runtimeConfig: { assignmentPolicy: "none" }, + }); + const task = await taskStore.createTask({ + description: "override mis-bound work", + source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } }, + }); + await taskStore.updateTask(task.id, { assignedAgentId: liaison.id }); + await taskStore.moveTask(task.id, "todo"); + await taskStore.moveTask(task.id, "in-progress"); + + const selection = await taskStore.selectNextTaskForAgent(liaison.id, { + id: liaison.id, + role: liaison.role, + runtimeConfig: liaison.runtimeConfig, + }); + expect(selection).toBeNull(); + }); + + it("still resumes in-progress work for a legitimate executor and honors executorRoleOverride for auto-policy agents", async () => { + const executor = await agentStore.createAgent({ name: "Exec", role: "executor" }); + const task = await taskStore.createTask({ description: "real work" }); + await taskStore.updateTask(task.id, { assignedAgentId: executor.id }); + await taskStore.moveTask(task.id, "todo"); + await taskStore.moveTask(task.id, "in-progress"); + + const selection = await taskStore.selectNextTaskForAgent(executor.id, { id: executor.id, role: executor.role }); + expect(selection?.task.id).toBe(task.id); + expect(selection?.priority).toBe("in_progress"); + + const custom = await agentStore.createAgent({ name: "Custom", role: "custom" }); + const overrideTask = await taskStore.createTask({ + description: "override-delegated", + source: { sourceType: "api", sourceMetadata: { executorRoleOverride: true } }, + }); + await taskStore.updateTask(overrideTask.id, { assignedAgentId: custom.id }); + await taskStore.moveTask(overrideTask.id, "todo"); + const overrideSelection = await taskStore.selectNextTaskForAgent(custom.id, { id: custom.id, role: custom.role }); + expect(overrideSelection?.task.id).toBe(overrideTask.id); + }); + }); + + describe("project isolation", () => { + it("an agent registered in another project's store can never be bound to this project's tasks", async () => { + const otherRoot = makeTmpDir(); + const otherTaskStore = new TaskStore(otherRoot, join(otherRoot, ".fusion-global-settings"), { inMemoryDb: true }); + await otherTaskStore.init(); + const otherAgentStore = new AgentStore({ rootDir: otherRoot, inMemoryDb: true, taskStore: otherTaskStore }); + await otherAgentStore.init(); + + try { + const foreignAgent = await otherAgentStore.createAgent({ name: "Foreign Executor", role: "executor" }); + const task = await taskStore.createTask({ description: "this project's work" }); + + // Every binding primitive resolves the agent against THIS project's store — a foreign agent id + // must be rejected outright, never bound. + await expect(agentStore.checkoutTask(foreignAgent.id, task.id)).rejects.toThrow(`Agent ${foreignAgent.id} not found`); + await expect(agentStore.assignTask(foreignAgent.id, task.id)).rejects.toThrow(`Agent ${foreignAgent.id} not found`); + await expect(agentStore.claimTaskForAgent(foreignAgent.id, task.id)).rejects.toThrow(`Agent ${foreignAgent.id} not found`); + + const after = await taskStore.getTask(task.id); + expect(after?.assignedAgentId).toBeUndefined(); + expect(after?.checkedOutBy).toBeUndefined(); + } finally { + otherAgentStore.close(); + otherTaskStore.close(); + await rm(otherRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + }); +}); diff --git a/packages/core/src/agent-role-policy.ts b/packages/core/src/agent-role-policy.ts index 6c903c21b2..0bcd2dc00c 100644 --- a/packages/core/src/agent-role-policy.ts +++ b/packages/core/src/agent-role-policy.ts @@ -7,6 +7,40 @@ const IMPLEMENTATION_TASK_COLUMNS: ReadonlySet = new Set([ "in-review", ]); +/* +FNXC:AgentRouting 2026-07-12-11:20: +GitHub issue Runfusion/Fusion#2015: product-code executor tasks were repeatedly routed to a liaison-only agent because +every routing path (scheduler auto-assign pool, heartbeat auto-claim, delegation, claim primitive) gated only on the +coarse `role` field — an agent whose mandate is "file upstream bug reports, never implement product code" is +indistinguishable from a real executor when its role is "executor". +The per-agent assignment policy (agent.runtimeConfig.assignmentPolicy) closes this: +- "auto" (default): current behavior — eligible for auto-assignment, backlog auto-claim, and explicit routing. +- "explicit-only": never auto-assigned or auto-claimed; may still receive explicitly routed/delegated tasks. +- "none": may NEVER be bound to implementation tasks by any path — including explicit delegation and the + sourceMetadata.executorRoleOverride bypass. This is the hard guarantee for liaison/observer-type agents. +*/ +export type AgentAssignmentPolicy = "auto" | "explicit-only" | "none"; + +export type AgentAssignmentPolicyInput = Pick & Partial>; + +export function getAgentAssignmentPolicy(agent: Partial>): AgentAssignmentPolicy { + const raw = (agent.runtimeConfig ?? {})["assignmentPolicy"]; + return raw === "explicit-only" || raw === "none" ? raw : "auto"; +} + +/** Eligible for automatic routing (scheduler auto-assign, no-task backlog auto-claim). */ +export function isAgentAutoAssignable(agent: Partial>): boolean { + return getAgentAssignmentPolicy(agent) === "auto"; +} + +/** + * Hard floor: policy "none" blocks implementation-task binding on EVERY path, + * including explicit delegation and executorRoleOverride (issue #2015). + */ +export function canAgentReceiveImplementationTasks(agent: Partial>): boolean { + return getAgentAssignmentPolicy(agent) !== "none"; +} + export function isImplementationTask(task: Pick): boolean { return IMPLEMENTATION_TASK_COLUMNS.has(task.column); } @@ -20,10 +54,12 @@ export function isEngineerRoleAgent(agent: Pick): boolean { } export function canAgentTakeImplementationTaskForExplicitRouting( - agent: Pick, + agent: AgentAssignmentPolicyInput, task: Pick, ): boolean { - return !isImplementationTask(task) || isExecutorRoleAgent(agent) || isEngineerRoleAgent(agent); + if (!isImplementationTask(task)) return true; + if (!canAgentReceiveImplementationTasks(agent)) return false; + return isExecutorRoleAgent(agent) || isEngineerRoleAgent(agent); } export interface BacklogPickupRoleOptions { @@ -32,24 +68,96 @@ export interface BacklogPickupRoleOptions { } export function canAgentTakeImplementationTaskForBacklogPickup( - agent: Pick, + agent: AgentAssignmentPolicyInput, task: Pick, options: BacklogPickupRoleOptions = {}, ): boolean { - return !isImplementationTask(task) || isExecutorRoleAgent(agent) || (options.allowEngineer === true && isEngineerRoleAgent(agent)); + if (!isImplementationTask(task)) return true; + // FNXC:AgentRouting 2026-07-12-11:20: backlog pickup is automatic routing — only "auto"-policy agents qualify (#2015). + if (!isAgentAutoAssignable(agent)) return false; + return isExecutorRoleAgent(agent) || (options.allowEngineer === true && isEngineerRoleAgent(agent)); } export function canAgentTakeImplementationTask( - agent: Pick, + agent: AgentAssignmentPolicyInput, task: Pick, options?: BacklogPickupRoleOptions, ): boolean { return canAgentTakeImplementationTaskForBacklogPickup(agent, task, options); } +/* +FNXC:AgentRouting 2026-07-12-11:40: +FN-7851 / issue #2015: the executor-role guard was enforced on user-facing binding surfaces but not the low-level +binding primitives (AgentStore.checkoutTask/assignTask, dashboard POST /tasks/:id/checkout), and the inbox selector +re-selected mis-bound in-progress tasks forever. Every binding surface must funnel through this ONE evaluator so the +policy can never drift between callers. +Override semantics: `executorRoleOverride` (explicit operator override) bypasses the ROLE check only — it never +bypasses assignmentPolicy "none", which is the hard liaison guarantee. +*/ +export interface ImplementationTaskBindContext { + /** True when the bind is explicit routing (task already assigned to this agent, operator/delegation choice). */ + explicitRouting?: boolean; + /** True when the task carries sourceMetadata.executorRoleOverride === true or an operator passed override. */ + executorRoleOverride?: boolean; + /** Backlog-pickup engineer opt-in (settings/runtimeConfig engineerBacklogAutoClaim). Only relevant when not explicit. */ + allowEngineer?: boolean; +} + +export type ImplementationTaskBindVerdict = { allowed: true } | { allowed: false; reason: string }; + +export function evaluateImplementationTaskBind( + agent: Pick & Partial>, + task: Pick, + context: ImplementationTaskBindContext = {}, +): ImplementationTaskBindVerdict { + if (!isImplementationTask(task)) { + return { allowed: true }; + } + if (!canAgentReceiveImplementationTasks(agent)) { + return { allowed: false, reason: formatRoleMismatchReason(agent, task) }; + } + if (context.executorRoleOverride === true) { + return { allowed: true }; + } + const explicit = context.explicitRouting === true; + const roleAllowed = explicit + ? canAgentTakeImplementationTaskForExplicitRouting(agent, task) + : canAgentTakeImplementationTask(agent, task, { allowEngineer: context.allowEngineer }); + return roleAllowed ? { allowed: true } : { allowed: false, reason: formatRoleMismatchReason(agent, task) }; +} + +/** Typed error thrown by binding primitives when a bind violates the routing policy. */ +export class AgentTaskRoutingPolicyError extends Error { + readonly code = "agent-task-routing-policy" as const; + constructor( + public readonly agentId: string, + public readonly taskId: string, + reason: string, + ) { + super(reason); + this.name = "AgentTaskRoutingPolicyError"; + } +} + +export function assertImplementationTaskBindAllowed( + agent: Pick & Partial>, + task: Pick, + context: ImplementationTaskBindContext = {}, +): void { + const verdict = evaluateImplementationTaskBind(agent, task, context); + if (!verdict.allowed) { + throw new AgentTaskRoutingPolicyError(agent.id, task.id, verdict.reason); + } +} + export function formatRoleMismatchReason( - agent: Pick, + agent: Pick & Partial>, task: Pick, ): string { + const policy = getAgentAssignmentPolicy(agent); + if (policy !== "auto") { + return `Agent ${agent.id} has assignmentPolicy "${policy}"; implementation task ${task.id} cannot be routed to it${policy === "none" ? " by any path (no override supported)" : " automatically — explicit routing only"}.`; + } return `Agent ${agent.id} has role "${agent.role}"; implementation task ${task.id} requires an "executor"-role agent by default, with durable "engineer" supported only for explicit routing. Pass override=true to bypass.`; } diff --git a/packages/core/src/agent-store.ts b/packages/core/src/agent-store.ts index 2a1aaa8479..460a3b1288 100644 --- a/packages/core/src/agent-store.ts +++ b/packages/core/src/agent-store.ts @@ -55,7 +55,7 @@ import { import type { CentralClaimStore, CheckoutClaimContext, RunMutationContext } from "./types.js"; import type { TaskStore } from "./store.js"; import { computeAccessState, normalizePermissions } from "./agent-permissions.js"; -import { canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, formatRoleMismatchReason } from "./agent-role-policy.js"; +import { assertImplementationTaskBindAllowed, evaluateImplementationTaskBind } from "./agent-role-policy.js"; import { normalizeAgentPermissionPolicy } from "./agent-permission-policy.js"; import { Database } from "./db.js"; import { createAgentRunSnapshot, createAgentSnapshot, validateSnapshotEnvelope, type AgentRunSnapshot, type AgentSnapshot } from "./shared-mesh-state.js"; @@ -1330,6 +1330,34 @@ export class AgentStore extends EventEmitter { * @returns The updated agent */ async assignTask(agentId: string, taskId: string | undefined, runContext?: RunMutationContext): Promise { + /* + FNXC:AgentRouting 2026-07-12-11:55: + FN-7851 / issue #2015: assignTask was an UNGUARDED binding primitive. Guard new assignments with the shared + bind evaluator (assignTask is an explicit route: a caller chose this agent). Clearing (taskId undefined) is + always allowed, and hosts without a TaskStore or with an unresolvable task stay fail-open because this + primitive is also used for display-only linkage in stores that cannot resolve tasks. + */ + if (taskId !== undefined) { + const agent = await this.getAgent(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + let task: Task | null = null; + if (this.taskStore) { + try { + task = await this.taskStore.getTask(taskId); + } catch { + task = null; + } + } + if (task) { + assertImplementationTaskBindAllowed(agent, task, { + explicitRouting: true, + executorRoleOverride: task.sourceMetadata?.executorRoleOverride === true, + }); + } + } + const updated = await this.syncExecutionTaskLink(agentId, taskId); // Emit agent:assigned only when assigning a task (not when clearing) @@ -1424,12 +1452,20 @@ export class AgentStore extends EventEmitter { return { ok: false, reason: "paused", task }; } + /* + FNXC:AgentRouting 2026-07-12-11:50: + FN-7851 / issue #2015: route the claim through the shared bind evaluator so role AND per-agent + assignmentPolicy are enforced identically to every other binding surface. executorRoleOverride is honored + only for explicit routing (task already assigned to this agent) — an override-marked task must not become + auto-claimable by role-incompatible agents; policy "none" is never overridable. + */ const isExplicitlyAssignedToAgent = task.assignedAgentId === agentId; - const roleAllowed = isExplicitlyAssignedToAgent - ? canAgentTakeImplementationTaskForExplicitRouting(agent, task) - : canAgentTakeImplementationTask(agent, task); - if (!roleAllowed) { - return { ok: false, reason: formatRoleMismatchReason(agent, task), task }; + const bindVerdict = evaluateImplementationTaskBind(agent, task, { + explicitRouting: isExplicitlyAssignedToAgent, + executorRoleOverride: isExplicitlyAssignedToAgent && task.sourceMetadata?.executorRoleOverride === true, + }); + if (!bindVerdict.allowed) { + return { ok: false, reason: bindVerdict.reason, task }; } if (task.column === "done" || task.column === "archived") { @@ -1487,6 +1523,21 @@ export class AgentStore extends EventEmitter { throw new CheckoutConflictError(taskId, task.checkedOutBy, agentId); } + /* + FNXC:AgentRouting 2026-07-12-11:55: + FN-7851 / issue #2015: checkout was an UNGUARDED binding primitive — a role-incompatible or policy-excluded + agent (e.g. a liaison) could acquire the lease directly (dashboard POST /tasks/:id/checkout, direct callers) + even though claimTaskForAgent would have refused. Guard fresh checkouts with the shared bind evaluator. + Lease renewals (agent already holds the lease, e.g. executor renewTaskLease) stay exempt so recovery of an + existing hold never strands; policy "none" still cannot acquire a NEW hold by any path. + */ + if (task.checkedOutBy !== agentId) { + assertImplementationTaskBindAllowed(agent, task, { + explicitRouting: task.assignedAgentId === agentId, + executorRoleOverride: task.assignedAgentId === agentId && task.sourceMetadata?.executorRoleOverride === true, + }); + } + const nextRenewedAt = leaseContext?.renewedAt ?? new Date().toISOString(); const existingNodeId = task.checkoutNodeId ?? null; const existingEpoch = task.checkoutLeaseEpoch ?? 0; diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 2df3769df4..05fcfa15ad 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -576,7 +576,14 @@ export { canAgentTakeImplementationTaskForExplicitRouting, canAgentTakeImplementationTaskForBacklogPickup, formatRoleMismatchReason, + getAgentAssignmentPolicy, + isAgentAutoAssignable, + canAgentReceiveImplementationTasks, + evaluateImplementationTaskBind, + assertImplementationTaskBindAllowed, + AgentTaskRoutingPolicyError, } from "./agent-role-policy.js"; +export type { AgentAssignmentPolicy, ImplementationTaskBindContext, ImplementationTaskBindVerdict } from "./agent-role-policy.js"; export { ReflectionStore } from "./reflection-store.js"; export type { ReflectionStoreEvents } from "./reflection-store.js"; export { MessageStore } from "./message-store.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 40b5b5dac9..f3365aaed9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -571,7 +571,14 @@ export { canAgentTakeImplementationTaskForExplicitRouting, canAgentTakeImplementationTaskForBacklogPickup, formatRoleMismatchReason, + getAgentAssignmentPolicy, + isAgentAutoAssignable, + canAgentReceiveImplementationTasks, + evaluateImplementationTaskBind, + assertImplementationTaskBindAllowed, + AgentTaskRoutingPolicyError, } from "./agent-role-policy.js"; +export type { AgentAssignmentPolicy, ImplementationTaskBindContext, ImplementationTaskBindVerdict } from "./agent-role-policy.js"; export { ReflectionStore } from "./reflection-store.js"; export type { ReflectionStoreEvents } from "./reflection-store.js"; export { MessageStore } from "./message-store.js"; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 1d33ec772c..2d937f76b5 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -143,7 +143,7 @@ import { validateLocale } from "./settings-validation.js"; import { normalizeTaskPriority } from "./task-priority.js"; import { validateBranchGroupBranchName, filterTasksByBranchGroup } from "./branch-assignment.js"; import { allowsAutoMergeProcessing } from "./task-merge.js"; -import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; +import { evaluateImplementationTaskBind } from "./agent-role-policy.js"; import { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js"; import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js"; import { ArchiveDatabase } from "./archive-db.js"; @@ -7205,7 +7205,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} async selectNextTaskForAgent( agentId: string, - agent?: Pick, + agent?: Pick & Partial>, ): Promise { const hasExecutorRoleOverride = (task: Task): boolean => task.sourceMetadata?.executorRoleOverride === true; const tasks = await this.listTasks({ slim: true }); @@ -7222,9 +7222,26 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return aSortAt.localeCompare(bSortAt); }; + /* + FNXC:AgentRouting 2026-07-12-12:05: + FN-7851 / issue #2015: the in-progress branch used to return unconditionally, so a task mis-bound to a + role-incompatible or policy-excluded agent was re-selected on every heartbeat forever (the NEXT-871 liaison + loop). Route BOTH branches through the shared bind evaluator. executorRoleOverride still bypasses the role + check but never assignmentPolicy "none" — that is the hard liaison guarantee. + */ + const isBindCompatible = (task: Task): boolean => { + if (!agent) return true; + return evaluateImplementationTaskBind(agent, task, { + explicitRouting: true, + executorRoleOverride: hasExecutorRoleOverride(task), + }).allowed; + }; + const assignedTasks = tasks.filter((task) => task.assignedAgentId === agentId); - const inProgress = assignedTasks.filter((task) => task.column === "in-progress").sort(sortByOldestColumnMove); + const inProgress = assignedTasks + .filter((task) => task.column === "in-progress" && isBindCompatible(task)) + .sort(sortByOldestColumnMove); if (inProgress.length > 0) { return { task: inProgress[0], @@ -7233,14 +7250,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }; } - const roleCompatibleAssignedTasks = agent - ? assignedTasks.filter((task) => { - if (task.column === "in-progress" || hasExecutorRoleOverride(task)) { - return true; - } - return canAgentTakeImplementationTaskForExplicitRouting(agent, task); - }) - : assignedTasks; + const roleCompatibleAssignedTasks = assignedTasks.filter(isBindCompatible); const todoCandidates = roleCompatibleAssignedTasks.filter((task) => task.column === "todo" && task.paused !== true); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5d09aaec0c..252ef11e04 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7177,6 +7177,13 @@ export interface AgentHeartbeatConfig { enabled?: boolean; /** Whether this agent should auto-claim relevant unowned tasks during no-task heartbeats (default: true when unset). */ autoClaimRelevantTasks?: boolean; + /** + * FNXC:AgentRouting 2026-07-12-11:20: + * Per-agent task-routing eligibility (GitHub issue Runfusion/Fusion#2015). "auto" (default) = current behavior; + * "explicit-only" = never auto-assigned/auto-claimed but accepts explicit delegation; "none" = never bound to + * implementation tasks by ANY path, including delegation with override=true. Set "none" on liaison/observer agents. + */ + assignmentPolicy?: "auto" | "explicit-only" | "none"; /** Number of auto-claim candidates to inject into no-task heartbeat prompts. Default: 5, range: 0-10. */ autoClaimCandidatesInPrompt?: number; /** Per-agent override for opting engineer-role agents into no-task backlog auto-claim. Default: project setting or false. */ diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index ec43135882..06c39d2c34 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -3319,6 +3319,17 @@ function deriveEngineerBacklogAutoClaim(runtimeConfig: AgentDetail["runtimeConfi return runtimeConfig?.engineerBacklogAutoClaim === true; } +/* +FNXC:AgentRouting 2026-07-12-13:50: +Issue #2015: operators need a per-agent switch that removes an agent from task routing. "auto" (default) +keeps today's behavior, "explicit-only" blocks automatic assignment/auto-claim, "none" guarantees the agent +can never be bound to implementation tasks (liaison/observer agents). +*/ +function deriveAssignmentPolicy(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): "auto" | "explicit-only" | "none" { + const raw = runtimeConfig?.assignmentPolicy; + return raw === "explicit-only" || raw === "none" ? raw : "auto"; +} + function deriveRunMissedHeartbeatOnStartup(runtimeConfig: AgentDetail["runtimeConfig"] | undefined): boolean { return runtimeConfig?.runMissedHeartbeatOnStartup === true; } @@ -3704,6 +3715,9 @@ function ConfigTab({ const [engineerBacklogAutoClaimEnabled, setEngineerBacklogAutoClaimEnabled] = useState( () => deriveEngineerBacklogAutoClaim(agent.runtimeConfig), ); + const [assignmentPolicy, setAssignmentPolicy] = useState<"auto" | "explicit-only" | "none">( + () => deriveAssignmentPolicy(agent.runtimeConfig), + ); const [runMissedHeartbeatOnStartup, setRunMissedHeartbeatOnStartup] = useState( () => deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig), ); @@ -4020,6 +4034,7 @@ function ConfigTab({ if (heartbeatEnabled !== deriveHeartbeatEnabled(agent.runtimeConfig)) return true; if (autoClaimRelevantTasksEnabled !== deriveAutoClaimRelevantTasksEnabled(agent.runtimeConfig)) return true; if (engineerBacklogAutoClaimEnabled !== deriveEngineerBacklogAutoClaim(agent.runtimeConfig)) return true; + if (assignmentPolicy !== deriveAssignmentPolicy(agent.runtimeConfig)) return true; if (runMissedHeartbeatOnStartup !== deriveRunMissedHeartbeatOnStartup(agent.runtimeConfig)) return true; if (allowParallelExecution !== deriveAllowParallelExecution(agent.runtimeConfig)) return true; if (skipHeartbeatWhenIdle !== deriveSkipHeartbeatWhenIdle(agent.runtimeConfig)) return true; @@ -4259,6 +4274,11 @@ function ConfigTab({ newRuntimeConfig.enabled = heartbeatEnabled; newRuntimeConfig.autoClaimRelevantTasks = autoClaimRelevantTasksEnabled; newRuntimeConfig.engineerBacklogAutoClaim = engineerBacklogAutoClaimEnabled; + if (assignmentPolicy === "auto") { + delete newRuntimeConfig.assignmentPolicy; + } else { + newRuntimeConfig.assignmentPolicy = assignmentPolicy; + } newRuntimeConfig.runMissedHeartbeatOnStartup = runMissedHeartbeatOnStartup; newRuntimeConfig.allowParallelExecution = allowParallelExecution; newRuntimeConfig.skipHeartbeatWhenIdle = skipHeartbeatWhenIdle; @@ -4369,7 +4389,7 @@ function ConfigTab({ runtimeConfig: newRuntimeConfig, bundleConfig: newBundleConfig, }; - }, [agent.metadata, agent.runtimeConfig, allowParallelExecution, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, engineerBacklogAutoClaimEnabled, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]); + }, [agent.metadata, agent.runtimeConfig, allowParallelExecution, assignmentPolicy, autoClaimRelevantTasksEnabled, budgetValues, bundleEntryFile, bundleExternalPath, bundleFiles, bundleMode, engineerBacklogAutoClaimEnabled, formValues, heartbeatEnabled, heartbeatPromptTemplate, heartbeatScopeDiscipline, heartbeatValues, iconValue, modelValue, nameValue, reportsToValue, roleValue, runMissedHeartbeatOnStartup, runtimeMode, selectedRuntimeId, selectedSkills, skipHeartbeatWhenIdle, titleValue, validationErrors]); const persistSettings = useCallback(async (showValidationToast: boolean, source: "auto" | "manual") => { const payload = buildSavePayload(); @@ -4867,6 +4887,24 @@ function ConfigTab({ {t("agents.engineerBacklogAutoClaimHint", "Per-agent override of the project default. Allows this engineer-role agent to auto-claim unowned backlog tasks; explicit assignment and delegation are unchanged.")} + {/* FNXC:AgentRouting 2026-07-12-13:55: issue #2015 — per-agent task-routing eligibility (liaison guarantee). */} +
+ + + {t("agents.assignmentPolicyHint", "Controls whether task routing may bind work to this agent. Use \"None\" for liaison/observer agents that must never execute product tasks — no override can bypass it.")} +
+