Spike S1: resume/fork/liveness — GO; flood: pause/resume — GO
Demonstrated on CLI 2.1.173: resuming a live session interleaves both
TUIs into one transcript (no lock, no warning) — liveness detection via
registry pid+procStart is mandatory; --fork-session is safe on live
sessions; --resume must run in the session's original cwd ("No
conversation found" otherwise); registry files are cleaned on graceful
exit AND SIGTERM, may be GC'd later after SIGKILL — never reason on
file presence. ANSI-stripped TUI text loses spaces (cursor-positioned
painting) — confirms @xterm/headless for screen parsing.
Flood: 21 MB through node-pty with 10s pause => 0 bytes leaked, no
loss, 4.7 ms echo after flood.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
155
spikes/s1-resume/run.mjs
Normal file
155
spikes/s1-resume/run.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
// Spike S1 — resume/fork/vivacité. Scénarios :
|
||||
// 1. session A vivante → claude --resume <id> en B : comportement (interleave ?)
|
||||
// 2. --fork-session sur session vivante : nouveau fichier, original intact
|
||||
// 3. kill -9 : fichier registre stale détectable (pid/procStart), resume sûr ensuite
|
||||
// 4. resume depuis un autre cwd : cwd effectif
|
||||
// 5. sortie gracieuse : nettoyage du fichier registre ?
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
ClaudeSession, registrySnapshot, jsonlInfo, listJsonl, sleep, setLogFile, log, munge,
|
||||
} from './harness.mjs';
|
||||
|
||||
const CAPTURES = new URL('./captures/', import.meta.url).pathname;
|
||||
mkdirSync(CAPTURES, { recursive: true });
|
||||
setLogFile(join(CAPTURES, 'steps.jsonl'));
|
||||
|
||||
const REPO = '/tmp/spike-s1-repo';
|
||||
const OTHER = '/tmp/spike-s1-other';
|
||||
for (const d of [REPO, OTHER]) {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
mkdirSync(d, { recursive: true });
|
||||
execSync(`git init -q -b main && echo hello > witness.txt && git add -A && git -c user.email=s@s -c user.name=spike commit -qm init`, { cwd: d, shell: '/bin/bash' });
|
||||
}
|
||||
const results = {};
|
||||
const save = () => writeFileSync(join(CAPTURES, 'results.json'), JSON.stringify(results, null, 1));
|
||||
|
||||
try {
|
||||
// ---------- Scénario 1 : resume d'une session VIVANTE ----------
|
||||
log('=== S1.1 spawn session A ===');
|
||||
const A = new ClaudeSession([], REPO, 'A', CAPTURES);
|
||||
const regA = await A.waitReady();
|
||||
const sid = regA.sessionId;
|
||||
log('A: sessionId', { sid, pid: A.pid });
|
||||
|
||||
await A.type('Reply with exactly: PONG-ALPHA');
|
||||
await A.waitTurnDone();
|
||||
results.s1_initial = { sid, jsonl: jsonlInfo(REPO, sid) };
|
||||
save();
|
||||
|
||||
log('=== S1.1 spawn B = claude --resume <sid> pendant que A est vivante ===');
|
||||
const B = new ClaudeSession(['--resume', sid], REPO, 'B', CAPTURES);
|
||||
let regB = null;
|
||||
try { regB = await B.waitReady(30000); } catch { /* peut refuser/ne pas s'inscrire */ }
|
||||
await sleep(3000);
|
||||
results.s1_resumeLive = {
|
||||
bStarted: !!regB,
|
||||
bSessionId: regB?.sessionId ?? null,
|
||||
bSameId: regB?.sessionId === sid,
|
||||
bExit: B.exited,
|
||||
bScreenTail: B.tail(900),
|
||||
registryAfterB: registrySnapshot().filter((r) => [A.pid, B.pid].includes(r.pid)),
|
||||
};
|
||||
save();
|
||||
|
||||
if (regB && !B.exited) {
|
||||
await B.type('Reply with exactly: PONG-BRAVO');
|
||||
await B.waitTurnDone();
|
||||
await A.type('Reply with exactly: PONG-CHARLIE');
|
||||
await A.waitTurnDone();
|
||||
await sleep(2000);
|
||||
const fileSid = jsonlInfo(REPO, sid);
|
||||
const fileB = regB.sessionId !== sid ? jsonlInfo(REPO, regB.sessionId) : null;
|
||||
results.s1_interleave = {
|
||||
originalFile: fileSid,
|
||||
bFile: fileB,
|
||||
interleaved: fileSid.userTexts.some((t) => t.includes('PONG-BRAVO')) && fileSid.userTexts.some((t) => t.includes('PONG-CHARLIE')),
|
||||
aScreenTail: A.tail(600),
|
||||
};
|
||||
save();
|
||||
}
|
||||
|
||||
// ---------- Scénario 2 : fork d'une session vivante ----------
|
||||
log('=== S1.2 fork-session pendant que A est vivante ===');
|
||||
if (regB && !B.exited) { B.kill('SIGTERM'); await sleep(1500); }
|
||||
const beforeFork = jsonlInfo(REPO, sid);
|
||||
const C = new ClaudeSession(['--resume', sid, '--fork-session'], REPO, 'C', CAPTURES);
|
||||
const regC = await C.waitReady();
|
||||
await C.type('Reply with exactly: PONG-DELTA');
|
||||
await C.waitTurnDone();
|
||||
await sleep(2000);
|
||||
const afterFork = jsonlInfo(REPO, sid);
|
||||
results.s2_fork = {
|
||||
cSessionId: regC.sessionId,
|
||||
forked: regC.sessionId !== sid,
|
||||
cFile: jsonlInfo(REPO, regC.sessionId),
|
||||
originalUserTexts: afterFork.userTexts,
|
||||
originalGotDelta: afterFork.userTexts.some((t) => t.includes('PONG-DELTA')),
|
||||
allJsonl: listJsonl(REPO),
|
||||
};
|
||||
save();
|
||||
|
||||
// ---------- Scénario 3 : kill -9 → registre stale → resume sûr ----------
|
||||
log('=== S1.3 kill -9 de C, détection stale, resume après mort ===');
|
||||
const cPid = C.pid, cSid = regC.sessionId;
|
||||
C.kill('SIGKILL');
|
||||
await sleep(2500);
|
||||
const staleSnap = registrySnapshot().filter((r) => r.pid === cPid);
|
||||
results.s3_kill9 = { registryFileRemains: staleSnap.length > 0, entry: staleSnap[0] ?? null };
|
||||
const D = new ClaudeSession(['--resume', cSid], REPO, 'D', CAPTURES);
|
||||
const regD = await D.waitReady();
|
||||
await D.type('Reply with exactly: PONG-ECHO');
|
||||
await D.waitTurnDone();
|
||||
results.s3_resumeAfterDeath = {
|
||||
dSessionId: regD.sessionId, sameAsC: regD.sessionId === cSid,
|
||||
dFile: jsonlInfo(REPO, regD.sessionId),
|
||||
};
|
||||
save();
|
||||
|
||||
// ---------- Scénario 5 : sortie gracieuse — nettoyage du registre ? ----------
|
||||
log('=== S1.5 sortie gracieuse de D (/exit) ===');
|
||||
await D.type('/exit');
|
||||
await sleep(4000);
|
||||
if (!D.exited) { D.write('\r'); await sleep(4000); }
|
||||
results.s5_gracefulExit = {
|
||||
exited: D.exited,
|
||||
registryCleaned: registrySnapshot().filter((r) => r.pid === D.pid).length === 0,
|
||||
};
|
||||
save();
|
||||
|
||||
// ---------- Scénario 4 : resume depuis un AUTRE cwd ----------
|
||||
log('=== S1.4 resume de la session de A depuis un autre cwd ===');
|
||||
A.kill('SIGTERM');
|
||||
await sleep(2000);
|
||||
const E = new ClaudeSession(['--resume', sid], OTHER, 'E', CAPTURES);
|
||||
let regE = null;
|
||||
try { regE = await E.waitReady(30000); } catch {}
|
||||
if (regE) {
|
||||
await E.type('Without using any tool, what is the current working directory given in your environment context? Reply with the path only.');
|
||||
await E.waitTurnDone();
|
||||
await sleep(1500);
|
||||
}
|
||||
results.s4_otherCwd = {
|
||||
started: !!regE,
|
||||
eSessionId: regE?.sessionId ?? null,
|
||||
registryCwd: regE?.cwd ?? null,
|
||||
eScreenTail: E.tail(900),
|
||||
jsonlInRepoDir: listJsonl(REPO),
|
||||
jsonlInOtherDir: listJsonl(OTHER),
|
||||
};
|
||||
E.kill('SIGTERM');
|
||||
save();
|
||||
|
||||
log('=== S1 terminé ===');
|
||||
} catch (err) {
|
||||
results.fatalError = String(err);
|
||||
save();
|
||||
log('ERREUR FATALE', { err: String(err) });
|
||||
} finally {
|
||||
// Nettoyage : tuer tout claude restant lancé par ce script (pas ceux de l'utilisateur !)
|
||||
await sleep(1000);
|
||||
save();
|
||||
process.exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user