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>
101 lines
3.9 KiB
TypeScript
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;
|
|
}
|