cc-viewer 1.7.2 → 1.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +21 -3
- package/dist/assets/{App-B224r-8M.js → App-DRsuJXEw.js} +1 -1
- package/dist/assets/{MdxEditorPanel-BUubiE6Y.js → MdxEditorPanel-D_dewIIc.js} +1 -1
- package/dist/assets/{Mobile-CKmfRRvo.js → Mobile-BIzb7vpA.js} +1 -1
- package/dist/assets/{index-CjsQEQc2.js → index-DidP9FCD.js} +2 -2
- package/dist/assets/{seqResourceLoaders-C-T_EMiO.js → seqResourceLoaders-BtGpfGRW.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/i18n.js +20 -0
- package/server/interceptor.js +45 -1
- package/server/lib/ensure-hooks.js +28 -2
- package/server/lib/log-watcher.js +8 -0
- package/server/lib/pid-alive.js +15 -0
- package/server/lib/session-start-bridge.js +121 -0
- package/server/lib/settings-merge.js +170 -0
- package/server/lib/v2/live-feed.js +189 -48
- package/server/lib/v2/session-owner.js +135 -0
- package/server/lib/v2/session-select.js +15 -2
- package/server/lib/v2/v2-writer.js +181 -6
- package/server/pty-manager.js +28 -4
- package/server/routes/events.js +44 -0
- package/server/server.js +4 -1
|
@@ -22,13 +22,15 @@
|
|
|
22
22
|
// through log-watcher's processWatchedEntry pipeline — the same enrichment,
|
|
23
23
|
// reconstruction and side-events the v1 tail produced.
|
|
24
24
|
|
|
25
|
-
import { watch, existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
25
|
+
import { watch, existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import { reportSwallowed } from '../error-report.js';
|
|
28
28
|
import { createIncrementalReconstructor } from '../delta-reconstructor.js';
|
|
29
29
|
import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
|
|
30
30
|
import { SessionSynthesizer } from './adapter.js';
|
|
31
31
|
import { isDiscardableSession } from './session-select.js';
|
|
32
|
+
import { isForeignLiveOwned } from './session-owner.js';
|
|
33
|
+
import { isConvertRunning } from './convert-manager.js';
|
|
32
34
|
import { computeCacheLoss } from './meta-rows.js';
|
|
33
35
|
import { classifyRequest } from '../../../src/utils/requestType.js';
|
|
34
36
|
|
|
@@ -38,6 +40,12 @@ const DEFER_MS = 3000; // synthesizer park deadline (lagging lines)
|
|
|
38
40
|
const ATTACH_RECENT_MS = 5 * 60 * 1000; // pre-existing dirs considered "live"
|
|
39
41
|
const TICK_RETRY_MS = 250; // in-process tick raced the first queue drain
|
|
40
42
|
const TICK_RETRY_MAX = 8;
|
|
43
|
+
// Idle cursor eviction: a followed dir whose journal hasn't moved for this
|
|
44
|
+
// long is detached, freeing its synthesizer/reconstructor state (a migration
|
|
45
|
+
// can promote hundreds of dirs at once — holding all of them pins ~the whole
|
|
46
|
+
// project in heap). Re-attach self-heals via the safety poll's mtime-bump
|
|
47
|
+
// scan or the leader's tick() (both suppress history on a seen dir).
|
|
48
|
+
const IDLE_EVICT_MS = 10 * 60 * 1000;
|
|
41
49
|
|
|
42
50
|
const READ_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
43
51
|
// Node's max string length (~512MiB) — a longer line can never be decoded.
|
|
@@ -149,7 +157,11 @@ export class V2LiveFeed {
|
|
|
149
157
|
* @param {number} [opts.safetyPollMs] - 0 disables the timer (tests drive
|
|
150
158
|
* _safetyTick manually)
|
|
151
159
|
* @param {number} [opts.deferMs]
|
|
160
|
+
* @param {number} [opts.idleEvictMs] - 0 disables idle cursor eviction (tests)
|
|
152
161
|
* @param {Function} [opts.now]
|
|
162
|
+
* @param {Function} [opts.isConvertRunningFn] - injection seam (tests)
|
|
163
|
+
* @param {Function} [opts.isForeignLiveOwnedFn] - injection seam (tests):
|
|
164
|
+
* (dir) => boolean, defaults to session-owner's isForeignLiveOwned
|
|
153
165
|
*/
|
|
154
166
|
constructor(opts = {}) {
|
|
155
167
|
this._clients = opts.clients || [];
|
|
@@ -159,7 +171,14 @@ export class V2LiveFeed {
|
|
|
159
171
|
this._watchImpl = opts.watchImpl || watch;
|
|
160
172
|
this._safetyPollMs = typeof opts.safetyPollMs === 'number' ? opts.safetyPollMs : SAFETY_POLL_MS;
|
|
161
173
|
this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : DEFER_MS;
|
|
174
|
+
this._idleEvictMs = typeof opts.idleEvictMs === 'number' ? opts.idleEvictMs : IDLE_EVICT_MS;
|
|
162
175
|
this._now = opts.now || Date.now;
|
|
176
|
+
this._isConvertRunning = opts.isConvertRunningFn || isConvertRunning;
|
|
177
|
+
// Claim owners are compared against THIS process's pid: the feed must
|
|
178
|
+
// co-reside with its V2Writer in one process (worker_threads would still
|
|
179
|
+
// share the pid; moving the feed into a forked CHILD process would make
|
|
180
|
+
// every own dir look foreign and break single-window live-follow).
|
|
181
|
+
this._isForeignLiveOwned = opts.isForeignLiveOwnedFn || isForeignLiveOwned;
|
|
163
182
|
// Wire v3 (V3.S2): when on, every emitted item ALSO broadcasts a metadata
|
|
164
183
|
// row (v2_requests_delta). Explicit ctor param — this module has no access
|
|
165
184
|
// to the server deps object.
|
|
@@ -207,7 +226,12 @@ export class V2LiveFeed {
|
|
|
207
226
|
if (!this._active || !sessionDir) return;
|
|
208
227
|
let cur = this._sessions.get(sessionDir);
|
|
209
228
|
if (!cur) {
|
|
210
|
-
|
|
229
|
+
// A dir we have ALREADY seen re-attaching through tick() is an idle
|
|
230
|
+
// eviction (or a watcher loss) resuming — its backlog is history and
|
|
231
|
+
// must be seeded suppressed, or the re-attach replays the whole session
|
|
232
|
+
// through the broadcast path (the exact OOM this module was fixed for).
|
|
233
|
+
// Only a genuinely never-seen dir emits from byte 0.
|
|
234
|
+
cur = this._attach(sessionDir, { suppressExisting: this._seenDirs.has(sessionDir) });
|
|
211
235
|
if (!cur) {
|
|
212
236
|
if (_retries < TICK_RETRY_MAX) {
|
|
213
237
|
setTimeout(() => this.tick(sessionDir, _retries + 1), TICK_RETRY_MS);
|
|
@@ -315,6 +339,16 @@ export class V2LiveFeed {
|
|
|
315
339
|
_attach(dir, { suppressExisting }) {
|
|
316
340
|
if (this._sessions.has(dir)) return this._sessions.get(dir);
|
|
317
341
|
if (!existsSync(join(dir, 'journal.jsonl'))) return null;
|
|
342
|
+
// Multi-window isolation: a dir exclusively claimed by ANOTHER live ccv
|
|
343
|
+
// window (owner.lock, pid-liveness validity) never enters this feed — its
|
|
344
|
+
// traffic belongs to that window. Checked BEFORE the discard gate so a
|
|
345
|
+
// foreign dir never lands in _discardGated (whose delete side-effect would
|
|
346
|
+
// flip suppressExisting to false on a later attach and flood clients with
|
|
347
|
+
// the whole foreign history). Re-evaluated on every attach attempt — no
|
|
348
|
+
// persistent gate set — so a dead owner's claim stops mattering the
|
|
349
|
+
// moment its pid dies. Own dirs (tick path) and unclaimed teammate/IM
|
|
350
|
+
// dirs pass through untouched.
|
|
351
|
+
if (this._isForeignLiveOwned(dir)) return null;
|
|
318
352
|
// Discardable sessions (quota-probe orphans — no main/teammate req, no
|
|
319
353
|
// meta.leader) are never followed: single choke point, every attach path
|
|
320
354
|
// (_initialScan / _maybeAttachNew / tick / _safetyTick / _rebuildCursor)
|
|
@@ -332,6 +366,32 @@ export class V2LiveFeed {
|
|
|
332
366
|
// not swallow it: cross-process producers (IM worker, second ccv) have no
|
|
333
367
|
// cold-load fallback for a connected client.
|
|
334
368
|
if (this._discardGated.delete(dir)) suppressExisting = false;
|
|
369
|
+
// Migration output is dead history: converted sessions (meta origin:
|
|
370
|
+
// 'convert') have no producer and never append again, yet a promote
|
|
371
|
+
// renames dozens of them into sessions/ at once with fresh birthtimes —
|
|
372
|
+
// full-history seeding synthesized + JSON-round-tripped every entry of
|
|
373
|
+
// every promoted session inside the safety-poll/debounce timers (the
|
|
374
|
+
// 4GB main-thread OOM). Seek their cursors to EOF instead: nothing is
|
|
375
|
+
// read, cloned, or broadcast; the cold-load channel owns their history.
|
|
376
|
+
let convertOrigin = false;
|
|
377
|
+
try {
|
|
378
|
+
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
|
|
379
|
+
convertOrigin = !!meta && meta.origin === 'convert';
|
|
380
|
+
} catch (err) {
|
|
381
|
+
// meta.json is written synchronously BEFORE the journal's first async
|
|
382
|
+
// drain (ensureSessionDirSync), so journal-present + meta-ABSENT is
|
|
383
|
+
// abnormal. Never fall through to a broadcast replay: mid-conversion
|
|
384
|
+
// (same-process check — the converter worker runs inside the server
|
|
385
|
+
// process) the dir is almost certainly a promote in flight — treat as
|
|
386
|
+
// convert output; a truly missing meta otherwise seeds suppressed (cold
|
|
387
|
+
// load covers the history). Any OTHER errno is a transient lock on an
|
|
388
|
+
// EXISTING meta (Windows AV/EBUSY) — keep the caller's suppress verdict:
|
|
389
|
+
// flipping a fresh live session to suppressed would silently drop its
|
|
390
|
+
// first turn (review finding).
|
|
391
|
+
reportSwallowed('v2-live.meta-read', err);
|
|
392
|
+
if (this._isConvertRunning()) convertOrigin = true;
|
|
393
|
+
else if (err && err.code === 'ENOENT') suppressExisting = true;
|
|
394
|
+
}
|
|
335
395
|
const cur = {
|
|
336
396
|
dir,
|
|
337
397
|
synth: new SessionSynthesizer(dir, { deferMs: this._deferMs, now: this._now }),
|
|
@@ -348,24 +408,92 @@ export class V2LiveFeed {
|
|
|
348
408
|
reading: false,
|
|
349
409
|
dirty: false,
|
|
350
410
|
suppress: !!suppressExisting,
|
|
411
|
+
convertOrigin,
|
|
412
|
+
// Journal req seqs with no done yet. The journal is only touched at
|
|
413
|
+
// request start and completion, so a single >10min request would look
|
|
414
|
+
// "idle" to the mtime-based eviction and get detached mid-flight — its
|
|
415
|
+
// completion would then re-attach suppressed and never resolve the
|
|
416
|
+
// already-broadcast placeholder (review finding). Eviction skips
|
|
417
|
+
// cursors with an open request. A crash orphan (req, no done ever)
|
|
418
|
+
// pins its one session un-evicted — acceptable: eviction is a memory
|
|
419
|
+
// optimization, a stuck live card is a correctness bug.
|
|
420
|
+
openReqs: new Set(),
|
|
351
421
|
};
|
|
352
422
|
this._sessions.set(dir, cur);
|
|
353
423
|
this._seenDirs.set(dir, this._journalMtime(dir));
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
424
|
+
if (!convertOrigin) {
|
|
425
|
+
try {
|
|
426
|
+
cur.watcher = this._watchImpl(dir, () => this._scheduleRead(cur));
|
|
427
|
+
cur.watcher.on('error', (err) => {
|
|
428
|
+
reportSwallowed('v2-live.session-watch', err);
|
|
429
|
+
try { cur.watcher.close(); } catch { /* already closed */ }
|
|
430
|
+
cur.watcher = null; // safety poll keeps the cursor alive
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
357
433
|
reportSwallowed('v2-live.session-watch', err);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
434
|
+
}
|
|
435
|
+
this._readCursor(cur); // seed (suppressed history or brand-new content)
|
|
436
|
+
} else {
|
|
437
|
+
// No per-dir watcher either: a promote can bring hundreds of dead dirs
|
|
438
|
+
// at once and each fs.watch costs an fd. The safety poll's unconditional
|
|
439
|
+
// _readCursor still picks up any (theoretical) append past the seeked
|
|
440
|
+
// offsets until idle eviction reclaims the cursor.
|
|
441
|
+
this._seekCursorsToEof(cur);
|
|
363
442
|
}
|
|
364
|
-
this._readCursor(cur); // seed (suppressed history or brand-new content)
|
|
365
443
|
cur.suppress = false;
|
|
366
444
|
return cur;
|
|
367
445
|
}
|
|
368
446
|
|
|
447
|
+
/** Position every cursor of a session at the current end of its file —
|
|
448
|
+
* attach without reading: nothing existing is synthesized or emitted, only
|
|
449
|
+
* bytes appended AFTER this point would flow. Enumerates ALL conv epoch
|
|
450
|
+
* files up front so pre-existing epochs can't be replayed from 0 later. */
|
|
451
|
+
_seekCursorsToEof(cur) {
|
|
452
|
+
const sizeOf = (p) => {
|
|
453
|
+
try { return statSync(p).size; } catch (err) {
|
|
454
|
+
if (err && err.code === 'ENOENT') return 0; // missing file: offset 0 IS its EOF
|
|
455
|
+
// Any other stat failure must never seed offset 0 — that re-arms the
|
|
456
|
+
// full replay this seek exists to prevent. Park the cursor past any
|
|
457
|
+
// real size; a later successful read sees size < offset and rebuilds.
|
|
458
|
+
reportSwallowed('v2-live.seek-stat', err);
|
|
459
|
+
return Number.MAX_SAFE_INTEGER;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
cur.journal.offset = sizeOf(cur.journal.path);
|
|
463
|
+
cur.responses.offset = sizeOf(cur.responses.path);
|
|
464
|
+
for (const { key, path } of this._enumerateConvFiles(cur.dir)) {
|
|
465
|
+
cur.convFiles.set(path, { key, path, offset: sizeOf(path) });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Enumerate a session's conversation epoch files as [{key, path}], epochs
|
|
470
|
+
* numerically ordered within each conv key — the single home of the
|
|
471
|
+
* two-level conversations/<key>/e<N>.jsonl walk (shared by the EOF-seek
|
|
472
|
+
* attach and the incremental reader; review dedup). NB: file order is NOT
|
|
473
|
+
* guaranteed to be globally seq-ordered (a pre-fix writer restart appended
|
|
474
|
+
* newer seqs into an older epoch file) — the synthesizer's ingestConvLine
|
|
475
|
+
* keeps its event window seq-sorted on insert, so consumers only need a
|
|
476
|
+
* stable feed order. */
|
|
477
|
+
_enumerateConvFiles(dir) {
|
|
478
|
+
const convRoot = join(dir, 'conversations');
|
|
479
|
+
const out = [];
|
|
480
|
+
let keys = [];
|
|
481
|
+
try {
|
|
482
|
+
keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
483
|
+
} catch {
|
|
484
|
+
return out; // no conversations dir yet
|
|
485
|
+
}
|
|
486
|
+
for (const key of keys) {
|
|
487
|
+
let files = [];
|
|
488
|
+
try {
|
|
489
|
+
files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
|
|
490
|
+
} catch { continue; }
|
|
491
|
+
files.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
|
|
492
|
+
for (const f of files) out.push({ key, path: join(convRoot, key, f) });
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
|
|
369
497
|
_closeCursor(cur) {
|
|
370
498
|
if (cur.debounce) { clearTimeout(cur.debounce); cur.debounce = null; }
|
|
371
499
|
if (cur.watcher) { try { cur.watcher.close(); } catch { /* already closed */ } cur.watcher = null; }
|
|
@@ -405,7 +533,11 @@ export class V2LiveFeed {
|
|
|
405
533
|
try { line = JSON.parse(raw); } catch (err) {
|
|
406
534
|
reportSwallowed('v2-live.journal-parse', new Error(`${cur.dir}: ${err.message}`));
|
|
407
535
|
}
|
|
408
|
-
if (line)
|
|
536
|
+
if (line) {
|
|
537
|
+
if (line.ph === 'req') cur.openReqs.add(line.seq);
|
|
538
|
+
else if (line.ph === 'done') cur.openReqs.delete(line.seq);
|
|
539
|
+
cur.synth.ingestJournalLine(line);
|
|
540
|
+
}
|
|
409
541
|
}
|
|
410
542
|
this._emitDrained(cur);
|
|
411
543
|
} while (cur.dirty);
|
|
@@ -415,44 +547,22 @@ export class V2LiveFeed {
|
|
|
415
547
|
}
|
|
416
548
|
|
|
417
549
|
_feedConvFiles(cur) {
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
} catch {
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
for (const key of keys) {
|
|
427
|
-
let files = [];
|
|
428
|
-
try {
|
|
429
|
-
files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
|
|
430
|
-
} catch {
|
|
431
|
-
continue;
|
|
550
|
+
for (const { key, path } of this._enumerateConvFiles(cur.dir)) {
|
|
551
|
+
let fc = cur.convFiles.get(path);
|
|
552
|
+
if (!fc) {
|
|
553
|
+
fc = { key, path, offset: 0 };
|
|
554
|
+
cur.convFiles.set(path, fc);
|
|
432
555
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const p = join(convRoot, key, f);
|
|
440
|
-
let fc = cur.convFiles.get(p);
|
|
441
|
-
if (!fc) {
|
|
442
|
-
fc = { key, path: p, offset: 0 };
|
|
443
|
-
cur.convFiles.set(p, fc);
|
|
556
|
+
const lines = readNewLines(fc);
|
|
557
|
+
if (lines === null) { this._rebuildCursor(cur); return; }
|
|
558
|
+
for (const raw of lines) {
|
|
559
|
+
let ev = null;
|
|
560
|
+
try { ev = JSON.parse(raw); } catch (err) {
|
|
561
|
+
reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
|
|
444
562
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
let ev = null;
|
|
449
|
-
try { ev = JSON.parse(raw); } catch (err) {
|
|
450
|
-
reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
|
|
451
|
-
}
|
|
452
|
-
if (ev) {
|
|
453
|
-
cur.synth.ingestConvLine(key, ev);
|
|
454
|
-
if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
|
|
455
|
-
}
|
|
563
|
+
if (ev) {
|
|
564
|
+
cur.synth.ingestConvLine(key, ev);
|
|
565
|
+
if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
|
|
456
566
|
}
|
|
457
567
|
}
|
|
458
568
|
}
|
|
@@ -622,7 +732,38 @@ export class V2LiveFeed {
|
|
|
622
732
|
if (!this._active) return;
|
|
623
733
|
this._armRootWatcher(); // re-arm after errors / late directory creation
|
|
624
734
|
// Attached sessions: unconditional slow re-read (fs.watch is lossy).
|
|
735
|
+
// Idle eviction first: a cursor whose journal has not moved for
|
|
736
|
+
// _idleEvictMs is detached, releasing its synthesizer/reconstructor/
|
|
737
|
+
// _v3PrevMain retained state (post-migration this is ~the whole project).
|
|
738
|
+
// _seenDirs keeps the mtime, so the existing mtime-bump scan below (or the
|
|
739
|
+
// leader's tick(), now suppress-on-seen) re-attaches on real new activity.
|
|
625
740
|
for (const cur of [...this._sessions.values()]) {
|
|
741
|
+
// Multi-window isolation: an ATTACHED dir can turn foreign-owned after
|
|
742
|
+
// the fact — an unowned (dead-owner) dir followed since startup gets
|
|
743
|
+
// adopted by another window's `ccv -c`. The attach-time gate can't see
|
|
744
|
+
// that, so re-check here and detach before the unconditional re-read
|
|
745
|
+
// would stream the adopter's traffic into this window (bounded to one
|
|
746
|
+
// safety-poll period). _seenDirs keeps the current mtime so a later
|
|
747
|
+
// ownership release (owner exits) plus new activity re-attaches through
|
|
748
|
+
// the normal mtime-bump path below.
|
|
749
|
+
if (this._isForeignLiveOwned(cur.dir)) {
|
|
750
|
+
this._closeCursor(cur);
|
|
751
|
+
this._sessions.delete(cur.dir);
|
|
752
|
+
this._seenDirs.set(cur.dir, this._journalMtime(cur.dir));
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
// openReqs guard: never evict mid-flight — a long request keeps the
|
|
756
|
+
// journal mtime frozen between its req and done lines, and evicting
|
|
757
|
+
// then would strand its already-broadcast placeholder (see openReqs).
|
|
758
|
+
if (this._idleEvictMs > 0 && cur.openReqs.size === 0) {
|
|
759
|
+
const mtime = this._journalMtime(cur.dir);
|
|
760
|
+
if (mtime > 0 && this._now() - mtime > this._idleEvictMs) {
|
|
761
|
+
this._closeCursor(cur);
|
|
762
|
+
this._sessions.delete(cur.dir);
|
|
763
|
+
this._seenDirs.set(cur.dir, mtime);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
626
767
|
this._readCursor(cur);
|
|
627
768
|
}
|
|
628
769
|
// Unattached dirs: attach brand-new ones (missed root events) and
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Per-session-dir live-owner claim (multi-window log isolation, 2026-07-17).
|
|
2
|
+
//
|
|
3
|
+
// Problem: every ccv window (one process = V2Writer + viewer server) writes
|
|
4
|
+
// into the SHARED `LOG_DIR/<project>/sessions/` root. The "newest session"
|
|
5
|
+
// selectors (cold-load fallback, `-c` folder adoption) and the live feed's
|
|
6
|
+
// whole-root follow have no notion of "which process owns which dir", so
|
|
7
|
+
// parallel windows on one project cross-read (and, via adoption, cross-WRITE)
|
|
8
|
+
// each other's sessions.
|
|
9
|
+
//
|
|
10
|
+
// Mechanism: the writing process claims its session dir with an `owner.lock`
|
|
11
|
+
// sidecar `{pid, startedAt}`. The file is only a POINTER — the claim's
|
|
12
|
+
// validity is "the owning process is alive" (`process.kill(pid, 0)`, a
|
|
13
|
+
// kernel-memory fact), so a crashed owner's claim expires the instant the
|
|
14
|
+
// process dies and a dir can never be locked forever. Same liveness model as
|
|
15
|
+
// im-lock.js / log-management.js. The sidecar never touches journal /
|
|
16
|
+
// conversations / blobs — it is not part of the wire format.
|
|
17
|
+
//
|
|
18
|
+
// Error-direction asymmetry (deliberate — each path errs toward its safe side):
|
|
19
|
+
// - acquire: an unreadable/half-written lock is treated as HELD (refuse) —
|
|
20
|
+
// worst case the caller mints a fresh dir, which is Claude Code's own
|
|
21
|
+
// `-c` fork semantics anyway; never two writers on one journal.
|
|
22
|
+
// - isForeignLiveOwned: an unreadable lock is treated as UNOWNED (readable) —
|
|
23
|
+
// a torn crash residue must never permanently gate a dir out of the live
|
|
24
|
+
// feed / cold-load fallback.
|
|
25
|
+
|
|
26
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { isPidAlive } from '../pid-alive.js';
|
|
29
|
+
import { reportSwallowed } from '../error-report.js';
|
|
30
|
+
|
|
31
|
+
export const OWNER_LOCK_NAME = 'owner.lock';
|
|
32
|
+
|
|
33
|
+
// Contention retries for the wx create-exclusive acquire (im-lock precedent).
|
|
34
|
+
const MAX_CLAIM_ATTEMPTS = 3;
|
|
35
|
+
|
|
36
|
+
export function ownerLockPath(dir) { return join(dir, OWNER_LOCK_NAME); }
|
|
37
|
+
|
|
38
|
+
/** Read the claim; missing / half-written / non-object → null (tolerated,
|
|
39
|
+
* never grounds for deletion here — acquire has its own recycle rules). */
|
|
40
|
+
export function readSessionClaim(dir) {
|
|
41
|
+
try {
|
|
42
|
+
const o = JSON.parse(readFileSync(ownerLockPath(dir), 'utf-8'));
|
|
43
|
+
return (o && typeof o === 'object') ? o : null;
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Atomically claim `dir` for `pid` (default: this process). Modeled on
|
|
49
|
+
* im-lock.js acquireImLock: `wx` create-exclusive IS the arbiter, so two
|
|
50
|
+
* processes racing to adopt the same dead-owned dir get exactly one winner.
|
|
51
|
+
* Two DELIBERATE divergences from that model (don't "fix" them back):
|
|
52
|
+
* own-pid holder → idempotent {ok:true} without rewriting (acquire runs twice
|
|
53
|
+
* per dir: adoption, then _session), where im-lock recycles its own residue;
|
|
54
|
+
* and content is written in the same call as creation ('wx' flag) — no
|
|
55
|
+
* empty-file window between sentinel and payload, so no temp+rename step.
|
|
56
|
+
* `startedAt` is diagnostic only — validity is pid-liveness alone (the
|
|
57
|
+
* accepted im-lock-style pid-reuse tradeoff; a socket-bind probe would close
|
|
58
|
+
* it and is backlog).
|
|
59
|
+
*
|
|
60
|
+
* @param {string} dir - absolute session dir
|
|
61
|
+
* @param {{pid?: number, pidAlive?: (pid:number)=>boolean}} [opts] test seams
|
|
62
|
+
* @returns {{ok:true}|{ok:false, holder?:object|null}} ok:false = cannot
|
|
63
|
+
* claim, for any of: a live foreign holder, an unreadable/half-written lock
|
|
64
|
+
* (conservatively treated as held), an unexpected write I/O failure
|
|
65
|
+
* (reported via reportSwallowed), or contention exhausting the retries.
|
|
66
|
+
* `holder` is set only when a live foreign pid was identified. Either way
|
|
67
|
+
* the caller must NOT write into this dir (adoption: mint a fresh one).
|
|
68
|
+
*/
|
|
69
|
+
export function acquireSessionClaim(dir, opts = {}) {
|
|
70
|
+
const pid = opts.pid ?? process.pid;
|
|
71
|
+
const pidAlive = opts.pidAlive || isPidAlive;
|
|
72
|
+
const p = ownerLockPath(dir);
|
|
73
|
+
for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(p, JSON.stringify({ pid, startedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
|
|
76
|
+
return { ok: true };
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (!e || e.code !== 'EEXIST') {
|
|
79
|
+
// Unexpected I/O failure (EACCES, EROFS, fd exhaustion…): a silently
|
|
80
|
+
// missing claim means the isolation quietly stops working — report.
|
|
81
|
+
reportSwallowed('v2-owner.claim', e);
|
|
82
|
+
return { ok: false, holder: null };
|
|
83
|
+
}
|
|
84
|
+
const holder = readSessionClaim(dir);
|
|
85
|
+
if (holder && holder.pid === pid) return { ok: true }; // idempotent re-entry
|
|
86
|
+
if (holder && pidAlive(holder.pid)) return { ok: false, holder };
|
|
87
|
+
if (!holder) {
|
|
88
|
+
// Unreadable: either a concurrent recycler unlinked it between our
|
|
89
|
+
// EEXIST and the read (→ retry the atomic create) or it is genuinely
|
|
90
|
+
// half-written (→ conservatively held; the writer is mid-claim).
|
|
91
|
+
if (!existsSync(p)) continue;
|
|
92
|
+
return { ok: false, holder: null };
|
|
93
|
+
}
|
|
94
|
+
// Dead holder → recycle the stale lock and retry the atomic create.
|
|
95
|
+
try { unlinkSync(p); } catch { /* concurrent recycler won the unlink — retry */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Contention exhausted the retries: report the current holder if live.
|
|
99
|
+
const holder = readSessionClaim(dir);
|
|
100
|
+
if (holder && holder.pid !== pid && pidAlive(holder.pid)) return { ok: false, holder };
|
|
101
|
+
return { ok: false, holder: holder || null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Release the claim on `dir` — identity-checked: only unlinks when the lock
|
|
106
|
+
* is held by `pid` (default: this process), so a successor process's claim is
|
|
107
|
+
* never deleted by a late-exiting predecessor. Missing file = already
|
|
108
|
+
* released. Synchronous on purpose: it runs at the top of V2Writer.close()
|
|
109
|
+
* ahead of the (bounded, raceable) async queue drain.
|
|
110
|
+
* @returns {boolean} true when the dir is no longer claimed by `pid`
|
|
111
|
+
*/
|
|
112
|
+
export function releaseSessionClaim(dir, opts = {}) {
|
|
113
|
+
const pid = opts.pid ?? process.pid;
|
|
114
|
+
const holder = readSessionClaim(dir);
|
|
115
|
+
if (holder && holder.pid !== pid) return false; // taken over — don't touch
|
|
116
|
+
try { unlinkSync(ownerLockPath(dir)); } catch { /* ENOENT = already released */ }
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Is `dir` exclusively owned by ANOTHER live process? The single skip
|
|
122
|
+
* predicate used by latestMainSession (cold-load fallback + `-c` adoption
|
|
123
|
+
* candidates) and the live feed's attach/re-check gates. History readers
|
|
124
|
+
* never call this — a dead owner's claim makes the dir plain unowned data.
|
|
125
|
+
* @param {string} dir - absolute session dir
|
|
126
|
+
* @param {{pid?: number, pidAlive?: (pid:number)=>boolean}} [opts] test seams
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
export function isForeignLiveOwned(dir, opts = {}) {
|
|
130
|
+
const pid = opts.pid ?? process.pid;
|
|
131
|
+
const pidAlive = opts.pidAlive || isPidAlive;
|
|
132
|
+
const holder = readSessionClaim(dir); // unreadable → null → unowned (self-healing)
|
|
133
|
+
if (!holder || !Number.isInteger(holder.pid)) return false;
|
|
134
|
+
return holder.pid !== pid && pidAlive(holder.pid);
|
|
135
|
+
}
|
|
@@ -24,6 +24,7 @@ import { join } from 'node:path';
|
|
|
24
24
|
import { StringDecoder } from 'node:string_decoder';
|
|
25
25
|
import { listSessionIds } from './replay.js';
|
|
26
26
|
import { isSupportedWireFormat } from './layout.js';
|
|
27
|
+
import { isForeignLiveOwned } from './session-owner.js';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Find the on-disk dir NAME of an existing session for `sessionId` (a UUID), or
|
|
@@ -247,17 +248,29 @@ export function isDiscardableSession(dir, meta) {
|
|
|
247
248
|
* has-a-main-req gate would otherwise re-select exactly the dir the caller's
|
|
248
249
|
* completed-turn gate just rejected (it IS the newest once its first main req
|
|
249
250
|
* is written), nullifying the fallback.
|
|
251
|
+
*
|
|
252
|
+
* `skipForeignLive` (multi-window isolation, 2026-07-17) drops candidates
|
|
253
|
+
* whose `owner.lock` is held by ANOTHER live process — a parallel ccv window's
|
|
254
|
+
* in-flight session. Without it, the cold-load fallback serves the other
|
|
255
|
+
* window's conversation and `-c` adoption writes into its journal. A dead
|
|
256
|
+
* owner's claim expires with its pid (kernel liveness), so crashed windows'
|
|
257
|
+
* sessions stay selectable — never a permanent lock. Both consumers
|
|
258
|
+
* (getLiveLogSource fallback, _resolveAdoption) pass true; default false keeps
|
|
259
|
+
* the raw picker semantics for everything else.
|
|
250
260
|
* @param {string} projectDir - absolute LOG_DIR/<project>
|
|
251
|
-
* @param {{excludeDir?: string}} [opts]
|
|
261
|
+
* @param {{excludeDir?: string, skipForeignLive?: boolean}} [opts]
|
|
252
262
|
* @returns {{dir:string, sessionId:string}|null}
|
|
253
263
|
*/
|
|
254
|
-
export function latestMainSession(projectDir, { excludeDir = '' } = {}) {
|
|
264
|
+
export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive = false } = {}) {
|
|
255
265
|
if (!projectDir) return null;
|
|
256
266
|
const candidates = []; // { dir, sessionId, startTs }
|
|
257
267
|
for (const name of listSessionIds(projectDir)) {
|
|
258
268
|
const dir = join(projectDir, 'sessions', name);
|
|
259
269
|
if (excludeDir && dir === excludeDir) continue;
|
|
260
270
|
if (!existsSync(join(dir, 'journal.jsonl'))) continue;
|
|
271
|
+
// After the cheaper journal-existence reject: skips one owner.lock read
|
|
272
|
+
// per torn/non-session dir on large-history cold loads.
|
|
273
|
+
if (skipForeignLive && isForeignLiveOwned(dir)) continue;
|
|
261
274
|
let meta = null;
|
|
262
275
|
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { continue; }
|
|
263
276
|
if (!meta) continue;
|