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:
5
.changeset/FN-3716-executor-role-enforcement.md
Normal file
5
.changeset/FN-3716-executor-role-enforcement.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|
||||||
|
|||||||
@@ -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}` }],
|
||||||
|
|||||||
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);
|
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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────
|
||||||
|
|||||||
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 { 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 ?? []);
|
||||||
|
|||||||
Reference in New Issue
Block a user