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:
@@ -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" });
|
||||
|
||||
114
packages/cli/src/commands/__tests__/db.test.ts
Normal file
114
packages/cli/src/commands/__tests__/db.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user