Files
fusion/packages/engine/src/worktrunk-failure-handler.ts
Fusion (runfusion.ai) 80382ed911 feat(FN-4626): complete Step 5 — align worktrunk failure payload shape
Fusion-Task-Id: FN-4626
Fusion-Task-Lineage: 04b06bf6-b08c-4ff1-b69a-ab93f6e508b3
2026-05-15 22:18:09 -07:00

145 lines
5.0 KiB
TypeScript

import type { RunMutationContext, Task, TaskStore, WorktrunkSettings } from "@fusion/core";
import type { RunAuditor } from "./run-audit.js";
import { createLogger } from "./logger.js";
const log = createLogger("worktrunk-failure-handler");
const MAX_STDERR_PREVIEW_CHARS = 4096;
export type WorktrunkOpName = "create" | "sync" | "prune" | "remove" | "install" | "resolve-binary";
export interface WorktrunkOperationFailure {
op: WorktrunkOpName;
cause: Error;
stderr?: string;
exitCode?: number | null;
durationMs?: number;
binaryPath?: string;
worktreePath?: string;
}
export type WorktreeOperationResult = { path: string; branch: string } | { skipped: boolean } | void;
export interface WorktrunkFailureNotification {
kind: "worktrunk-fallback-native";
task: Task;
op: WorktrunkOpName;
stderr?: string;
}
export interface HandleFailureParams {
failure: WorktrunkOperationFailure;
task: Task;
settings: WorktrunkSettings;
store: Pick<TaskStore, "pauseTask" | "updateTask">;
runContext?: RunMutationContext;
runAudit?: Pick<RunAuditor, "git">;
notify: (event: WorktrunkFailureNotification) => Promise<void> | void;
nativeFallback?: () => Promise<WorktreeOperationResult>;
}
export type WorktrunkDisposition =
| { kind: "paused"; pausedReason: "worktrunk_operation_failed" }
| { kind: "fallback-native"; result: WorktreeOperationResult; alerted: boolean };
function toStderrPreview(stderr?: string): string | undefined {
if (!stderr) return undefined;
return stderr.length > MAX_STDERR_PREVIEW_CHARS
? `${stderr.slice(0, MAX_STDERR_PREVIEW_CHARS)}`
: stderr;
}
async function pauseTaskWithFailure(
params: Omit<HandleFailureParams, "nativeFallback" | "notify">,
): Promise<WorktrunkDisposition> {
const { task, store, runContext, failure, runAudit } = params;
const attemptedAt = new Date().toISOString();
await store.pauseTask(task.id, true, runContext);
await store.updateTask(task.id, {
pausedReason: "worktrunk_operation_failed",
worktrunkFailure: {
op: failure.op,
stderr: failure.stderr,
exitCode: failure.exitCode ?? null,
attemptedAt,
},
}, runContext);
await runAudit?.git({
type: "worktree:worktrunk-failure",
target: task.id,
metadata: {
taskId: task.id,
runId: runContext?.runId,
op: failure.op,
exitCode: failure.exitCode ?? null,
stderrPreview: toStderrPreview(failure.stderr),
binaryPath: failure.binaryPath,
worktreePath: failure.worktreePath,
durationMs: failure.durationMs,
},
});
log.error(`${task.id}: worktrunk ${failure.op} failed; pausing task`);
return { kind: "paused", pausedReason: "worktrunk_operation_failed" };
}
/**
* Handles delegated worktrunk failures using the configured `worktrunk.onFailure` policy.
* Throws the original error for fail-hard outcomes.
*/
export async function handleWorktrunkOperationFailure(params: HandleFailureParams): Promise<WorktrunkDisposition> {
const { failure, task, settings, store, runContext, runAudit, notify, nativeFallback } = params;
const mode = settings.onFailure ?? "fail";
if (mode === "fallback-native") {
if (!nativeFallback) {
log.warn(`${task.id}: fallback-native requested for ${failure.op} but no native fallback provided; failing hard`);
await pauseTaskWithFailure({ failure, task, settings, store, runContext, runAudit });
throw failure.cause;
}
const alreadyAlerted = Boolean(task.worktrunkFallbackAlertedAt);
let alerted = false;
if (alreadyAlerted) {
log.log(`${task.id}: notify-skipped for worktrunk ${failure.op}; fallback alert already sent`);
} else {
const alertedAt = new Date().toISOString();
await store.updateTask(task.id, { worktrunkFallbackAlertedAt: alertedAt }, runContext);
await notify({ kind: "worktrunk-fallback-native", task, op: failure.op, stderr: failure.stderr });
alerted = true;
log.warn(`${task.id}: worktrunk ${failure.op} failed; falling back to native backend`);
}
await runAudit?.git({
type: "worktree:worktrunk-fallback-native",
target: task.id,
metadata: {
taskId: task.id,
runId: runContext?.runId,
op: failure.op,
exitCode: failure.exitCode ?? null,
stderrPreview: toStderrPreview(failure.stderr),
binaryPath: failure.binaryPath,
worktreePath: failure.worktreePath,
durationMs: failure.durationMs,
alerted,
},
});
try {
const result = await nativeFallback();
return { kind: "fallback-native", result, alerted };
} catch (fallbackError) {
log.error(`${task.id}: native-fallback-error for ${failure.op}: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
throw fallbackError;
}
}
await pauseTaskWithFailure({ failure, task, settings, store, runContext, runAudit });
throw failure.cause;
}
export function truncateWorktrunkStderr(stderr?: string): string | undefined {
return toStderrPreview(stderr);
}