Spaces:
Running
Running
File size: 18,248 Bytes
9fb59f5 dce99f0 9fb59f5 dce99f0 9fb59f5 dce99f0 9fb59f5 dce99f0 9fb59f5 9b614df 9fb59f5 9b614df 9fb59f5 9b614df 9fb59f5 9b614df 9fb59f5 9b614df 9fb59f5 1dec339 7112a46 dce99f0 7112a46 dce99f0 7112a46 dce99f0 7112a46 dce99f0 7112a46 1dec339 7112a46 dce99f0 7112a46 dce99f0 f1575b9 dce99f0 9b8a289 cb0645f 9b8a289 9b614df 9b8a289 9b614df 9b8a289 9b614df 9b8a289 7112a46 df112f9 7112a46 df112f9 7112a46 9fb59f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 | // Conversation re-pinning: which transcript a session should follow.
//
// Regression cover for the bug where a mid-session `/clear` left the pin on the
// abandoned conversation, so `--resume` restored the pre-/clear thread after a
// restart. Run with: node test/repin.test.mjs
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'repin-'));
const CFG = path.join(TMP, 'cfg');
const DATA = path.join(TMP, 'data');
const REPIN = path.join(TMP, 'repin');
const CODEX = path.join(TMP, 'codex');
fs.mkdirSync(path.join(CFG, 'projects'), { recursive: true });
fs.mkdirSync(DATA, { recursive: true });
fs.mkdirSync(path.join(CODEX, 'sessions', '2026', '08', '06'), { recursive: true });
process.env.CLAUDE_CONFIG_DIR = CFG;
process.env.DATA_DIR = DATA;
process.env.AM_REPIN_DIR = REPIN;
process.env.CODEX_HOME = CODEX;
const sessions = await import('../src/sessions.js');
const runner = await import('../src/runner.js');
const cfg = await import('../src/config.js');
sessions.init();
const WORKDIR = path.join(cfg.WORKSPACES_DIR, 'proj-a');
fs.mkdirSync(WORKDIR, { recursive: true });
// A transcript opens with metadata lines that carry no cwd (mode,
// file-history-snapshot, …); only the conversation lines have one. Mirror that,
// or the head-reading logic isn't really being exercised.
function transcript(uuid, { cwd = WORKDIR, startMs, mtimeMs, projDir = '-proj-a' }) {
const dir = path.join(CFG, 'projects', projDir);
fs.mkdirSync(dir, { recursive: true });
const p = path.join(dir, `${uuid}.jsonl`);
fs.writeFileSync(p, [
JSON.stringify({ type: 'mode', mode: 'default' }),
JSON.stringify({ type: 'file-history-snapshot' }),
JSON.stringify({ type: 'user', sessionId: uuid, cwd, timestamp: new Date(startMs).toISOString() }),
].join('\n') + '\n');
const t = (mtimeMs ?? startMs) / 1000;
fs.utimesSync(p, t, t);
return p;
}
let pass = 0, fail = 0;
const check = (name, got, want) => {
const ok = got === want;
ok ? pass++ : fail++;
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : `\n got ${got} want ${want}`}`);
};
const NOW = 1770000000000; // fixed clock: these are all relative comparisons
const SINCE = NOW - 2000; // the launch window scheduleClaudeCapture opens with
const A = 'aaaaaaaa-0000-0000-0000-000000000001';
const B = 'bbbbbbbb-0000-0000-0000-000000000002';
console.log('\n/clear: the later-born conversation in this folder wins');
transcript(A, { startMs: NOW });
transcript(B, { startMs: NOW + 60_000 });
check('follows the successor', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, B);
console.log('\na conversation in a different folder is never claimed');
transcript('cccccccc-0000-0000-0000-000000000003',
{ cwd: path.join(cfg.WORKSPACES_DIR, 'proj-b'), startMs: NOW + 120_000, projDir: '-proj-b' });
check('other folder ignored', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, B);
console.log('\nan older thread that merely received writes is not a successor');
transcript('dddddddd-0000-0000-0000-000000000004', { startMs: NOW - 3600_000, mtimeMs: NOW + 180_000 });
check('pre-window thread rejected despite a fresh mtime',
(await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, B);
console.log('\na conversation another session has pinned is left to it');
const rival = sessions.create({ name: 'rival', cli: 'claude', path: 'proj-a' });
sessions.update(rival.id, { sessionUuid: B });
check('claimed uuid skipped', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, A);
console.log('\nnothing new in the window means no re-pin');
check('no candidate', await runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);
// ---------- the folder is a tree ----------
// An agent that enters a git worktree is still the same pane in the same folder,
// and the conversation its /clear starts records the DEEPER cwd. Equality here
// meant the pin could never follow that — the reader kept showing the pre-/clear
// thread while the terminal showed the new one, and the next launch would
// `--resume` the abandoned id.
console.log('\ncwdUnderWorkdir: the pane owns its whole tree, and nothing beside it');
const WT = path.join(WORKDIR, '.claude', 'worktrees', 'wt-1');
check('the folder itself', runner.cwdUnderWorkdir(WORKDIR, WORKDIR), true);
check('a worktree below it', runner.cwdUnderWorkdir(WT, WORKDIR), true);
check('a plain subdirectory', runner.cwdUnderWorkdir(path.join(WORKDIR, 'server'), WORKDIR), true);
check('a sibling sharing a name prefix',
runner.cwdUnderWorkdir(`${WORKDIR}2`, WORKDIR), false);
check('the parent folder', runner.cwdUnderWorkdir(cfg.WORKSPACES_DIR, WORKDIR), false);
check('somewhere else entirely', runner.cwdUnderWorkdir('/elsewhere', WORKDIR), false);
check('no cwd at all', runner.cwdUnderWorkdir(null, WORKDIR), false);
check('no workdir at all', runner.cwdUnderWorkdir(WORKDIR, ''), false);
console.log('\na /clear inside a worktree is followed');
const F = 'ffffffff-0000-0000-0000-000000000006';
transcript(F, { cwd: WT, startMs: NOW + 240_000, projDir: '-proj-a--claude-worktrees-wt-1' });
check('worktree conversation claimed', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, F);
console.log('\na neighbouring folder whose name starts the same is still not ours');
transcript('99999999-0000-0000-0000-000000000009',
{ cwd: `${WORKDIR}2`, startMs: NOW + 300_000, projDir: '-proj-a2' });
check('prefix neighbour ignored', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, F);
// ---------- breadcrumbs: attribution the scan cannot do in shared folders ----------
const E = 'eeeeeeee-0000-0000-0000-000000000005';
const RUN = '11111111-2222-4333-8444-555555555555';
const crumb = (over = {}) => ({
amId: 's1', runId: RUN, cli: 'claude', claudePid: 4242,
payload: { session_id: E, cwd: WORKDIR, source: 'clear' },
...over,
});
const facts = (over = {}) => ({
cli: 'claude', runId: RUN, workdir: WORKDIR, pinned: A,
claimed: new Set([B]), pidTrusted: true, ...over,
});
const verdict = (c, f) => runner.breadcrumbVerdict(c, 's1', f);
console.log('\na trusted breadcrumb re-pins, no folder-sharing question asked');
check('clean crumb accepted', verdict(crumb(), facts()).repin, E);
console.log('\na breadcrumb only speaks for the pane that wrote it');
check('amId mismatch rejected', verdict(crumb({ amId: 's2' }), facts()).repin, null);
check('stale launch rejected', verdict(crumb({ runId: 'old-run' }), facts()).why, 'runId mismatch');
check('wrong harness rejected', verdict(crumb({ cli: 'codex' }), facts()).why, 'cli mismatch');
check('cwd mismatch rejected',
verdict(crumb({ payload: { session_id: E, cwd: '/elsewhere', source: 'clear' } }), facts()).repin, null);
check('a crumb from a parent folder rejected',
verdict(crumb({ payload: { session_id: E, cwd: cfg.WORKSPACES_DIR, source: 'clear' } }), facts()).why,
'cwd outside the session folder');
check('a crumb from a prefix neighbour rejected',
verdict(crumb({ payload: { session_id: E, cwd: `${WORKDIR}2`, source: 'clear' } }), facts()).repin, null);
console.log('\na /clear reported from a worktree below the folder is this pane speaking');
check('worktree crumb accepted',
verdict(crumb({ payload: { session_id: E, cwd: WT, source: 'clear' } }), facts()).repin, E);
console.log('\na nested claude -p cannot claim the pane (pid not under the pane root)');
check('untrusted pid rejected', verdict(crumb(), facts({ pidTrusted: false })).repin, null);
check('untrusted pid says why', verdict(crumb(), facts({ pidTrusted: false })).why, 'pid not top-level pane agent');
console.log('\nno-ops and garbage stay no-ops');
check('already-pinned crumb is a no-op',
verdict(crumb({ payload: { session_id: A, cwd: WORKDIR, source: 'resume' } }), facts()).why, 'already pinned');
check('claimed uuid left to its session',
verdict(crumb({ payload: { session_id: B, cwd: WORKDIR, source: 'clear' } }), facts()).why, 'claimed by another session');
check('malformed session_id rejected',
verdict(crumb({ payload: { session_id: 'not-a-uuid', cwd: WORKDIR } }), facts()).repin, null);
check('null crumb rejected', verdict(null, facts()).repin, null);
console.log('\nCodex and OpenCode use the same pane/run attribution contract');
const CODEX_ID = '12345678-1234-4234-8234-123456789abc';
const rollout = path.join(CODEX, 'sessions', '2026', '08', '06', `rollout-2026-08-06T00-00-00-${CODEX_ID}.jsonl`);
fs.writeFileSync(rollout, '{}\n');
const codexCrumb = {
amId: 's1', runId: RUN, cli: 'codex',
payload: { session_id: CODEX_ID, transcript_path: rollout, cwd: WORKDIR, source: 'clear' },
};
check('Codex exact id accepted', runner.breadcrumbVerdict(codexCrumb, 's1', {
cli: 'codex', runId: RUN, workdir: WORKDIR, pinned: null, claimed: new Set(), pidTrusted: true,
}).repin, CODEX_ID);
check('Codex rollout path retained', runner.codexRolloutForBreadcrumb(codexCrumb), rollout);
// Awaited: the id lookup walks the rollout tree, which is async now.
check('Codex null transcript resolves by exact id', await runner.codexRolloutForId(CODEX_ID), rollout);
const CODEX_ALIAS = path.join(TMP, 'codex-alias');
fs.mkdirSync(CODEX_ALIAS);
fs.symlinkSync(path.join(CODEX, 'sessions'), path.join(CODEX_ALIAS, 'sessions'));
process.env.CODEX_HOME = CODEX_ALIAS;
check('Codex canonical path accepted through a symlinked sessions root',
runner.codexRolloutForBreadcrumb(codexCrumb), rollout);
process.env.CODEX_HOME = CODEX;
check('Codex path outside CODEX_HOME rejected', runner.codexRolloutForBreadcrumb({
...codexCrumb, payload: { ...codexCrumb.payload, transcript_path: `/tmp/rollout-x-${CODEX_ID}.jsonl` },
}), null);
check('OpenCode exact id accepted', runner.breadcrumbVerdict({
amId: 's1', runId: RUN, cli: 'opencode',
payload: { session_id: 'ses_1234567890abcdef', cwd: WORKDIR },
}, 's1', {
cli: 'opencode', runId: RUN, workdir: WORKDIR, pinned: null, claimed: new Set(), pidTrusted: true,
}).repin, 'ses_1234567890abcdef');
console.log('\nonly the top-level agent process may emit a breadcrumb');
const proc = new Map([
[99, '99 (bash) S 1 0 0'],
[100, '100 (node) S 1 0 0'],
[101, '101 (claude) S 100 0 0'],
[102, '102 (nested claude) S 101 0 0'],
[110, '110 (codex-x64 (native)) S 100 0 0'],
[120, '120 (tool shell) S 110 0 0'],
[200, '200 (node) S 120 0 0'],
[210, '210 (codex-x64) S 200 0 0'],
]);
const readStat = (pid) => {
if (!proc.has(pid)) throw new Error('gone');
return proc.get(pid);
};
check('pane root accepted', runner.pidIsPaneRootOrDirectChild(100, 100, readStat), true);
check('direct Claude child accepted', runner.pidIsPaneRootOrDirectChild(101, 100, readStat), true);
check('nested Claude rejected', runner.pidIsPaneRootOrDirectChild(102, 100, readStat), false);
check('top native Codex accepted', runner.codexProcessPidTrusted(110, 100, readStat), true);
proc.set(100, '100 (node) S 99 0 0');
check('Codex behind retained launch shell accepted', runner.codexProcessPidTrusted(110, 99, readStat), true);
check('nested native Codex rejected', runner.codexProcessPidTrusted(210, 100, readStat), false);
check('gone Codex pid rejected', runner.codexProcessPidTrusted(999, 100, readStat), false);
console.log('\nhook scripts preserve the pane and launch identity');
const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts');
const repoRoot = path.dirname(scripts);
const requirements = fs.readFileSync(path.join(repoRoot, 'codex-requirements.toml'), 'utf8');
check('Codex hook is managed (no user trust prompt)', requirements.includes('managed_dir = "/etc/codex/hooks"'), true);
check('managed policy runs the root-owned hook', requirements.includes('command = "/etc/codex/hooks/am-codex-repin-hook.sh"'), true);
const hookPayload = JSON.stringify({ session_id: E, transcript_path: '/tmp/t.jsonl', cwd: WORKDIR, source: 'clear' });
let hook = spawnSync('sh', [path.join(scripts, 'am-repin-hook.sh')], {
input: hookPayload,
env: {
...process.env, AM_ID: 's1', AM_RUN_ID: RUN, AM_CLI: 'claude',
AM_PANE_PID: String(process.pid), CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_PID: String(process.pid),
},
});
check('Claude hook exits cleanly', hook.status, 0);
let written = JSON.parse(fs.readFileSync(path.join(REPIN, 's1.claude.json'), 'utf8'));
check('Claude hook writes run id', written.runId, RUN);
check('Claude hook writes exact session id', written.payload.session_id, E);
const codexShim = path.join(TMP, 'codex-test');
fs.symlinkSync('/bin/sh', codexShim);
const paneShim = path.join(TMP, 'pane-shell');
fs.symlinkSync('/bin/sh', paneShim);
const codexLauncher = path.join(TMP, 'codex-launcher.cjs');
fs.writeFileSync(codexLauncher, [
"const fs = require('node:fs');",
"const { spawnSync } = require('node:child_process');",
"const child = spawnSync(process.argv[2], ['-c', `sh '${process.argv[3]}'`], {",
" input: fs.readFileSync(0), env: process.env, stdio: ['pipe', 'inherit', 'inherit'],",
"});",
"process.exit(child.status ?? 1);",
].join('\n'));
hook = spawnSync(paneShim, ['-c',
`export AM_PANE_PID=$$; node '${codexLauncher}' '${codexShim}' '${path.join(scripts, 'am-codex-repin-hook.sh')}'`], {
input: JSON.stringify(codexCrumb.payload),
env: { ...process.env, AM_ID: 's1', AM_RUN_ID: RUN, AM_CLI: 'codex' },
});
check('Codex hook exits cleanly', hook.status, 0);
written = JSON.parse(fs.readFileSync(path.join(REPIN, 's1.codex.json'), 'utf8'));
check('Codex hook writes run id', written.runId, RUN);
check('Codex hook writes exact session id', written.payload.session_id, CODEX_ID);
check('Codex hook records its live agent process', Number.isInteger(written.codexPid), true);
// ---------- the pane root: the fact every breadcrumb is trusted against ----------
// paneRootPid used to shell out to `tmux list-panes`. The libghostty migration
// removed tmux but left the call, referencing identifiers that no longer exist —
// so it threw into its own bare catch, returned null, and every breadcrumb was
// rejected as 'pid not in pane'. A null pane root disables the hook path wholesale,
// so this asserts against a REAL running session rather than a mock.
console.log('\nthe pane root is the session PTY the server holds');
check('unknown session has no pane root', runner.paneRootPid('nope'), null);
// Spawning a real pane needs libghostty's native addon, which not every checkout
// has built. Skip rather than throw out of ensureRunning: an unhandled throw here
// aborts the process and takes the cadence and installer checks below with it,
// which reads as a broken test run rather than a missing optional dependency.
if (!runner.ghosttyReady()) {
console.log(' SKIP live pane checks (libghostty-vt unavailable)');
} else {
const live = sessions.create({ name: 'live', cli: 'shell', path: 'proj-a' });
try {
runner.ensureRunning(live, 80, 24);
const liveRoot = runner.paneRootPid(live.id);
check('live session has a pane root', Number.isInteger(liveRoot) && liveRoot > 1, true);
check('the pane root is a live process', fs.existsSync(`/proc/${liveRoot}`), true);
} finally {
runner.stop(live.id);
}
}
// ---------- scan cadence: the breadcrumb is the mechanism, the scan a backstop ----------
// With the scan awaited the cadence is no longer about event-loop block time — it
// is about not walking the bucket 3x a minute per pane for an answer the
// breadcrumb already gave. So the backstop is a minute, and a minute is also the
// longest a pin can stay stale if a crumb is ever lost.
const MINUTE = 60_000;
console.log('\nwithout a proven hook the scan runs every tick (unchanged)');
check('no proof: due immediately', runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW), true);
check('no proof: still due 20s later',
runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW + 20_000), true);
console.log('\na proven hook steps the scan down to the backstop cadence');
check('proven: not due on the next beat',
runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + 20_000), false);
check('proven: not due at 59s',
runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + MINUTE - 1000), false);
check('proven: due again at a minute',
runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + MINUTE), true);
console.log('\na proven pane still scans once before its first backstop');
check('proven but never scanned: due',
runner.claudeScanDue({ hookProven: true, lastScanAt: 0 }, NOW), true);
// ---------- hook installer: merge, never replace; idempotent; refuse corrupt ----------
console.log('\ninstaller merges into existing settings and is idempotent');
const settings = path.join(CFG, 'settings.json');
fs.writeFileSync(settings, JSON.stringify({ model: 'opus', permissions: { allow: ['Bash'] } }));
check('installs into existing file', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
let s = JSON.parse(fs.readFileSync(settings, 'utf8'));
check('other keys kept', s.model, 'opus');
check('hook entry present', s.hooks.SessionStart.length, 1);
check('attention notification hook present', s.hooks.Notification.length, 1);
check('attention hook uses post-dialog events', s.hooks.Notification[0].matcher,
'permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|agent_completed');
check('attention marker gets a native clear path', s.hooks.PostToolBatch.length, 1);
check('second run is a no-op', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
s = JSON.parse(fs.readFileSync(settings, 'utf8'));
check('no duplicate entry', s.hooks.SessionStart.length, 1);
check('no duplicate attention entry', s.hooks.Notification.length, 1);
console.log('\ninstaller refuses to clobber a corrupt settings file');
fs.writeFileSync(settings, '{ not json');
check('corrupt file left alone', runner.installClaudeRepinHook('/x.sh'), false);
check('corrupt content untouched', fs.readFileSync(settings, 'utf8'), '{ not json');
console.log(`\n${pass} passed, ${fail} failed`);
fs.rmSync(TMP, { recursive: true, force: true });
process.exit(fail ? 1 : 0);
|