export type MergePhase = "total" | "prep" | "preview" | "fetch" | "rebase" | "gates" | "synthesize" | "worktree-add" | "cleanup"; const MERGE_PHASE_TIMEOUTS_MS: Record = { total: 40 * 60_000, prep: 60_000, preview: 30_000, fetch: 60_000, rebase: 60_000, gates: 35 * 60_000, synthesize: 60_000, "worktree-add": 60_000, cleanup: 30_000, }; function positiveEnvMs(name: string): number | undefined { const raw = process.env[name]; if (!raw) return undefined; const parsed = Number(raw); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } export function mergePhaseTimeoutMs(phase: MergePhase, minimumMs = 0): number { const key = `AGENT_RELAY_WORKSPACE_MERGE_${phase.toUpperCase().replace(/-/g, "_")}_TIMEOUT_MS`; const configured = positiveEnvMs(key) ?? positiveEnvMs("AGENT_RELAY_WORKSPACE_MERGE_PHASE_TIMEOUT_MS") ?? MERGE_PHASE_TIMEOUTS_MS[phase]; return Math.max(configured, minimumMs); } function abortError(signal: AbortSignal): Error { const reason = signal.reason; if (reason instanceof Error) return reason; if (typeof reason === "string" && reason) return new Error(reason); return new Error("workspace merge aborted"); } export function throwIfMergeAborted(signal?: AbortSignal): void { if (signal?.aborted) throw abortError(signal); } export async function withMergePhaseTimeout( phase: MergePhase, run: (signal: AbortSignal) => Promise, options: { signal?: AbortSignal; minimumMs?: number } = {}, ): Promise { const timeoutMs = mergePhaseTimeoutMs(phase, options.minimumMs); const controller = new AbortController(); let timeout: ReturnType | undefined; const abortFromParent = () => controller.abort(abortError(options.signal!)); if (options.signal?.aborted) controller.abort(abortError(options.signal)); else options.signal?.addEventListener("abort", abortFromParent, { once: true }); const work = run(controller.signal); work.catch(() => {}); try { return await Promise.race([ work, new Promise((_, reject) => { timeout = setTimeout(() => { const error = new Error(`workspace merge phase "${phase}" timed out after ${timeoutMs}ms`); controller.abort(error); reject(error); }, timeoutMs); timeout.unref?.(); }), ]); } finally { if (timeout) clearTimeout(timeout); options.signal?.removeEventListener("abort", abortFromParent); } }