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:
7
.changeset/agent-assignment-policy-routing-guard.md
Normal file
7
.changeset/agent-assignment-policy-routing-guard.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
315
packages/core/src/__tests__/agent-store-routing-policy.test.ts
Normal file
315
packages/core/src/__tests__/agent-store-routing-policy.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user