Harden architecture hot paths

This commit is contained in:
gsxdsm
2026-04-12 15:13:27 -07:00
parent 7b78963a4c
commit a34ba41ad1
38 changed files with 1212 additions and 561 deletions

View File

@@ -5904,6 +5904,77 @@ describe("TaskExecutor task_done with summary", () => {
});
});
describe("TaskExecutor task_done blockers", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("rejects task_done when the task is explicitly blocked", async () => {
const store = createMockStore();
let capturedTool: any = null;
store.getTask.mockImplementation(async (taskId: string) => {
if (taskId === "FN-001") {
return {
id: "FN-001",
title: "Blocked task",
description: "Blocked task",
column: "in-progress",
blockedBy: "FN-DEP-1",
dependencies: [],
steps: [{ name: "Step 1", status: "in-progress" }],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
return {
id: taskId,
column: "done",
};
});
mockedCreateHaiAgent.mockImplementation(async ({ customTools }: any) => {
capturedTool = customTools?.find((t: any) => t.name === "task_done");
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Blocked task",
description: "Blocked task",
column: "in-progress",
dependencies: [],
steps: [{ name: "Step 1", status: "in-progress" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(capturedTool).toBeDefined();
store.updateStep.mockClear();
store.updateTask.mockClear();
const result = await capturedTool.execute("tool-1", {});
expect(result.content[0].text).toContain("Cannot mark task done yet");
expect(store.updateStep).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
});
});
describe("Workflow Steps Execution", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -1,4 +1,4 @@
import { execSync, exec } from "node:child_process";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
@@ -39,6 +39,7 @@ import {
taskCreateParams,
taskLogParams,
} from "./agent-tools.js";
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
// Re-export for backward compatibility (tests import from executor.ts)
export { summarizeToolArgs } from "./agent-logger.js";
@@ -619,11 +620,20 @@ export class TaskExecutor {
}
private async shouldFinalizeCompletedTask(taskId: string, taskDone: boolean): Promise<boolean> {
if (taskDone) return true;
const task = await this.store.getTask(taskId);
const completionBlocker = await this.getTaskCompletionBlocker(task);
if (completionBlocker) {
executorLog.log(`${taskId} completion blocked — ${completionBlocker}`);
return false;
}
if (taskDone) return true;
return this.isTaskWorkComplete(task);
}
private async getTaskCompletionBlocker(task: Task): Promise<string | undefined> {
return getTaskCompletionBlockerForStore(this.store, task);
}
/**
* Execute a review handoff: move the task to in-review column with
* awaiting-user-review status, assign the requesting user, and dispose
@@ -2136,9 +2146,21 @@ export class TaskExecutor {
})),
}),
execute: async (_id: string, params: { summary?: string }) => {
onDone();
// Mark all pending/in-progress steps as done
const task = await store.getTask(taskId);
const completionBlocker = await this.getTaskCompletionBlocker(task);
if (completionBlocker) {
return {
content: [{
type: "text" as const,
text: `Cannot mark task done yet — ${completionBlocker}. Resolve the blocker before calling task_done().`,
}],
details: {},
};
}
onDone();
// Mark all pending/in-progress steps as done
for (let i = 0; i < task.steps.length; i++) {
if (task.steps[i].status !== "done" && task.steps[i].status !== "skipped") {
await store.updateStep(taskId, i, "done");

View File

@@ -632,6 +632,40 @@ describe("MissionAutopilot", () => {
autopilot.stop();
});
it("does not promote a feature to done when the linked task has unresolved dependencies", async () => {
autopilot.start();
autopilot.watchMission("M-TEST1");
missionStore.getMissionWithHierarchy.mockReturnValue({
...createMockMission(),
milestones: [{
...createMockMilestone(),
slices: [{
...createMockSlice({ status: "active" }),
features: [createMockFeature({ id: "F-001", status: "triaged", taskId: "FN-001" })],
}],
}],
});
taskStore.getTask.mockImplementation(async (taskId: string) => {
if (taskId === "FN-001") {
return {
id: "FN-001",
column: "done",
dependencies: ["FN-DEP-1"],
blockedBy: undefined,
};
}
return {
id: "FN-DEP-1",
column: "in-progress",
};
});
await autopilot.recoverMissions(missionStore as any);
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalledWith("F-001", "done");
autopilot.stop();
});
it("fixes feature status when task is in-progress but feature is triaged", async () => {
autopilot.start();
autopilot.watchMission("M-TEST1");

View File

@@ -29,6 +29,7 @@ import type {
MissionEventType,
} from "@fusion/core";
import { autopilotLog } from "./logger.js";
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
/** Maximum retry attempts for slice activation failures. */
const MAX_RETRY_ATTEMPTS = 3;
@@ -762,32 +763,21 @@ export class MissionAutopilot {
continue;
}
if (task.status === "failed" && feature.status === "in-progress") {
const reconciliation = await reconcileMissionFeatureState(this.taskStore, task, feature);
if (reconciliation.kind === "failure") {
await this.handleTaskFailure(feature.taskId);
fixedCount++;
continue;
}
if (task.column === "done" && feature.status !== "done") {
this.missionStore.updateFeatureStatus(feature.id, "done");
fixedCount++;
if (reconciliation.kind === "blocked") {
autopilotLog.warn(`Skipping feature ${feature.id} reconciliation — ${reconciliation.reason}`);
continue;
}
if (
task.column === "in-progress"
&& (feature.status === "triaged" || feature.status === "defined")
) {
this.missionStore.updateFeatureStatus(feature.id, "in-progress");
fixedCount++;
continue;
}
if (
(task.column === "triage" || task.column === "todo")
&& feature.status === "in-progress"
) {
this.missionStore.updateFeatureStatus(feature.id, "triaged");
if (reconciliation.kind === "update") {
this.missionStore.updateFeatureStatus(feature.id, reconciliation.status);
fixedCount++;
}
}

View File

@@ -0,0 +1,64 @@
import type { MissionFeature, Task, TaskStore } from "@fusion/core";
import { getTaskCompletionBlockerForStore } from "./task-completion.js";
export type MissionFeatureSyncTargetStatus = "done" | "in-progress" | "triaged";
export type MissionFeatureSyncDecision =
| { kind: "failure"; reason: string }
| { kind: "blocked"; reason: string }
| { kind: "update"; status: MissionFeatureSyncTargetStatus; reason: string }
| { kind: "noop" };
export async function reconcileMissionFeatureState(
taskStore: Pick<TaskStore, "getTask">,
task: Task,
feature: Pick<MissionFeature, "id" | "status">,
): Promise<MissionFeatureSyncDecision> {
if (task.status === "failed" && feature.status === "in-progress") {
return {
kind: "failure",
reason: `task ${task.id} failed while feature ${feature.id} is in-progress`,
};
}
if (task.column === "done") {
const blocker = await getTaskCompletionBlockerForStore(taskStore, task);
if (blocker) {
return { kind: "blocked", reason: blocker };
}
if (feature.status !== "done") {
return {
kind: "update",
status: "done",
reason: `task ${task.id} completed`,
};
}
return { kind: "noop" };
}
if (
task.column === "in-progress"
&& (feature.status === "triaged" || feature.status === "defined")
) {
return {
kind: "update",
status: "in-progress",
reason: `task ${task.id} started`,
};
}
if (
(task.column === "triage" || task.column === "todo")
&& feature.status === "in-progress"
) {
return {
kind: "update",
status: "triaged",
reason: `task ${task.id} returned to triage`,
};
}
return { kind: "noop" };
}

View File

@@ -45,6 +45,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
listTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}),
getTask: vi.fn().mockResolvedValue(createMockTask()),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
@@ -1867,6 +1868,42 @@ describe("Scheduler", () => {
// Delegates to autopilot, which internally checks autoAdvance
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
});
it("does not mark a feature done when the completed task is blocked", async () => {
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
blockedBy: "FN-000",
column: "done",
})),
});
const mockAutopilot = {
setScheduler: vi.fn(),
watchMission: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
isWatching: vi.fn(() => true),
};
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({
id: "F-001",
sliceId: "SL-001",
status: "in-progress",
}),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
missionAutopilot: mockAutopilot as any,
});
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(mockAutopilot.handleTaskCompletion).not.toHaveBeenCalled();
});
});
describe("reconcileAllMissionFeatures", () => {
@@ -1972,6 +2009,95 @@ describe("Scheduler", () => {
expect(result).toBe(1);
});
it("does not reconcile feature to done when the linked task has unresolved dependencies", async () => {
const getTask = vi.fn(async (id: string) => {
if (id === "FN-001") {
return createMockTask({
id: "FN-001",
column: "done",
dependencies: ["FN-000"],
});
}
return createMockTask({ id, column: "in-progress" });
});
const store = createMockStore({ getTask: getTask as any });
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([
{ id: "M-001", status: "active" },
]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{
id: "MS-001",
slices: [{
id: "SL-001",
status: "active",
features: [{
id: "F-001",
taskId: "FN-001",
status: "in-progress",
}],
}],
}],
}),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(result).toBe(0);
});
it("routes failed linked tasks through onTaskFailed during reconciliation", async () => {
const store = createMockStore({
getTask: vi.fn().mockReturnValue(createMockTask({
id: "FN-001",
column: "in-progress",
status: "failed",
})),
});
const onTaskFailed = vi.fn().mockResolvedValue(undefined);
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([
{ id: "M-001", status: "active" },
]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{
id: "MS-001",
slices: [{
id: "SL-001",
status: "active",
features: [{
id: "F-001",
taskId: "FN-001",
status: "in-progress",
}],
}],
}],
}),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
onTaskFailed,
});
const result = await scheduler.reconcileAllMissionFeatures();
expect(onTaskFailed).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(result).toBe(1);
});
it("updates feature to triaged when task moves back to todo and feature is in-progress", async () => {
const store = createMockStore({
getTask: vi.fn().mockReturnValue(createMockTask({ id: "FN-001", column: "todo" })),

View File

@@ -6,6 +6,7 @@ import type { AgentSemaphore } from "./concurrency.js";
import { generateReservedWorktreeName, slugify } from "./worktree-names.js";
import { schedulerLog } from "./logger.js";
import { type PrMonitor, type PrComment } from "./pr-monitor.js";
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
/**
* Check whether two sets of file scope paths overlap.
@@ -746,6 +747,10 @@ export class Scheduler {
const missionStore = this.options.missionStore;
try {
const task = await this.store.getTask(taskId);
if (!task) {
return;
}
const feature = missionStore.getFeatureByTaskId(taskId);
if (!feature) return;
@@ -756,9 +761,24 @@ export class Scheduler {
return;
}
const reconciliation = await reconcileMissionFeatureState(
this.store,
{ ...task, column: "done" },
feature,
);
if (reconciliation.kind === "blocked") {
schedulerLog.warn(`Task ${taskId} mission completion blocked — ${reconciliation.reason}`);
return;
}
if (reconciliation.kind === "failure") {
schedulerLog.warn(`Task ${taskId} mission completion reported failure — ${reconciliation.reason}`);
return;
}
const sliceIdBeforeUpdate = feature.sliceId;
if (feature.status !== "done") {
if (reconciliation.kind === "update" && reconciliation.status === "done") {
missionStore.updateFeatureStatus(feature.id, "done");
schedulerLog.log(`Feature ${feature.id} marked done (task ${taskId} completed)`);
}
@@ -930,29 +950,25 @@ export class Scheduler {
const task = await this.store.getTask(feature.taskId);
if (!task) continue;
// Task done but feature not done -> update feature to done
if (task.column === "done" && feature.status !== "done") {
missionStore.updateFeatureStatus(feature.id, "done");
totalFixed++;
const reconciliation = await reconcileMissionFeatureState(this.store, task, feature);
if (reconciliation.kind === "failure") {
if (this.options.onTaskFailed) {
await this.options.onTaskFailed(task.id);
totalFixed++;
} else {
schedulerLog.warn(`Skipping failed feature reconciliation for ${feature.id}${reconciliation.reason}`);
}
continue;
}
// Task in-progress and feature triaged/defined -> update to in-progress
if (
task.column === "in-progress"
&& (feature.status === "triaged" || feature.status === "defined")
) {
missionStore.updateFeatureStatus(feature.id, "in-progress");
totalFixed++;
if (reconciliation.kind === "blocked") {
schedulerLog.warn(`Skipping feature ${feature.id} reconciliation — ${reconciliation.reason}`);
continue;
}
// Task in triage/todo and feature in-progress -> update to triaged
if (
(task.column === "triage" || task.column === "todo")
&& feature.status === "in-progress"
) {
missionStore.updateFeatureStatus(feature.id, "triaged");
if (reconciliation.kind === "update") {
missionStore.updateFeatureStatus(feature.id, reconciliation.status);
totalFixed++;
}
}

View File

@@ -392,6 +392,7 @@ describe("SelfHealingManager", () => {
const result = await managerWithRecovery.recoverNoProgressNoTaskDoneFailures();
expect(result).toBe(1);
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-progress" });
expect(store.updateTask).toHaveBeenCalledWith("FN-1473", {
status: "stuck-killed",
worktree: null,
@@ -586,6 +587,7 @@ describe("SelfHealingManager", () => {
const result = await managerWithRecovery.recoverCompletedTasks();
expect(result).toBe(1);
expect(store.listTasks).toHaveBeenCalledWith({ column: "in-progress" });
expect(recoverFn).toHaveBeenCalledWith(
expect.objectContaining({ id: "FN-001" }),
);

View File

@@ -426,7 +426,7 @@ export class SelfHealingManager {
if (!recoverFn) return 0;
try {
const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
const tasks = await this.store.listTasks({ column: "in-progress" });
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const stuckCompleted = tasks.filter((t) =>
@@ -469,7 +469,7 @@ export class SelfHealingManager {
*/
async recoverMergeableReviewTasks(): Promise<number> {
try {
const tasks = await this.store.listTasks({ slim: true, column: "in-review" });
const tasks = await this.store.listTasks({ column: "in-review" });
const mergeable = tasks.filter((t) =>
t.column === "in-review" &&
@@ -520,7 +520,7 @@ export class SelfHealingManager {
*/
async recoverMergedReviewTasks(): Promise<number> {
try {
const tasks = await this.store.listTasks({ slim: true, column: "in-review" });
const tasks = await this.store.listTasks({ column: "in-review" });
const mergedButNotDone = tasks.filter((t) =>
t.column === "in-review" &&
@@ -573,7 +573,7 @@ export class SelfHealingManager {
*/
async recoverMisclassifiedFailures(): Promise<number> {
try {
const tasks = await this.store.listTasks({ slim: true, column: "in-review" });
const tasks = await this.store.listTasks({ column: "in-review" });
const misclassified = tasks.filter((t) =>
t.column === "in-review" &&
@@ -622,7 +622,7 @@ export class SelfHealingManager {
*/
async recoverOrphanedExecutions(): Promise<number> {
try {
const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
const tasks = await this.store.listTasks({ column: "in-progress" });
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const now = Date.now();
@@ -690,7 +690,7 @@ export class SelfHealingManager {
*/
async recoverNoProgressNoTaskDoneFailures(): Promise<number> {
try {
const tasks = await this.store.listTasks({ slim: true, column: "in-progress" });
const tasks = await this.store.listTasks({ column: "in-progress" });
const executingIds = this.options.getExecutingTaskIds?.() ?? new Set<string>();
const candidates = tasks.filter((task) =>
@@ -789,7 +789,7 @@ export class SelfHealingManager {
if (!recoverFn) return 0;
try {
const tasks = await this.store.listTasks({ slim: true, column: "triage" });
const tasks = await this.store.listTasks({ column: "triage" });
const specifyingIds = this.options.getSpecifyingTaskIds?.() ?? new Set<string>();
const now = Date.now();

View File

@@ -0,0 +1,16 @@
import { getTaskCompletionBlocker, type Task, type TaskStore } from "@fusion/core";
export async function getTaskCompletionBlockerForStore(
store: Pick<TaskStore, "getTask">,
task: Task,
): Promise<string | undefined> {
return getTaskCompletionBlocker(task, {
resolveTask: async (dependencyId) => {
try {
return await store.getTask(dependencyId);
} catch {
return null;
}
},
});
}

View File

@@ -7,9 +7,10 @@ import {
readAttachmentContents,
computeUserCommentFingerprint,
} from "./triage.js";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdir, writeFile, rm, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { setTimeout as delay } from "node:timers/promises";
const { mockReviewStep, mockCreateKbAgent } = vi.hoisted(() => ({
mockReviewStep: vi.fn(),
@@ -34,7 +35,28 @@ vi.mock("@fusion/core", async () => {
};
});
const __dirname = dirname(fileURLToPath(import.meta.url));
async function createTriageFixtureRoot(prefix: string): Promise<string> {
return mkdtemp(join(tmpdir(), prefix));
}
async function cleanupTriageFixtureRoot(rootDir: string | undefined): Promise<void> {
if (!rootDir) return;
const retryableCodes = new Set(["ENOTEMPTY", "EBUSY", "EPERM"]);
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
await rm(rootDir, { recursive: true, force: true });
return;
} catch (error: any) {
if (!retryableCodes.has(error?.code) || attempt === 4) {
throw error;
}
await delay(25 * (attempt + 1));
}
}
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
@@ -417,17 +439,20 @@ describe("TRIAGE_SYSTEM_PROMPT", () => {
});
describe("readAttachmentContents", () => {
const testDir = join(__dirname, "test-attachments");
let testDir = "";
const taskId = "FN-TEST";
beforeEach(async () => {
// Clean up and create test directory
await rm(testDir, { recursive: true, force: true });
testDir = await createTriageFixtureRoot("fusion-triage-attachments-");
await mkdir(join(testDir, ".fusion", "tasks", taskId, "attachments"), {
recursive: true,
});
});
afterEach(async () => {
await cleanupTriageFixtureRoot(testDir);
});
it("returns empty arrays when no attachments provided", async () => {
const result = await readAttachmentContents(testDir, taskId, undefined);
@@ -578,75 +603,77 @@ describe("TriageProcessor", () => {
it("re-reads settings when review_spec runs so reviewer uses the latest validator model", async () => {
const taskId = "FN-001";
const testRootDir = join(__dirname, "__test_triage_review_spec__");
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
const taskDir = join(testRootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
const testRootDir = await createTriageFixtureRoot("fusion-triage-review-spec-");
try {
const promptPath = `.fusion/tasks/${taskId}/PROMPT.md`;
const taskDir = join(testRootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# Spec\n\nCurrent prompt");
const freshSettings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
validatorProvider: "zai",
validatorModelId: "glm-5.1",
};
store = createMockStore({
getSettings: vi.fn().mockResolvedValue(freshSettings),
getTask: vi.fn().mockResolvedValue({
...mockTaskDetail,
id: taskId,
comments: [],
}),
});
processor = new TriageProcessor(store, testRootDir);
mockReviewStep.mockResolvedValue({
verdict: "APPROVE",
review: "Looks good.",
summary: "approved",
});
const tool = (processor as any).createReviewSpecTool(
taskId,
promptPath,
{ current: null },
{ current: null },
{ current: null },
{
defaultProvider: "anthropic",
defaultModelId: "claude-opus-4-6",
validatorProvider: "anthropic",
validatorModelId: "claude-opus-4-6",
},
);
await tool.execute({});
expect(store.getSettings).toHaveBeenCalled();
expect(mockReviewStep).toHaveBeenCalledWith(
testRootDir,
taskId,
0,
"Specification",
"spec",
"# Spec\n\nCurrent prompt",
undefined,
expect.objectContaining({
const freshSettings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
validatorModelProvider: "zai",
validatorProvider: "zai",
validatorModelId: "glm-5.1",
userComments: undefined,
}),
);
};
await rm(testRootDir, { recursive: true, force: true });
store = createMockStore({
getSettings: vi.fn().mockResolvedValue(freshSettings),
getTask: vi.fn().mockResolvedValue({
...mockTaskDetail,
id: taskId,
comments: [],
}),
});
processor = new TriageProcessor(store, testRootDir);
mockReviewStep.mockResolvedValue({
verdict: "APPROVE",
review: "Looks good.",
summary: "approved",
});
const tool = (processor as any).createReviewSpecTool(
taskId,
promptPath,
{ current: null },
{ current: null },
{ current: null },
{
defaultProvider: "anthropic",
defaultModelId: "claude-opus-4-6",
validatorProvider: "anthropic",
validatorModelId: "claude-opus-4-6",
},
);
await tool.execute({});
expect(store.getSettings).toHaveBeenCalled();
expect(mockReviewStep).toHaveBeenCalledWith(
testRootDir,
taskId,
0,
"Specification",
"spec",
"# Spec\n\nCurrent prompt",
undefined,
expect.objectContaining({
defaultProvider: "openai-codex",
defaultModelId: "gpt-5.4",
validatorModelProvider: "zai",
validatorModelId: "glm-5.1",
userComments: undefined,
}),
);
} finally {
await cleanupTriageFixtureRoot(testRootDir);
}
});
});
@@ -713,10 +740,14 @@ describe("Re-specification flow", () => {
});
describe("requirePlanApproval setting", () => {
const rootDir = join(__dirname, "__test_triage_approval__");
let rootDir = "";
beforeEach(async () => {
await mkdir(rootDir, { recursive: true });
rootDir = await createTriageFixtureRoot("fusion-triage-approval-");
});
afterEach(async () => {
await cleanupTriageFixtureRoot(rootDir);
});
it("sets awaiting-approval status instead of moving to todo when requirePlanApproval is true", async () => {
@@ -776,8 +807,6 @@ describe("requirePlanApproval setting", () => {
// We can't easily run the full specifyTask without mocking the AI,
// but we can verify the store setup is correct
expect(await store.getSettings()).toHaveProperty("requirePlanApproval", true);
await rm(rootDir, { recursive: true, force: true });
});
it("auto-moves to todo when requirePlanApproval is false", async () => {
@@ -813,9 +842,10 @@ describe("requirePlanApproval setting", () => {
});
describe("approved triage recovery", () => {
const rootDir = join(__dirname, "__test_triage_recovery__");
let rootDir = "";
beforeEach(async () => {
rootDir = await createTriageFixtureRoot("fusion-triage-recovery-");
await mkdir(join(rootDir, ".fusion", "tasks", "FN-001"), { recursive: true });
await writeFile(
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
@@ -824,7 +854,7 @@ describe("approved triage recovery", () => {
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
await cleanupTriageFixtureRoot(rootDir);
});
it("moves approved specifying task to todo during recovery", async () => {
@@ -1742,6 +1772,16 @@ describe("awaiting-approval poll exclusion", () => {
});
describe("stale approval detection", () => {
let rootDir = "";
beforeEach(async () => {
rootDir = await createTriageFixtureRoot("fusion-triage-stale-approval-");
});
afterEach(async () => {
await cleanupTriageFixtureRoot(rootDir);
});
it("computeUserCommentFingerprint detects added user comment", () => {
const before = [
{ id: "c1", text: "First", author: "user", createdAt: "2026-01-01T00:00:00.000Z" },
@@ -1771,7 +1811,6 @@ describe("stale approval detection", () => {
});
it("captures fingerprint on review_spec APPROVE", async () => {
const rootDir = join(__dirname, "__test_stale_approval_capture__");
const taskId = "FN-CAP";
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
@@ -1820,12 +1859,9 @@ describe("stale approval detection", () => {
// Verify fingerprint was captured from the user comments at approval time
expect(approvedCommentFingerprintRef.current).toBe("c1");
await rm(rootDir, { recursive: true, force: true });
});
it("fingerprint is empty string when review_spec returns REVISE (no capture)", async () => {
const rootDir = join(__dirname, "__test_stale_approval_revise__");
const taskId = "FN-REV";
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
@@ -1869,8 +1905,6 @@ describe("stale approval detection", () => {
// Fingerprint should NOT be captured on REVISE
expect(approvedCommentFingerprintRef.current).toBe("");
await rm(rootDir, { recursive: true, force: true });
});
});