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:
45
packages/core/src/__tests__/agent-role-policy.test.ts
Normal file
45
packages/core/src/__tests__/agent-role-policy.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
30
packages/core/src/agent-role-policy.ts
Normal file
30
packages/core/src/agent-role-policy.ts
Normal 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.`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user