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.
This commit is contained in:
Mikael Hugo 2026-05-02 20:20:40 +02:00
parent 14efcd7734
commit e82e878eaa
4 changed files with 91 additions and 2 deletions

View file

@ -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;
}

View file

@ -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);

View file

@ -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. */

View file

@ -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);
}
});