feat(FN-3935): align engineer role routing with explicit assignment and del

Centralized routing policy helpers in core and aligned both direct assignment and delegation to route engineer-role agents consistently, ensuring assigned tasks respect explicit engineer routing the same way delegated tasks do. Added comprehensive test coverage across core, CLI, dashboard, and engin

Fusion-Task-Id: FN-3935

Fusion-Task-Lineage: 069f4d54-f7a8-4bd3-a2c3-4de830de042f
This commit is contained in:
Fusion
2026-05-11 20:20:43 -07:00
committed by gsxdsm
parent f76867301c
commit e4ec922212
18 changed files with 342 additions and 29 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix `fn db --vacuum` exit handling so successful exits are not caught as VACUUM failures, and await async vacuum errors correctly.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Allow durable `role: "engineer"` agents to receive explicitly routed implementation tasks via assignment and delegation flows without requiring `override=true`.

View File

@@ -883,7 +883,7 @@ Create a new task and assign it to a specific agent for execution. The task goes
**Error cases:**
- `"ERROR: Agent {agent_id} not found"`
- `"ERROR: Cannot delegate to ephemeral/runtime agent {agent_id}"`
- `"ERROR: Agent {agent_id} has role \"...\"; implementation task <new> requires an \"executor\"-role agent. Pass override=true to bypass."`
- `"ERROR: Agent {agent_id} has role \"...\"; implementation task <new> requires an \"executor\"-role agent by default, with durable \"engineer\" supported only for explicit routing. Pass override=true to bypass."`
### `agent_create`
@@ -927,11 +927,11 @@ Delete a non-ephemeral direct-report agent. Deletion is blocked when the target
### Role-based assignment policy
Implementation tasks require an agent with `role: "executor"`.
Implementation-task routing distinguishes explicit specialist assignment from generic backlog pickup:
- 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.
- **Explicit assignment/delegation** (`PATCH /api/tasks/:id/assign`, `fn_delegate_task`, `fn_task_create`/`fn_task_update` with `agentId`): `role: "executor"` is always supported, and durable `role: "engineer"` is also supported without override.
- **Backlog pickup/auto-claim** (unassigned implementation work): remains executor-only by default; durable engineer agents do not auto-claim generic unassigned implementation backlog.
- Other non-executor roles (for example `reviewer`, `merger`, `custom`) still require an explicit override path on that surface (`override: true`) when intentional.
- Override delegations are persisted with task source metadata (`executorRoleOverride`) so inbox selection and heartbeat execution can intentionally run that assigned implementation task on the targeted durable non-executor agent.
## Heartbeat Monitoring and Trigger Scheduling

View File

