feat(daemon): stub mid_turn_death behavior — the seat dies mid-turn (items 6-8)

The third seeded behavior. `emit mid_turn_death` emits a partial-turn pane line then
dies via a new die() IO effect: the runner records the exited sidecar (synchronous,
so the daemon never false-greens a stale ready), prints the EXIT marker, and
process.exit(STUB_MID_TURN_DEATH_EXIT_CODE=137, i.e. 128+SIGKILL). The executor
RETURNS on mid_turn_death — halting before the trailing Stop — so hooks CEASE
(UserPromptSubmit fired, Stop never does), the production-identical signature of a
turn that never completed. Later steps do not run.

Fire-and-forget activity POSTs may be truncated by the exit — which is faithful: a
real death loses in-flight hooks. So the reliable death signals are the sync exited
sidecar + exit code + the never-sent Stop.

RED-first: hermetic executor (die recorded, no Stop, later steps skipped) +
real-spawn death e2e (process exits 137, exited sidecar code 137, sink never receives
Stop).

daemon: 47/47 across the stub area, tsc 0. Remaining: restore (heaviest, real resume path).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-06 03:35:22 +00:00
1 parent 882675b8ad
commit ac335ff2e0
3 files changed
+124 -4

No files matched your search

+28 -2
View File
@@ -73,10 +73,17 @@ export interface StubRunnerIO {
fireCompaction(): CompactionResult;
/** Fire-and-forget POST of a canonical activity event to /api/activity/hooks. */
postActivity(payload: Record<string, unknown>): void;
/** Kill the seat MID-TURN (mid_turn_death): record the exited sidecar + EXIT marker,
* then terminate the process. The real runner never returns from this. */
die(code: number): void;
/** Injectable clock (OPENRIG_TEST_CLOCK_NOW when set, real wall-clock otherwise). */
now(): string;
}
/** mid_turn_death's exit code — 128+SIGKILL(9), the conventional "process killed" code,
* evoking a seat struck down mid-turn. Deterministic + distinct from the error paths. */
export const STUB_MID_TURN_DEATH_EXIT_CODE = 137;
/** The seat's activity identity (mirror pi-runner). runtime is always "stub". */
export interface StubActivityIdentity {
sessionName: string;
@@ -136,8 +143,17 @@ export function executeStubScript(script: StubScript, io: StubRunnerIO, identity
}
continue;
}
// mid_turn_death / restore are seeded but not yet simulated — surface that loudly
// rather than silently drop the step (honest labeling).
if (step.behavior === "mid_turn_death") {
// The seat dies MID-TURN: emit a partial-turn line, then die. `return` halts the
// loop BEFORE the trailing Stop — so hooks CEASE (UserPromptSubmit fired, no Stop),
// the production-identical signature of a turn that never completed. The real
// runner's die() exits the process here, so nothing after this runs either way.
io.mirrorLine("[stub] mid_turn_death — dying mid-turn; hooks cease");
io.die(STUB_MID_TURN_DEATH_EXIT_CODE);
return;
}
// restore is seeded but not yet simulated — surface that loudly rather than
// silently drop the step (honest labeling).
io.mirrorLine(`[stub] behavior '${step.behavior}' not yet simulated (deferred increment)`);
}
io.postActivity(stubActivityPayload(identity, "Stop", null, io.now()));
@@ -278,6 +294,16 @@ export async function runStubRunner(args: StubRunnerArgs): Promise<void> {
signal: controller.signal,
}).catch(() => { /* best-effort — never blocks the loop */ }).finally(() => clearTimeout(timeout));
},
die: (code) => {
// Record the honest exited sidecar so the daemon sees the seat die (never a stale
// ready), print the EXIT marker, then terminate — no graceful idle, no Stop.
try {
writeSidecar(args.cwd, { ready: false, launchId: args.launchId, exited: { code, at: nowIso() }, updatedAt: nowIso() });
} catch { /* best-effort on the way out */ }
// eslint-disable-next-line no-console
console.log(STUB_RUNNER_EXIT_MARKER);
process.exit(code);
},
now: nowIso,
};
// SessionStart signals the seat came up (running); it precedes the turn's events.
+25 -2
View File
@@ -5,6 +5,7 @@ import { join } from "node:path";
import {
executeStubScript,
resolveStubScript,
STUB_MID_TURN_DEATH_EXIT_CODE,
type StubRunnerIO,
} from "../src/adapters/stub-runner.js";
import { DEFAULT_STUB_SCRIPT, StubScriptError, type StubScript } from "../src/adapters/stub-script.js";
@@ -23,17 +24,21 @@ import type { CompactionResult } from "../src/adapters/stub-compaction.js";
const IDENTITY = { sessionName: "dev-worker@exec", nodeId: "exec-node" };
/** A recording fake of the StubRunnerIO seam. */
function fakeIo(): StubRunnerIO & { lines: string[]; fireCount: number; activities: Record<string, unknown>[] } {
function fakeIo(): StubRunnerIO & { lines: string[]; fireCount: number; activities: Record<string, unknown>[]; died: boolean; diedCode: number | undefined } {
const state = {
lines: [] as string[],
fireCount: 0,
activities: [] as Record<string, unknown>[],
died: false,
diedCode: undefined as number | undefined,
mirrorLine(line: string) { this.lines.push(line); },
fireCompaction(): CompactionResult {
this.fireCount++;
return { markerPath: "/fake/restore-pending/seat.json" };
},
postActivity(payload: Record<string, unknown>) { this.activities.push(payload); },
// The real runner's die() exits the process; the fake records it so dispatch is testable.
die(code: number) { this.died = true; this.diedCode = code; },
now() { return "2021-06-06T06:06:06.000Z"; },
};
return state;
@@ -100,8 +105,26 @@ describe("executeStubScript (R1 dispatch over the StubRunnerIO seam)", () => {
expect(chunks[chunks.length - 1]).toContain(`${chunks.length}/${chunks.length}`);
});
it("simulates mid_turn_death: the turn dies mid-flight — hooks CEASE (no Stop) and later steps do NOT run", () => {
const io = fakeIo();
executeStubScript({
steps: [
{ kind: "emit", behavior: "mid_turn_death" },
{ kind: "say", text: "SHOULD NOT RUN — the seat is dead" },
],
}, io, IDENTITY);
// The turn opened (UserPromptSubmit) but died before completing — NO Stop was posted.
const events = io.activities.map((a) => a.hookEvent);
expect(events).toContain("UserPromptSubmit");
expect(events).not.toContain("Stop"); // hooks ceased
// The process was told to die (real runner exits here); later steps never ran.
expect(io.died).toBe(true);
expect(io.diedCode).toBe(STUB_MID_TURN_DEATH_EXIT_CODE);
expect(io.lines).not.toContain("SHOULD NOT RUN — the seat is dead");
});
it("does NOT fabricate a not-yet-wired behavior — it mirrors an honest deferral, never a silent no-op", () => {
for (const behavior of ["mid_turn_death", "restore"] as const) {
for (const behavior of ["restore"] as const) {
const io = fakeIo();
executeStubScript({ steps: [{ kind: "emit", behavior }] }, io, IDENTITY);
// No compaction seam fired for a non-compaction behavior…
@@ -0,0 +1,71 @@
import { describe, it, expect, afterEach } from "vitest";
import { execFile, type ChildProcess } from "node:child_process";
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { createServer, type Server } from "node:http";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { STUB_MID_TURN_DEATH_EXIT_CODE } from "../src/adapters/stub-runner.js";
import type { StubScript } from "../src/adapters/stub-script.js";
// Slice 51-01 items 6-8 — mid_turn_death real-spawn observable: the WIRED runner really
// DIES mid-turn. Production-identical death signals (the reliable ones — activity POSTs
// are fire-and-forget and process.exit may truncate in-flight ones, which is FAITHFUL: a
// real death loses in-flight hooks): the process exits with the death code, the exited
// sidecar is recorded (synchronous, so the daemon never false-greens a stale ready), and
// Stop is NEVER sent (hooks ceased). The hermetic executor test proves the no-Stop /
// halt dispatch deterministically; this closes the in-memory-hides-real-spawn gap.
const HERE = dirname(fileURLToPath(import.meta.url));
const RUNNER = resolve(HERE, "../src/adapters/stub-runner.ts");
const SEAT = "dev-worker@death-e2e";
describe("stub-runner mid_turn_death (real-spawn death)", () => {
let child: ChildProcess | undefined;
let dir: string | undefined;
let server: Server | undefined;
afterEach(async () => {
if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
child = undefined;
if (server) await new Promise<void>((r) => server!.close(() => r()));
server = undefined;
if (dir) rmSync(dir, { recursive: true, force: true });
dir = undefined;
});
it("exits with the death code, records the exited sidecar, and NEVER sends Stop", async () => {
dir = mkdtempSync(join(tmpdir(), "death-e2e-"));
const home = join(dir, ".openrig");
mkdirSync(join(dir, ".openrig", "stub"), { recursive: true });
const script: StubScript = { steps: [{ kind: "emit", behavior: "mid_turn_death" }, { kind: "say", text: "unreachable" }] };
writeFileSync(join(dir, ".openrig", "stub", "script.json"), JSON.stringify(script), "utf8");
const events: string[] = [];
server = createServer((req, res) => {
let raw = ""; req.on("data", (c) => { raw += c; });
req.on("end", () => {
try { events.push(String((JSON.parse(raw) as Record<string, unknown>).hookEvent)); } catch { /* ignore */ }
res.writeHead(200); res.end("{}");
});
});
await new Promise<void>((r) => server!.listen(0, "127.0.0.1", r));
const port = (server!.address() as { port: number }).port;
const exitCode = await new Promise<number | null>((resolvePromise) => {
child = execFile("node", ["--import", "tsx", RUNNER,
"--session-name", SEAT, "--cwd", dir, "--launch-id", "death-1", "--posture", "floor"],
{ env: { ...process.env, OPENRIG_HOME: home, OPENRIG_URL: `http://127.0.0.1:${port}`, OPENRIG_ACTIVITY_HOOK_TOKEN: "t" } as NodeJS.ProcessEnv });
child.on("exit", (code) => resolvePromise(code));
});
// The process really died with the death code.
expect(exitCode).toBe(STUB_MID_TURN_DEATH_EXIT_CODE);
// The exited sidecar was recorded synchronously (daemon never false-greens a dead seat).
const sidecar = JSON.parse(readFileSync(join(dir, ".openrig", "stub", "state.json"), "utf8"));
expect(sidecar.ready).toBe(false);
expect(sidecar.exited?.code).toBe(STUB_MID_TURN_DEATH_EXIT_CODE);
// Hooks ceased: Stop was NEVER sent (grace already elapsed — the process has exited).
await new Promise((r) => setTimeout(r, 200));
expect(events).not.toContain("Stop");
}, 30_000);
});