fix(headless): do not restart graceful child exits

This commit is contained in:
Mikael Hugo 2026-05-15 07:25:06 +02:00
parent 9ba9b55f7a
commit 81425230f5
3 changed files with 35 additions and 3 deletions

View file

@ -67,6 +67,23 @@ export interface HeadlessRestartDecisionInput {
maxRestarts: number;
}
/**
* Convert an unexpected child-process exit into the outer headless exit code.
*
* Purpose: keep crash recovery for real child failures without turning a
* graceful child exit or operator stop into an automatic restart loop.
*
* Consumer: headless.ts child process exit handler before restart policy runs.
*/
export function classifyUnexpectedChildExit(
code: number | null,
signal?: NodeJS.Signals | null,
): number {
if (code === EXIT_SUCCESS) return EXIT_SUCCESS;
if (signal === "SIGINT" || signal === "SIGTERM") return EXIT_CANCELLED;
return EXIT_ERROR;
}
/**
* Decide whether the headless outer loop should restart a completed run.
*

View file

@ -35,6 +35,7 @@ import {
} from "./headless-context.js";
import {
classifyUnexpectedChildExit,
EXIT_BLOCKED,
EXIT_CANCELLED,
EXIT_ERROR,
@ -1985,11 +1986,11 @@ async function runHeadlessOnce(
// Detect child process crash (read-only exit event subscription — not stdin access)
const internalProcess = (client as any).process as ChildProcess;
if (internalProcess) {
internalProcess.on("exit", (code) => {
internalProcess.on("exit", (code, signal) => {
if (!completed) {
const msg = `[headless] Child process exited unexpectedly with code ${code ?? "null"}\n`;
const msg = `[headless] Child process exited unexpectedly with code ${code ?? "null"}${signal ? ` signal ${signal}` : ""}\n`;
process.stderr.write(msg);
exitCode = EXIT_ERROR;
exitCode = classifyUnexpectedChildExit(code, signal);
resolveCompletion();
}
});

View file

@ -12,6 +12,7 @@ import { test } from "vitest";
// ─── Import exit code constants & mapStatusToExitCode ──────────────────────
import {
classifyUnexpectedChildExit,
EXIT_BLOCKED,
EXIT_CANCELLED,
EXIT_ERROR,
@ -384,6 +385,19 @@ test("shouldRestartHeadlessRun still retries unexpected errors within budget", (
);
});
test("classifyUnexpectedChildExit treats graceful child exit as terminal success", () => {
assert.equal(classifyUnexpectedChildExit(0, null), EXIT_SUCCESS);
});
test("classifyUnexpectedChildExit treats operator child signal as cancellation", () => {
assert.equal(classifyUnexpectedChildExit(null, "SIGTERM"), EXIT_CANCELLED);
assert.equal(classifyUnexpectedChildExit(null, "SIGINT"), EXIT_CANCELLED);
});
test("classifyUnexpectedChildExit keeps nonzero child exits restartable errors", () => {
assert.equal(classifyUnexpectedChildExit(1, null), EXIT_ERROR);
});
// ─── HeadlessJsonResult type shape ─────────────────────────────────────────
test("HeadlessJsonResult satisfies expected shape", () => {