fix(FN-7851): enforce per-agent assignment policy across all task-routing binding primitives

Issue #2015: product-code executor tasks were repeatedly routed to a
liaison-only agent because every routing path gated only on the coarse
role field, and several binding primitives had no guard at all.

- Add runtimeConfig.assignmentPolicy ("auto" | "explicit-only" | "none");
  "none" can never be bound to implementation tasks by ANY path — no
  override bypasses it (the liaison guarantee)
- Route every binding surface through one shared evaluator
  (evaluateImplementationTaskBind): claimTaskForAgent, the previously
  unguarded checkoutTask/assignTask primitives, selectNextTaskForAgent
  (including the in-progress re-selection loop), scheduler auto-assign
  pool, heartbeat inbox/auto-claim, fn_delegate_task, CLI agent-id
  validation, and dashboard assign/checkout/inbox routes
- Lock project isolation with a regression test: a foreign-project
  agent id is rejected by every binding primitive
- Expose Assignment Policy in Agent Detail settings; document in
  docs/agents.md; add changeset

Fusion-Task-Id: FN-7851

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-12 09:57:24 -07:00
parent f23619c2d4
commit 8b601810e0
20 changed files with 868 additions and 46 deletions

View File

@@ -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.

View File

@@ -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 |

View File

@@ -24,8 +24,6 @@ import {
isResearchExperimentalEnabled,
isEphemeralAgent,
resolveResearchSettings,
canAgentTakeImplementationTaskForExplicitRouting,
formatRoleMismatchReason,
getTaskDuplicateLineage,
resolveAgentProvisioningPolicy,
TASK_PRIORITIES,
@@ -229,7 +227,7 @@ async function validateAssignableAgentId(
task?: Pick<Task, "id" | "column"> | null,
override = false,
): Promise<string | null> {
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;
}

View File

@@ -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");
});
});

View File

@@ -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 });
}
});
});
});

View File

