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:
Johan LEROY
2026-06-11 17:47:05 +02:00
parent 45dba47b8b
commit c98f619b25
7 changed files with 1050 additions and 0 deletions

67
spikes/s3-tui/flood.mjs Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
// Spike S3 (volet flood) — pause()/resume() node-pty sous 20 Mo de débit, sans claude (zéro quota).
// Critères : aucune perte (marqueur final reçu, volume >= attendu), 0 octet pendant pause,
// process bash survit à 10 s de pause, écho < 100 ms après le flood.
import ptyMod from '@homebridge/node-pty-prebuilt-multiarch';
import { execSync } from 'node:child_process';
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
const CAPTURES = new URL('./captures/', import.meta.url).pathname;
mkdirSync(CAPTURES, { recursive: true });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 20 Mo de base64 (lignes de 76 chars) — la conversion LF→CRLF du TTY est prise en compte
execSync('head -c 15728640 /dev/urandom | base64 > /tmp/spike-flood.txt && wc -c < /tmp/spike-flood.txt');
const fileBytes = parseInt(execSync('wc -c < /tmp/spike-flood.txt').toString().trim(), 10);
const fileLines = parseInt(execSync('wc -l < /tmp/spike-flood.txt').toString().trim(), 10);
const expectedMin = fileBytes; // reçu = fileBytes + fileLines (CR ajoutés) ; on vérifie >= fileBytes
const p = ptyMod.spawn('bash', ['--norc'], { name: 'xterm-256color', cols: 120, rows: 32, cwd: '/tmp', env: process.env });
let received = 0;
let buf = '';
let pausedBytes = 0;
let paused = false;
p.onData((d) => {
received += Buffer.byteLength(d);
if (paused) pausedBytes += Buffer.byteLength(d);
buf = (buf + d).slice(-4000);
});
const results = {};
const t0 = Date.now();
await sleep(500);
p.write('cat /tmp/spike-flood.txt; echo FLOOD-DONE-MARKER\r');
// Après ~2 Mo reçus : pause 10 s
while (received < 2 * 1024 * 1024 && Date.now() - t0 < 60000) await sleep(50);
const beforePause = received;
p.pause(); paused = true;
await sleep(10000);
p.resume(); paused = false;
results.pause = { beforePause, bytesDuringPause: pausedBytes, pauseHeld: pausedBytes < 65536 };
// Attendre la fin du flood
const tFlood = Date.now();
while (!buf.includes('FLOOD-DONE-MARKER') && Date.now() - tFlood < 120000) await sleep(100);
results.flood = {
fileBytes, fileLines, received, receivedAtLeastFile: received >= expectedMin,
markerReceived: buf.includes('FLOOD-DONE-MARKER'),
totalMs: Date.now() - t0,
};
// Écho après flood : write d'un marqueur, mesure du round-trip
await sleep(500);
const echoT0 = process.hrtime.bigint();
p.write('echo ECHO-RT-TEST');
let echoMs = null;
const tEcho = Date.now();
while (Date.now() - tEcho < 5000) {
if (buf.includes('ECHO-RT-TEST')) { echoMs = Number(process.hrtime.bigint() - echoT0) / 1e6; break; }
await sleep(5);
}
results.echo = { echoMs, under100ms: echoMs !== null && echoMs < 100 };
p.kill();
writeFileSync(join(CAPTURES, 'flood-results.json'), JSON.stringify(results, null, 1));
console.log(JSON.stringify(results, null, 1));

