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 |
| `description` | `string` (required) | What needs to be done |
| `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:**
@@ -344,6 +345,7 @@ delegate_task({
**Error cases:**
- `"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: 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

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.
### 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
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 |
| `description` | string | ✓ | What needs to be done |
| `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

View File

@@ -1342,6 +1342,46 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
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", () => {
it("returns agent list", async () => {
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");
});
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 () => {
const agentId = await seedAgent(tmpDir, { name: "dep-agent" });

View File

@@ -16,6 +16,8 @@ import {
RESEARCH_RUN_STATUSES,
isResearchExperimentalEnabled,
resolveResearchSettings,
canAgentTakeImplementationTask,
formatRoleMismatchReason,
} from "@fusion/core";
import {
getGhErrorMessage,
@@ -89,6 +91,8 @@ function getFusionDir(cwd: string): string {
async function validateAssignableAgentId(
cwd: string,
agentId: string,
task?: Pick<Task, "id" | "column"> | null,
override = false,
): Promise<string | null> {
const { AgentStore, isEphemeralAgent } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: getFusionDir(cwd) });
@@ -100,6 +104,9 @@ async function validateAssignableAgentId(
if (isEphemeralAgent(agent)) {
return `Cannot assign task to ephemeral/runtime agent ${agentId}`;
}
if (task && !override && !canAgentTakeImplementationTask(agent, task)) {
return formatRoleMismatchReason(agent, task);
}
return null;
}
@@ -387,7 +394,8 @@ export default function kbExtension(pi: ExtensionAPI) {
const store = await getStore(ctx.cwd);
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) {
return {
content: [{ type: "text", text: error }],
@@ -505,7 +513,7 @@ export default function kbExtension(pi: ExtensionAPI) {
}
if (params.agentId !== undefined) {
if (params.agentId !== null) {
const error = await validateAssignableAgentId(ctx.cwd, params.agentId);
const error = await validateAssignableAgentId(ctx.cwd, params.agentId, task);
if (error) {
return {
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",
"The task is created in 'todo' and assigned to the target agent",
"Cannot delegate to ephemeral/runtime agents",
"Implementation tasks require an executor-role agent unless override=true",
"Optionally specify dependencies on other tasks",
],
parameters: Type.Object({
@@ -2524,11 +2533,15 @@ export default function kbExtension(pi: ExtensionAPI) {
dependencies: Type.Optional(
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) {
// 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) {
return {
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);
});
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 () => {
await taskStore.updateTask(taskId, { paused: true });

View File

@@ -871,6 +871,34 @@ describe("TaskStore", () => {
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 ──────────────────────────────────────

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 { TaskStore } from "./store.js";
import { computeAccessState } from "./agent-permissions.js";
import { canAgentTakeImplementationTask, formatRoleMismatchReason } from "./agent-role-policy.js";
import { resolveEffectiveAgentPermissionPolicy } from "./agent-permission-policy.js";
import { Database } from "./db.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 };
}
if (!canAgentTakeImplementationTask(agent, task)) {
return { ok: false, reason: formatRoleMismatchReason(agent, task), task };
}
if (task.column === "done" || task.column === "archived") {
return { ok: false, reason: "terminal", task };
}

View File

@@ -58,6 +58,12 @@ export {
export type { BuiltInAgentPermissionPolicyPreset } from "./agent-permission-policy.js";
export { AgentStore, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS } 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 type { ReflectionStoreEvents } from "./reflection-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 { join } from "node:path";
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 { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { normalizeTaskPriority } from "./task-priority.js";
import { canAgentTakeImplementationTask } from "./agent-role-policy.js";
import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./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));
}
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 });
if (tasks.length === 0) {
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
.filter((task) => {

View File

@@ -2054,6 +2054,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
let tempDir: string;
let fusionDir: string;
let agentId: string;
let reviewerAgentId: string;
let store: TaskStore;
// 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",
role: "executor",
});
const reviewer = await agentStore.createAgent({
name: "Assignment reviewer agent",
role: "reviewer",
});
agentId = agent.id;
reviewerAgentId = reviewer.id;
}, 30_000);
beforeEach(() => {
store = createMockStore({
getFusionDir: vi.fn().mockReturnValue(fusionDir),
updateTask: vi.fn(),
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "FN-200", column: "todo" }),
listTasks: vi.fn().mockResolvedValue([]),
selectNextTaskForAgent: vi.fn().mockResolvedValue(null),
} as any);
@@ -2109,6 +2116,39 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
expect(res.body.assignedAgentId).toBe(agentId);
}, 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 () => {
const res = await REQUEST(
buildApp(),

View File

@@ -11,6 +11,8 @@ import {
resolveTitleSummarizerSettingsModel,
toReplicatedCreateInput,
validateNodeOverrideChange,
canAgentTakeImplementationTask,
formatRoleMismatchReason,
} from "@fusion/core";
import { planTaskWorktreePath } from "@fusion/engine";
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
router.patch("/tasks/:id/assign", async (req, res) => {
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") {
throw badRequest("agentId must be a string or null");
}
@@ -1680,6 +1682,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (!agent) {
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, {

View File

@@ -596,6 +596,39 @@ describe("executeHeartbeat", () => {
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 () => {
const store = createStoreWithAgentForExec({ taskId: undefined, instructionsText: "Monitor task board and create follow-up tasks" });
const mockSession = createMockAgentSession();
@@ -1666,7 +1699,7 @@ describe("executeHeartbeat", () => {
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
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(mockTaskStore.getTask).toHaveBeenCalledWith("FN-INBOX");
});
@@ -1713,7 +1746,7 @@ describe("executeHeartbeat", () => {
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
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" });
});
@@ -1794,7 +1827,7 @@ describe("executeHeartbeat", () => {
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
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(result.resultJson).toEqual({ reason: "no_assignment" });
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 { 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, type Static } from "@mariozechner/pi-ai";
import { createHash } from "node:crypto";
@@ -1458,7 +1458,13 @@ export class HeartbeatMonitor {
let inboxSelection: InboxTask | null = null;
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) {
taskId = inboxSelection.task.id;
heartbeatLog.log(`Inbox selected task ${taskId} (priority: ${inboxSelection.priority}) for agent ${agentId}`);
@@ -1529,8 +1535,16 @@ export class HeartbeatMonitor {
})
.slice(0, 10);
autoClaimCandidates = openCandidates;
const ranked = openCandidates
const roleCompatibleCandidates = openCandidates.filter((candidate) => canAgentTakeImplementationTask(agent, candidate));
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) }))
.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));

View File

@@ -205,7 +205,15 @@ function checkAgainstBaseline() {
.map((name) => name.trim())
.filter(Boolean);
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 unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []);