Files
fusion/packages/engine/src/agent-assignment.ts
gsxdsm 8b601810e0 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>
2026-07-12 10:04:29 -07:00

101 lines
3.9 KiB
TypeScript

import type { Agent, AgentStore, Task, TaskStore } from "@fusion/core";
import { isAgentAutoAssignable, isEphemeralAgent } from "@fusion/core";
const ACTIVE_COLUMNS = new Set(["todo", "in-progress", "in-review"]);
type SelectPermanentAgentForTaskOptions = {
task: Task;
agentStore: Pick<AgentStore, "listAgents" | "getChainOfCommand">;
taskStore: Pick<TaskStore, "listTasks">;
};
function isAgentEnabled(agent: Agent): boolean {
return (agent.runtimeConfig?.enabled as boolean | undefined) !== false;
}
/**
* Permanent, enabled, non-errored executor agents — the pool the scheduler can
* auto-assign mission/queue tasks to when ephemeral agents are disabled.
*
* Catalog-imported "company" agents land with role "custom" (see
* mapRoleToCapability) and are therefore NOT in this pool, which is why a
* mission can silently stall when ephemeral agents are off and the only agents
* present came from an import. Callers use this to preflight that situation.
*/
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)
&& isAgentAutoAssignable(agent),
);
}
function taskLinksToScope(task: Pick<Task, "id" | "missionId" | "sliceId">, scopeTask: Pick<Task, "id" | "missionId" | "sliceId">): boolean {
if (task.id === scopeTask.id) return false;
if (scopeTask.sliceId && task.sliceId === scopeTask.sliceId) return true;
if (scopeTask.missionId && task.missionId === scopeTask.missionId) return true;
return false;
}
export async function selectPermanentAgentForTask({ task, agentStore, taskStore }: SelectPermanentAgentForTaskOptions): Promise<Agent | null> {
const eligibleAgents = await listEligibleExecutorAgents(agentStore);
if (eligibleAgents.length === 0) {
return null;
}
const allTasks = await taskStore.listTasks({ slim: true });
const linkedAssignedAgentIds = new Set<string>();
if (task.missionId || task.sliceId) {
for (const candidateTask of allTasks) {
if (!candidateTask.assignedAgentId) continue;
if (taskLinksToScope(candidateTask, task)) {
linkedAssignedAgentIds.add(candidateTask.assignedAgentId);
}
}
}
const preferredAgentIds = new Set<string>();
for (const linkedAgentId of linkedAssignedAgentIds) {
preferredAgentIds.add(linkedAgentId);
const chain = await agentStore.getChainOfCommand(linkedAgentId).catch(() => []);
for (const chainAgent of chain) {
preferredAgentIds.add(chainAgent.id);
}
}
const preferredEligible = eligibleAgents.filter((agent) => preferredAgentIds.has(agent.id));
const candidatePool = preferredEligible.length > 0 ? preferredEligible : eligibleAgents;
const assignmentLoad = new Map<string, number>();
for (const taskItem of allTasks) {
if (!taskItem.assignedAgentId || !ACTIVE_COLUMNS.has(taskItem.column)) continue;
assignmentLoad.set(taskItem.assignedAgentId, (assignmentLoad.get(taskItem.assignedAgentId) ?? 0) + 1);
}
const sorted = [...candidatePool].sort((a, b) => {
const loadA = assignmentLoad.get(a.id) ?? 0;
const loadB = assignmentLoad.get(b.id) ?? 0;
if (loadA !== loadB) return loadA - loadB;
const createdAtCompare = a.createdAt.localeCompare(b.createdAt);
if (createdAtCompare !== 0) return createdAtCompare;
return a.id.localeCompare(b.id);
});
return sorted[0] ?? null;
}