Skip to content

Commit 857ca69

Browse files
committed
Register the active run for headless exec so signals finalize it
Process signal handlers already finalize the registered run. Headless exec wrote running to run.json but never registered, so a supervisor stop left the session looking live.
1 parent df0dbe4 commit 857ca69

7 files changed

Lines changed: 343 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- Headless `corbits exec` now registers the active run so SIGINT/SIGTERM/SIGHUP finalize `run.json`.
19+
1620
## [0.3.15] - 2026-09-04
1721

1822
### Added

src/exec/runner.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ import {
4444
sessionContextDir,
4545
sessionDir,
4646
} from "../session/index.js";
47-
import { saveState, type ConnectedMcpServer } from "../session/state.js";
47+
import { setActiveRun } from "../session/active-run.js";
48+
import { finalizeRunState, saveState, type ConnectedMcpServer } from "../session/state.js";
4849
import { resolveExecRunStatus, type RunSink } from "../session/run-sink.js";
4950
import { createRunSummary } from "../session/hooks.js";
5051
import {
@@ -292,7 +293,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
292293
): Promise<void> => {
293294
if (finalized && status === "running") return;
294295
if (status !== "running") finalized = true;
295-
await saveState(config.cwd, sessionId, {
296+
const snapshot = {
296297
status,
297298
turnsUsed: runSink?.getTurnCount() ?? turnsUsed,
298299
task,
@@ -301,7 +302,22 @@ export async function runExec(config: Config): Promise<ExecResult> {
301302
mcpServers: connectedMcp,
302303
...(status !== "running" ? { finishedAt: Date.now() } : {}),
303304
...(extra?.error !== undefined ? { error: extra.error } : {}),
304-
}).catch((err: unknown) => {
305+
};
306+
const write =
307+
status === "running"
308+
? saveState(config.cwd, sessionId, snapshot).then(() => {
309+
if (!finalized) {
310+
setActiveRun({
311+
sessionId,
312+
cwd: config.cwd,
313+
task,
314+
startedAt,
315+
model: `${config.providerName}:${config.model}`,
316+
});
317+
}
318+
})
319+
: finalizeRunState(config.cwd, sessionId, snapshot);
320+
await write.catch((err: unknown) => {
305321
// Persistence failure must not fail the run, but dropping it silently
306322
// hides disk/permission problems that leave run.json stale.
307323
logger.warn("saveState failed for session {sessionId} status={status}: {error}", {

src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,8 @@ export async function handleFatal(kind: CrashKind, error: unknown): Promise<void
164164

165165
// A crash reaching here escaped without ever hitting runTUI's own try/catch
166166
// (e.g. a throw inside a fire-and-forget `void` call), so run.json was never
167-
// closed out. getActiveRun surfaces the in-flight session set by runTUI, with
167+
// closed out. getActiveRun surfaces the in-flight session set by the in-flight
168+
// runner (TUI or exec), with
168169
// enough (task, startedAt, model) carried on the handle itself that no read
169170
// of run.json is needed — a readFile here would be exactly the kind of
170171
// unbounded crash-path I/O primeCrashReporting (src/crash/report.ts) exists
@@ -251,8 +252,9 @@ const SIGNAL_EXIT_NUMBER: Record<"SIGINT" | "SIGTERM" | "SIGHUP", number> = {
251252
// exists for the signal actually reaching the process: external
252253
// orchestration (kill, systemd, docker stop), or a terminal that never
253254
// entered raw mode at all (exec mode has no TUI host and no raw stdin, so
254-
// its Ctrl+C is a real SIGINT today with no listener at all — Bun's default
255-
// disposition kills it immediately without a chance to close out run.json).
255+
// its Ctrl+C is a real SIGINT today). Listeners are already installed at
256+
// process entry; they finalize the registered handle, and no-op only when
257+
// none is registered.
256258
//
257259
// Terminal restore is done directly here, the same way handleFatal does it,
258260
// rather than left to OpenTUI's own same-signal listener (registered later,

src/session/active-run.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// A module-level slot the top-level uncaughtException/unhandledRejection
2-
// handler (src/index.ts) can reach even though persistRunSnapshot is a
3-
// closure local to runTUI. Only ever consulted from the crash path: a run
4-
// that never crashes never has this read.
2+
// handler (src/index.ts) can reach even though persist is a closure local to
3+
// the in-flight runner (TUI persistRunSnapshot or exec persist). Only ever
4+
// consulted from the crash path and from signal handlers: a run that never
5+
// crashes and is never signaled never has this read.
56
//
67
// Carries enough of the live run state (task, startedAt, model) that the
78
// crash handler can build a full RunState record itself. It must not read
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Spawned as a subprocess by tests/integration/exec-signal-finalize.test.ts.
2+
// Installs process-level signal handlers the way import.meta.main does, then
3+
// calls production runExec. Does not register the active-run handle itself —
4+
// that is the product path under test.
5+
import { existsSync, readFileSync } from "node:fs";
6+
import { join } from "node:path";
7+
8+
import type { Config } from "../../../src/config/index.js";
9+
import { sessionDir } from "../../../src/session/index.js";
10+
import { withMockedModuleDuring } from "../../helpers/mock-module.js";
11+
12+
const cwd = process.cwd();
13+
const sessionId = process.env["SIGNAL_TEST_SESSION_ID"];
14+
if (sessionId === undefined) {
15+
throw new Error("SIGNAL_TEST_SESSION_ID must be set");
16+
}
17+
18+
const task = "headless exec signal task";
19+
const runDir = sessionDir(cwd, sessionId);
20+
const runJsonPath = join(runDir, "run.json");
21+
22+
async function waitForRunningRunJson(): Promise<void> {
23+
for (;;) {
24+
if (existsSync(runJsonPath)) {
25+
try {
26+
const state = JSON.parse(readFileSync(runJsonPath, "utf8")) as { status?: string };
27+
if (state.status === "running") return;
28+
} catch {
29+
// rename/parse race on the first persist
30+
}
31+
}
32+
await new Promise((resolve) => setTimeout(resolve, 20));
33+
}
34+
}
35+
36+
await withMockedModuleDuring(
37+
import.meta.resolve("../../../src/session/assemble-runtime.js"),
38+
(real: typeof import("../../../src/session/assemble-runtime.js")) => ({
39+
...real,
40+
// Stall the first await after persist("running") so bootstrap catch cannot
41+
// persist("failed") before the parent sends a signal.
42+
assembleInferenceBase: () => new Promise<never>(() => undefined),
43+
}),
44+
async () => {
45+
const { installSignalHandlers } = await import("../../../src/index.js");
46+
const { runExec } = await import("../../../src/exec/runner.js");
47+
installSignalHandlers();
48+
const config = {
49+
command: "exec",
50+
task,
51+
cwd,
52+
configured: true,
53+
providerName: "test-provider",
54+
model: "test-model",
55+
providers: {},
56+
force: false,
57+
dangerouslySkipPermissions: true,
58+
autoMode: false,
59+
sessionId,
60+
} as unknown as Config;
61+
void runExec(config);
62+
await waitForRunningRunJson();
63+
process.stdout.write(`${runDir}\n`);
64+
await new Promise<never>(() => undefined);
65+
},
66+
);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
5+
import { describe, expect, test } from "bun:test";
6+
7+
import { generateSessionId } from "../../src/session/index.js";
8+
import type { RunState } from "../../src/session/state.js";
9+
10+
const FIXTURE = join(import.meta.dirname, "../fixtures/crash-run/simulate-exec-signal.ts");
11+
12+
async function readLine(stream: ReadableStream<Uint8Array>): Promise<string> {
13+
const reader = stream.getReader();
14+
const decoder = new TextDecoder();
15+
let buffer = "";
16+
while (!buffer.includes("\n")) {
17+
const { value, done } = await reader.read();
18+
if (done) break;
19+
buffer += decoder.decode(value, { stream: true });
20+
}
21+
reader.releaseLock();
22+
return buffer;
23+
}
24+
25+
describe("integration — signaled exec process finalizes run.json", () => {
26+
test.each([
27+
["SIGINT", 130],
28+
["SIGTERM", 143],
29+
["SIGHUP", 129],
30+
] as const)(
31+
"%s writes status: failed and exits with %i",
32+
async (signal, expectedExitCode) => {
33+
const cwd = mkdtempSync(join(tmpdir(), "corbits-exec-signal-cwd-"));
34+
const home = mkdtempSync(join(tmpdir(), "corbits-exec-signal-home-"));
35+
const sessionId = generateSessionId();
36+
37+
try {
38+
const proc = Bun.spawn(["bun", "run", FIXTURE], {
39+
cwd,
40+
env: { ...process.env, HOME: home, SIGNAL_TEST_SESSION_ID: sessionId },
41+
stdout: "pipe",
42+
stderr: "pipe",
43+
});
44+
45+
const output = await readLine(proc.stdout);
46+
const [runDir] = output.split("\n");
47+
if (runDir === undefined || runDir.length === 0) {
48+
const errText = await new Response(proc.stderr).text();
49+
throw new Error(
50+
`fixture did not report a run directory: ${JSON.stringify(output)} stderr=${errText}`,
51+
);
52+
}
53+
54+
proc.kill(signal);
55+
const exitCode = await proc.exited;
56+
57+
expect(exitCode).toBe(expectedExitCode);
58+
59+
const runJsonPath = join(runDir, "run.json");
60+
const raw = readFileSync(runJsonPath, "utf8");
61+
const state = JSON.parse(raw) as RunState;
62+
63+
expect(state.status).toBe("failed");
64+
expect(state.status).not.toBe("running");
65+
expect(state.finishedAt).toBeGreaterThan(0);
66+
expect(state.error).toBe(`terminated by ${signal}`);
67+
expect(state.task).toBe("headless exec signal task");
68+
} finally {
69+
rmSync(cwd, { recursive: true, force: true });
70+
rmSync(home, { recursive: true, force: true });
71+
}
72+
},
73+
15_000,
74+
);
75+
});

0 commit comments

Comments
 (0)