Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename

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

### Fixed

- Headless `corbits exec` now registers the active run so SIGINT/SIGTERM/SIGHUP finalize `run.json`.


## [0.3.15] - 2026-09-04

### Added
Expand Down
22 changes: 19 additions & 3 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ import {
sessionContextDir,
sessionDir,
} from "../session/index.js";
import { saveState, type ConnectedMcpServer } from "../session/state.js";
import { setActiveRun } from "../session/active-run.js";
import { finalizeRunState, saveState, type ConnectedMcpServer } from "../session/state.js";
import { resolveExecRunStatus, type RunSink } from "../session/run-sink.js";
import { createRunSummary } from "../session/hooks.js";
import {
Expand Down Expand Up @@ -292,7 +293,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
): Promise<void> => {
if (finalized && status === "running") return;
if (status !== "running") finalized = true;
await saveState(config.cwd, sessionId, {
const snapshot = {
status,
turnsUsed: runSink?.getTurnCount() ?? turnsUsed,
task,
Expand All @@ -301,7 +302,22 @@ export async function runExec(config: Config): Promise<ExecResult> {
mcpServers: connectedMcp,
...(status !== "running" ? { finishedAt: Date.now() } : {}),
...(extra?.error !== undefined ? { error: extra.error } : {}),
}).catch((err: unknown) => {
};
const write =
status === "running"
? saveState(config.cwd, sessionId, snapshot).then(() => {
if (!finalized) {
setActiveRun({
sessionId,
cwd: config.cwd,
task,
startedAt,
model: `${config.providerName}:${config.model}`,
});
}
})
: finalizeRunState(config.cwd, sessionId, snapshot);
await write.catch((err: unknown) => {
// Persistence failure must not fail the run, but dropping it silently
// hides disk/permission problems that leave run.json stale.
logger.warn("saveState failed for session {sessionId} status={status}: {error}", {
Expand Down
8 changes: 5 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ export async function handleFatal(kind: CrashKind, error: unknown): Promise<void

// A crash reaching here escaped without ever hitting runTUI's own try/catch
// (e.g. a throw inside a fire-and-forget `void` call), so run.json was never
// closed out. getActiveRun surfaces the in-flight session set by runTUI, with
// closed out. getActiveRun surfaces the in-flight session set by the in-flight
// runner (TUI or exec), with
// enough (task, startedAt, model) carried on the handle itself that no read
// of run.json is needed — a readFile here would be exactly the kind of
// unbounded crash-path I/O primeCrashReporting (src/crash/report.ts) exists
Expand Down Expand Up @@ -251,8 +252,9 @@ const SIGNAL_EXIT_NUMBER: Record<"SIGINT" | "SIGTERM" | "SIGHUP", number> = {
// exists for the signal actually reaching the process: external
// orchestration (kill, systemd, docker stop), or a terminal that never
// entered raw mode at all (exec mode has no TUI host and no raw stdin, so
// its Ctrl+C is a real SIGINT today with no listener at all — Bun's default
// disposition kills it immediately without a chance to close out run.json).
// its Ctrl+C is a real SIGINT today). Listeners are already installed at
// process entry; they finalize the registered handle, and no-op only when
// none is registered.
//
// Terminal restore is done directly here, the same way handleFatal does it,
// rather than left to OpenTUI's own same-signal listener (registered later,
Expand Down
7 changes: 4 additions & 3 deletions src/session/active-run.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// A module-level slot the top-level uncaughtException/unhandledRejection
// handler (src/index.ts) can reach even though persistRunSnapshot is a
// closure local to runTUI. Only ever consulted from the crash path: a run
// that never crashes never has this read.
// handler (src/index.ts) can reach even though persist is a closure local to
// the in-flight runner (TUI persistRunSnapshot or exec persist). Only ever
// consulted from the crash path and from signal handlers: a run that never
// crashes and is never signaled never has this read.
//
// Carries enough of the live run state (task, startedAt, model) that the
// crash handler can build a full RunState record itself. It must not read
Expand Down
66 changes: 66 additions & 0 deletions tests/fixtures/crash-run/simulate-exec-signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Spawned as a subprocess by tests/integration/exec-signal-finalize.test.ts.
// Installs process-level signal handlers the way import.meta.main does, then
// calls production runExec. Does not register the active-run handle itself —
// that is the product path under test.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";

import type { Config } from "../../../src/config/index.js";
import { sessionDir } from "../../../src/session/index.js";
import { withMockedModuleDuring } from "../../helpers/mock-module.js";

const cwd = process.cwd();
const sessionId = process.env["SIGNAL_TEST_SESSION_ID"];
if (sessionId === undefined) {
throw new Error("SIGNAL_TEST_SESSION_ID must be set");
}

const task = "headless exec signal task";
const runDir = sessionDir(cwd, sessionId);
const runJsonPath = join(runDir, "run.json");

async function waitForRunningRunJson(): Promise<void> {
for (;;) {
if (existsSync(runJsonPath)) {
try {
const state = JSON.parse(readFileSync(runJsonPath, "utf8")) as { status?: string };
if (state.status === "running") return;
} catch {
// rename/parse race on the first persist
}
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
}

await withMockedModuleDuring(
import.meta.resolve("../../../src/session/assemble-runtime.js"),
(real: typeof import("../../../src/session/assemble-runtime.js")) => ({
...real,
// Stall the first await after persist("running") so bootstrap catch cannot
// persist("failed") before the parent sends a signal.
assembleInferenceBase: () => new Promise<never>(() => undefined),
}),
async () => {
const { installSignalHandlers } = await import("../../../src/index.js");
const { runExec } = await import("../../../src/exec/runner.js");
installSignalHandlers();
const config = {
command: "exec",
task,
cwd,
configured: true,
providerName: "test-provider",
model: "test-model",
providers: {},
force: false,
dangerouslySkipPermissions: true,
autoMode: false,
sessionId,
} as unknown as Config;
void runExec(config);
await waitForRunningRunJson();
process.stdout.write(`${runDir}\n`);
await new Promise<never>(() => undefined);
},
);
75 changes: 75 additions & 0 deletions tests/integration/exec-signal-finalize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { describe, expect, test } from "bun:test";

import { generateSessionId } from "../../src/session/index.js";
import type { RunState } from "../../src/session/state.js";

const FIXTURE = join(import.meta.dirname, "../fixtures/crash-run/simulate-exec-signal.ts");

async function readLine(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!buffer.includes("\n")) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
}
reader.releaseLock();
return buffer;
}

describe("integration — signaled exec process finalizes run.json", () => {
test.each([
["SIGINT", 130],
["SIGTERM", 143],
["SIGHUP", 129],
] as const)(
"%s writes status: failed and exits with %i",
async (signal, expectedExitCode) => {
const cwd = mkdtempSync(join(tmpdir(), "corbits-exec-signal-cwd-"));
const home = mkdtempSync(join(tmpdir(), "corbits-exec-signal-home-"));
const sessionId = generateSessionId();

try {
const proc = Bun.spawn(["bun", "run", FIXTURE], {
cwd,
env: { ...process.env, HOME: home, SIGNAL_TEST_SESSION_ID: sessionId },
stdout: "pipe",
stderr: "pipe",
});

const output = await readLine(proc.stdout);
const [runDir] = output.split("\n");
if (runDir === undefined || runDir.length === 0) {
const errText = await new Response(proc.stderr).text();
throw new Error(
`fixture did not report a run directory: ${JSON.stringify(output)} stderr=${errText}`,
);
}

proc.kill(signal);
const exitCode = await proc.exited;

expect(exitCode).toBe(expectedExitCode);

const runJsonPath = join(runDir, "run.json");
const raw = readFileSync(runJsonPath, "utf8");
const state = JSON.parse(raw) as RunState;

expect(state.status).toBe("failed");
expect(state.status).not.toBe("running");
expect(state.finishedAt).toBeGreaterThan(0);
expect(state.error).toBe(`terminated by ${signal}`);
expect(state.task).toBe("headless exec signal task");
} finally {
rmSync(cwd, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}
},
15_000,
);
});
Loading
Loading