From 8c5cbe97391eaaea2f378db02b2002186a1a1f43 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Sun, 13 Sep 2026 19:29:52 -0500 Subject: [PATCH] =?UTF-8?q?fix(perch):=20dead-transcript=20preflight=20?= =?UTF-8?q?=E2=80=94=20a=20wake=20no=20longer=20crash-loops=20on=20a=20--s?= =?UTF-8?q?ession=20id=20pi=20never=20persisted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi reports its session id at ready but writes the transcript only after the first COMPLETED turn. cycle()/control() (or a restart adopt) of a never-conversed session handed pi a --session id it could not find: pi exited code 1 ("No session found"), attachExit parked, and every later wake re-read the same dead row id — a permanent pi_gone loop (measured live on R4 closing PR-E's joint smoke, 2026-09-13). startChild now globs /sessions/*_.jsonl before resuming: absent means wake FRESH with an honest log line, and the reported-id stamp repairs the row. The two tests that assert resume keep passing by planting the transcript a conversed session really would have left. Tests: tests/perch-interactive.test.js 71/71 (+1 new preflight test); neighboring wake-path suites 239/239. --- servers/gateway/perch-interactive.js | 37 +++++++++++++++++++++++++--- tests/perch-interactive.test.js | 31 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/servers/gateway/perch-interactive.js b/servers/gateway/perch-interactive.js index 898b89dc..c4e09744 100644 --- a/servers/gateway/perch-interactive.js +++ b/servers/gateway/perch-interactive.js @@ -139,7 +139,7 @@ * (spec §9). */ import { randomUUID } from "node:crypto"; -import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join, sep } from "node:path"; @@ -1059,6 +1059,23 @@ export function createInteractiveEngine({ } } + /** True when pi's session store holds a transcript for `id` — the file pi + * writes is `_.jsonl` under `/sessions` (the dir + * PiRpc passes as `--session-dir`), and it appears only AFTER the first + * completed turn. An unreadable dir means no transcript. The dead-resume + * preflight in startChild globs here rather than matching pi's exit + * "No session found" stderr because the file is the fact; the stderr is + * pi's phrasing. */ + function resumeTranscriptExists(sessionDir, id) { + try { + return readdirSync(join(sessionDir, "sessions")).some( + (f) => f.endsWith(".jsonl") && f.includes(id) + ); + } catch { + return false; + } + } + /** Persist the pi session id the child reported (the resume handle, and what * the P1 transcript endpoint globs the session file by). */ async function writePiSessionId(s) { @@ -1438,7 +1455,8 @@ export function createInteractiveEngine({ /** * Build the world FRESH, warm the model, construct the child, attach the exit * handler, and stamp the row. Shared by spawn (piSessionId null) and wake - * (piSessionId = the stored pi session, so pi resumes the same transcript). + * (piSessionId = the stored pi session, so pi resumes the same transcript — + * when that transcript actually exists on disk, see the preflight below). */ async function startChild(S, s) { const slog = sessionLog(s); @@ -1533,7 +1551,20 @@ export function createInteractiveEngine({ s.outputsDir = outputsDir; s.uploadsDir = uploadsDir; - const resume = (world.session && world.session.pi_session_id) || s.piSessionId || null; + let resume = (world.session && world.session.pi_session_id) || s.piSessionId || null; + // Dead-transcript preflight (measured on R4 2026-09-13): pi reports its + // session id at ready but only PERSISTS the transcript after the first + // COMPLETED turn — so a cycle()/control() switch (or a restart adopt) of a + // never-conversed session handed pi a `--session` id it cannot find. pi + // exited code 1 ("No session found"), attachExit parked, and every later + // wake re-read the same dead row id: a permanent pi_gone crash-loop. An + // absent transcript now means wake FRESH; the reported-id stamp at this + // function's tail repairs the row. The transcript is lost either way — the + // child never completed a turn — so fresh costs nothing real. + if (resume && !resumeTranscriptExists(world.sessionDir, resume)) { + slog("resume transcript missing — waking fresh (" + resume + ")"); + resume = null; + } const pi = new S.PiRpc(Object.assign({}, prep.piRpcOpts, { piSessionId: resume, // Open-anywhere B3/B4: pi's process cwd is the operator's chosen dir. diff --git a/tests/perch-interactive.test.js b/tests/perch-interactive.test.js index 2efa724e..afa8a8b3 100644 --- a/tests/perch-interactive.test.js +++ b/tests/perch-interactive.test.js @@ -978,6 +978,11 @@ test("wake: a message to a hibernating session rebuilds the world FRESH and resu // The operator narrowed the session (or edited the envelope) while it slept. state.narrowedTools = '["bash","write"]'; state.piSessionIdInRow = firstPi.piSessionId; + // The dead-resume preflight drops a resume whose transcript file is absent + // (pi writes `_.jsonl` only after the first completed turn), + // so — mirroring a session that HAS conversed — plant the file pi left. + mkdirSync(join(dir, "bots", "botty", "sessions"), { recursive: true }); + writeFileSync(join(dir, "bots", "botty", "sessions", `2026-01-01T00-00-00-000Z_${firstPi.piSessionId}.jsonl`), ""); await engine.message(s.sessionId, "back again"); await tick(); @@ -992,6 +997,28 @@ test("wake: a message to a hibernating session rebuilds the world FRESH and resu assert.equal(state.instances[1].turns[0].message, "back again"); }); +test("wake preflight: a dead pi session id (transcript never written — the cycle-before-first-turn brick, R4 2026-09-13) wakes FRESH instead of crash-looping", async () => { + const { engine, clock, state } = makeEngine(); + const s = await spawned(engine); + const firstPi = state.instances[0]; + clock.advance(600_001); + await tick(); // hibernate + // The row claims a resume handle pi never persisted — exactly the state the + // live smoke hit: id recorded at ready, no completed turn, no .jsonl. + state.piSessionIdInRow = "pisess-dead-" + firstPi.proc.pid; + const sub = await collect(engine, s.sessionId); + await engine.message(s.sessionId, "wake me"); + await tick(); + assert.equal(state.instances.length, 2, "the wake spawned its single child"); + assert.equal(state.instances[1].opts.piSessionId, null, + "the dead id is NOT handed to pi — a --session resume of it exits code 1"); + assert.ok(sub.ofType("log").some((e) => /resume transcript missing/.test(e.text)), + "the drop is logged honestly on the session stream"); + assert.equal((await engine.get(s.sessionId)).state, "awake", "the wake lands awake"); + assert.equal(rowFor(s.threadId).pi_session_id, state.instances[1].piSessionId, + "the reported fresh id repairs the row"); +}); + test("C8: a wake past the interactive cap with no eligible victim is refused with the exact code and spawns no child", async () => { const { engine, clock, state } = makeEngine({ env: { PERCH_INTERACTIVE_MAX_AWAKE: "1" } }); const a = await spawned(engine, "a"); @@ -1346,6 +1373,10 @@ test("I-1 restart: stopAll parks every row so a NEW process on the same DB can w assert.equal(r.stopped, 1); assert.equal(rowFor(s.threadId).status, "waiting-user", "stopAll PARKS the row — a fresh 'active' would read as a live claim and 409 the next boot for a full turn budget"); + // The adopted resume handle must survive the dead-transcript preflight — + // plant the transcript a real pi writes after a completed turn. + mkdirSync(join(dir, "bots", "restarty", "sessions"), { recursive: true }); + writeFileSync(join(dir, "bots", "restarty", "sessions", `2026-01-01T00-00-00-000Z_${A.state.instances[0].piSessionId}.jsonl`), ""); // The restart: a fresh engine (fresh bridge seam, empty sessions map), same DB. const B = makeEngine();