@@ -7,6 +7,40 @@ const IMPLEMENTATION_TASK_COLUMNS: ReadonlySet<Task["column"]> = 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<Agent, "role"> & Partial<Pick<Agent, "runtimeConfig">>;
export function getAgentAssignmentPolicy(agent: Partial<Pick<Agent, "id" | "role" | "runtimeConfig">>): 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<Pick<Agent, "id" | "role" | "runtimeConfig">>): 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<Pick<Agent, "id" | "role" | "runtimeConfig">>): boolean {
return getAgentAssignmentPolicy(agent) !== "none";
}
export function isImplementationTask(task: Pick<Task, "column">): boolean {
return IMPLEMENTATION_TASK_COLUMNS.has(task.column);
}
@@ -20,10 +54,12 @@ export function isEngineerRoleAgent(agent: Pick<Agent, "role">): boolean {
}
export function canAgentTakeImplementationTaskForExplicitRouting(
agent: Pick<Agent, "role">,
agent: AgentAssignmentPolicyInput,
task: Pick<Task, "column">,
): 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, "role">,
agent: AgentAssignmentPolicyInput,
task: Pick<Task, "column">,
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, "role">,
agent: AgentAssignmentPolicyInput,
task: Pick<Task, "column">,
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<Agent, "id" | "role"> & Partial<Pick<Agent, "runtimeConfig">>,
task: Pick<Task, "id" | "column">,
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<Agent, "id" | "role"> & Partial<Pick<Agent, "runtimeConfig">>,
task: Pick<Task, "id" | "column">,
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, "id" | "role">,
agent: Pick<Agent, "id" | "role"> & Partial<Pick<Agent, "runtimeConfig">>,
task: Pick<Task, "id" | "column">,
): 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.`;
}

View File

@@ -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<Agent> {
/*
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;

View File

@@ -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";

View File

@@ -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";

View File

@@ -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, "id" | "role">,
agent?: Pick<Agent, "id" | "role"> & Partial<Pick<Agent, "runtimeConfig">>,
): Promise<InboxTask | null> {
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);

View File

@@ -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. */

View File

@@ -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<boolean>(
() => deriveEngineerBacklogAutoClaim(agent.runtimeConfig),
);
const [assignmentPolicy, setAssignmentPolicy] = useState<"auto" | "explicit-only" | "none">(
() => deriveAssignmentPolicy(agent.runtimeConfig),
);
const [runMissedHeartbeatOnStartup, setRunMissedHeartbeatOnStartup] = useState<boolean>(
() => 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({
<span className="config-hint">{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.")}</span>
</div>
{/* FNXC:AgentRouting 2026-07-12-13:55: issue #2015 — per-agent task-routing eligibility (liaison guarantee). */}
<div className="config-field">
<label htmlFor="hb-assignmentPolicy">{t("agents.assignmentPolicy", "Assignment Policy")}</label>
<select
id="hb-assignmentPolicy"
value={assignmentPolicy}
onChange={(e) => {
setAssignmentPolicy(e.target.value as "auto" | "explicit-only" | "none");
void scheduleAutoSave();
}}
>
<option value="auto">{t("agents.assignmentPolicyAuto", "Auto (default) — eligible for automatic assignment")}</option>
<option value="explicit-only">{t("agents.assignmentPolicyExplicitOnly", "Explicit only — never auto-assigned; accepts direct assignment/delegation")}</option>
<option value="none">{t("agents.assignmentPolicyNone", "None — can never receive implementation tasks")}</option>
</select>
<span className="config-hint">{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.")}</span>
</div>
<div className="config-field">
<label className="checkbox-label" htmlFor="hb-enabled">
<input

View File

@@ -4105,6 +4105,35 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: reviewerAgentId });
}, 20000);
/*
FNXC:AgentRouting 2026-07-12-13:35:
Issue #2015: the /assign route must honor per-agent assignmentPolicy — "none" (liaison guarantee) is
refused even with override=true.
*/
it("returns 409 when assigning to a policy-'none' executor, even with override", async () => {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const liaison = await agentStore.createAgent({
name: "Platform Liaison",
role: "executor",
runtimeConfig: { assignmentPolicy: "none" },
});
for (const override of [undefined, true]) {
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-200/assign",
JSON.stringify({ agentId: liaison.id, ...(override ? { override } : {}) }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body.error).toContain("assignmentPolicy \"none\"");
}
expect(store.updateTask).not.toHaveBeenCalled();
}, 30_000);
it("returns 404 when assigning to a non-existent agent", async () => {
const res = await REQUEST(
buildApp(),
@@ -4260,7 +4289,8 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/inbox`);
expect(res.status).toBe(200);
expect(store.selectNextTaskForAgent).toHaveBeenCalledWith(agentId);
// FNXC:AgentRouting 2026-07-12-14:20: issue #2015 — the inbox preview now passes the agent so role/assignmentPolicy filtering applies.
expect(store.selectNextTaskForAgent).toHaveBeenCalledWith(agentId, expect.objectContaining({ id: agentId, role: "executor" }));
expect(res.body).toEqual({
task: expect.objectContaining({ id: "FN-500" }),
priority: "todo",
@@ -4274,7 +4304,8 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
const res = await REQUEST(buildApp(), "POST", `/api/agents/${agentId}/inbox`);
expect(res.status).toBe(200);
expect(store.selectNextTaskForAgent).toHaveBeenCalledWith(agentId);
// FNXC:AgentRouting 2026-07-12-14:20: issue #2015 — the inbox preview now passes the agent so role/assignmentPolicy filtering applies.
expect(store.selectNextTaskForAgent).toHaveBeenCalledWith(agentId, expect.objectContaining({ id: agentId, role: "executor" }));
expect(res.body).toEqual({ task: null });
}, 30_000);
@@ -4379,6 +4410,53 @@ describe("Task checkout routes", () => {
expect(res.body.checkedOutAt).toBeTruthy();
}, 20_000);
/*
FNXC:AgentRouting 2026-07-12-13:40:
Issue #2015: POST /tasks/:id/checkout was an UNGUARDED binding surface — role-incompatible or
policy-excluded agents could acquire the lease. The guard now lives in AgentStore.checkoutTask and must
surface here as 409.
*/
it("POST /tasks/:id/checkout — returns 409 for a role-incompatible agent", async () => {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const liaison = await agentStore.createAgent({ name: "Custom Liaison", role: "custom" });
const res = await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({ agentId: liaison.id }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(taskState.checkedOutBy).toBeUndefined();
}, 20_000);
it("POST /tasks/:id/checkout — returns 409 for an executor with assignmentPolicy 'none'", async () => {
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: fusionDir });
await agentStore.init();
const liaison = await agentStore.createAgent({
name: "Platform Liaison",
role: "executor",
runtimeConfig: { assignmentPolicy: "none" },
});
const res = await REQUEST(
buildApp(),
"POST",
`/api/tasks/${taskState.id}/checkout`,
JSON.stringify({ agentId: liaison.id }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body.error).toContain("assignmentPolicy \"none\"");
expect(taskState.checkedOutBy).toBeUndefined();
}, 20_000);
it("POST /tasks/:id/checkout — returns 409 on conflict", async () => {
await REQUEST(
buildApp(),

View File

@@ -904,7 +904,8 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun
throw notFound("Agent not found");
}
const selection = await scopedStore.selectNextTaskForAgent(agentId);
// FNXC:AgentRouting 2026-07-12-14:10: issue #2015 — pass the agent so the inbox preview applies the same role/assignmentPolicy bind filter as the heartbeat selector.
const selection = await scopedStore.selectNextTaskForAgent(agentId, { id: agent.id, role: agent.role, runtimeConfig: agent.runtimeConfig });
if (!selection) {
res.json({ task: null });
return;

View File

@@ -28,10 +28,9 @@ import {
REPO_OVERRIDE_RE,
resolveTitleSummarizerSettingsModel,
validateNodeOverrideChange,
canAgentTakeImplementationTaskForExplicitRouting,
evaluateImplementationTaskBind,
applyWorkflowSettingsOverlay,
resolveEffectiveSettingsDetailed,
formatRoleMismatchReason,
getCurrentRepo,
findDuplicateMatches,
deterministicGuardLocks,
@@ -4662,8 +4661,17 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw notFound("Task not found");
}
if (override !== true && !canAgentTakeImplementationTaskForExplicitRouting(agent, targetTask)) {
throw new ApiError(409, formatRoleMismatchReason(agent, targetTask));
/*
FNXC:AgentRouting 2026-07-12-12:25:
Issue #2015: route through the shared bind evaluator so per-agent assignmentPolicy is enforced.
override=true still bypasses the role check but never assignmentPolicy "none".
*/
const bindVerdict = evaluateImplementationTaskBind(agent, targetTask, {
explicitRouting: true,
executorRoleOverride: override === true,
});
if (!bindVerdict.allowed) {
throw new ApiError(409, bindVerdict.reason);
}
}
@@ -5129,6 +5137,10 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (err instanceof ApiError) {
throw err;
}
// FNXC:AgentRouting 2026-07-12-12:25: issue #2015 — checkout is now policy-guarded in AgentStore.checkoutTask; surface the refusal as 409, not 500.
if (err instanceof Error && err.name === "AgentTaskRoutingPolicyError") {
throw new ApiError(409, err.message);
}
if (err instanceof Error && err.name === "CheckoutConflictError") {
const checkoutErr = err as Error & { currentHolderId?: string; taskId?: string };
res.status(409).json({

View File

@@ -162,4 +162,37 @@ describe("listEligibleExecutorAgents", () => {
expect(eligible.map((agent) => agent.id)).toEqual(["ok"]);
});
/*
FNXC:AgentRouting 2026-07-12-13:20:
Issue #2015 regression: an executor-ROLE liaison agent must be excludable from the scheduler's auto-assign
pool via runtimeConfig.assignmentPolicy — this pool was the routing path that bound NEXT-871 to the liaison.
*/
it("excludes executors with assignmentPolicy 'explicit-only' or 'none' from the auto-assign pool", async () => {
const eligible = await listEligibleExecutorAgents({
listAgents: async () => [
makeAgent({ id: "liaison-none", runtimeConfig: { assignmentPolicy: "none" } }),
makeAgent({ id: "explicit-only", runtimeConfig: { assignmentPolicy: "explicit-only" } }),
makeAgent({ id: "auto-explicitly", runtimeConfig: { assignmentPolicy: "auto" } }),
makeAgent({ id: "auto-default" }),
],
} as never);
expect(eligible.map((agent) => agent.id)).toEqual(["auto-explicitly", "auto-default"]);
});
it("never auto-assigns a task to a policy-excluded executor even when it is the only agent", async () => {
const selected = await selectPermanentAgentForTask({
task: makeTask({ id: "NEXT-871" }),
agentStore: {
listAgents: async () => [
makeAgent({ id: "liaison", runtimeConfig: { assignmentPolicy: "none" } }),
],
getChainOfCommand: async () => [],
} as never,
taskStore: { listTasks: async () => [] } as never,
});
expect(selected).toBeNull();
});
});

View File

@@ -387,6 +387,54 @@ describe("createDelegateTaskTool", () => {
}), expect.objectContaining({ settings: { autoSummarizeTitles: false } }));
});
/*
FNXC:AgentRouting 2026-07-12-13:25:
Issue #2015: delegation must honor per-agent assignmentPolicy. "none" is the liaison guarantee —
not even override=true can delegate implementation work to such an agent; "explicit-only" accepts delegation.
*/
it("rejects a policy-'none' executor target even with override=true", async () => {
const liaison = createAgent({
id: "agent-liaison",
name: "Platform Liaison",
role: "executor",
runtimeConfig: { assignmentPolicy: "none" },
});
vi.mocked(agentStore.getAgent).mockResolvedValue(liaison);
const tool = createDelegateTaskTool(agentStore, taskStore);
for (const override of [false, true]) {
const result = await tool.execute("session-1", {
agent_id: "agent-liaison",
description: "Do something",
override,
}, undefined as any, undefined as any, undefined as any);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("assignmentPolicy \"none\"");
}
expect(taskStore.createTask).not.toHaveBeenCalled();
});
it("allows delegation to an 'explicit-only' executor without override", async () => {
const explicitOnly = createAgent({
id: "agent-explicit",
name: "Explicit Only",
role: "executor",
runtimeConfig: { assignmentPolicy: "explicit-only" },
});
vi.mocked(agentStore.getAgent).mockResolvedValue(explicitOnly);
const tool = createDelegateTaskTool(agentStore, taskStore);
await tool.execute("session-1", {
agent_id: "agent-explicit",
description: "Do something",
}, undefined as any, undefined as any, undefined as any);
expect(taskStore.createTask).toHaveBeenCalledWith(
expect.objectContaining({ assignedAgentId: "agent-explicit" }),
expect.anything(),
);
});
it("passes dependencies through to task creation", async () => {
const agent = createAgent({ id: "agent-001", name: "Bob" });
vi.mocked(agentStore.getAgent).mockResolvedValue(agent);

View File

@@ -1,5 +1,5 @@
import type { Agent, AgentStore, Task, TaskStore } from "@fusion/core";
import { isEphemeralAgent } from "@fusion/core";
import { isAgentAutoAssignable, isEphemeralAgent } from "@fusion/core";
const ACTIVE_COLUMNS = new Set(["todo", "in-progress", "in-review"]);
@@ -26,11 +26,18 @@ export async function listEligibleExecutorAgents(
agentStore: Pick<AgentStore, "listAgents">,
): Promise<Agent[]> {
const agents = await agentStore.listAgents({ role: "executor", includeEphemeral: true });
/*
FNXC:AgentRouting 2026-07-12-12:15:
Issue #2015 (NEXT-871): the scheduler auto-assign pool admitted EVERY enabled executor-role agent, so a
liaison-type agent whose role field is "executor" was round-robin-assigned product-code tasks. Agents with
runtimeConfig.assignmentPolicy "explicit-only"/"none" are excluded from all automatic assignment.
*/
return agents.filter(
(agent) => agent.role === "executor"
&& !isEphemeralAgent(agent)
&& agent.state !== "error"
&& isAgentEnabled(agent),
&& isAgentEnabled(agent)
&& isAgentAutoAssignable(agent),
);
}

View File

@@ -19,7 +19,7 @@
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore, ChatStore, ChatRoom, ChatRoomMessage, AgentMemoryInclusionMode } from "@fusion/core";
import { AutoClaimSnapshotManager, resolveFreshAutoClaimCandidates, type AutoClaimCandidate } from "./auto-claim-snapshot.js";
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode, FUSION_RUNTIME_SELF_AWARENESS, AWAITING_APPROVAL_PAUSE_REASON } from "@fusion/core";
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, canAgentTakeImplementationTask, evaluateImplementationTaskBind, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode, FUSION_RUNTIME_SELF_AWARENESS, AWAITING_APPROVAL_PAUSE_REASON } from "@fusion/core";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Type, type Static } from "@earendil-works/pi-ai";
import { createHash } from "node:crypto";
@@ -2318,10 +2318,16 @@ export class HeartbeatMonitor {
let inboxSelection: InboxTask | null = null;
if (!taskId) {
inboxSelection = await taskStore.selectNextTaskForAgent(agentId, { id: agent.id, role: agent.role });
if (inboxSelection && !canAgentTakeImplementationTaskForExplicitRouting(agent, inboxSelection.task)) {
const hasRoleOverride = inboxSelection.task.sourceMetadata?.executorRoleOverride === true;
if (!hasRoleOverride) {
// FNXC:AgentRouting 2026-07-12-12:10: pass runtimeConfig so the inbox selector can enforce per-agent assignmentPolicy (issue #2015).
inboxSelection = await taskStore.selectNextTaskForAgent(agentId, { id: agent.id, role: agent.role, runtimeConfig: agent.runtimeConfig });
if (inboxSelection) {
// Defense-in-depth re-check with the shared evaluator: executorRoleOverride bypasses the role
// check only — assignmentPolicy "none" is never overridable (issue #2015).
const bindVerdict = evaluateImplementationTaskBind(agent, inboxSelection.task, {
explicitRouting: true,
executorRoleOverride: inboxSelection.task.sourceMetadata?.executorRoleOverride === true,
});
if (!bindVerdict.allowed) {
heartbeatLog.log(
`Agent ${agentId} (role=${agent.role}) skipped inbox-selected task ${inboxSelection.task.id} due to executor-role assignment policy`,
);

View File

@@ -16,7 +16,7 @@ import * as fusionCore from "@fusion/core";
import type { AgentState, AgentCapability, AgentUpdateInput, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus } from "@fusion/core";
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon } from "@fusion/core";
import { promoteHeldTask } from "./hold-release.js";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
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";
import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js";
import { ResearchStepRunner } from "./research-step-runner.js";
@@ -3839,11 +3839,21 @@ export function createDelegateTaskTool(
};
}
/*
FNXC:AgentRouting 2026-07-12-12:20:
Issue #2015: delegation must honor per-agent assignmentPolicy. override=true still bypasses the ROLE
check, but an agent with assignmentPolicy "none" (liaison guarantee) can never be delegated an
implementation task — no override exists for that.
*/
const override = params.override === true;
const newTaskRef = { id: "<new>", column: "todo" } as const;
if (!override && !canAgentTakeImplementationTaskForExplicitRouting(agent, { column: newTaskRef.column })) {
const bindVerdict = evaluateImplementationTaskBind(agent, newTaskRef, {
explicitRouting: true,
executorRoleOverride: override,
});
if (!bindVerdict.allowed) {
return {
content: [{ type: "text" as const, text: `ERROR: ${formatRoleMismatchReason(agent, newTaskRef)}` }],
content: [{ type: "text" as const, text: `ERROR: ${bindVerdict.reason}` }],
details: {},
};
}