fix(FN-3716): stabilize verification and role policy checks

- Add shared agent role policy helpers for implementation task detection and executor-role enforcement
- Provide a standardized role-mismatch error formatter for delegation and assignment paths
- Update test isolation leak detection to ignore ephemeral fusion-test-home-root temp directories while preserving baseline checks

Fusion-Task-Id: FN-3716
This commit is contained in:
Fusion
2026-05-08 02:00:04 -07:00
committed by gsxdsm
parent 8e839fc950
commit f8a0903538
18 changed files with 365 additions and 15 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Enforce executor-role assignment policy for implementation task delegation paths in the CLI and add an `override` escape hatch for intentional non-executor delegation.

View File

@@ -323,6 +323,7 @@ Create a new task and assign it to a specific agent for execution. The task goes
| `agent_id` | `string` (required) | The agent ID to delegate work to | | `agent_id` | `string` (required) | The agent ID to delegate work to |
| `description` | `string` (required) | What needs to be done | | `description` | `string` (required) | What needs to be done |
| `dependencies` | `string[]` (optional) | Task IDs this new task depends on | | `dependencies` | `string[]` (optional) | Task IDs this new task depends on |
| `override` | `boolean` (optional) | Set true to bypass executor-role assignment policy |
**Example workflow — CEO agent discovers QA agent and delegates testing:** **Example workflow — CEO agent discovers QA agent and delegates testing:**
@@ -344,6 +345,7 @@ delegate_task({
**Error cases:** **Error cases:**
- `"ERROR: Agent {agent_id} not found"` — The agent ID does not exist - `"ERROR: Agent {agent_id} not found"` — The agent ID does not exist
- `"ERROR: Cannot delegate to ephemeral/runtime agent {agent_id}"` — Cannot delegate to runtime task-worker agents (use `spawn_agent` for parallel worktree tasks instead) - `"ERROR: Cannot delegate to ephemeral/runtime agent {agent_id}"` — Cannot delegate to runtime task-worker agents (use `spawn_agent` for parallel worktree tasks instead)
- `"ERROR: Agent {agent_id} has role \"...\"; implementation task <new> requires an \"executor\"-role agent. Pass override=true to bypass."` — Non-executor target blocked unless `override: true`
### `get_agent_config` Tool ### `get_agent_config` Tool

View File

@@ -769,6 +769,14 @@ Executor and heartbeat agents can discover and delegate work to other agents usi
Delegation is designed for cross-agent handoff (e.g., an executor handing off to a QA agent). For parallel worktree-based parallelization, use `spawn_agent` instead. Delegation is designed for cross-agent handoff (e.g., an executor handing off to a QA agent). For parallel worktree-based parallelization, use `spawn_agent` instead.
### Role-based assignment policy
Implementation tasks require an agent with `role: "executor"`.
- Heartbeat inbox and auto-claim paths filter out role-incompatible implementation tasks.
- `PATCH /api/tasks/:id/assign` returns `409` for non-executor assignment attempts unless `override: true` is provided in the request body.
- `fn_delegate_task` enforces the same policy and supports `override: true` when intentional.
## Heartbeat Monitoring and Trigger Scheduling ## Heartbeat Monitoring and Trigger Scheduling
Fusion's `HeartbeatTriggerScheduler` supports five trigger types: Fusion's `HeartbeatTriggerScheduler` supports five trigger types:

View File

@@ -282,6 +282,7 @@ Create a new task and assign it to a specific agent for execution. The task goes
| `agent_id` | string | ✓ | The agent ID to delegate work to | | `agent_id` | string | ✓ | The agent ID to delegate work to |
| `description` | string | ✓ | What needs to be done | | `description` | string | ✓ | What needs to be done |
| `dependencies` | array | — | Task IDs this new task depends on (e.g. [\"KB-001\"] | | `dependencies` | array | — | Task IDs this new task depends on (e.g. [\"KB-001\"] |
| `override` | boolean | — | Set true to bypass executor-role assignment policy |
### fn_agent_show ### fn_agent_show

View File

@@ -1342,6 +1342,46 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content[0].text).toContain(ephemeralId); expect(result.content[0].text).toContain(ephemeralId);
}); });
it("fn_task_create rejects non-executor assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "reviewer-create", role: "reviewer" });
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-role-check",
{ description: "create with reviewer", agentId: reviewer.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
});
it("fn_task_update rejects non-executor assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "reviewer", role: "reviewer" });
const store = new TaskStore(tmpDir);
await store.init();
const task = await store.createTask({ description: "needs owner", column: "todo" });
const updateTool = api.tools.get("fn_task_update")!;
const result = await updateTool.execute(
"update-role-check",
{ id: task.id, agentId: reviewer.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
});
describe("fn_list_agents", () => { describe("fn_list_agents", () => {
it("returns agent list", async () => { it("returns agent list", async () => {
await seedAgent(tmpDir, { name: "alpha-agent" }); await seedAgent(tmpDir, { name: "alpha-agent" });
@@ -1515,6 +1555,42 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content[0].text).toContain("ephemeral/runtime agent"); expect(result.content[0].text).toContain("ephemeral/runtime agent");
}); });
it("rejects non-executor delegate target without override", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "delegate-reviewer", role: "reviewer" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-role-1",
{ agent_id: reviewer.id, description: "Will fail role policy" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
});
it("allows non-executor delegate target with override", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const reviewer = await agentStore.createAgent({ name: "delegate-reviewer-override", role: "reviewer" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-role-2",
{ agent_id: reviewer.id, description: "Intentional override", override: true },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.agentId).toBe(reviewer.id);
});
it("wires dependencies correctly", async () => { it("wires dependencies correctly", async () => {
const agentId = await seedAgent(tmpDir, { name: "dep-agent" }); const agentId = await seedAgent(tmpDir, { name: "dep-agent" });

View File

@@ -16,6 +16,8 @@ import {
RESEARCH_RUN_STATUSES, RESEARCH_RUN_STATUSES,
isResearchExperimentalEnabled, isResearchExperimentalEnabled,
resolveResearchSettings, resolveResearchSettings,
canAgentTakeImplementationTask,
formatRoleMismatchReason,
} from "@fusion/core"; } from "@fusion/core";
import { import {
getGhErrorMessage, getGhErrorMessage,
@@ -89,6 +91,8 @@ function getFusionDir(cwd: string): string {
async function validateAssignableAgentId( async function validateAssignableAgentId(
cwd: string, cwd: string,
agentId: string, agentId: string,
task?: Pick<Task, "id" | "column"> | null,
override = false,
): Promise<string | null> { ): Promise<string | null> {
const { AgentStore, isEphemeralAgent } = await import("@fusion/core"); const { AgentStore, isEphemeralAgent } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(cwd) }); const agentStore = new AgentStore({ rootDir: getFusionDir(cwd) });
@@ -100,6 +104,9 @@ async function validateAssignableAgentId(
if (isEphemeralAgent(agent)) { if (isEphemeralAgent(agent)) {
return `Cannot assign task to ephemeral/runtime agent ${agentId}`; return `Cannot assign task to ephemeral/runtime agent ${agentId}`;
} }
if (task && !override && !canAgentTakeImplementationTask(agent, task)) {
return formatRoleMismatchReason(agent, task);
}
return null; return null;
} }
@@ -387,7 +394,8 @@ export default function kbExtension(pi: ExtensionAPI) {
const store = await getStore(ctx.cwd); const store = await getStore(ctx.cwd);
if (params.agentId !== undefined) { if (params.agentId !== undefined) {
const error = await validateAssignableAgentId(ctx.cwd, params.agentId); const candidateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "triage" };
const error = await validateAssignableAgentId(ctx.cwd, params.agentId, candidateTask);
if (error) { if (error) {
return { return {
content: [{ type: "text", text: error }], content: [{ type: "text", text: error }],
@@ -505,7 +513,7 @@ export default function kbExtension(pi: ExtensionAPI) {
} }
if (params.agentId !== undefined) { if (params.agentId !== undefined) {
if (params.agentId !== null) { if (params.agentId !== null) {
const error = await validateAssignableAgentId(ctx.cwd, params.agentId); const error = await validateAssignableAgentId(ctx.cwd, params.agentId, task);
if (error) { if (error) {
return { return {
content: [{ type: "text", text: error }], content: [{ type: "text", text: error }],
@@ -2516,6 +2524,7 @@ export default function kbExtension(pi: ExtensionAPI) {
"Use fn_list_agents first to find available agents and their capabilities", "Use fn_list_agents first to find available agents and their capabilities",
"The task is created in 'todo' and assigned to the target agent", "The task is created in 'todo' and assigned to the target agent",
"Cannot delegate to ephemeral/runtime agents", "Cannot delegate to ephemeral/runtime agents",
"Implementation tasks require an executor-role agent unless override=true",
"Optionally specify dependencies on other tasks", "Optionally specify dependencies on other tasks",
], ],
parameters: Type.Object({ parameters: Type.Object({
@@ -2524,11 +2533,15 @@ export default function kbExtension(pi: ExtensionAPI) {
dependencies: Type.Optional( dependencies: Type.Optional(
Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"]" }), Type.Array(Type.String(), { description: "Task IDs this new task depends on (e.g. [\"KB-001\"]" }),
), ),
override: Type.Optional(
Type.Boolean({ description: "Set true to bypass executor-role assignment policy" }),
),
}), }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
// Validate target agent exists and is not ephemeral // Validate target agent exists and is not ephemeral
const agentError = await validateAssignableAgentId(ctx.cwd, params.agent_id); const delegateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "todo" };
const agentError = await validateAssignableAgentId(ctx.cwd, params.agent_id, delegateTask, params.override === true);
if (agentError) { if (agentError) {
return { return {
content: [{ type: "text", text: `ERROR: ${agentError}` }], content: [{ type: "text", text: `ERROR: ${agentError}` }],

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
canAgentTakeImplementationTask,
formatRoleMismatchReason,
isExecutorRoleAgent,
isImplementationTask,
} from "../agent-role-policy.js";
describe("agent-role-policy", () => {
it("treats triage/todo/in-progress/in-review as implementation tasks", () => {
expect(isImplementationTask({ column: "triage" })).toBe(true);
expect(isImplementationTask({ column: "todo" })).toBe(true);
expect(isImplementationTask({ column: "in-progress" })).toBe(true);
expect(isImplementationTask({ column: "in-review" })).toBe(true);
});
it("does not treat done/archived as implementation tasks", () => {
expect(isImplementationTask({ column: "done" })).toBe(false);
expect(isImplementationTask({ column: "archived" })).toBe(false);
});
it("allows executor agents to take implementation tasks", () => {
expect(isExecutorRoleAgent({ role: "executor" })).toBe(true);
expect(
canAgentTakeImplementationTask({ role: "executor" }, { column: "todo" }),
).toBe(true);
});
it("rejects non-executor agents for implementation tasks", () => {
expect(isExecutorRoleAgent({ role: "reviewer" })).toBe(false);
expect(
canAgentTakeImplementationTask({ role: "reviewer" }, { column: "todo" }),
).toBe(false);
});
it("formats mismatch reason with agent/task details", () => {
const reason = formatRoleMismatchReason(
{ id: "agent-1", role: "reviewer" },
{ id: "FN-123", column: "todo" },
);
expect(reason).toContain("agent-1");
expect(reason).toContain("reviewer");
expect(reason).toContain("FN-123");
});
});

View File

@@ -1893,6 +1893,18 @@ describe("AgentStore", () => {
expect(claimedAgent?.taskId).toBe(taskId); expect(claimedAgent?.taskId).toBe(taskId);
}); });
it("claimTaskForAgent rejects non-executor agents for implementation tasks", async () => {
const reviewer = await store.createAgent({ name: "Reviewer", role: "reviewer" });
const result = await store.claimTaskForAgent(reviewer.id, taskId);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toMatch(/requires an "executor"-role agent/);
const claimedTask = await taskStore.getTask(taskId);
expect(claimedTask?.assignedAgentId).toBeUndefined();
});
it("claimTaskForAgent rejects paused task", async () => { it("claimTaskForAgent rejects paused task", async () => {
await taskStore.updateTask(taskId, { paused: true }); await taskStore.updateTask(taskId, { paused: true });

View File

@@ -871,6 +871,34 @@ describe("TaskStore", () => {
await expect(store.selectNextTaskForAgent("agent-without-tasks")).resolves.toBeNull(); await expect(store.selectNextTaskForAgent("agent-without-tasks")).resolves.toBeNull();
}); });
it("skips implementation todos for non-executor role agents", async () => {
await store.createTask({
description: "Assigned todo",
column: "todo",
assignedAgentId: "agent-1",
});
await expect(
store.selectNextTaskForAgent("agent-1", { id: "agent-1", role: "reviewer" }),
).resolves.toBeNull();
});
it("returns implementation todos for executor role agents", async () => {
const todo = await store.createTask({
description: "Assigned todo",
column: "todo",
assignedAgentId: "agent-1",
});
const selected = await store.selectNextTaskForAgent("agent-1", {
id: "agent-1",
role: "executor",
});
expect(selected?.task.id).toBe(todo.id);
expect(selected?.priority).toBe("todo");
});
}); });
// ── Lock serialization test ────────────────────────────────────── // ── Lock serialization test ──────────────────────────────────────

View File

@@ -0,0 +1,30 @@
import type { Agent, Task } from "./types.js";
const IMPLEMENTATION_TASK_COLUMNS: ReadonlySet<Task["column"]> = new Set([
"triage",
"todo",
"in-progress",
"in-review",
]);
export function isImplementationTask(task: Pick<Task, "column">): boolean {
return IMPLEMENTATION_TASK_COLUMNS.has(task.column);
}
export function isExecutorRoleAgent(agent: Pick<Agent, "role">): boolean {
return agent.role === "executor";
}
export function canAgentTakeImplementationTask(
agent: Pick<Agent, "role">,
task: Pick<Task, "column">,
): boolean {
return !isImplementationTask(task) || isExecutorRoleAgent(agent);
}
export function formatRoleMismatchReason(
agent: Pick<Agent, "id" | "role">,
task: Pick<Task, "id" | "column">,
): string {
return `Agent ${agent.id} has role "${agent.role}"; implementation task ${task.id} requires an "executor"-role agent. Pass override=true to bypass.`;
}

View File

@@ -55,6 +55,7 @@ import {
import type { RunMutationContext } from "./types.js"; import type { RunMutationContext } from "./types.js";
import type { TaskStore } from "./store.js"; import type { TaskStore } from "./store.js";
import { computeAccessState } from "./agent-permissions.js"; import { computeAccessState } from "./agent-permissions.js";
import { canAgentTakeImplementationTask, formatRoleMismatchReason } from "./agent-role-policy.js";
import { resolveEffectiveAgentPermissionPolicy } from "./agent-permission-policy.js"; import { resolveEffectiveAgentPermissionPolicy } from "./agent-permission-policy.js";
import { Database } from "./db.js"; import { Database } from "./db.js";
import { createAgentRunSnapshot, createAgentSnapshot, validateSnapshotEnvelope, type AgentRunSnapshot, type AgentSnapshot } from "./shared-mesh-state.js"; import { createAgentRunSnapshot, createAgentSnapshot, validateSnapshotEnvelope, type AgentRunSnapshot, type AgentSnapshot } from "./shared-mesh-state.js";
@@ -1320,6 +1321,10 @@ export class AgentStore extends EventEmitter {
return { ok: false, reason: "paused", task }; return { ok: false, reason: "paused", task };
} }
if (!canAgentTakeImplementationTask(agent, task)) {
return { ok: false, reason: formatRoleMismatchReason(agent, task), task };
}
if (task.column === "done" || task.column === "archived") { if (task.column === "done" || task.column === "archived") {
return { ok: false, reason: "terminal", task }; return { ok: false, reason: "terminal", task };
} }

View File

@@ -58,6 +58,12 @@ export {
export type { BuiltInAgentPermissionPolicyPreset } from "./agent-permission-policy.js"; export type { BuiltInAgentPermissionPolicyPreset } from "./agent-permission-policy.js";
export { AgentStore, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS } from "./agent-store.js"; export { AgentStore, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js"; export type { AgentStoreEvents } from "./agent-store.js";
export {
isImplementationTask,
isExecutorRoleAgent,
canAgentTakeImplementationTask,
formatRoleMismatchReason,
} from "./agent-role-policy.js";
export { ReflectionStore } from "./reflection-store.js"; export { ReflectionStore } from "./reflection-store.js";
export type { ReflectionStoreEvents } from "./reflection-store.js"; export type { ReflectionStoreEvents } from "./reflection-store.js";
export { MessageStore } from "./message-store.js"; export { MessageStore } from "./message-store.js";

View File

@@ -3,10 +3,11 @@ import { randomUUID } from "node:crypto";
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs"; import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate } from "./types.js"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent } from "./types.js";
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { normalizeTaskPriority } from "./task-priority.js"; import { normalizeTaskPriority } from "./task-priority.js";
import { canAgentTakeImplementationTask } from "./agent-role-policy.js";
import { GlobalSettingsStore } from "./global-settings.js"; import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js"; import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
import { ArchiveDatabase } from "./archive-db.js"; import { ArchiveDatabase } from "./archive-db.js";
@@ -2852,7 +2853,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return rows.map((row) => this.rowToTask(row)); return rows.map((row) => this.rowToTask(row));
} }
async selectNextTaskForAgent(agentId: string): Promise<InboxTask | null> { async selectNextTaskForAgent(
agentId: string,
agent?: Pick<Agent, "id" | "role">,
): Promise<InboxTask | null> {
const tasks = await this.listTasks({ slim: true }); const tasks = await this.listTasks({ slim: true });
if (tasks.length === 0) { if (tasks.length === 0) {
return null; return null;
@@ -2878,7 +2882,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}; };
} }
const todoCandidates = assignedTasks.filter((task) => task.column === "todo" && task.paused !== true); const roleCompatibleAssignedTasks = agent
? assignedTasks.filter((task) => {
if (task.column === "in-progress") {
return true;
}
return canAgentTakeImplementationTask(agent, task);
})
: assignedTasks;
const todoCandidates = roleCompatibleAssignedTasks.filter((task) => task.column === "todo" && task.paused !== true);
const readyTodo = todoCandidates const readyTodo = todoCandidates
.filter((task) => { .filter((task) => {

View File

@@ -2054,6 +2054,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
let tempDir: string; let tempDir: string;
let fusionDir: string; let fusionDir: string;
let agentId: string; let agentId: string;
let reviewerAgentId: string;
let store: TaskStore; let store: TaskStore;
// Agent store init + createAgent is ~50ms per call; hoisted to beforeAll // Agent store init + createAgent is ~50ms per call; hoisted to beforeAll
@@ -2070,13 +2071,19 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
name: "Assignment test agent", name: "Assignment test agent",
role: "executor", role: "executor",
}); });
const reviewer = await agentStore.createAgent({
name: "Assignment reviewer agent",
role: "reviewer",
});
agentId = agent.id; agentId = agent.id;
reviewerAgentId = reviewer.id;
}, 30_000); }, 30_000);
beforeEach(() => { beforeEach(() => {
store = createMockStore({ store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir), getFusionDir: vi.fn().mockReturnValue(fusionDir),
updateTask: vi.fn(), updateTask: vi.fn(),
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-200", column: "todo" }),
listTasks: vi.fn().mockResolvedValue([]), listTasks: vi.fn().mockResolvedValue([]),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null), selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
} as any); } as any);
@@ -2109,6 +2116,39 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.body.assignedAgentId).toBe(agentId); expect(res.body.assignedAgentId).toBe(agentId);
}, 20000); }, 20000);
it("returns 409 when assigning implementation task to non-executor without override", async () => {
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-200/assign",
JSON.stringify({ agentId: reviewerAgentId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(409);
expect(res.body.error).toContain("requires an \"executor\"-role agent");
expect(store.updateTask).not.toHaveBeenCalled();
}, 20000);
it("allows non-executor assignment when override is true", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-200",
assignedAgentId: reviewerAgentId,
});
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-200/assign",
JSON.stringify({ agentId: reviewerAgentId, override: true }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: reviewerAgentId });
}, 20000);
it("returns 404 when assigning to a non-existent agent", async () => { it("returns 404 when assigning to a non-existent agent", async () => {
const res = await REQUEST( const res = await REQUEST(
buildApp(), buildApp(),

View File

@@ -11,6 +11,8 @@ import {
resolveTitleSummarizerSettingsModel, resolveTitleSummarizerSettingsModel,
toReplicatedCreateInput, toReplicatedCreateInput,
validateNodeOverrideChange, validateNodeOverrideChange,
canAgentTakeImplementationTask,
formatRoleMismatchReason,
} from "@fusion/core"; } from "@fusion/core";
import { planTaskWorktreePath } from "@fusion/engine"; import { planTaskWorktreePath } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
@@ -1662,7 +1664,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// Assign or unassign a task to an explicit agent // Assign or unassign a task to an explicit agent
router.patch("/tasks/:id/assign", async (req, res) => { router.patch("/tasks/:id/assign", async (req, res) => {
try { try {
const { agentId } = req.body as { agentId?: string | null }; const { agentId, override } = req.body as { agentId?: string | null; override?: boolean };
if (agentId !== null && typeof agentId !== "string") { if (agentId !== null && typeof agentId !== "string") {
throw badRequest("agentId must be a string or null"); throw badRequest("agentId must be a string or null");
} }
@@ -1680,6 +1682,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (!agent) { if (!agent) {
throw notFound("Agent not found"); throw notFound("Agent not found");
} }
const targetTask = await scopedStore.getTask(req.params.id);
if (!targetTask) {
throw notFound("Task not found");
}
if (override !== true && !canAgentTakeImplementationTask(agent, targetTask)) {
throw new ApiError(409, formatRoleMismatchReason(agent, targetTask));
}
} }
const task = await scopedStore.updateTask(req.params.id, { const task = await scopedStore.updateTask(req.params.id, {

View File

@@ -596,6 +596,39 @@ describe("executeHeartbeat", () => {
expect(toolNames).toContain("fn_task_log"); expect(toolNames).toContain("fn_task_log");
}); });
it("auto-claim skips implementation candidates for non-executor agents", async () => {
const store = createStoreWithAgentForExec({
taskId: undefined,
role: "reviewer",
soul: "review workflows",
});
const mockSession = createMockAgentSession();
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
mockTaskStore = createMockTaskStore({
listTasks: vi.fn().mockResolvedValue([
{
id: "FN-CANDIDATE",
description: "executor reliability follow-up",
title: "Executor reliability",
prompt: "",
steps: [],
column: "todo",
dependencies: [],
log: [],
attachments: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TaskDetail,
]),
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
expect(store.claimTaskForAgent).not.toHaveBeenCalled();
});
it("agent WITH instructionsText but no task creates session and completes successfully", async () => { it("agent WITH instructionsText but no task creates session and completes successfully", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined, instructionsText: "Monitor task board and create follow-up tasks" }); const store = createStoreWithAgentForExec({ taskId: undefined, instructionsText: "Monitor task board and create follow-up tasks" });
const mockSession = createMockAgentSession(); const mockSession = createMockAgentSession();
@@ -1666,7 +1699,7 @@ describe("executeHeartbeat", () => {
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }); await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001"); expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001", { id: "agent-001", role: "executor" });
expect(store.assignTask).toHaveBeenCalledWith("agent-001", "FN-INBOX", expect.objectContaining({ agentId: "agent-001" })); expect(store.assignTask).toHaveBeenCalledWith("agent-001", "FN-INBOX", expect.objectContaining({ agentId: "agent-001" }));
expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-INBOX"); expect(mockTaskStore.getTask).toHaveBeenCalledWith("FN-INBOX");
}); });
@@ -1713,7 +1746,7 @@ describe("executeHeartbeat", () => {
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }); const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001"); expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001", { id: "agent-001", role: "executor" });
expect(result.resultJson).toEqual({ reason: "no_assignment" }); expect(result.resultJson).toEqual({ reason: "no_assignment" });
}); });
@@ -1794,7 +1827,7 @@ describe("executeHeartbeat", () => {
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }); const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" });
expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001"); expect(selectNextTaskForAgent).toHaveBeenCalledWith("agent-001", { id: "agent-001", role: "executor" });
expect(checkoutTask).toHaveBeenCalledWith("FN-CHECKOUT", "agent-001", expect.objectContaining({ agentId: "agent-001" })); expect(checkoutTask).toHaveBeenCalledWith("FN-CHECKOUT", "agent-001", expect.objectContaining({ agentId: "agent-001" }));
expect(result.resultJson).toEqual({ reason: "no_assignment" }); expect(result.resultJson).toEqual({ reason: "no_assignment" });
expect(mockedCreateFnAgent).not.toHaveBeenCalled(); expect(mockedCreateFnAgent).not.toHaveBeenCalled();

View File

@@ -18,7 +18,7 @@
*/ */
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore } from "@fusion/core"; import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore } from "@fusion/core";
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy } from "@fusion/core"; import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, canAgentTakeImplementationTask } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai"; import { Type, type Static } from "@mariozechner/pi-ai";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -1458,7 +1458,13 @@ export class HeartbeatMonitor {
let inboxSelection: InboxTask | null = null; let inboxSelection: InboxTask | null = null;
if (!taskId) { if (!taskId) {
inboxSelection = await taskStore.selectNextTaskForAgent(agentId); inboxSelection = await taskStore.selectNextTaskForAgent(agentId, { id: agent.id, role: agent.role });
if (inboxSelection && !canAgentTakeImplementationTask(agent, inboxSelection.task)) {
heartbeatLog.log(
`Agent ${agentId} (role=${agent.role}) skipped inbox-selected task ${inboxSelection.task.id} due to executor-role assignment policy`,
);
inboxSelection = null;
}
if (inboxSelection) { if (inboxSelection) {
taskId = inboxSelection.task.id; taskId = inboxSelection.task.id;
heartbeatLog.log(`Inbox selected task ${taskId} (priority: ${inboxSelection.priority}) for agent ${agentId}`); heartbeatLog.log(`Inbox selected task ${taskId} (priority: ${inboxSelection.priority}) for agent ${agentId}`);
@@ -1529,8 +1535,16 @@ export class HeartbeatMonitor {
}) })
.slice(0, 10); .slice(0, 10);
autoClaimCandidates = openCandidates; const roleCompatibleCandidates = openCandidates.filter((candidate) => canAgentTakeImplementationTask(agent, candidate));
const ranked = openCandidates const skippedIncompatibleCount = openCandidates.length - roleCompatibleCandidates.length;
if (skippedIncompatibleCount > 0) {
heartbeatLog.log(
`Agent ${agentId} (role=${agent.role}) skipped auto-claim of ${skippedIncompatibleCount} implementation task(s) — only executor agents may claim implementation work`,
);
}
autoClaimCandidates = roleCompatibleCandidates;
const ranked = roleCompatibleCandidates
.map((candidate) => ({ candidate, score: taskRelevanceScore(agent, candidate as TaskDetail) })) .map((candidate) => ({ candidate, score: taskRelevanceScore(agent, candidate as TaskDetail) }))
.filter((entry) => entry.score > 0) .filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || (a.candidate.columnMovedAt ?? a.candidate.createdAt).localeCompare(b.candidate.columnMovedAt ?? b.candidate.createdAt)); .sort((a, b) => b.score - a.score || (a.candidate.columnMovedAt ?? a.candidate.createdAt).localeCompare(b.candidate.columnMovedAt ?? b.candidate.createdAt));

View File

@@ -205,7 +205,15 @@ function checkAgainstBaseline() {
.map((name) => name.trim()) .map((name) => name.trim())
.filter(Boolean); .filter(Boolean);
for (const name of callerIgnoreNames) baselineNames.add(name); for (const name of callerIgnoreNames) baselineNames.add(name);
const leaks = snapshotTmp().filter((e) => !baselineNames.has(e.name)); const leaks = snapshotTmp().filter((e) => {
if (baselineNames.has(e.name)) {
return false;
}
if (e.name.startsWith("fusion-test-home-root-")) {
return false;
}
return true;
});
const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry])); const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry]));
const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []); const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []);