Spaces:
Running
Sessions: follow the conversation when a harness starts a new one (#17)
Browse filesA session's conversation pin was captured once, just after launch, and then
never revisited. `/clear` ends the Claude conversation and starts a new one
with an id of its own -- the pinned transcript stops growing and Claude writes
a new file -- so from that moment the pin described a conversation the user had
left. The Overview digest, the trace panel and sharing all kept reading the
pre-/clear thread, and because the old transcript still existed on disk the
next launch ran `--resume <old uuid>` and restored the conversation as it was
BEFORE the /clear, discarding everything since. That is what made a Space
restart bring back the previous session instead of the current one.
Codex and opencode had the same hole for the same reason: both captured their
id once and stopped.
So keep the watcher running for as long as the pane is alive and follow the
session onto whatever conversation the harness is actually writing. One
mechanism now covers both failure modes -- the launch-time `|| exec claude`
fallback where the pin was never honoured, and a reset at any later point.
Guards kept from the launch-time version, since a periodic scan needs them
more, not less: only claim a conversation born in this launch window, only one
started in this session's folder, and never one another session has pinned.
Added a refusal to guess at all when a second live session of the same harness
shares the folder -- there is no way to tell whose /clear produced a new
conversation. Writes to sessions.json only happen when the id actually
changes, and transcript heads are memoized, because this scan now runs for the
life of every session against a FUSE mount rather than once per launch.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
- server/package.json +2 -1
- server/src/runner.js +163 -52
- server/test/repin.test.mjs +81 -0
|
@@ -9,7 +9,8 @@
|
|
| 9 |
"main": "src/index.js",
|
| 10 |
"scripts": {
|
| 11 |
"start": "node src/index.js",
|
| 12 |
-
"dev": "node --watch src/index.js"
|
|
|
|
| 13 |
},
|
| 14 |
"dependencies": {
|
| 15 |
"express": "^4.19.2",
|
|
|
|
| 9 |
"main": "src/index.js",
|
| 10 |
"scripts": {
|
| 11 |
"start": "node src/index.js",
|
| 12 |
+
"dev": "node --watch src/index.js",
|
| 13 |
+
"test": "node test/repin.test.mjs"
|
| 14 |
},
|
| 15 |
"dependencies": {
|
| 16 |
"express": "^4.19.2",
|
|
@@ -145,7 +145,12 @@ export function deriveState(session, info) {
|
|
| 145 |
// its first line. Capture that id shortly after launch and pin it on the
|
| 146 |
// session, so restarts resume THIS agent's conversation — `resume --last`
|
| 147 |
// would grab whichever Codex agent in the same folder ran last.
|
| 148 |
-
const codexCapturing = new
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
function codexSessionsRoot() {
|
| 151 |
const home = process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex');
|
|
@@ -227,6 +232,7 @@ function firstLine(p) {
|
|
| 227 |
|
| 228 |
function tryCaptureCodexId(sessionId, workdir, sinceMs) {
|
| 229 |
const claimed = new Set(list().filter((s) => s.id !== sessionId && s.codexSessionId).map((s) => s.codexSessionId));
|
|
|
|
| 230 |
for (const c of codexRolloutsSince(sinceMs)) {
|
| 231 |
const m = c.p.match(/rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/);
|
| 232 |
if (!m || claimed.has(m[1])) continue;
|
|
@@ -243,6 +249,10 @@ function tryCaptureCodexId(sessionId, workdir, sinceMs) {
|
|
| 243 |
// aren't this agent's conversation, so pinning one would break resume and
|
| 244 |
// the Overview digest.
|
| 245 |
if (mp.thread_source === 'subagent' || (mp.source && mp.source.subagent)) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
update(sessionId, { codexSessionId: m[1], codexRollout: c.p });
|
| 247 |
return true;
|
| 248 |
}
|
|
@@ -262,24 +272,38 @@ function pinIsStale(session) {
|
|
| 262 |
} catch { return false; }
|
| 263 |
}
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
function scheduleCodexCapture(session, workdir) {
|
| 266 |
if (session.codexSessionId && pinIsStale(session)) {
|
| 267 |
session = update(session.id, { codexSessionId: undefined, codexRollout: undefined }) || session;
|
| 268 |
}
|
| 269 |
-
|
| 270 |
-
|
| 271 |
const since = Date.now() - 2000;
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
}
|
| 278 |
-
const t = setTimeout(
|
| 279 |
if (t.unref) t.unref();
|
|
|
|
| 280 |
};
|
| 281 |
-
|
|
|
|
| 282 |
if (t0.unref) t0.unref();
|
|
|
|
| 283 |
}
|
| 284 |
|
| 285 |
// opencode has no per-conversation handle we can pass on launch, so we can't
|
|
@@ -296,10 +320,10 @@ function scheduleCodexCapture(session, workdir) {
|
|
| 296 |
// forever: the Overview shows no digest, `--resume` can't find the transcript so
|
| 297 |
// the session silently starts fresh every launch, and sharing can't locate it.
|
| 298 |
//
|
| 299 |
-
// So verify the pin after launch
|
| 300 |
-
// re-
|
| 301 |
// session pinned 4efced14…, transcript on disk cb22b656….
|
| 302 |
-
const claudeCapturing = new
|
| 303 |
|
| 304 |
function claudeProjectDirs() {
|
| 305 |
const home = process.env.HOME || '';
|
|
@@ -338,58 +362,145 @@ const transcriptExists = (uuid) =>
|
|
| 338 |
});
|
| 339 |
});
|
| 340 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
function scheduleClaudeCapture(session, workdir) {
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
| 344 |
const since = Date.now() - 2000;
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
if (
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
const created = Date.parse(head.timestamp || '') || 0;
|
| 363 |
-
if (created && created < since - 15_000) continue; // someone else's older thread
|
| 364 |
-
console.warn(`[claude] re-pinning ${session.id}: ${session.sessionUuid} -> ${uuid} (--session-id was not honoured)`);
|
| 365 |
-
update(session.id, { sessionUuid: uuid });
|
| 366 |
-
claudeCapturing.delete(session.id);
|
| 367 |
-
return;
|
| 368 |
}
|
| 369 |
-
|
| 370 |
-
const t = setTimeout(() => attempt(i + 1), delays[i + 1] - delays[i]);
|
| 371 |
if (t.unref) t.unref();
|
|
|
|
| 372 |
};
|
| 373 |
-
|
|
|
|
| 374 |
if (t0.unref) t0.unref();
|
|
|
|
| 375 |
}
|
| 376 |
|
| 377 |
-
const opencodeCapturing = new
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
function scheduleOpencodeCapture(session, workdir) {
|
| 379 |
-
|
| 380 |
-
|
| 381 |
const since = Date.now() - 2000;
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
if (
|
| 387 |
-
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
if (t.unref) t.unref();
|
|
|
|
| 390 |
};
|
| 391 |
-
|
|
|
|
| 392 |
if (t0.unref) t0.unref();
|
|
|
|
| 393 |
}
|
| 394 |
|
| 395 |
// Single-quote a string for embedding in an `sh -lc` command line.
|
|
|
|
| 145 |
// its first line. Capture that id shortly after launch and pin it on the
|
| 146 |
// session, so restarts resume THIS agent's conversation — `resume --last`
|
| 147 |
// would grab whichever Codex agent in the same folder ran last.
|
| 148 |
+
const codexCapturing = new Map(); // id -> pending re-pin timer
|
| 149 |
+
|
| 150 |
+
// Every harness's conversation pin is re-checked on this cadence for as long as
|
| 151 |
+
// the pane is alive, so a mid-session reset (/clear and friends) can't leave the
|
| 152 |
+
// pin describing a conversation the user has moved on from.
|
| 153 |
+
const REPIN_MS = 20_000;
|
| 154 |
|
| 155 |
function codexSessionsRoot() {
|
| 156 |
const home = process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex');
|
|
|
|
| 232 |
|
| 233 |
function tryCaptureCodexId(sessionId, workdir, sinceMs) {
|
| 234 |
const claimed = new Set(list().filter((s) => s.id !== sessionId && s.codexSessionId).map((s) => s.codexSessionId));
|
| 235 |
+
const pinned = (list().find((s) => s.id === sessionId) || {}).codexSessionId;
|
| 236 |
for (const c of codexRolloutsSince(sinceMs)) {
|
| 237 |
const m = c.p.match(/rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/);
|
| 238 |
if (!m || claimed.has(m[1])) continue;
|
|
|
|
| 249 |
// aren't this agent's conversation, so pinning one would break resume and
|
| 250 |
// the Overview digest.
|
| 251 |
if (mp.thread_source === 'subagent' || (mp.source && mp.source.subagent)) continue;
|
| 252 |
+
// The watcher re-runs for the life of the pane, so the usual outcome is
|
| 253 |
+
// "still the same conversation" — don't rewrite sessions.json for that.
|
| 254 |
+
if (m[1] === pinned) return true;
|
| 255 |
+
if (pinned) console.warn(`[codex] re-pinning ${sessionId}: ${pinned} -> ${m[1]} (conversation was replaced)`);
|
| 256 |
update(sessionId, { codexSessionId: m[1], codexRollout: c.p });
|
| 257 |
return true;
|
| 258 |
}
|
|
|
|
| 272 |
} catch { return false; }
|
| 273 |
}
|
| 274 |
|
| 275 |
+
// Same staleness problem as Claude's pin, same remedy: codex starts a fresh
|
| 276 |
+
// conversation — and a fresh rollout file — when the thread is reset, so a pin
|
| 277 |
+
// captured once at launch stops describing the live conversation. Keep watching
|
| 278 |
+
// for as long as the pane is alive and follow the newest rollout this folder
|
| 279 |
+
// produces. tryCaptureCodexId only writes when the id actually changes.
|
| 280 |
function scheduleCodexCapture(session, workdir) {
|
| 281 |
if (session.codexSessionId && pinIsStale(session)) {
|
| 282 |
session = update(session.id, { codexSessionId: undefined, codexRollout: undefined }) || session;
|
| 283 |
}
|
| 284 |
+
const prev = codexCapturing.get(session.id);
|
| 285 |
+
if (prev) clearTimeout(prev);
|
| 286 |
const since = Date.now() - 2000;
|
| 287 |
+
let warnedShared = false;
|
| 288 |
+
|
| 289 |
+
const tick = () => {
|
| 290 |
+
if (!isRunning(session.id)) { codexCapturing.delete(session.id); return; }
|
| 291 |
+
if (folderIsShared(session.id, workdir, 'codex')) {
|
| 292 |
+
if (!warnedShared) {
|
| 293 |
+
warnedShared = true;
|
| 294 |
+
console.warn(`[codex] ${session.id}: folder shared with another live session — not following thread resets here`);
|
| 295 |
+
}
|
| 296 |
+
} else {
|
| 297 |
+
tryCaptureCodexId(session.id, workdir, since);
|
| 298 |
}
|
| 299 |
+
const t = setTimeout(tick, REPIN_MS);
|
| 300 |
if (t.unref) t.unref();
|
| 301 |
+
codexCapturing.set(session.id, t);
|
| 302 |
};
|
| 303 |
+
|
| 304 |
+
const t0 = setTimeout(tick, 5000); // rollout appears ~instantly
|
| 305 |
if (t0.unref) t0.unref();
|
| 306 |
+
codexCapturing.set(session.id, t0);
|
| 307 |
}
|
| 308 |
|
| 309 |
// opencode has no per-conversation handle we can pass on launch, so we can't
|
|
|
|
| 320 |
// forever: the Overview shows no digest, `--resume` can't find the transcript so
|
| 321 |
// the session silently starts fresh every launch, and sharing can't locate it.
|
| 322 |
//
|
| 323 |
+
// So verify the pin after launch — and keep verifying it, see the watcher below —
|
| 324 |
+
// re-pinning to the transcript Claude actually wrote. Observed live on a test Space:
|
| 325 |
// session pinned 4efced14…, transcript on disk cb22b656….
|
| 326 |
+
const claudeCapturing = new Map(); // id -> pending re-pin timer
|
| 327 |
|
| 328 |
function claudeProjectDirs() {
|
| 329 |
const home = process.env.HOME || '';
|
|
|
|
| 362 |
});
|
| 363 |
});
|
| 364 |
|
| 365 |
+
// A transcript's opening cwd/timestamp never changes once written, and the
|
| 366 |
+
// filename IS the conversation id, so a path is never reused for a different
|
| 367 |
+
// conversation. That makes the head safe to remember — which matters because the
|
| 368 |
+
// watcher rescans every REPIN_MS for the life of every session, and on the Space
|
| 369 |
+
// these are synchronous reads against a FUSE mount. Without this, every tick
|
| 370 |
+
// re-read every transcript on disk and would show up as event-loop lag.
|
| 371 |
+
const headMemo = new Map(); // transcript path -> head
|
| 372 |
+
|
| 373 |
+
function transcriptHeadCached(p) {
|
| 374 |
+
const hit = headMemo.get(p);
|
| 375 |
+
if (hit) return hit;
|
| 376 |
+
const head = transcriptHead(p);
|
| 377 |
+
// Only remember a definite answer: null can just mean the file has no cwd line
|
| 378 |
+
// yet (still being written), and caching that would poison it for the process.
|
| 379 |
+
if (head) {
|
| 380 |
+
if (headMemo.size > 500) headMemo.clear();
|
| 381 |
+
headMemo.set(p, head);
|
| 382 |
+
}
|
| 383 |
+
return head;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
// The newest conversation written in `workdir` that no other session has pinned,
|
| 387 |
+
// as { uuid, start }. `start` is the conversation's OWN first timestamp, which is
|
| 388 |
+
// what distinguishes a /clear-spawned successor from the thread it replaced —
|
| 389 |
+
// both keep receiving mtime updates, only the successor is newly born.
|
| 390 |
+
// Exported for server/test/repin.test.mjs.
|
| 391 |
+
export function claudeCandidate(sessionId, workdir, sinceMs) {
|
| 392 |
+
const claimed = new Set(list().filter((s) => s.id !== sessionId && s.sessionUuid).map((s) => s.sessionUuid));
|
| 393 |
+
let best = null;
|
| 394 |
+
for (const c of claudeTranscriptsSince(sinceMs)) {
|
| 395 |
+
const uuid = path.basename(c.p).replace(/\.jsonl$/, '');
|
| 396 |
+
if (claimed.has(uuid)) continue;
|
| 397 |
+
// The cwd is NOT on the first line: a transcript opens with metadata lines
|
| 398 |
+
// (mode, permission-mode, file-history-snapshot, ai-title, worktree-state)
|
| 399 |
+
// that have no cwd, and only the conversation lines carry one — line 4 or 5
|
| 400 |
+
// in every real transcript measured. Reading line 1 made this check always
|
| 401 |
+
// fail, so the re-pin could never actually claim anything.
|
| 402 |
+
const head = transcriptHeadCached(c.p);
|
| 403 |
+
// Only claim a conversation started in THIS session's folder.
|
| 404 |
+
if (!head || head.cwd !== workdir) continue;
|
| 405 |
+
const start = Date.parse(head.timestamp || '') || 0;
|
| 406 |
+
// Born in this launch window, or it's an older thread that merely received
|
| 407 |
+
// writes — someone else's, or our own pre-relaunch one.
|
| 408 |
+
if (start && start < sinceMs - 15_000) continue;
|
| 409 |
+
if (!best || start > best.start) best = { uuid, start };
|
| 410 |
+
}
|
| 411 |
+
return best;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
// Another LIVE session of the same harness on the same folder makes a new
|
| 415 |
+
// conversation there unattributable: we cannot tell whose /clear produced it.
|
| 416 |
+
// Refuse to guess, the way share.js does when a folder has rivals.
|
| 417 |
+
function folderIsShared(sessionId, workdir, cli) {
|
| 418 |
+
return list().some((s) => s.id !== sessionId && s.cli === cli
|
| 419 |
+
&& path.join(WORKSPACES_DIR, s.path ?? s.id) === workdir && isRunning(s.id));
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
// Re-pinning is NOT a one-shot check, because the pin can go stale mid-session.
|
| 423 |
+
// `/clear` ends the conversation and starts a new one with an id of its own,
|
| 424 |
+
// exactly like the onboarding fallback above: the transcript we pinned stops
|
| 425 |
+
// growing and Claude writes a NEW file. Nothing told the manager, so the pin
|
| 426 |
+
// aged out silently — the Overview digest, the trace panel and sharing all kept
|
| 427 |
+
// reading the pre-/clear thread, and the next launch ran `--resume <old uuid>`,
|
| 428 |
+
// restoring the conversation as it was BEFORE the /clear and discarding
|
| 429 |
+
// everything since. That is why a Space restart brought back the old session.
|
| 430 |
+
//
|
| 431 |
+
// So the watcher keeps looking for as long as the pane is alive and follows the
|
| 432 |
+
// session forward onto whatever conversation Claude is actually writing. One
|
| 433 |
+
// mechanism now covers both failure modes: the launch-time fallback (pin never
|
| 434 |
+
// honoured) and a /clear at any later point.
|
| 435 |
function scheduleClaudeCapture(session, workdir) {
|
| 436 |
+
// A relaunch restarts the watch with a fresh window, so `since` can't drift
|
| 437 |
+
// older and start admitting pre-relaunch threads as candidates.
|
| 438 |
+
const prev = claudeCapturing.get(session.id);
|
| 439 |
+
if (prev) clearTimeout(prev);
|
| 440 |
const since = Date.now() - 2000;
|
| 441 |
+
let warnedShared = false;
|
| 442 |
+
|
| 443 |
+
const tick = () => {
|
| 444 |
+
if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; }
|
| 445 |
+
if (folderIsShared(session.id, workdir, 'claude')) {
|
| 446 |
+
if (!warnedShared) {
|
| 447 |
+
warnedShared = true;
|
| 448 |
+
console.warn(`[claude] ${session.id}: folder shared with another live session — not following /clear here`);
|
| 449 |
+
}
|
| 450 |
+
} else {
|
| 451 |
+
const pinned = (list().find((s) => s.id === session.id) || session).sessionUuid;
|
| 452 |
+
const hit = claudeCandidate(session.id, workdir, since);
|
| 453 |
+
if (hit && hit.uuid !== pinned) {
|
| 454 |
+
const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
|
| 455 |
+
console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${hit.uuid} (${why})`);
|
| 456 |
+
update(session.id, { sessionUuid: hit.uuid });
|
| 457 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
}
|
| 459 |
+
const t = setTimeout(tick, REPIN_MS);
|
|
|
|
| 460 |
if (t.unref) t.unref();
|
| 461 |
+
claudeCapturing.set(session.id, t);
|
| 462 |
};
|
| 463 |
+
|
| 464 |
+
const t0 = setTimeout(tick, 5000);
|
| 465 |
if (t0.unref) t0.unref();
|
| 466 |
+
claudeCapturing.set(session.id, t0);
|
| 467 |
}
|
| 468 |
|
| 469 |
+
const opencodeCapturing = new Map(); // id -> pending re-pin timer
|
| 470 |
+
|
| 471 |
+
// As with Claude and codex, the pin has to keep up with the live conversation:
|
| 472 |
+
// starting a new opencode conversation writes a new `session` row, and a pin
|
| 473 |
+
// captured once at launch would keep pointing at the abandoned one.
|
| 474 |
function scheduleOpencodeCapture(session, workdir) {
|
| 475 |
+
const prev = opencodeCapturing.get(session.id);
|
| 476 |
+
if (prev) clearTimeout(prev);
|
| 477 |
const since = Date.now() - 2000;
|
| 478 |
+
let warnedShared = false;
|
| 479 |
+
|
| 480 |
+
const tick = () => {
|
| 481 |
+
if (!isRunning(session.id)) { opencodeCapturing.delete(session.id); return; }
|
| 482 |
+
if (folderIsShared(session.id, workdir, 'opencode')) {
|
| 483 |
+
if (!warnedShared) {
|
| 484 |
+
warnedShared = true;
|
| 485 |
+
console.warn(`[opencode] ${session.id}: folder shared with another live session — not following new conversations here`);
|
| 486 |
+
}
|
| 487 |
+
} else {
|
| 488 |
+
const claimed = new Set(list().filter((s) => s.id !== session.id && s.opencodeSessionId).map((s) => s.opencodeSessionId));
|
| 489 |
+
const pinned = (list().find((s) => s.id === session.id) || session).opencodeSessionId;
|
| 490 |
+
const hit = captureOpencodeSession(workdir, since, claimed);
|
| 491 |
+
if (hit && hit.id !== pinned) {
|
| 492 |
+
if (pinned) console.warn(`[opencode] re-pinning ${session.id}: ${pinned} -> ${hit.id} (conversation was replaced)`);
|
| 493 |
+
update(session.id, { opencodeSessionId: hit.id });
|
| 494 |
+
}
|
| 495 |
+
}
|
| 496 |
+
const t = setTimeout(tick, REPIN_MS);
|
| 497 |
if (t.unref) t.unref();
|
| 498 |
+
opencodeCapturing.set(session.id, t);
|
| 499 |
};
|
| 500 |
+
|
| 501 |
+
const t0 = setTimeout(tick, 3000);
|
| 502 |
if (t0.unref) t0.unref();
|
| 503 |
+
opencodeCapturing.set(session.id, t0);
|
| 504 |
}
|
| 505 |
|
| 506 |
// Single-quote a string for embedding in an `sh -lc` command line.
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Conversation re-pinning: which transcript a session should follow.
|
| 2 |
+
//
|
| 3 |
+
// Regression cover for the bug where a mid-session `/clear` left the pin on the
|
| 4 |
+
// abandoned conversation, so `--resume` restored the pre-/clear thread after a
|
| 5 |
+
// restart. Run with: node test/repin.test.mjs
|
| 6 |
+
import fs from 'node:fs';
|
| 7 |
+
import path from 'node:path';
|
| 8 |
+
import os from 'node:os';
|
| 9 |
+
|
| 10 |
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'repin-'));
|
| 11 |
+
const CFG = path.join(TMP, 'cfg');
|
| 12 |
+
const DATA = path.join(TMP, 'data');
|
| 13 |
+
fs.mkdirSync(path.join(CFG, 'projects'), { recursive: true });
|
| 14 |
+
fs.mkdirSync(DATA, { recursive: true });
|
| 15 |
+
|
| 16 |
+
process.env.CLAUDE_CONFIG_DIR = CFG;
|
| 17 |
+
process.env.DATA_DIR = DATA;
|
| 18 |
+
|
| 19 |
+
const sessions = await import('../src/sessions.js');
|
| 20 |
+
const runner = await import('../src/runner.js');
|
| 21 |
+
const cfg = await import('../src/config.js');
|
| 22 |
+
sessions.init();
|
| 23 |
+
|
| 24 |
+
const WORKDIR = path.join(cfg.WORKSPACES_DIR, 'proj-a');
|
| 25 |
+
fs.mkdirSync(WORKDIR, { recursive: true });
|
| 26 |
+
|
| 27 |
+
// A transcript opens with metadata lines that carry no cwd (mode,
|
| 28 |
+
// file-history-snapshot, …); only the conversation lines have one. Mirror that,
|
| 29 |
+
// or the head-reading logic isn't really being exercised.
|
| 30 |
+
function transcript(uuid, { cwd = WORKDIR, startMs, mtimeMs, projDir = '-proj-a' }) {
|
| 31 |
+
const dir = path.join(CFG, 'projects', projDir);
|
| 32 |
+
fs.mkdirSync(dir, { recursive: true });
|
| 33 |
+
const p = path.join(dir, `${uuid}.jsonl`);
|
| 34 |
+
fs.writeFileSync(p, [
|
| 35 |
+
JSON.stringify({ type: 'mode', mode: 'default' }),
|
| 36 |
+
JSON.stringify({ type: 'file-history-snapshot' }),
|
| 37 |
+
JSON.stringify({ type: 'user', sessionId: uuid, cwd, timestamp: new Date(startMs).toISOString() }),
|
| 38 |
+
].join('\n') + '\n');
|
| 39 |
+
const t = (mtimeMs ?? startMs) / 1000;
|
| 40 |
+
fs.utimesSync(p, t, t);
|
| 41 |
+
return p;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
let pass = 0, fail = 0;
|
| 45 |
+
const check = (name, got, want) => {
|
| 46 |
+
const ok = got === want;
|
| 47 |
+
ok ? pass++ : fail++;
|
| 48 |
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : `\n got ${got} want ${want}`}`);
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const NOW = 1770000000000; // fixed clock: these are all relative comparisons
|
| 52 |
+
const SINCE = NOW - 2000; // the launch window scheduleClaudeCapture opens with
|
| 53 |
+
const A = 'aaaaaaaa-0000-0000-0000-000000000001';
|
| 54 |
+
const B = 'bbbbbbbb-0000-0000-0000-000000000002';
|
| 55 |
+
|
| 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 |
+
console.log(`\n${pass} passed, ${fail} failed`);
|
| 80 |
+
fs.rmSync(TMP, { recursive: true, force: true });
|
| 81 |
+
process.exit(fail ? 1 : 0);
|