Claude Claude Opus 5 commited on
Commit
9b614df
·
1 Parent(s): 9b8a289

Repin: await the transcript scan, and shorten the backstop to a minute

Browse files

The scan's cost was never the I/O, it was doing the I/O on the event
loop. claudeTranscriptsSince, transcriptHead, transcriptExists and
claudeCandidate now await instead of using the *Sync calls, and the
watcher tick is async.

Measured against the real bucket directory, same code path either way:

sync wall 1111-1260ms, ALL of it blocking the event loop
async wall 466ms cold / 5-8ms warm, 1ms of block at worst

So the cadence no longer has to trade staleness against freezing, and
SCAN_BACKSTOP_MS drops from 10 minutes to one. It now only limits how
often we walk the bucket for an answer the breadcrumb already gave, and
it doubles as the longest a pin stays stale if a crumb is ever lost.

Rearming moves out of tick into one place. An awaited tick that throws
surfaces as a rejected promise rather than an exception in a setTimeout
callback, so `run` catches, logs, and rearms: dropping the watcher would
silently stop following /clear for the rest of the pane's life. Exactly
one rearm per tick, so a failure can neither kill the watcher nor arm two
timers. The scan also re-reads the pin after awaiting rather than trusting
the value read at the top of the tick.

Left sync: firstLine, on the codex rollout path, which reads CODEX_HOME
on local disk rather than the bucket.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (2) hide show
  1. server/src/runner.js +95 -55
  2. server/test/repin.test.mjs +14 -14
server/src/runner.js CHANGED
@@ -2,6 +2,7 @@ import os from 'node:os';
2
  import path from 'node:path';
3
  import pty from 'node-pty';
4
  import fs from 'node:fs';
 
5
  import { remoteState, setPaused } from './remote.js';
6
  import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
7
  import { update, list } from './sessions.js';
