Skip to content

Commit a1221e8

Browse files
Merge pull request #781 from corbitsdev/cl-7332-headless-exec-leaves-run-state-stale-after-process-signal
Register the active run for headless exec so signals finalize it
2 parents 90806ae + 259553b commit a1221e8

7 files changed

Lines changed: 344 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1717

1818
- Drop unused `@opentui/keymap`, `@opentui/solid`, and `solid-js`. The interactive TUI is imperative `@opentui/core` only.
1919

20+
### Fixed
21+
22+
- Headless `corbits exec` now registers the active run so SIGINT/SIGTERM/SIGHUP finalize `run.json`.
23+
24+
2025
## [0.3.15] - 2026-09-04
2126

2227
### 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)