@@ -1474,7 +1474,25 @@ 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 () => {
it("fn_task_create allows durable engineer assignment for implementation tasks", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const engineer = await agentStore.createAgent({ name: "engineer-create", role: "engineer" });
const createTool = api.tools.get("fn_task_create")!;
const result = await createTool.execute(
"create-role-check-engineer",
{ description: "create with engineer", agentId: engineer.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.content[0].text).toContain(`Assigned to: ${engineer.id}`);
});
it("fn_task_create rejects reviewer 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" });
@@ -1492,7 +1510,7 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
expect(result.content[0].text).toContain("requires an \"executor\"-role agent");
});
it("fn_task_update rejects non-executor assignment for implementation tasks", async () => {
it("fn_task_update rejects reviewer 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" });
@@ -1973,7 +1991,25 @@ 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 () => {
it("allows durable engineer delegate target without override", async () => {
const agentStore = new AgentStore({ rootDir: join(tmpDir, ".fusion") });
await agentStore.init();
const engineer = await agentStore.createAgent({ name: "delegate-engineer", role: "engineer" });
const tool = api.tools.get("fn_delegate_task")!;
const result = await tool.execute(
"dt-role-eng",
{ agent_id: engineer.id, description: "Engineer routing" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).not.toBe(true);
expect(result.details.agentId).toBe(engineer.id);
});
it("rejects reviewer 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" });

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Hoist mocks so they are evaluated before module imports
const { mockGetDatabase, mockVacuum, mockResolveProject } = vi.hoisted(() => ({
mockGetDatabase: vi.fn(),
mockVacuum: vi.fn(),
mockResolveProject: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn(),
getDatabase: mockGetDatabase,
})),
}));
vi.mock("../../project-context.js", () => ({
resolveProject: mockResolveProject,
}));
import { runDbVacuum } from "../db.ts";
describe("runDbVacuum", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("resolves project store and calls vacuum", async () => {
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getDatabase: mockGetDatabase },
});
mockGetDatabase.mockReturnValue({
vacuum: mockVacuum.mockReturnValue({
beforeSize: 10_485_760,
afterSize: 7_340_416,
durationMs: 123,
}),
getPath: () => "/projects/demo/.fusion/fusion.db",
});
await expect(runDbVacuum("demo-project")).rejects.toThrow("process.exit:0");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(mockVacuum).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("VACUUM"));
});
it("exits 1 on vacuum error", async () => {
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getDatabase: mockGetDatabase },
});
mockGetDatabase.mockReturnValue({
vacuum: mockVacuum.mockRejectedValue(new Error("database locked")),
getPath: () => "/projects/demo/.fusion/fusion.db",
});
await expect(runDbVacuum("demo-project")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("database locked"));
});
it("falls back to cwd TaskStore when resolveProject fails", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/fallback/project");
mockResolveProject.mockRejectedValue(new Error("no project"));
const mockStore = { init: vi.fn(), getDatabase: mockGetDatabase };
mockGetDatabase.mockReturnValue({
vacuum: mockVacuum.mockReturnValue({ beforeSize: 0, afterSize: 0, durationMs: 0 }),
getPath: () => "/fallback/project/.fusion/fusion.db",
});
await expect(runDbVacuum("missing")).rejects.toThrow("process.exit:0");
expect(mockResolveProject).toHaveBeenCalledWith("missing");
cwdSpy.mockRestore();
});
it("skips vacuum on in-memory database (returns zero sizes)", async () => {
mockResolveProject.mockResolvedValue({
projectId: "proj-1",
projectName: "mem-project",
projectPath: "/mem",
isRegistered: true,
store: { getDatabase: mockGetDatabase },
});
mockGetDatabase.mockReturnValue({
vacuum: mockVacuum.mockReturnValue({ beforeSize: 0, afterSize: 0, durationMs: 0 }),
getPath: () => ":memory:",
});
await expect(runDbVacuum("mem-project")).rejects.toThrow("process.exit:0");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("in-memory"));
});
});

View File

