feat(FN-4625): complete Step 4 — add self-healing worktrunk pause guardrail

Fusion-Task-Id: FN-4625
Fusion-Task-Lineage: c1d9b414-b5dd-4786-ba51-0b8adec4acf5
This commit is contained in:
Fusion (runfusion.ai)
2026-05-15 20:41:14 -07:00
committed by gsxdsm
parent 743b7a480d
commit 3339d94d42
4 changed files with 129 additions and 24 deletions

View File

@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { Task, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
vi.mock("../../worktree-pool.js", async () => {
const actual = await vi.importActual<any>("../../worktree-pool.js");
return { ...actual, isUsableTaskWorktree: vi.fn().mockResolvedValue(true) };
});
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-4625",
title: "FN-4625",
description: "task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
branch: "fusion/fn-4625",
worktree: "/tmp/fn-4625",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
function makeStore(tasks: Task[]): TaskStore & EventEmitter {
const emitter = new EventEmitter();
return Object.assign(emitter, {
getSettings: vi.fn(async () => ({ maintenanceIntervalMs: 0, globalPause: false, enginePaused: false })),
listTasks: vi.fn(async ({ column, includeArchived }: any = {}) => tasks.filter((task) => {
if (!includeArchived && task.column === "archived") return false;
return !column || task.column === column;
})),
updateTask: vi.fn(async () => undefined),
logEntry: vi.fn(async () => undefined),
getTask: vi.fn(async () => tasks[0]),
}) as unknown as TaskStore & EventEmitter;
}
describe("reliability interactions: worktrunk failure", () => {
it("self-healing skips reclaim for tasks paused by worktrunk failures", async () => {
const store = makeStore([
makeTask({ paused: true, pausedReason: "worktrunk_operation_failed" }),
]);
const manager = new SelfHealingManager(store, {
rootDir: process.cwd(),
getExecutingTaskIds: () => new Set(),
});
const inspectSpy = vi.spyOn(manager as any, "inspectOrphanedBranch");
const recovered = await manager.reclaimStaleActiveBranches();
expect(recovered).toBe(0);
expect(inspectSpy).not.toHaveBeenCalled();
expect(store.updateTask).not.toHaveBeenCalled();
});
});

View File

@@ -25,6 +25,7 @@ const task = {
const makeStore = () => ({
updateTask: vi.fn().mockResolvedValue(undefined),
pauseTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
});
@@ -111,7 +112,7 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
).rejects.toMatchObject({ code: "worktrunk_operation_failed", operation: "create" });
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1"'))).toBe(true);
expect(events.some((event) => event.type === "worktree:worktrunk-fallback")).toBe(false);
expect(events.some((event) => event.type === "worktree:worktrunk-fallback-native")).toBe(false);
});
it("falls back to native when onFailure=fallback-native", async () => {
@@ -138,7 +139,7 @@ describe("acquireTaskWorktree worktrunk wiring", () => {
expect(execMock.mock.calls.some((call) => String(call[0]).includes('"worktrunk" "switch" "--create" "fusion/fn-1"'))).toBe(true);
expect(execMock.mock.calls.some((call) => String(call[0]).includes("git worktree add -b"))).toBe(true);
expect(events.filter((event) => event.type === "worktree:worktrunk-fallback")).toHaveLength(1);
expect(events.filter((event) => event.type === "worktree:worktrunk-fallback-native")).toHaveLength(1);
});
it("fails with binary missing when enabled and binaryPath absent", async () => {

View File

@@ -1293,6 +1293,10 @@ export class SelfHealingManager {
for (const task of candidates) {
if (task.checkedOutBy || activeTaskIds.has(task.id.toUpperCase()) || !task.branch || !task.worktree) continue;
if (task.userPaused) continue;
if (task.pausedReason === "worktrunk_operation_failed") {
log.log(`[self-healing] skipping worktrunk-paused task ${task.id}`);
continue;
}
if (!await isUsableTaskWorktree(this.options.rootDir, task.worktree)) continue;
try {
@@ -1651,6 +1655,10 @@ export class SelfHealingManager {
const task = taskById.get(derivedTaskId.toUpperCase());
if (!task || task.column === "archived" || task.checkedOutBy || task.userPaused) continue;
if (task.pausedReason === "worktrunk_operation_failed") {
log.log(`[self-healing] skipping worktrunk-paused task ${task.id}`);
continue;
}
if (activeTaskIds.has(task.id.toUpperCase())) continue;
if (task.worktree && await isUsableTaskWorktree(this.options.rootDir, task.worktree)) continue;

View File

@@ -14,6 +14,15 @@ import {
resolveWorktreeBackend,
type WorktreeBackend,
} from "./worktree-backend.js";
import {
WorktrunkBinaryUnavailableError,
WorktrunkInstallDeniedError,
WorktrunkInstallFailedError,
} from "./worktrunk-installer.js";
import {
handleWorktrunkOperationFailure,
type WorktrunkOpName,
} from "./worktrunk-failure-handler.js";
import type { RunAuditor } from "./run-audit.js";
const execAsync = promisify(exec);
@@ -97,7 +106,45 @@ async function maybeWarnForeignTaskStartPoint(
export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Promise<AcquireTaskWorktreeResult> {
const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv } = opts;
const backend = opts.backend ?? resolveWorktreeBackend(settings, { logger });
const notifyFallback = async (op: WorktrunkOpName, stderr?: string) => {
await store.logEntry(task.id, `Worktrunk ${op} failed; continuing with native worktree backend (${stderr ?? "no stderr"})`, undefined, runContext);
};
const handleWorktrunkFailure = async (
op: WorktrunkOpName,
error: Error,
nativeFallback?: () => Promise<unknown>,
) => {
const stderr = error instanceof WorktrunkOperationError ? error.stderr : undefined;
const exitCode = error instanceof WorktrunkOperationError ? error.exitCode : null;
const disposition = await handleWorktrunkOperationFailure({
failure: { op, cause: error, stderr, exitCode },
task,
settings: settings.worktrunk ?? {},
store,
runContext,
runAudit: audit,
notify: ({ op: failedOp, stderr: failedStderr }) => notifyFallback(failedOp, failedStderr),
nativeFallback: nativeFallback as (() => Promise<any>) | undefined,
});
if (disposition.kind === "fallback-native") {
return disposition.result;
}
throw error;
};
let backend: WorktreeBackend;
try {
backend = opts.backend ?? resolveWorktreeBackend(settings, { logger });
} catch (error) {
if (
settings.worktrunk?.enabled
&& (error instanceof WorktrunkBinaryUnavailableError || error instanceof WorktrunkInstallFailedError || error instanceof WorktrunkInstallDeniedError)
) {
await handleWorktrunkFailure("resolve-binary", error);
}
throw error;
}
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
const naming = settings.worktreeNaming || "random";
const allowSiblingBranchRename = settings.executorAllowSiblingBranchRename === true;
@@ -232,27 +279,16 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
return created;
} catch (error) {
if (backend.kind === "worktrunk" && error instanceof WorktrunkOperationError) {
if (settings.worktrunk?.onFailure === "fallback-native") {
logger?.warn?.(`${task.id}: worktrunk create failed, falling back to native backend: ${error.stderr ?? error.message}`);
await audit?.git({
type: "worktree:worktrunk-fallback",
target: path,
metadata: {
branch,
operation: "create",
stderr: error.stderr,
},
});
const nativeBackend = new NativeWorktreeBackend({ logger: logger ?? undefined });
return nativeBackend.create({
rootDir,
branch,
worktreePath: path,
startPoint,
taskId,
allowSiblingBranchRename: allowRename,
});
}
const nativeBackend = new NativeWorktreeBackend({ logger: logger ?? undefined });
const fallback = () => nativeBackend.create({
rootDir,
branch,
worktreePath: path,
startPoint,
taskId,
allowSiblingBranchRename: allowRename,
});
return await handleWorktrunkFailure("create", error, fallback) as { path: string; branch: string };
}
throw error;
}