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:
parent
14efcd7734
commit
e82e878eaa
4 changed files with 91 additions and 2 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue