fix(FN-4625): address step 2 typing and exports

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:36:46 -07:00
committed by gsxdsm
parent 0da21bb880
commit 743b7a480d
4 changed files with 188 additions and 1 deletions

View File

@@ -0,0 +1,166 @@
import { describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import {
handleWorktrunkOperationFailure,
type WorktrunkOperationFailure,
type WorktreeOperationResult,
} from "../worktrunk-failure-handler.js";
import {
WorktrunkBinaryUnavailableError,
WorktrunkInstallDeniedError,
WorktrunkInstallFailedError,
} from "../worktrunk-installer.js";
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-4625",
description: "task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
function makeFailure(cause: Error, stderr = "boom", exitCode: number | null = 1): WorktrunkOperationFailure {
return { op: "create", cause, stderr, exitCode };
}
describe("handleWorktrunkOperationFailure", () => {
it("fail mode pauses, persists details, audits, and rethrows", async () => {
const pauseTask = vi.fn().mockResolvedValue(undefined);
const updateTask = vi.fn().mockResolvedValue(undefined);
const git = vi.fn().mockResolvedValue(undefined);
const notify = vi.fn();
const cause = new Error("create failed");
await expect(handleWorktrunkOperationFailure({
failure: makeFailure(cause),
task: makeTask(),
settings: { enabled: true, onFailure: "fail" },
store: { pauseTask, updateTask } as any,
runContext: { runId: "run-1", agentId: "agent-1" },
runAudit: { git },
notify,
})).rejects.toThrow("create failed");
expect(pauseTask).toHaveBeenCalledWith("FN-4625", true, expect.any(Object));
expect(updateTask).toHaveBeenCalledWith(
"FN-4625",
expect.objectContaining({
pausedReason: "worktrunk_operation_failed",
worktrunkFailure: expect.objectContaining({ op: "create", stderr: "boom", exitCode: 1 }),
}),
expect.any(Object),
);
expect(git).toHaveBeenCalledWith(expect.objectContaining({ type: "worktree:worktrunk-failure" }));
expect(notify).not.toHaveBeenCalled();
});
it("fallback-native calls nativeFallback, notifies once, audits, and returns result", async () => {
const pauseTask = vi.fn();
const updateTask = vi.fn().mockResolvedValue(undefined);
const git = vi.fn().mockResolvedValue(undefined);
const notify = vi.fn().mockResolvedValue(undefined);
const result: WorktreeOperationResult = { path: "/tmp/wt", branch: "fusion/fn-4625" };
const nativeFallback = vi.fn().mockResolvedValue(result);
const disposition = await handleWorktrunkOperationFailure({
failure: makeFailure(new Error("create failed")),
task: makeTask(),
settings: { enabled: true, onFailure: "fallback-native" },
store: { pauseTask, updateTask } as any,
runAudit: { git },
notify,
nativeFallback,
});
expect(disposition).toEqual({ kind: "fallback-native", result, alerted: true });
expect(updateTask).toHaveBeenCalledWith("FN-4625", expect.objectContaining({ worktrunkFallbackAlertedAt: expect.any(String) }), undefined);
expect(notify).toHaveBeenCalledTimes(1);
expect(git).toHaveBeenCalledWith(expect.objectContaining({ type: "worktree:worktrunk-fallback-native" }));
expect(pauseTask).not.toHaveBeenCalled();
});
it("fallback-native does not re-alert when already alerted", async () => {
const updateTask = vi.fn().mockResolvedValue(undefined);
const notify = vi.fn();
const nativeFallback = vi.fn().mockResolvedValue(undefined);
const disposition = await handleWorktrunkOperationFailure({
failure: makeFailure(new Error("create failed")),
task: makeTask({ worktrunkFallbackAlertedAt: "2026-05-15T00:00:00.000Z" }),
settings: { enabled: true, onFailure: "fallback-native" },
store: { pauseTask: vi.fn(), updateTask } as any,
notify,
nativeFallback,
});
expect(disposition).toEqual({ kind: "fallback-native", result: undefined, alerted: false });
expect(notify).not.toHaveBeenCalled();
expect(updateTask).not.toHaveBeenCalledWith("FN-4625", expect.objectContaining({ worktrunkFallbackAlertedAt: expect.any(String) }), undefined);
});
it("fallback-native without nativeFallback degrades to fail-hard", async () => {
const pauseTask = vi.fn().mockResolvedValue(undefined);
const updateTask = vi.fn().mockResolvedValue(undefined);
const cause = new Error("create failed");
await expect(handleWorktrunkOperationFailure({
failure: makeFailure(cause),
task: makeTask(),
settings: { enabled: true, onFailure: "fallback-native" },
store: { pauseTask, updateTask } as any,
notify: vi.fn(),
})).rejects.toThrow("create failed");
expect(pauseTask).toHaveBeenCalledTimes(1);
expect(updateTask).toHaveBeenCalledWith("FN-4625", expect.objectContaining({ pausedReason: "worktrunk_operation_failed" }), undefined);
});
it("native fallback errors are rethrown", async () => {
const fallbackError = new Error("native failed");
await expect(handleWorktrunkOperationFailure({
failure: makeFailure(new Error("create failed")),
task: makeTask(),
settings: { enabled: true, onFailure: "fallback-native" },
store: { pauseTask: vi.fn(), updateTask: vi.fn().mockResolvedValue(undefined) } as any,
notify: vi.fn(),
nativeFallback: vi.fn().mockRejectedValue(fallbackError),
})).rejects.toThrow("native failed");
});
it("truncates stderr preview to 4KB in run-audit metadata", async () => {
const git = vi.fn().mockResolvedValue(undefined);
const longStderr = "x".repeat(5000);
await expect(handleWorktrunkOperationFailure({
failure: makeFailure(new Error("create failed"), longStderr),
task: makeTask(),
settings: { enabled: true, onFailure: "fail" },
store: { pauseTask: vi.fn().mockResolvedValue(undefined), updateTask: vi.fn().mockResolvedValue(undefined) } as any,
runAudit: { git },
notify: vi.fn(),
})).rejects.toThrow();
const call = git.mock.calls.find((entry) => entry[0]?.type === "worktree:worktrunk-failure");
expect(call?.[0]?.metadata?.stderrPreview.length).toBe(4097);
expect(call?.[0]?.metadata?.stderrPreview.endsWith("…")).toBe(true);
});
it.each([
new WorktrunkBinaryUnavailableError("missing"),
new WorktrunkInstallFailedError("install failed", { stage: "release" }),
new WorktrunkInstallDeniedError("denied"),
])("handles installer error class %s in fail-hard mode", async (cause) => {
await expect(handleWorktrunkOperationFailure({
failure: makeFailure(cause, cause.message),
task: makeTask(),
settings: { enabled: true, onFailure: "fail" },
store: { pauseTask: vi.fn().mockResolvedValue(undefined), updateTask: vi.fn().mockResolvedValue(undefined) } as any,
notify: vi.fn(),
})).rejects.toBe(cause);
});
});

View File

@@ -128,6 +128,15 @@ export {
WORKTRUNK_DOWNLOAD_MAX_BYTES,
WORKTRUNK_CARGO_TIMEOUT_MS,
} from "./worktrunk-installer.js";
export {
handleWorktrunkOperationFailure,
truncateWorktrunkStderr,
type WorktreeOperationResult,
type WorktrunkDisposition,
type WorktrunkFailureNotification,
type WorktrunkOpName,
type WorktrunkOperationFailure,
} from "./worktrunk-failure-handler.js";
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
export { withRateLimitRetry } from "./rate-limit-retry.js";
export { ResearchOrchestrator, type ResearchOrchestratorOptions, type ResearchOrchestratorStatus, type ResearchOrchestratorStartOptions } from "./research-orchestrator.js";

View File

@@ -71,6 +71,8 @@ export type GitMutationType =
| "worktree:worktrunk-sync"
| "worktree:worktrunk-prune"
| "worktree:worktrunk-fallback"
| "worktree:worktrunk-failure"
| "worktree:worktrunk-fallback-native"
| "branch:create"
| "branch:delete"
| "branch:checkout"