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:
2026-06-11 17:47:05 +02:00
parent 3f7cdab386
commit 66dc21bf91
7 changed files with 1050 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
# Spike S1 — resume / fork / vivacité — VERDICT : ✅ GO
Exécuté le 2026-06-11 (CLI claude 2.1.173, repos jetables /tmp/spike-s1-*). Scripts : `harness.mjs` + `run.mjs`, captures brutes et `results.json` dans `captures/` (non versionnés).
## Faits démontrés
| # | Scénario | Résultat |
|---|---|---|
| 1 | `claude --resume <id>` pendant que la session est **vivante** | **Aucun verrou, aucun avertissement.** B s'ouvre sur le même sessionId, les deux process s'inscrivent au registre avec le même sessionId, et les messages des deux TUI **s'entrelacent dans le même transcript** (ALPHA/BRAVO/CHARLIE dans un seul fichier). Corruption logique confirmée → le daemon DOIT détecter la vivacité avant tout resume. |
| 2 | `--resume <id> --fork-session` sur session vivante | **Sûr.** Nouveau sessionId, nouveau fichier contenant la copie de l'historique + ses propres messages ; l'original n'est **pas** pollué. C'est l'opération à proposer pour une session vivante (« Dupliquer »). |
| 3 | kill -9 puis détection | Le fichier registre **reste** (stale) immédiatement après ; `pid` mort + `procStart` discordant le détectent à coup sûr. `--resume` après mort : même sessionId, continuation propre dans le même fichier, 0 parentUuid cassé. |
| 4 | resume depuis un **autre cwd** | **Échec explicite** : « No conversation found with session ID » — le CLI cherche la session dans le dossier projet dérivé du cwd **courant**. → Le daemon doit toujours lancer `--resume` dans le cwd d'origine de la session (champ `cwd` du JSONL), comme prévu au design. |
| 5 | sortie gracieuse (`/exit`) | Exit code 0, fichier registre **nettoyé**. |
| 6 | SIGTERM | Fichier registre **nettoyé** aussi (handler du CLI). |
| 7 | GC des stale | Le fichier stale du kill -9 a été nettoyé plus tard par une autre instance du CLI → **ne jamais raisonner sur la présence/absence du fichier**, toujours valider pid+procStart à la lecture. |
## Enseignements supplémentaires pour le claude-adapter
- **Le strip ANSI naïf mange les espaces** : le TUI peint le texte par déplacements de curseur (« Isthisaprojectyoucreated… »). Toute détection sur texte brut doit matcher sur une version sans espaces ; la reconstruction d'écran fiable passe par `@xterm/headless` (confirme la reco du design moteur).
- **Trust dialog** (nouveau dossier) : précède l'inscription au registre, texte « Quick safety check: Is this a project you created or one you trust? », options ` 1. Yes, I trust this folder / 2. No, exit`, « Enter to confirm · Esc to cancel ». **Entrée** valide l'option par défaut → géré.
- Le registre s'écrit ~immédiatement après le trust ; `waitReady` = poll du registre par pid est une primitive fiable de readiness.
- Les réponses des sessions `-n`/spawnées apparaissent bien dans `~/.claude/projects/<munge(cwd)>/<sid>.jsonl` en continu (flush par tour).
## Décisions actées pour P1/P3
1. Politique de reprise (inchangée vs design) : vivante → Observer/Dupliquer uniquement ; morte → resume direct ; incertain → traiter comme vivante.
2. `--resume` toujours exécuté dans le cwd d'origine lu dans le JSONL.
3. `isLive()` = pid+procStart à chaque lecture du registre, jamais de cache de présence de fichier.
4. La machine à états peut compter sur le nettoyage du registre aux sorties propres (exit//exit/SIGTERM) et sur la détection procStart pour les morts brutales.

View File

@@ -0,0 +1,135 @@
// Spike S1 — harnais commun : spawn de claude dans un PTY + observation registre/JSONL
import ptyMod from '@homebridge/node-pty-prebuilt-multiarch';
import { readdirSync, readFileSync, existsSync, mkdirSync, appendFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
export const SESSIONS_DIR = join(homedir(), '.claude', 'sessions');
export const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
export const munge = (p) => p.replace(/[^A-Za-z0-9]/g, '-');
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][AB0-2]|\x1b[=>]|[\x00-\x08\x0b-\x1f]/g;
export const stripAnsi = (s) => s.replace(ANSI, '');
export function log(step, data = {}) {
const line = JSON.stringify({ t: new Date().toISOString(), step, ...data });
console.log(line);
appendFileSync(LOG_FILE, line + '\n');
}
let LOG_FILE = '/tmp/s1.log';
export function setLogFile(p) { LOG_FILE = p; }
export class ClaudeSession {
constructor(args, cwd, label, capturesDir) {
mkdirSync(capturesDir, { recursive: true });
this.label = label;
this.rawPath = join(capturesDir, `${label}.raw.log`);
this.buf = '';
this.pty = ptyMod.spawn('claude', args, {
name: 'xterm-256color', cols: 120, rows: 32, cwd,
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
});
this.pid = this.pty.pid;
this.exited = null;
this.pty.onData((d) => { this.buf += d; appendFileSync(this.rawPath, d); });
this.pty.onExit((e) => { this.exited = e; });
}
write(s) { this.pty.write(s); }
// Tape un prompt puis Entrée (séparés : le CLI traite les écritures multi-caractères comme un collage)
async type(text) { this.write(text); await sleep(400); this.write('\r'); }
plain() { return stripAnsi(this.buf); }
tail(n = 1200) { return this.plain().slice(-n); }
registry() {
if (!existsSync(SESSIONS_DIR)) return null;
for (const f of readdirSync(SESSIONS_DIR)) {
if (!f.endsWith('.json')) continue;
try {
const r = JSON.parse(readFileSync(join(SESSIONS_DIR, f), 'utf8'));
if (r.pid === this.pid) return { file: f, ...r };
} catch { /* fichier en cours d'écriture */ }
}
return null;
}
async until(desc, fn, timeoutMs = 60000, everyMs = 400) {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
const v = await fn();
if (v) { log(`${this.label}: ${desc}`, { afterMs: Date.now() - t0 }); return v; }
await sleep(everyMs);
}
log(`${this.label}: TIMEOUT — ${desc}`, { timeoutMs, screenTail: this.tail(600) });
throw new Error(`timeout: ${this.label} ${desc}`);
}
// Démarrage : gère le trust dialog (précède l'inscription au registre) puis attend le registre.
// NB : le TUI peint avec des déplacements de curseur, pas des espaces → matcher sur texte SANS espaces.
async waitReady(timeoutMs = 90000) {
const t0 = Date.now();
let trustHandled = false;
while (Date.now() - t0 < timeoutMs) {
const reg = this.registry();
if (reg) { log(`${this.label}: prêt (registre)`, { sessionId: reg.sessionId, status: reg.status, trustHandled }); return reg; }
const flat = this.tail(2500).replace(/\s+/g, '');
if (!trustHandled && /trust/i.test(flat) && /(|Entertoconfirm)/i.test(flat)) {
log(`${this.label}: trust dialog détecté → Entrée`, { screenTail: this.tail(500) });
this.write('\r');
trustHandled = true;
}
await sleep(400);
}
log(`${this.label}: TIMEOUT waitReady`, { screenTail: this.tail(1500) });
throw new Error(`timeout: ${this.label} waitReady`);
}
// Attend la fin d'un tour : busy observé (ou déjà passé) puis retour à idle/waiting
async waitTurnDone(timeoutMs = 150000) {
let sawBusy = false;
await this.until('tour terminé', () => {
const r = this.registry();
if (!r) return false;
if (r.status === 'busy') { sawBusy = true; return false; }
return sawBusy && r.status !== 'busy' ? r : false;
}, timeoutMs, 500);
return this.registry();
}
kill(signal = 'SIGTERM') { try { process.kill(this.pid, signal); } catch {} }
}
export function registrySnapshot() {
if (!existsSync(SESSIONS_DIR)) return [];
return readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json')).map((f) => {
try {
const r = JSON.parse(readFileSync(join(SESSIONS_DIR, f), 'utf8'));
let alive = false, procStartOk = null;
try {
const stat = readFileSync(`/proc/${r.pid}/stat`, 'utf8');
const starttime = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19];
alive = true; procStartOk = String(r.procStart) === String(starttime);
} catch {}
return { file: f, pid: r.pid, sessionId: r.sessionId, status: r.status, waitingFor: r.waitingFor ?? null, alive, procStartOk };
} catch (e) { return { file: f, error: e.message }; }
});
}
export function jsonlInfo(cwd, sessionId) {
const p = join(PROJECTS_DIR, munge(cwd), `${sessionId}.jsonl`);
if (!existsSync(p)) return { path: p, exists: false };
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
const objs = [];
for (const l of lines) { try { objs.push(JSON.parse(l)); } catch {} }
const users = objs.filter((o) => o.type === 'user' && typeof o.message?.content === 'string');
const userTexts = users.map((o) => o.message.content.slice(0, 80));
const uuids = new Map(objs.filter((o) => o.uuid).map((o) => [o.uuid, o]));
let brokenParents = 0;
for (const o of objs) if (o.parentUuid && !uuids.has(o.parentUuid)) brokenParents++;
return {
path: p, exists: true, mtimeMs: statSync(p).mtimeMs, lines: lines.length,
userTexts, brokenParents,
sessionIds: [...new Set(objs.map((o) => o.sessionId).filter(Boolean))],
};
}
export function listJsonl(cwd) {
const dir = join(PROJECTS_DIR, munge(cwd));
if (!existsSync(dir)) return [];
return readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
}

155
spikes/s1-resume/run.mjs Normal file
View 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);
}