190
spikes/s3-tui/run.mjs Normal file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env node
// Spike S3 — dialogues TUI : détection (registre + écran), extraction des options, frappes de réponse.
// Types couverts : trust dialog (nouveau dossier), permission Bash, permission Write (édition),
// AskUserQuestion, refus par Esc. (Approbation de plan : suivi manuel, voir VERDICT.)
import { execSync } from 'node:child_process';
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { ClaudeSession, sleep, setLogFile, log } from '../s1-resume/harness.mjs';
const CAPTURES = new URL('./captures/', import.meta.url).pathname;
mkdirSync(CAPTURES, { recursive: true });
setLogFile(join(CAPTURES, 'steps.jsonl'));
const results = {};
const save = () => writeFileSync(join(CAPTURES, 'results.json'), JSON.stringify(results, null, 1));
function freshRepo(name) {
const d = `/tmp/spike-s3-${name}`;
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' });
return d;
}
// Extraction des options d'un dialogue depuis l'écran (regex du design moteur).
// NB : le strip ANSI mange les espaces peints par déplacements de curseur → \s* après le point,
// et les labels peuvent être concaténés (« Yes,Itrustthisfolder »).
function extractOptions(screenTail) {
const opts = [];
for (const m of screenTail.matchAll(/^\s*(\s*)?(\d+)\.\s*(.+)$/gm)) {
opts.push({ selected: !!m[1], n: m[2], label: m[3].trim().slice(0, 90) });
}
return opts;
}
async function waitWaiting(S, timeoutMs = 120000) {
return S.until('registre → waiting', () => {
const r = S.registry();
return r && r.status === 'waiting' ? r : false;
}, timeoutMs, 400);
}
// Répond au dialogue : essaie la touche chiffre seule, puis Entrée, puis flèches+Entrée.
async function respond(S, digit) {
const tries = [];
S.write(digit);
await sleep(2500);
let r = S.registry();
tries.push({ method: `digit ${digit}`, statusAfter: r?.status });
if (r?.status !== 'waiting') return { ok: true, tries };
S.write('\r');
await sleep(2500);
r = S.registry();
tries.push({ method: 'enter', statusAfter: r?.status });
if (r?.status !== 'waiting') return { ok: true, tries };
const downs = Math.max(0, parseInt(digit, 10) - 1);
for (let i = 0; i < downs; i++) { S.write('\x1b[B'); await sleep(250); }
S.write('\r');
await sleep(2500);
r = S.registry();
tries.push({ method: `arrows x${downs} + enter`, statusAfter: r?.status });
return { ok: r?.status !== 'waiting', tries };
}
try {
// ---------- D1 : trust dialog sur dossier neuf + état ~/.claude.json ----------
log('=== D1 trust dialog ===');
const d1 = freshRepo('trust');
const claudeJsonBefore = existsSync(join(homedir(), '.claude.json'))
? JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')) : null;
const trustBefore = claudeJsonBefore?.projects?.[d1] ?? null;
const T = new ClaudeSession([], d1, 'trust', CAPTURES);
// waitReady gère le trust par Entrée et logge l'écran du dialogue
const regT = await T.waitReady(60000);
await sleep(1500);
const claudeJsonAfter = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8'));
results.d1_trust = {
sessionStarted: !!regT,
projectEntryBefore: trustBefore,
projectEntryAfter: claudeJsonAfter?.projects?.[d1] ?? null,
note: 'écran du dialogue dans trust.raw.log + steps.jsonl',
};
T.kill('SIGTERM');
save();
// ---------- D2 : permission Bash → réponse "1" (Yes) ----------
log('=== D2 permission Bash, approbation ===');
const d2 = freshRepo('bash');
const P = new ClaudeSession([], d2, 'perm-bash', CAPTURES);
await P.waitReady(60000);
await P.type('Run the bash command `echo spike-s3-marker-xyz` and show me its output. Do not do anything else.');
const regP = await waitWaiting(P);
await sleep(1200); // laisse Ink finir de peindre
const screenP = P.tail(2500);
const optsP = extractOptions(screenP);
const respP = await respond(P, '1');
let completed = false;
try { await P.waitTurnDone(120000); completed = true; } catch {}
results.d2_permBash = {
waitingFor: regP.waitingFor ?? null,
options: optsP,
screenExcerpt: screenP.slice(-1100),
respond: respP,
turnCompleted: completed,
markerInOutput: P.plain().includes('spike-s3-marker-xyz'),
};
P.kill('SIGTERM');
save();
// ---------- D3 : permission Write (édition) → réponse "1" ----------
log('=== D3 permission Write ===');
const d3 = freshRepo('write');
const W = new ClaudeSession([], d3, 'perm-write', CAPTURES);
await W.waitReady(60000);
await W.type('Create a file named s3-edit.txt containing exactly the word hello. Do not do anything else.');
const regW = await waitWaiting(W);
await sleep(1200);
const screenW = W.tail(2500);
const respW = await respond(W, '1');
let completedW = false;
try { await W.waitTurnDone(120000); completedW = true; } catch {}
results.d3_permWrite = {
waitingFor: regW.waitingFor ?? null,
options: extractOptions(screenW),
screenExcerpt: screenW.slice(-1100),
respond: respW,
turnCompleted: completedW,
fileCreated: existsSync(join(d3, 's3-edit.txt')),
};
W.kill('SIGTERM');
save();
// ---------- D4 : refus par Esc ----------
log('=== D4 refus par Esc ===');
const d4 = freshRepo('deny');
const X = new ClaudeSession([], d4, 'deny-esc', CAPTURES);
await X.waitReady(60000);
await X.type('Run the bash command `echo should-be-denied` and show me its output. Do not do anything else.');
await waitWaiting(X);
await sleep(1200);
X.write('\x1b'); // Esc
await sleep(3500);
const regX = X.registry();
results.d4_denyEsc = {
statusAfterEsc: regX?.status,
denied: !X.plain().includes('should-be-denied\r\nshould-be-denied') /* marqueur non exécuté */,
screenTail: X.tail(900),
};
X.kill('SIGTERM');
save();
// ---------- D5 : AskUserQuestion ----------
log('=== D5 AskUserQuestion ===');
const d5 = freshRepo('ask');
const Q = new ClaudeSession([], d5, 'ask-question', CAPTURES);
await Q.waitReady(60000);
await Q.type('Use the AskUserQuestion tool to ask me whether I prefer red or blue. Offer exactly those two options.');
const regQ = await waitWaiting(Q);
await sleep(1200);
const screenQ = Q.tail(2500);
// Sur AskUserQuestion : essaie flèche bas + Entrée (sélection de la 2e option)
Q.write('\x1b[B'); await sleep(400); Q.write('\r');
await sleep(3000);
let regQ2 = Q.registry();
const arrowsWorked = regQ2?.status !== 'waiting';
let digitTried = null;
if (!arrowsWorked) { Q.write('2'); await sleep(2500); regQ2 = Q.registry(); digitTried = regQ2?.status; }
let completedQ = false;
try { await Q.waitTurnDone(90000); completedQ = true; } catch {}
results.d5_askUserQuestion = {
waitingFor: regQ.waitingFor ?? null,
options: extractOptions(screenQ),
screenExcerpt: screenQ.slice(-1100),
arrowsEnterWorked: arrowsWorked,
digitFallbackStatus: digitTried,
turnCompleted: completedQ,
};
Q.kill('SIGTERM');
save();
log('=== S3 dialogues terminé ===');
} catch (err) {
results.fatalError = String(err);
save();
log('ERREUR FATALE', { err: String(err) });
} finally {
save();
process.exit(0);
}