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
37 changes: 34 additions & 3 deletions servers/gateway/perch-interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -1059,6 +1059,23 @@ export function createInteractiveEngine({
}
}

/** True when pi's session store holds a transcript for `id` — the file pi
* writes is `<timestamp>_<id>.jsonl` under `<sessionDir>/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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions tests/perch-interactive.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<timestamp>_<id>.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();
Expand All @@ -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");
Expand Down Expand Up @@ -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();
Expand Down
Loading