@@ -16,7 +16,7 @@ import {
RESEARCH_RUN_STATUSES,
isResearchExperimentalEnabled,
resolveResearchSettings,
canAgentTakeImplementationTask,
canAgentTakeImplementationTaskForExplicitRouting,
formatRoleMismatchReason,
resolveAgentProvisioningPolicy,
} from "@fusion/core";
@@ -106,7 +106,7 @@ async function validateAssignableAgentId(
if (isEphemeralAgent(agent)) {
return `Cannot assign task to ephemeral/runtime agent ${agentId}`;
}
if (task && !override && !canAgentTakeImplementationTask(agent, task)) {
if (task && !override && !canAgentTakeImplementationTaskForExplicitRouting(agent, task)) {
return formatRoleMismatchReason(agent, task);
}
return null;
@@ -414,7 +414,7 @@ export default function kbExtension(pi: ExtensionAPI) {
const normalizedAgentId = normalizeNullableStringInput(params.agentId);
if (normalizedAgentId !== undefined && normalizedAgentId !== null) {
const candidateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "triage" };
const candidateTask: Pick<Task, "id" | "column"> = { id: "<new>", column: "todo" };
const error = await validateAssignableAgentId(ctx.cwd ?? process.cwd(), normalizedAgentId, candidateTask);
if (error) {
return {
@@ -2732,7 +2732,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",
"Implementation tasks use executor by default; durable engineer supports explicit routing without override, other non-executor roles require override=true",
"Optionally specify dependencies on other tasks",
],
parameters: Type.Object({

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import {
canAgentTakeImplementationTask,
canAgentTakeImplementationTaskForBacklogPickup,
canAgentTakeImplementationTaskForExplicitRouting,
formatRoleMismatchReason,
isEngineerRoleAgent,
isExecutorRoleAgent,
isImplementationTask,
} from "../agent-role-policy.js";
@@ -19,17 +22,36 @@ describe("agent-role-policy", () => {
expect(isImplementationTask({ column: "archived" })).toBe(false);
});
it("allows executor agents to take implementation tasks", () => {
it("allows executor agents in both explicit routing and backlog pickup", () => {
expect(isExecutorRoleAgent({ role: "executor" })).toBe(true);
expect(
canAgentTakeImplementationTaskForExplicitRouting({ role: "executor" }, { column: "todo" }),
).toBe(true);
expect(
canAgentTakeImplementationTaskForBacklogPickup({ role: "executor" }, { column: "todo" }),
).toBe(true);
expect(
canAgentTakeImplementationTask({ role: "executor" }, { column: "todo" }),
).toBe(true);
});
it("rejects non-executor agents for implementation tasks", () => {
it("allows durable engineer only for explicit routing", () => {
expect(isEngineerRoleAgent({ role: "engineer" })).toBe(true);
expect(
canAgentTakeImplementationTaskForExplicitRouting({ role: "engineer" }, { column: "todo" }),
).toBe(true);
expect(
canAgentTakeImplementationTaskForBacklogPickup({ role: "engineer" }, { column: "todo" }),
).toBe(false);
});
it("keeps reviewer blocked by default", () => {
expect(isExecutorRoleAgent({ role: "reviewer" })).toBe(false);
expect(
canAgentTakeImplementationTask({ role: "reviewer" }, { column: "todo" }),
canAgentTakeImplementationTaskForExplicitRouting({ role: "reviewer" }, { column: "todo" }),
).toBe(false);
expect(
canAgentTakeImplementationTaskForBacklogPickup({ role: "reviewer" }, { column: "todo" }),
).toBe(false);
});
@@ -41,5 +63,7 @@ describe("agent-role-policy", () => {
expect(reason).toContain("agent-1");
expect(reason).toContain("reviewer");
expect(reason).toContain("FN-123");
expect(reason).toContain("requires an \"executor\"-role agent by default");
expect(reason).toContain("durable \"engineer\" supported only for explicit routing");
});
});

View File

@@ -1975,6 +1975,32 @@ describe("AgentStore", () => {
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toMatch(/requires an "executor"-role agent/);
expect(result.reason).toMatch(/durable "engineer" supported only for explicit routing/);
const claimedTask = await taskStore.getTask(taskId);
expect(claimedTask?.assignedAgentId).toBeUndefined();
});
it("claimTaskForAgent allows engineer claim for explicitly assigned implementation tasks", async () => {
const engineer = await store.createAgent({ name: "Engineer", role: "engineer" });
await taskStore.updateTask(taskId, { assignedAgentId: engineer.id });
const result = await store.claimTaskForAgent(engineer.id, taskId);
expect(result.ok).toBe(true);
if (!result.ok) return;
const claimedTask = await taskStore.getTask(taskId);
expect(claimedTask?.assignedAgentId).toBe(engineer.id);
expect(claimedTask?.checkedOutBy).toBe(engineer.id);
});
it("claimTaskForAgent rejects engineer auto-claim for unassigned implementation tasks", async () => {
const engineer = await store.createAgent({ name: "Engineer", role: "engineer" });
const result = await store.claimTaskForAgent(engineer.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();

View File

@@ -296,6 +296,33 @@ describe("TaskStore", () => {
expect(selected?.priority).toBe("todo");
});
it("returns assigned implementation todos for engineer role agents", async () => {
const todo = await store.createTask({
description: "Assigned engineer todo",
column: "todo",
assignedAgentId: "agent-1",
});
const selected = await store.selectNextTaskForAgent("agent-1", {
id: "agent-1",
role: "engineer",
});
expect(selected?.task.id).toBe(todo.id);
expect(selected?.priority).toBe("todo");
});
it("does not auto-claim unassigned implementation backlog for engineer role agents", async () => {
await store.createTask({
description: "Unassigned todo",
column: "todo",
});
await expect(
store.selectNextTaskForAgent("agent-1", { id: "agent-1", role: "engineer" }),
).resolves.toBeNull();
});
it("allows non-executor role agents to pick assigned todos when override metadata is set", async () => {
const delegated = await store.createTask({
description: "Assigned todo override",

View File

@@ -15,16 +15,34 @@ export function isExecutorRoleAgent(agent: Pick<Agent, "role">): boolean {
return agent.role === "executor";
}
export function canAgentTakeImplementationTask(
export function isEngineerRoleAgent(agent: Pick<Agent, "role">): boolean {
return agent.role === "engineer";
}
export function canAgentTakeImplementationTaskForExplicitRouting(
agent: Pick<Agent, "role">,
task: Pick<Task, "column">,
): boolean {
return !isImplementationTask(task) || isExecutorRoleAgent(agent) || isEngineerRoleAgent(agent);
}
export function canAgentTakeImplementationTaskForBacklogPickup(
agent: Pick<Agent, "role">,
task: Pick<Task, "column">,
): boolean {
return !isImplementationTask(task) || isExecutorRoleAgent(agent);
}
export function canAgentTakeImplementationTask(
agent: Pick<Agent, "role">,
task: Pick<Task, "column">,
): boolean {
return canAgentTakeImplementationTaskForBacklogPickup(agent, task);
}
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.`;
return `Agent ${agent.id} has role "${agent.role}"; implementation task ${task.id} requires an "executor"-role agent by default, with durable "engineer" supported only for explicit routing. Pass override=true to bypass.`;
}

View File

@@ -62,7 +62,7 @@ interface CheckoutLeaseContext {
renewedAt?: string;
}
import { computeAccessState } from "./agent-permissions.js";
import { canAgentTakeImplementationTask, formatRoleMismatchReason } from "./agent-role-policy.js";
import { canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, 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";
@@ -1331,7 +1331,11 @@ export class AgentStore extends EventEmitter {
return { ok: false, reason: "paused", task };
}
if (!canAgentTakeImplementationTask(agent, task)) {
const isExplicitlyAssignedToAgent = task.assignedAgentId === agentId;
const roleAllowed = isExplicitlyAssignedToAgent
? canAgentTakeImplementationTaskForExplicitRouting(agent, task)
: canAgentTakeImplementationTask(agent, task);
if (!roleAllowed) {
return { ok: false, reason: formatRoleMismatchReason(agent, task), task };
}

View File

@@ -68,6 +68,8 @@ export {
isImplementationTask,
isExecutorRoleAgent,
canAgentTakeImplementationTask,
canAgentTakeImplementationTaskForExplicitRouting,
canAgentTakeImplementationTaskForBacklogPickup,
formatRoleMismatchReason,
} from "./agent-role-policy.js";
export { ReflectionStore } from "./reflection-store.js";

View File

@@ -7,7 +7,7 @@ import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry,
import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { normalizeTaskPriority } from "./task-priority.js";
import { canAgentTakeImplementationTask } from "./agent-role-policy.js";
import { canAgentTakeImplementationTaskForExplicitRouting } 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";
@@ -3117,7 +3117,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (task.column === "in-progress" || hasExecutorRoleOverride(task)) {
return true;
}
return canAgentTakeImplementationTask(agent, task);
return canAgentTakeImplementationTaskForExplicitRouting(agent, task);
})
: assignedTasks;

View File

@@ -2371,6 +2371,7 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
let fusionDir: string;
let agentId: string;
let reviewerAgentId: string;
let engineerAgentId: string;
let store: TaskStore;
// Agent store init + createAgent is ~50ms per call; hoisted to beforeAll
@@ -2391,8 +2392,13 @@ describe("PATCH /tasks/:id/assign and GET /agents/:id/tasks", () => {
name: "Assignment reviewer agent",
role: "reviewer",
});
const engineer = await agentStore.createAgent({
name: "Assignment engineer agent",
role: "engineer",
});
agentId = agent.id;
reviewerAgentId = reviewer.id;
engineerAgentId = engineer.id;
}, 30_000);
beforeEach(() => {
@@ -2432,7 +2438,26 @@ 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 () => {
it("allows assigning implementation task to durable engineer without override", async () => {
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "FN-200",
assignedAgentId: engineerAgentId,
});
const res = await REQUEST(
buildApp(),
"PATCH",
"/api/tasks/FN-200/assign",
JSON.stringify({ agentId: engineerAgentId }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("FN-200", { assignedAgentId: engineerAgentId });
}, 20000);
it("returns 409 when assigning implementation task to reviewer without override", async () => {
const res = await REQUEST(
buildApp(),
"PATCH",

View File

@@ -10,7 +10,7 @@ import {
resolveTitleSummarizerSettingsModel,
toReplicatedCreateInput,
validateNodeOverrideChange,
canAgentTakeImplementationTask,
canAgentTakeImplementationTaskForExplicitRouting,
formatRoleMismatchReason,
getCurrentRepo,
} from "@fusion/core";
@@ -1880,7 +1880,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw notFound("Task not found");
}
if (override !== true && !canAgentTakeImplementationTask(agent, targetTask)) {
if (override !== true && !canAgentTakeImplementationTaskForExplicitRouting(agent, targetTask)) {
throw new ApiError(409, formatRoleMismatchReason(agent, targetTask));
}
}

View File

@@ -256,7 +256,34 @@ describe("createDelegateTaskTool", () => {
expect(taskStore.createTask).not.toHaveBeenCalled();
});
it("rejects non-executor target without override", async () => {
it("allows durable engineer target without override", async () => {
const engineer = createAgent({ id: "agent-009", name: "Eli", role: "engineer" });
vi.mocked(agentStore.getAgent).mockResolvedValue(engineer);
vi.mocked(taskStore.createTask).mockResolvedValue({
id: "FN-053",
description: "Do something",
dependencies: [],
column: "todo" as const,
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const tool = createDelegateTaskTool(agentStore, taskStore);
await tool.execute("session-1", {
agent_id: "agent-009",
description: "Do something",
}, undefined as any, undefined as any, undefined as any);
expect(taskStore.createTask).toHaveBeenCalledWith(expect.objectContaining({
assignedAgentId: "agent-009",
source: { sourceType: "api" },
}), expect.anything());
});
it("rejects reviewer target without override", async () => {
const reviewer = createAgent({ id: "agent-002", name: "Rita", role: "reviewer" });
vi.mocked(agentStore.getAgent).mockResolvedValue(reviewer);

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, canAgentTakeImplementationTask } from "@fusion/core";
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createHash } from "node:crypto";
@@ -1502,7 +1502,7 @@ export class HeartbeatMonitor {
if (!taskId) {
inboxSelection = await taskStore.selectNextTaskForAgent(agentId, { id: agent.id, role: agent.role });
if (inboxSelection && !canAgentTakeImplementationTask(agent, inboxSelection.task)) {
if (inboxSelection && !canAgentTakeImplementationTaskForExplicitRouting(agent, inboxSelection.task)) {
const hasRoleOverride = inboxSelection.task.sourceMetadata?.executorRoleOverride === true;
if (!hasRoleOverride) {
heartbeatLog.log(

View File

@@ -12,7 +12,7 @@ import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative, resolve } from "node:path";
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings } from "@fusion/core";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTask, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
import { ResearchOrchestrator } from "./research-orchestrator.js";
import { ResearchProviderRegistry } from "./research/provider-registry.js";
import { ResearchStepRunner } from "./research-step-runner.js";
@@ -1754,7 +1754,7 @@ export function createDelegateTaskTool(
const override = params.override === true;
const newTaskRef = { id: "<new>", column: "todo" } as const;
if (!override && !canAgentTakeImplementationTask(agent, { column: newTaskRef.column })) {
if (!override && !canAgentTakeImplementationTaskForExplicitRouting(agent, { column: newTaskRef.column })) {
return {
content: [{ type: "text" as const, text: `ERROR: ${formatRoleMismatchReason(agent, newTaskRef)}` }],
details: {},