feat(FN-4153): complete Step 2 — add permanent agent assignment selector
Fusion-Task-Id: FN-4153 Fusion-Task-Lineage: 6ec09467-c7a0-4a9d-9a09-16d5ef6f7a86
This commit is contained in:
140
packages/engine/src/__tests__/agent-assignment.test.ts
Normal file
140
packages/engine/src/__tests__/agent-assignment.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { Agent, Task } from "@fusion/core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { selectPermanentAgentForTask } from "../agent-assignment.js";
|
||||||
|
|
||||||
|
function makeAgent(overrides: Partial<Agent> & Pick<Agent, "id">): Agent {
|
||||||
|
return {
|
||||||
|
id: overrides.id,
|
||||||
|
name: overrides.name ?? overrides.id,
|
||||||
|
role: overrides.role ?? "executor",
|
||||||
|
state: overrides.state ?? "idle",
|
||||||
|
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
|
||||||
|
metadata: overrides.metadata ?? {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> & Pick<Task, "id">): Task {
|
||||||
|
return {
|
||||||
|
id: overrides.id,
|
||||||
|
title: overrides.title ?? overrides.id,
|
||||||
|
description: overrides.description ?? "",
|
||||||
|
column: overrides.column ?? "todo",
|
||||||
|
priority: overrides.priority ?? "normal",
|
||||||
|
dependencies: overrides.dependencies ?? [],
|
||||||
|
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
|
||||||
|
log: overrides.log ?? [],
|
||||||
|
...overrides,
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("selectPermanentAgentForTask", () => {
|
||||||
|
it("returns null when no eligible permanent executor exists", async () => {
|
||||||
|
const agent = makeAgent({ id: "ephemeral-1", metadata: { agentKind: "task-worker" } });
|
||||||
|
const selected = await selectPermanentAgentForTask({
|
||||||
|
task: makeTask({ id: "FN-1" }),
|
||||||
|
agentStore: {
|
||||||
|
listAgents: async () => [agent],
|
||||||
|
getChainOfCommand: async () => [],
|
||||||
|
} as never,
|
||||||
|
taskStore: { listTasks: async () => [] } as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out ephemeral, disabled, errored, and non-executor agents", async () => {
|
||||||
|
const selected = await selectPermanentAgentForTask({
|
||||||
|
task: makeTask({ id: "FN-2" }),
|
||||||
|
agentStore: {
|
||||||
|
listAgents: async () => [
|
||||||
|
makeAgent({ id: "ephemeral", metadata: { agentKind: "task-worker" } }),
|
||||||
|
makeAgent({ id: "disabled", runtimeConfig: { enabled: false } }),
|
||||||
|
makeAgent({ id: "errored", state: "error" }),
|
||||||
|
makeAgent({ id: "reviewer", role: "reviewer" }),
|
||||||
|
makeAgent({ id: "ok", createdAt: "2026-01-01T00:00:01.000Z" }),
|
||||||
|
],
|
||||||
|
getChainOfCommand: async () => [],
|
||||||
|
} as never,
|
||||||
|
taskStore: { listTasks: async () => [] } as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected?.id).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects least-loaded agent", async () => {
|
||||||
|
const selected = await selectPermanentAgentForTask({
|
||||||
|
task: makeTask({ id: "FN-3" }),
|
||||||
|
agentStore: {
|
||||||
|
listAgents: async () => [
|
||||||
|
makeAgent({ id: "a", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||||
|
makeAgent({ id: "b", createdAt: "2026-01-01T00:00:01.000Z" }),
|
||||||
|
],
|
||||||
|
getChainOfCommand: async () => [],
|
||||||
|
} as never,
|
||||||
|
taskStore: {
|
||||||
|
listTasks: async () => [
|
||||||
|
makeTask({ id: "T1", column: "in-progress", assignedAgentId: "a" }),
|
||||||
|
makeTask({ id: "T2", column: "todo", assignedAgentId: "a" }),
|
||||||
|
makeTask({ id: "T3", column: "in-review", assignedAgentId: "b" }),
|
||||||
|
makeTask({ id: "T4", column: "done", assignedAgentId: "b" }),
|
||||||
|
],
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selected?.id).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses createdAt then id for deterministic tie-break", async () => {
|
||||||
|
const selectedByCreatedAt = await selectPermanentAgentForTask({
|
||||||
|
task: makeTask({ id: "FN-4" }),
|
||||||
|
agentStore: {
|
||||||
|
listAgents: async () => [
|
||||||
|
makeAgent({ id: "b", createdAt: "2026-01-02T00:00:00.000Z" }),
|
||||||
|
makeAgent({ id: "a", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||||
|
],
|
||||||
|
getChainOfCommand: async () => [],
|
||||||
|
} as never,
|
||||||
|
taskStore: { listTasks: async () => [] } as never,
|
||||||
|
});
|
||||||
|
expect(selectedByCreatedAt?.id).toBe("a");
|
||||||
|
|
||||||
|
const selectedById = await selectPermanentAgentForTask({
|
||||||
|
task: makeTask({ id: "FN-5" }),
|
||||||
|
agentStore: {
|
||||||
|
listAgents: async () => [
|
||||||
|
makeAgent({ id: "b", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||||
|
makeAgent({ id: "a", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||||
|
],
|
||||||
|
getChainOfCommand: async () => [],
|
||||||
|
} as never,
|
||||||
|
taskStore: { listTasks: async () => [] } as never,
|
||||||
|
});
|
||||||
|
expect(selectedById?.id).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers agents in reporting chain of mission/slice-linked assignees", async () => {
|
||||||
|
const selected = await selectPermanentAgentForTask({
|
||||||
|
task: makeTask({ id: "FN-6", missionId: "M-1", sliceId: "SL-1" }),
|
||||||
|
agentStore: {
|
||||||
|
listAgents: async () => [
|
||||||
|
makeAgent({ id: "agent-a", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||||
|
makeAgent({ id: "agent-b", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||||
|
makeAgent({ id: "agent-c", createdAt: "2026-01-01T00:00:00.000Z" }),
|
||||||
|
],
|
||||||
|
getChainOfCommand: async (agentId: string) => (agentId === "agent-c" ? [makeAgent({ id: "agent-b" })] : []),
|
||||||
|
} as never,
|
||||||
|
taskStore: {
|
||||||
|
listTasks: async () => [
|
||||||
|
makeTask({ id: "FN-linked", missionId: "M-1", sliceId: "SL-1", assignedAgentId: "agent-c", column: "todo" }),
|
||||||
|
makeTask({ id: "FN-other", missionId: "M-2", assignedAgentId: "agent-a", column: "todo" }),
|
||||||
|
],
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(["agent-b", "agent-c"]).toContain(selected?.id);
|
||||||
|
expect(selected?.id).toBe("agent-b");
|
||||||
|
});
|
||||||
|
});
|
||||||
78
packages/engine/src/agent-assignment.ts
Normal file
78
packages/engine/src/agent-assignment.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Agent, AgentStore, Task, TaskStore } from "@fusion/core";
|
||||||
|
import { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 allAgents = await agentStore.listAgents({ role: "executor", includeEphemeral: true });
|
||||||
|
const eligibleAgents = allAgents.filter(
|
||||||
|
(agent) => agent.role === "executor"
|
||||||
|
&& !isEphemeralAgent(agent)
|
||||||
|
&& agent.state !== "error"
|
||||||
|
&& isAgentEnabled(agent),
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user