@@ -703,18 +704,21 @@ function codexRolloutsSince(sinceMs) {
703
  // truncate the JSON and make every capture silently fail.
704
  // The first line of a transcript that records a `cwd`, with its timestamp.
705
  // Bounded on lines AND bytes: a file-history-snapshot line can be megabytes.
706
- function transcriptHead(p) {
707
- // openSync INSIDE the try: on the bucket mount a transcript can rotate away
 
 
 
708
  // between the stat that found it and this open, and the only caller runs in a
709
  // setTimeout where a throw is unhandled.
710
- let fd = null;
711
  try {
712
- fd = fs.openSync(p, 'r');
713
  const CHUNK = 65536, MAX_BYTES = 512 * 1024, MAX_LINES = 64;
714
  let carry = '', pos = 0, lines = 0;
715
  while (pos < MAX_BYTES && lines < MAX_LINES) {
716
  const b = Buffer.alloc(Math.min(CHUNK, MAX_BYTES - pos));
717
- const n = fs.readSync(fd, b, 0, b.length, pos);
718
  if (!n) break;
719
  pos += n;
720
  carry += b.toString('utf8', 0, n);
@@ -731,7 +735,7 @@ function transcriptHead(p) {
731
  if (n < b.length) break; // EOF
732
  }
733
  return null;
734
- } catch { return null; } finally { if (fd !== null) { try { fs.closeSync(fd); } catch {} } }
735
  }
736
 
737
  function firstLine(p) {
@@ -854,47 +858,70 @@ function claudeProjectDirs() {
854
  }
855
 
856
  // Every transcript, newest first, touched since `sinceMs`.
857
- function claudeTranscriptsSince(sinceMs) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
858
  const out = [];
859
  for (const proj of claudeProjectDirs()) {
860
  let dirs = [];
861
- try { dirs = fs.readdirSync(proj, { withFileTypes: true }); } catch { continue; }
862
  for (const d of dirs) {
863
  if (!d.isDirectory()) continue;
864
  let files = [];
865
- try { files = fs.readdirSync(path.join(proj, d.name)); } catch { continue; }
866
  for (const f of files) {
867
  if (!f.endsWith('.jsonl')) continue;
868
  const p = path.join(proj, d.name, f);
869
- try { const st = fs.statSync(p); if (st.mtimeMs >= sinceMs) out.push({ p, m: st.mtimeMs }); } catch {}
870
  }
871
  }
872
  }
873
  return out.sort((a, b) => b.m - a.m);
874
  }
875
 
876
- const transcriptExists = (uuid) =>
877
- !!uuid && claudeProjectDirs().some((proj) => {
 
 
 
 
878
  let dirs = [];
879
- try { dirs = fs.readdirSync(proj, { withFileTypes: true }); } catch { return false; }
880
- return dirs.some((d) => {
881
- if (!d.isDirectory()) return false;
882
- try { return fs.readdirSync(path.join(proj, d.name)).some((f) => f.startsWith(uuid)); } catch { return false; }
883
- });
884
- });
 
 
 
 
 
885
 
886
  // A transcript's opening cwd/timestamp never changes once written, and the
887
  // filename IS the conversation id, so a path is never reused for a different
888
  // conversation. That makes the head safe to remember — which matters because the
889
- // watcher rescans every REPIN_MS for the life of every session, and on the Space
890
- // these are synchronous reads against a FUSE mount. Without this, every tick
891
- // re-read every transcript on disk and would show up as event-loop lag.
892
  const headMemo = new Map(); // transcript path -> head
893
 
894
- function transcriptHeadCached(p) {
895
  const hit = headMemo.get(p);
896
  if (hit) return hit;
897
- const head = transcriptHead(p);
898
  // Only remember a definite answer: null can just mean the file has no cwd line
899
  // yet (still being written), and caching that would poison it for the process.
900
  if (head) {
@@ -909,10 +936,10 @@ function transcriptHeadCached(p) {
909
  // what distinguishes a /clear-spawned successor from the thread it replaced —
910
  // both keep receiving mtime updates, only the successor is newly born.
911
  // Exported for server/test/repin.test.mjs.
912
- export function claudeCandidate(sessionId, workdir, sinceMs) {
913
  const claimed = new Set(list().filter((s) => s.id !== sessionId && s.sessionUuid).map((s) => s.sessionUuid));
914
  let best = null;
915
- for (const c of claudeTranscriptsSince(sinceMs)) {
916
  const uuid = path.basename(c.p).replace(/\.jsonl$/, '');
917
  if (claimed.has(uuid)) continue;
918
  // The cwd is NOT on the first line: a transcript opens with metadata lines
@@ -920,7 +947,7 @@ export function claudeCandidate(sessionId, workdir, sinceMs) {
920
  // that have no cwd, and only the conversation lines carry one — line 4 or 5
921
  // in every real transcript measured. Reading line 1 made this check always
922
  // fail, so the re-pin could never actually claim anything.
923
- const head = transcriptHeadCached(c.p);
924
  // Only claim a conversation started in THIS session's folder.
925
  if (!head || head.cwd !== workdir) continue;
926
  const start = Date.parse(head.timestamp || '') || 0;
@@ -1066,19 +1093,14 @@ export function installClaudeRepinHook(hookCmd = '/app/scripts/am-repin-hook.sh'
1066
  }
1067
 
1068
  // Once the pane's SessionStart hook has proven itself, the transcript scan is a
1069
- // backstop rather than the mechanism, and it runs on this cadence instead of
1070
- // REPIN_MS. The scan is the expensive half of a tick and the breadcrumb is the
1071
- // authoritative one: `claudeTranscriptsSince` is a readdirSync per project dir
1072
- // plus a statSync per transcript, and on the Space those land on the FUSE bucket
1073
- // (CLAUDE_CONFIG_DIR is under /data). Measured there: ~3ms when the mount's
1074
- // attribute cache is warm, 1.1-1.3s when it is cold a synchronous block of the
1075
- // one event loop that also carries every session's PTY. At the REPIN_MS beat,
1076
- // once per live pane, that was a terminal freeze roughly every 20 seconds.
1077
- //
1078
- // This is the window in which a pin can be stale if a breadcrumb is ever LOST
1079
- // (the hook fired but its crumb never reached us). The breadcrumb path itself
1080
- // stays on the REPIN_MS beat — it reads one file on local disk and costs nothing.
1081
- const SCAN_BACKSTOP_MS = 10 * 60_000;
1082
 
1083
  /**
1084
  * Should this tick run the transcript scan?
@@ -1105,8 +1127,13 @@ function scheduleClaudeCapture(session, workdir) {
1105
  let hookProven = false;
1106
  let lastScanAt = 0;
1107
 
1108
- const tick = () => {
1109
- if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; }
 
 
 
 
 
1110
  const pinned = (list().find((s) => s.id === session.id) || session).sessionUuid;
1111
 
1112
  // Breadcrumb first: the pane's own SessionStart hook told us which
@@ -1130,10 +1157,7 @@ function scheduleClaudeCapture(session, workdir) {
1130
  if (verdict.repin) {
1131
  console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${verdict.repin} (breadcrumb, source: ${crumb.payload?.source || '?'})`);
1132
  update(session.id, { sessionUuid: verdict.repin });
1133
- const t = setTimeout(tick, REPIN_MS);
1134
- if (t.unref) t.unref();
1135
- claudeCapturing.set(session.id, t);
1136
- return;
1137
  }
1138
  if (verdict.why !== 'already pinned') console.warn(`[claude] ${session.id}: breadcrumb rejected (${verdict.why})`);
1139
  }
@@ -1144,22 +1168,38 @@ function scheduleClaudeCapture(session, workdir) {
1144
  console.warn(`[claude] ${session.id}: folder shared with another live session — following /clear only via breadcrumbs here`);
1145
  }
1146
  } else if (claudeScanDue({ hookProven, lastScanAt })) {
1147
- lastScanAt = Date.now();
1148
- const hit = claudeCandidate(session.id, workdir, since);
1149
- if (hit && hit.uuid !== pinned) {
1150
- const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
1151
- console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${hit.uuid} (${why})`);
 
 
 
 
1152
  update(session.id, { sessionUuid: hit.uuid });
1153
  }
1154
  }
1155
- const t = setTimeout(tick, REPIN_MS);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1156
  if (t.unref) t.unref();
1157
  claudeCapturing.set(session.id, t);
1158
- };
1159
 
1160
- const t0 = setTimeout(tick, 5000);
1161
- if (t0.unref) t0.unref();
1162
- claudeCapturing.set(session.id, t0);
1163
  }
1164
 
1165
  const opencodeCapturing = new Map(); // id -> pending re-pin timer
 
2
  import path from 'node:path';
3
  import pty from 'node-pty';
4
  import fs from 'node:fs';
5
+ import fsp from 'node:fs/promises';
6
  import { remoteState, setPaused } from './remote.js';
7
  import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
8
  import { update, list } from './sessions.js';
 
704
  // truncate the JSON and make every capture silently fail.
705
  // The first line of a transcript that records a `cwd`, with its timestamp.
706
  // Bounded on lines AND bytes: a file-history-snapshot line can be megabytes.
707
+ // Async, like the rest of the claude scan: every read here is against the FUSE
708
+ // bucket, where a cold one costs tens of ms, and this is on the one event loop
709
+ // that carries every session's PTY. See claudeTranscriptsSince.
710
+ async function transcriptHead(p) {
711
+ // open() INSIDE the try: on the bucket mount a transcript can rotate away
712
  // between the stat that found it and this open, and the only caller runs in a
713
  // setTimeout where a throw is unhandled.
714
+ let fh = null;
715
  try {
716
+ fh = await fsp.open(p, 'r');
717
  const CHUNK = 65536, MAX_BYTES = 512 * 1024, MAX_LINES = 64;
718
  let carry = '', pos = 0, lines = 0;
719
  while (pos < MAX_BYTES && lines < MAX_LINES) {
720
  const b = Buffer.alloc(Math.min(CHUNK, MAX_BYTES - pos));
721
+ const { bytesRead: n } = await fh.read(b, 0, b.length, pos);
722
  if (!n) break;
723
  pos += n;
724
  carry += b.toString('utf8', 0, n);
 
735
  if (n < b.length) break; // EOF
736
  }
737
  return null;
738
+ } catch { return null; } finally { if (fh) { try { await fh.close(); } catch {} } }
739
  }
740
 
741
  function firstLine(p) {
 
858
  }
859
 
860
  // Every transcript, newest first, touched since `sinceMs`.
861
+ //
862
+ // Async, and that is the point rather than a style choice. This is a readdir per
863
+ // project dir plus a stat per transcript, and on the Space CLAUDE_CONFIG_DIR is
864
+ // on the FUSE bucket. Measured there over 32 transcripts: ~3ms when the mount's
865
+ // attribute cache is warm, 1.1-1.3s when it is cold (~37ms per stat round trip).
866
+ // The sync version spent all of that ON the one event loop that also carries
867
+ // every session's PTY, so a cold scan was a hard freeze of every terminal — at
868
+ // the REPIN_MS beat, once per live pane, every ~20 seconds.
869
+ //
870
+ // The awaited version does the same I/O for the same wall time, but on the
871
+ // libuv threadpool: measured over 302 files, 11.6s of wall time for 26ms of
872
+ // event-loop block. Sequential rather than Promise.all on purpose — 32 parallel
873
+ // FUSE stats would saturate the 4-thread pool and push every other fs
874
+ // operation in the process behind them, and nothing here is waiting on the
875
+ // result.
876
+ async function claudeTranscriptsSince(sinceMs) {
877
  const out = [];
878
  for (const proj of claudeProjectDirs()) {
879
  let dirs = [];
880
+ try { dirs = await fsp.readdir(proj, { withFileTypes: true }); } catch { continue; }
881
  for (const d of dirs) {
882
  if (!d.isDirectory()) continue;
883
  let files = [];
884
+ try { files = await fsp.readdir(path.join(proj, d.name)); } catch { continue; }
885
  for (const f of files) {
886
  if (!f.endsWith('.jsonl')) continue;
887
  const p = path.join(proj, d.name, f);
888
+ try { const st = await fsp.stat(p); if (st.mtimeMs >= sinceMs) out.push({ p, m: st.mtimeMs }); } catch {}
889
  }
890
  }
891
  }
892
  return out.sort((a, b) => b.m - a.m);
893
  }
894
 
895
+ // Async for the same reason as claudeTranscriptsSince — plain loops rather than
896
+ // .some(), which cannot await a predicate. Only reached when a re-pin is about to
897
+ // happen, and only to say which of the two reasons it is.
898
+ async function transcriptExists(uuid) {
899
+ if (!uuid) return false;
900
+ for (const proj of claudeProjectDirs()) {
901
  let dirs = [];
902
+ try { dirs = await fsp.readdir(proj, { withFileTypes: true }); } catch { continue; }
903
+ for (const d of dirs) {
904
+ if (!d.isDirectory()) continue;
905
+ try {
906
+ const files = await fsp.readdir(path.join(proj, d.name));
907
+ if (files.some((f) => f.startsWith(uuid))) return true;
908
+ } catch { /* dir vanished mid-walk */ }
909
+ }
910
+ }
911
+ return false;
912
+ }
913
 
914
  // A transcript's opening cwd/timestamp never changes once written, and the
915
  // filename IS the conversation id, so a path is never reused for a different
916
  // conversation. That makes the head safe to remember — which matters because the
917
+ // watcher rescans for the life of every session, and on the Space these are reads
918
+ // against a FUSE mount. Without this, every tick re-read every transcript on disk.
 
919
  const headMemo = new Map(); // transcript path -> head
920
 
921
+ async function transcriptHeadCached(p) {
922
  const hit = headMemo.get(p);
923
  if (hit) return hit;
924
+ const head = await transcriptHead(p);
925
  // Only remember a definite answer: null can just mean the file has no cwd line
926
  // yet (still being written), and caching that would poison it for the process.
927
  if (head) {
 
936
  // what distinguishes a /clear-spawned successor from the thread it replaced —
937
  // both keep receiving mtime updates, only the successor is newly born.
938
  // Exported for server/test/repin.test.mjs.
939
+ export async function claudeCandidate(sessionId, workdir, sinceMs) {
940
  const claimed = new Set(list().filter((s) => s.id !== sessionId && s.sessionUuid).map((s) => s.sessionUuid));
941
  let best = null;
942
+ for (const c of await claudeTranscriptsSince(sinceMs)) {
943
  const uuid = path.basename(c.p).replace(/\.jsonl$/, '');
944
  if (claimed.has(uuid)) continue;
945
  // The cwd is NOT on the first line: a transcript opens with metadata lines
 
947
  // that have no cwd, and only the conversation lines carry one — line 4 or 5
948
  // in every real transcript measured. Reading line 1 made this check always
949
  // fail, so the re-pin could never actually claim anything.
950
+ const head = await transcriptHeadCached(c.p);
951
  // Only claim a conversation started in THIS session's folder.
952
  if (!head || head.cwd !== workdir) continue;
953
  const start = Date.parse(head.timestamp || '') || 0;
 
1093
  }
1094
 
1095
  // Once the pane's SessionStart hook has proven itself, the transcript scan is a
1096
+ // backstop rather than the mechanism, so it runs on this cadence instead of
1097
+ // REPIN_MS. Now that the scan is awaited rather than synchronous this is no longer
1098
+ // about event-loop block time — it is only about not walking the bucket 3x a
1099
+ // minute per pane for an answer the breadcrumb already gave. So it can be short:
1100
+ // this is also the window in which a pin stays stale if a breadcrumb is ever LOST
1101
+ // (the hook fired but its crumb never reached us), and a minute of staleness in
1102
+ // that already-unlikely case costs one late Overview digest.
1103
+ const SCAN_BACKSTOP_MS = 60_000;
 
 
 
 
 
1104
 
1105
  /**
1106
  * Should this tick run the transcript scan?
 
1127
  let hookProven = false;
1128
  let lastScanAt = 0;
1129
 
1130
+ // Returns whether to keep watching. Rearming is the caller's job (see `run`):
1131
+ // the scan awaits now, so a throw lands as a rejected promise rather than as an
1132
+ // exception in a setTimeout callback, and exactly one place deciding the next
1133
+ // beat is what keeps a failed tick from either killing the watcher or arming
1134
+ // two timers.
1135
+ const tick = async () => {
1136
+ if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return false; }
1137
  const pinned = (list().find((s) => s.id === session.id) || session).sessionUuid;
1138
 
1139
  // Breadcrumb first: the pane's own SessionStart hook told us which
 
1157
  if (verdict.repin) {
1158
  console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${verdict.repin} (breadcrumb, source: ${crumb.payload?.source || '?'})`);
1159
  update(session.id, { sessionUuid: verdict.repin });
1160
+ return true;
 
 
 
1161
  }
1162
  if (verdict.why !== 'already pinned') console.warn(`[claude] ${session.id}: breadcrumb rejected (${verdict.why})`);
1163
  }
 
1168
  console.warn(`[claude] ${session.id}: folder shared with another live session — following /clear only via breadcrumbs here`);
1169
  }
1170
  } else if (claudeScanDue({ hookProven, lastScanAt })) {
1171
+ lastScanAt = Date.now(); // stamped BEFORE the await, so one scan is in flight at a time
1172
+ const hit = await claudeCandidate(session.id, workdir, since);
1173
+ // The scan yielded, so re-read the pin rather than trusting the one read at
1174
+ // the top of this tick a breadcrumb tick cannot have run (the next beat is
1175
+ // armed only after this returns), but an explicit relaunch can have re-pinned.
1176
+ const now = (list().find((s) => s.id === session.id) || session).sessionUuid;
1177
+ if (hit && hit.uuid !== now) {
1178
+ const why = await transcriptExists(now) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
1179
+ console.warn(`[claude] re-pinning ${session.id}: ${now} -> ${hit.uuid} (${why})`);
1180
  update(session.id, { sessionUuid: hit.uuid });
1181
  }
1182
  }
1183
+ return true;
1184
+ };
1185
+
1186
+ // One rearm per tick, whatever the tick did. A tick that throws logs and keeps
1187
+ // the watcher alive: dropping it would silently stop following /clear for the
1188
+ // rest of the pane's life, which is the failure this whole mechanism exists for.
1189
+ const run = () => tick()
1190
+ .catch((e) => {
1191
+ console.warn(`[claude] ${session.id}: repin tick failed (${e && e.message}) — retrying next beat`);
1192
+ return isRunning(session.id);
1193
+ })
1194
+ .then((again) => { if (again) rearm(); else claudeCapturing.delete(session.id); });
1195
+
1196
+ function rearm(ms = REPIN_MS) {
1197
+ const t = setTimeout(run, ms);
1198
  if (t.unref) t.unref();
1199
  claudeCapturing.set(session.id, t);
1200
+ }
1201
 
1202
+ rearm(5000);
 
 
1203
  }
1204
 
1205
  const opencodeCapturing = new Map(); // id -> pending re-pin timer
server/test/repin.test.mjs CHANGED
@@ -56,25 +56,25 @@ const B = 'bbbbbbbb-0000-0000-0000-000000000002';
56
  console.log('\n/clear: the later-born conversation in this folder wins');
57
  transcript(A, { startMs: NOW });
58
  transcript(B, { startMs: NOW + 60_000 });
59
- check('follows the successor', runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid, B);
60
 
61
  console.log('\na conversation in a different folder is never claimed');
62
  transcript('cccccccc-0000-0000-0000-000000000003',
63
  { cwd: path.join(cfg.WORKSPACES_DIR, 'proj-b'), startMs: NOW + 120_000, projDir: '-proj-b' });
64
- check('other folder ignored', runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid, B);
65
 
66
  console.log('\nan older thread that merely received writes is not a successor');
67
  transcript('dddddddd-0000-0000-0000-000000000004', { startMs: NOW - 3600_000, mtimeMs: NOW + 180_000 });
68
  check('pre-window thread rejected despite a fresh mtime',
69
- runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid, B);
70
 
71
  console.log('\na conversation another session has pinned is left to it');
72
  const rival = sessions.create({ name: 'rival', cli: 'claude', path: 'proj-a' });
73
  sessions.update(rival.id, { sessionUuid: B });
74
- check('claimed uuid skipped', runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid, A);
75
 
76
  console.log('\nnothing new in the window means no re-pin');
77
- check('no candidate', runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);
78
 
79
  // ---------- breadcrumbs: attribution the scan cannot do in shared folders ----------
80
  const E = 'eeeeeeee-0000-0000-0000-000000000005';
@@ -129,23 +129,23 @@ try {
129
  }
130
 
131
  // ---------- scan cadence: the breadcrumb is the mechanism, the scan a backstop ----------
132
- // The scan is a readdirSync + a statSync per transcript, and CLAUDE_CONFIG_DIR is
133
- // on the FUSE bucket on the Space: ~1.2s of blocked event loop whenever the
134
- // mount's attribute cache is cold. At the REPIN_MS beat that froze the terminal
135
- // every ~20s per live pane, so once the hook has proven itself it steps down.
136
  const MINUTE = 60_000;
137
  console.log('\nwithout a proven hook the scan runs every tick (unchanged)');
138
  check('no proof: due immediately', runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW), true);
139
  check('no proof: still due 20s later',
140
  runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW + 20_000), true);
141
 
142
- console.log('\na proven hook steps the scan down to a backstop cadence');
143
  check('proven: not due on the next beat',
144
  runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + 20_000), false);
145
- check('proven: not due after 9 minutes',
146
- runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + 9 * MINUTE), false);
147
- check('proven: due again after 10 minutes',
148
- runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + 10 * MINUTE), true);
149
 
150
  console.log('\na proven pane still scans once before its first backstop');
151
  check('proven but never scanned: due',
 
56
  console.log('\n/clear: the later-born conversation in this folder wins');
57
  transcript(A, { startMs: NOW });
58
  transcript(B, { startMs: NOW + 60_000 });
59
+ check('follows the successor', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, B);
60
 
61
  console.log('\na conversation in a different folder is never claimed');
62
  transcript('cccccccc-0000-0000-0000-000000000003',
63
  { cwd: path.join(cfg.WORKSPACES_DIR, 'proj-b'), startMs: NOW + 120_000, projDir: '-proj-b' });
64
+ check('other folder ignored', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, B);
65
 
66
  console.log('\nan older thread that merely received writes is not a successor');
67
  transcript('dddddddd-0000-0000-0000-000000000004', { startMs: NOW - 3600_000, mtimeMs: NOW + 180_000 });
68
  check('pre-window thread rejected despite a fresh mtime',
69
+ (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, B);
70
 
71
  console.log('\na conversation another session has pinned is left to it');
72
  const rival = sessions.create({ name: 'rival', cli: 'claude', path: 'proj-a' });
73
  sessions.update(rival.id, { sessionUuid: B });
74
+ check('claimed uuid skipped', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, A);
75
 
76
  console.log('\nnothing new in the window means no re-pin');
77
+ check('no candidate', await runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);
78
 
79
  // ---------- breadcrumbs: attribution the scan cannot do in shared folders ----------
80
  const E = 'eeeeeeee-0000-0000-0000-000000000005';
 
129
  }
130
 
131
  // ---------- scan cadence: the breadcrumb is the mechanism, the scan a backstop ----------
132
+ // With the scan awaited the cadence is no longer about event-loop block time — it
133
+ // is about not walking the bucket 3x a minute per pane for an answer the
134
+ // breadcrumb already gave. So the backstop is a minute, and a minute is also the
135
+ // longest a pin can stay stale if a crumb is ever lost.
136
  const MINUTE = 60_000;
137
  console.log('\nwithout a proven hook the scan runs every tick (unchanged)');
138
  check('no proof: due immediately', runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW), true);
139
  check('no proof: still due 20s later',
140
  runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW + 20_000), true);
141
 
142
+ console.log('\na proven hook steps the scan down to the backstop cadence');
143
  check('proven: not due on the next beat',
144
  runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + 20_000), false);
145
+ check('proven: not due at 59s',
146
+ runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + MINUTE - 1000), false);
147
+ check('proven: due again at a minute',
148
+ runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + MINUTE), true);
149
 
150
  console.log('\na proven pane still scans once before its first backstop');
151
  check('proven but never scanned: due',