Spaces:
Running
Repin: await the transcript scan, and shorten the backstop to a minute
Browse filesThe 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>
- server/src/runner.js +95 -55
- server/test/repin.test.mjs +14 -14
|
@@ -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 |
-
|
| 707 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 711 |
try {
|
| 712 |
-
|
| 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 =
|
| 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 (
|
| 735 |
}
|
| 736 |
|
| 737 |
function firstLine(p) {
|
|
@@ -854,47 +858,70 @@ function claudeProjectDirs() {
|
|
| 854 |
}
|
| 855 |
|
| 856 |
// Every transcript, newest first, touched since `sinceMs`.
|
| 857 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 858 |
const out = [];
|
| 859 |
for (const proj of claudeProjectDirs()) {
|
| 860 |
let dirs = [];
|
| 861 |
-
try { dirs =
|
| 862 |
for (const d of dirs) {
|
| 863 |
if (!d.isDirectory()) continue;
|
| 864 |
let files = [];
|
| 865 |
-
try { files =
|
| 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 =
|
| 870 |
}
|
| 871 |
}
|
| 872 |
}
|
| 873 |
return out.sort((a, b) => b.m - a.m);
|
| 874 |
}
|
| 875 |
|
| 876 |
-
|
| 877 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
let dirs = [];
|
| 879 |
-
try { dirs =
|
| 880 |
-
|
| 881 |
-
if (!d.isDirectory())
|
| 882 |
-
try {
|
| 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
|
| 890 |
-
//
|
| 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,
|
| 1070 |
-
// REPIN_MS.
|
| 1071 |
-
//
|
| 1072 |
-
//
|
| 1073 |
-
//
|
| 1074 |
-
//
|
| 1075 |
-
//
|
| 1076 |
-
|
| 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 |
-
|
| 1109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 1150 |
-
|
| 1151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1152 |
update(session.id, { sessionUuid: hit.uuid });
|
| 1153 |
}
|
| 1154 |
}
|
| 1155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1156 |
if (t.unref) t.unref();
|
| 1157 |
claudeCapturing.set(session.id, t);
|
| 1158 |
-
}
|
| 1159 |
|
| 1160 |
-
|
| 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
|
|
@@ -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 |
-
//
|
| 133 |
-
//
|
| 134 |
-
//
|
| 135 |
-
//
|
| 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
|
| 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
|
| 146 |
-
runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW +
|
| 147 |
-
check('proven: due again
|
| 148 |
-
runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW +
|
| 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',
|