mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
1 parent
882675b8ad
commit
ac335ff2e0
3 files changed
+124
-4
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user