From e82e878eaa2b59d6a31398e9969db8562ffdc822 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 20:20:40 +0200 Subject: [PATCH] fix(uok): write parity exit heartbeat on SIGTERM/SIGINT before process.exit The signal handler in auto-supervisor.ts called process.exit(0) directly, bypassing the finally block in runAutoLoopWithUok() that writes the UOK parity exit heartbeat. This caused 55+ missing exit events in the parity log (78 enters vs 22 exits), making the enter/exit mismatch report meaningless. Changes: - auto-supervisor.ts: add optional onSignal callback to registerSigtermHandler, invoked before process.exit(0) with best-effort error swallowing - auto.ts: wrapper now passes a callback that writes the UOK parity exit heartbeat + refreshes the parity report before the hard exit - auto-start.ts: update BootstrapDeps interface to accept optional onSignal - tests: add 2 tests verifying callback invocation and error swallowing Fixes the UOK parity critical mismatch reported in uok-parity-report.json. --- src/resources/extensions/sf/auto-start.ts | 2 +- .../extensions/sf/auto-supervisor.ts | 10 ++++ src/resources/extensions/sf/auto.ts | 29 ++++++++++- .../sf/tests/signal-handlers.test.ts | 52 +++++++++++++++++++ 4 files changed, 91 insertions(+), 2 deletions(-) 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); + } +});