Harden architecture hot paths
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
64
packages/engine/src/mission-feature-sync.ts
Normal file
64
packages/engine/src/mission-feature-sync.ts
Normal 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" };
|
||||
}
|
||||
@@ -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" })),
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
16
packages/engine/src/task-completion.ts
Normal file
16
packages/engine/src/task-completion.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user