diff --git a/src/resources/extensions/sf/auto-start.ts b/src/resources/extensions/sf/auto-start.ts index 9579506f3..ec19beef6 100644 --- a/src/resources/extensions/sf/auto-start.ts +++ b/src/resources/extensions/sf/auto-start.ts @@ -112,7 +112,7 @@ import { emitWorktreeOrphaned } from "./worktree-telemetry.js"; export interface BootstrapDeps { shouldUseWorktreeIsolation: () => boolean; - registerSigtermHandler: (basePath: string) => void; + registerSigtermHandler: (basePath: string, onSignal?: () => void) => void; lockBase: () => string; buildResolver: () => WorktreeResolver; } diff --git a/src/resources/extensions/sf/auto-supervisor.ts b/src/resources/extensions/sf/auto-supervisor.ts index dd0cba34d..1880285d3 100644 --- a/src/resources/extensions/sf/auto-supervisor.ts +++ b/src/resources/extensions/sf/auto-supervisor.ts @@ -27,11 +27,16 @@ let _currentSigtermHandler: (() => void) | null = null; * always references the correct path even if the module variable changes. * Removes any previously registered handler before installing the new one. * + * The optional `onSignal` callback is invoked before `process.exit(0)` so + * callers can write diagnostics (e.g., UOK parity heartbeat) that would + * otherwise be lost when the finally block is bypassed by the hard exit. + * * Returns the new handler so the caller can store and deregister it later. */ export function registerSigtermHandler( currentBasePath: string, previousHandler: (() => void) | null, + onSignal?: () => void, ): () => void { // Remove the explicitly-passed previous handler if (previousHandler) { @@ -43,6 +48,11 @@ export function registerSigtermHandler( for (const sig of CLEANUP_SIGNALS) process.off(sig, _currentSigtermHandler); } const handler = () => { + try { + onSignal?.(); + } catch { + // Best-effort: signal handler must not throw. + } clearLock(currentBasePath); releaseSessionLock(currentBasePath); process.exit(0); diff --git a/src/resources/extensions/sf/auto.ts b/src/resources/extensions/sf/auto.ts index 581ff2804..aa5866104 100644 --- a/src/resources/extensions/sf/auto.ts +++ b/src/resources/extensions/sf/auto.ts @@ -195,6 +195,7 @@ import { } from "./skill-telemetry.js"; import { resolveUokFlags } from "./uok/flags.js"; import { runAutoLoopWithUok } from "./uok/kernel.js"; +import { writeParityHeartbeat, writeParityReport } from "./uok/parity-report.js"; import { logWarning, setLogBasePath } from "./workflow-logger.js"; import { autoCommitCurrentBranch, @@ -410,7 +411,33 @@ export { /** Wrapper: register SIGTERM handler and store reference. */ function registerSigtermHandler(currentBasePath: string): void { - s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler); + const prefs = loadEffectiveSFPreferences()?.preferences; + const flags = resolveUokFlags(prefs); + const pathLabel = flags.legacyFallback + ? "legacy-fallback" + : flags.enabled + ? "uok-kernel" + : "legacy-wrapper"; + + const onSignal = (): void => { + // Write UOK parity exit heartbeat before process.exit(0) bypasses + // the finally block in runAutoLoopWithUok. Fixes the enter/exit + // mismatch that occurs when auto-mode terminates via signal. + writeParityHeartbeat(currentBasePath, { + ts: new Date().toISOString(), + path: pathLabel, + flags: { ...flags }, + phase: "exit", + status: "signal", + }); + writeParityReport(currentBasePath); + }; + + s.sigtermHandler = _registerSigtermHandler( + currentBasePath, + s.sigtermHandler, + onSignal, + ); } /** Wrapper: deregister SIGTERM handler and clear reference. */ diff --git a/src/resources/extensions/sf/tests/signal-handlers.test.ts b/src/resources/extensions/sf/tests/signal-handlers.test.ts index 27023566d..e46bb0dd0 100644 --- a/src/resources/extensions/sf/tests/signal-handlers.test.ts +++ b/src/resources/extensions/sf/tests/signal-handlers.test.ts @@ -104,3 +104,55 @@ test("registerSigtermHandler deregisters previous handler from all signals", () // Clean up deregisterSigtermHandler(handler2); }); + +test("registerSigtermHandler invokes onSignal callback before process.exit", () => { + let callbackCalled = false; + let exitCode: string | number | null | undefined; + const originalExit = process.exit; + process.exit = ((code?: string | number | null | undefined): never => { + exitCode = code; + throw new Error("process.exit intercepted"); + }) as typeof process.exit; + + const handler = registerSigtermHandler( + "/tmp/test-signal-handlers", + null, + () => { + callbackCalled = true; + }, + ); + + try { + assert.throws(() => handler(), /process\.exit intercepted/); + assert.equal(callbackCalled, true, "onSignal callback should run before exit"); + assert.equal(exitCode, 0, "signal handler should exit with code 0"); + } finally { + process.exit = originalExit; + deregisterSigtermHandler(handler); + } +}); + +test("registerSigtermHandler swallows onSignal errors and still exits cleanly", () => { + let exitCode: string | number | null | undefined; + const originalExit = process.exit; + process.exit = ((code?: string | number | null | undefined): never => { + exitCode = code; + throw new Error("process.exit intercepted"); + }) as typeof process.exit; + + const handler = registerSigtermHandler( + "/tmp/test-signal-handlers", + null, + () => { + throw new Error("callback boom"); + }, + ); + + try { + assert.throws(() => handler(), /process\.exit intercepted/); + assert.equal(exitCode, 0, "signal handler should still exit with code 0"); + } finally { + process.exit = originalExit; + deregisterSigtermHandler(handler); + } +});