pi-crew 0.10.2 → 0.10.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. package/src/worktree/worktree-manager.ts +65 -4
@@ -138,6 +138,396 @@ function positiveInteger(value: number | undefined): number | undefined {
138
138
  return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
139
139
  }
140
140
 
141
+ // --- Perf round 2 / Task 6 (fix round 1): VERIFIED watermark tail cache -------
142
+ //
143
+ // The first cut of this task scaled the tail window down with `limit` and
144
+ // accepted it whenever the window's first parsed seq <= sinceSeq — assuming
145
+ // file order == seq order. That assumption is FALSE:
146
+ // (1) compaction recovery re-appends lost events at the END of the file
147
+ // with their OLD seqs (event-log-rotation.ts applyCompaction);
148
+ // (2) the sync (.mkdirlock) and async (.alok) lock families are disjoint,
149
+ // so interleaved writers can append reserved seqs out of order;
150
+ // (3) explicit baseMetadata.seq appends bypass reservation ordering.
151
+ // A file can therefore look like [1..200, 5000, 201..1700], and the window
152
+ // acceptance silently dropped event 5000 forever.
153
+ //
154
+ // This fix keeps NO order assumption. Instead each events path carries a
155
+ // cache entry with a VERIFIED watermark (transcript-cache.ts pattern):
156
+ // verifiedOffset byte offset just past the last VERIFIED complete line
157
+ // (a partial trailing line is held back, not consumed)
158
+ // lastSeq seq of the last event at verifiedOffset
159
+ // ring bounded, file-ordered suffix of the verified events
160
+ // ino/dev inode identity stamped from the SAME stat call as the
161
+ // size/mtimeMs stamps (fix round 2, below)
162
+ // The watermark is ESTABLISHED only by a full parse from offset 0 in which
163
+ // every parsed event carries a finite seq and seqs are non-decreasing, and
164
+ // EXTENDED only by deltas whose first seq >= lastSeq and whose seqs are
165
+ // non-decreasing. Any violation — out-of-order append, seq-less line, file
166
+ // shrink, stamp mismatch, INODE CHANGE, short delta read — rebuilds from a
167
+ // fresh full parse. With the watermark held, answering from the ring is sound:
168
+ // - verified && ringStartSeq <= sinceSeq: every event BEFORE the ring
169
+ // start lies inside the verified range, where seqs are non-decreasing,
170
+ // so its seq <= ringStartSeq <= sinceSeq — the sinceSeq filter would
171
+ // drop it in a full read too. The ring's post-filter set IS the full
172
+ // read's post-filter set, so TAIL_EVENT_CAP slice, archive-tail merge
173
+ // (R18/R16-B1), dedupe+sort, total/nextSeq are all identical.
174
+ // - ringStartOffset === 0: the ring IS the whole file (nothing dropped).
175
+ // An UNVERIFIED lineage (an out-of-order file, e.g. after compaction
176
+ // recovery) may only answer via ringStartOffset === 0; anything else takes
177
+ // the full parse. Files larger than the wide 4MB window can return a strict
178
+ // SUPERSET of the old byte-window answer (older post-sinceSeq events the
179
+ // window used to drop) — never fewer events.
180
+ //
181
+ // Fix round 2 — INODE STAMPING (the rewrite-then-regrow hole): size+mtimeMs
182
+ // alone cannot distinguish "grew" from "was replaced and grew back past the
183
+ // old size". Every real REWRITER of the events file produces a NEW inode —
184
+ // compaction (applyCompactionUnlocked → atomicWriteFile temp+rename),
185
+ // rotation (renameSync + 'wx' create), forget+import — while appends
186
+ // PRESERVE it. So both the unchanged branch and the delta branch additionally
187
+ // require stat.ino === entry.ino && stat.dev === entry.dev; a mismatch is
188
+ // treated as a replacement and rebuilds from a full parse. Repro it closes:
189
+ // file [1..10] → cached read primes the entry → rewrite keeping [9,10]
190
+ // (shrink, new inode) → append 11..20 (regrow past the old size, mtimeMs now
191
+ // >= the primed mtimeMs) → the stamp-only check used to take the delta
192
+ // branch and serve [6,7,8,9,10,19,20] — 8 events permanently dropped, 3
193
+ // deleted phantom events served. With inode identity the read rebuilds and
194
+ // returns [9,10,11..20], byte-identical to a full parse.
195
+ //
196
+ // Two hygiene rules that keep the stamps trustworthy:
197
+ // - the wide/FIND-05 path (sinceSeq=0 / no-limit / stat failures) DROPS
198
+ // the entry for the path: mixed call patterns must not keep stale stamps
199
+ // alive across reads that never re-validate them (an entry could then
200
+ // outlive its inode up to the point of inode-number reuse);
201
+ // - the delta read fstat's the fd it just opened and rejects a swap
202
+ // (rotation between statSync and openSync) instead of attributing the
203
+ // new inode's bytes to the old watermark.
204
+ // Residual (documented, empty in the current writer set): an IN-PLACE
205
+ // truncate rewrite — same inode, then regrowth past the old size — would
206
+ // still pass the delta branch. No such writer exists: appenders use
207
+ // appendFileSync, and every rewriter (compaction, rotation, forget+import)
208
+ // swaps the inode. Same accepted-risk class as transcript-cache.
209
+ // Accepted divergence, unchanged from fix round 1: a trailing fragment
210
+ // without "\n" is held back rather than parsed (a mid-append partial line),
211
+ // which can differ from the wide reader at the sub-line level.
212
+
213
+ const TAIL_BYTES = 4 * 1024 * 1024; // FIND-05: 4 MB wide tail window
214
+ const TAIL_EVENT_CAP = 5000;
215
+ const CURSOR_READ_CHUNK_BYTES = 64 * 1024;
216
+ const CURSOR_TAIL_CACHE_MAX_ENTRIES = 100;
217
+
218
+ /** One parsed event plus the absolute byte offset of its line's start. */
219
+ interface CursorLineEvent {
220
+ event: TeamEvent;
221
+ startOffset: number;
222
+ }
223
+
224
+ interface CursorTailCacheEntry {
225
+ /** Stamp pair from the last update; any mismatch forces a rebuild. */
226
+ size: number;
227
+ mtimeMs: number;
228
+ /** Inode identity from the SAME stat call as the stamps above. Appends
229
+ * preserve it; every rewriter (compaction's atomicWriteFile temp+rename,
230
+ * rotation's renameSync+'wx' create, forget+import) produces a NEW one.
231
+ * A mismatch means "the path now names a different file" — even when
232
+ * size/mtimeMs alone would pass (rewrite-then-regrow past the old size
233
+ * with a >= mtimeMs) — and forces a full-parse rebuild. */
234
+ ino: number;
235
+ dev: number;
236
+ verifiedOffset: number;
237
+ lastSeq: number;
238
+ /** True only while every consumed line since offset 0 carried a finite,
239
+ * non-decreasing seq. False => the ring may answer only from offset 0. */
240
+ verified: boolean;
241
+ ring: CursorLineEvent[];
242
+ }
243
+
244
+ const cursorTailCache = new Map<string, CursorTailCacheEntry>();
245
+
246
+ /** Fix round 2: inode identity check — the path must still name the exact
247
+ * file (same dev+ino) the entry's stamps describe. Appends preserve the
248
+ * inode; compaction (temp+rename), rotation (rename+'wx'), and forget+import
249
+ * all produce a new one, so a mismatch reliably means "replaced". */
250
+ function sameInode(stat: { ino: number; dev: number }, entry: CursorTailCacheEntry): boolean {
251
+ return stat.ino === entry.ino && stat.dev === entry.dev;
252
+ }
253
+
254
+ /** Drop events from the ring's front (file order) past `bound`, keeping the
255
+ * newest suffix. Ring bound: max(TAIL_EVENT_CAP, limit) * 2 events — enough
256
+ * lookback behind the anchor for the provability check while bounding
257
+ * memory (the answer itself is capped to TAIL_EVENT_CAP events regardless,
258
+ * so a larger ring only widens provability, it never changes results). */
259
+ function evictCursorRing(ring: CursorLineEvent[], bound: number): CursorLineEvent[] {
260
+ return ring.length > bound ? ring.slice(ring.length - bound) : ring;
261
+ }
262
+
263
+ /** Parse COMPLETE lines out of `buf`, which starts at absolute `baseOffset`
264
+ * (itself a line boundary). A trailing fragment without "\n" is held back
265
+ * (mid-append partial line). Corrupt/blank lines advance the verified
266
+ * offset but yield no event. `minSeq` seeds the watermark check: `ok` is
267
+ * false when any parsed event lacks a finite seq or goes backwards — i.e.
268
+ * the non-decreasing watermark is broken and the caller must rebuild.
269
+ * Parsed events are returned regardless (a full-parse answer must keep
270
+ * them, mirroring the wide reader). */
271
+ function scanEventLines(
272
+ buf: Buffer,
273
+ baseOffset: number,
274
+ minSeq: number | undefined,
275
+ ): { lines: CursorLineEvent[]; verifiedBytes: number; ok: boolean } {
276
+ const lines: CursorLineEvent[] = [];
277
+ let ok = true;
278
+ let lastSeq = minSeq;
279
+ let pos = 0;
280
+ for (;;) {
281
+ const newline = buf.indexOf(0x0a, pos);
282
+ if (newline < 0) break;
283
+ const text = buf.toString("utf-8", pos, newline).trim();
284
+ if (text) {
285
+ try {
286
+ const event = JSON.parse(text) as TeamEvent;
287
+ const seq = event.metadata?.seq;
288
+ if (typeof seq !== "number" || !Number.isFinite(seq) || (lastSeq !== undefined && seq < lastSeq)) {
289
+ ok = false;
290
+ } else {
291
+ lastSeq = seq;
292
+ }
293
+ lines.push({ event, startOffset: baseOffset + pos });
294
+ } catch {
295
+ /* corrupt line — skipped, but its bytes still advance */
296
+ }
297
+ }
298
+ pos = newline + 1;
299
+ }
300
+ return { lines, verifiedBytes: pos, ok };
301
+ }
302
+
303
+ /** Read exactly [start, end) from an already-open fd, or return null on any
304
+ * short read (concurrent shrink/rotation) so the caller rebuilds from a
305
+ * full parse. Reading from the caller's fd keeps the stat and the read on
306
+ * the SAME inode. */
307
+ function readCursorByteRange(fd: number, start: number, end: number): Buffer | null {
308
+ if (end <= start) return Buffer.alloc(0);
309
+ const length = end - start;
310
+ const buf = Buffer.alloc(length);
311
+ let totalRead = 0;
312
+ while (totalRead < length) {
313
+ const chunk = Math.min(CURSOR_READ_CHUNK_BYTES, length - totalRead);
314
+ const n = fs.readSync(fd, buf, totalRead, chunk, start + totalRead);
315
+ if (n <= 0) return null;
316
+ totalRead += n;
317
+ }
318
+ return buf;
319
+ }
320
+
321
+ /** Full parse from offset 0 — the only way to ESTABLISH the watermark, and
322
+ * the answer basis when the ring cannot prove coverage (total coverage by
323
+ * construction). Returns undefined when the file cannot be read (the caller
324
+ * falls back to the wide window). O_NOFOLLOW matches readJsonlTail's
325
+ * symlink refusal. */
326
+ function rebuildCursorTailCache(eventsPath: string, bound: number): { entry: CursorTailCacheEntry; lines: CursorLineEvent[] } | undefined {
327
+ let fd: number | undefined;
328
+ try {
329
+ fd = fs.openSync(eventsPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
330
+ } catch {
331
+ return undefined;
332
+ }
333
+ try {
334
+ const stat = fs.fstatSync(fd);
335
+ const buf = readCursorByteRange(fd, 0, stat.size);
336
+ if (buf === null) return undefined;
337
+ const scan = scanEventLines(buf, 0, undefined);
338
+ const lastLine = scan.lines.at(-1)?.event.metadata?.seq;
339
+ const entry: CursorTailCacheEntry = {
340
+ size: stat.size,
341
+ mtimeMs: stat.mtimeMs,
342
+ ino: stat.ino,
343
+ dev: stat.dev,
344
+ verifiedOffset: scan.verifiedBytes,
345
+ lastSeq: typeof lastLine === "number" ? lastLine : 0,
346
+ verified: scan.ok,
347
+ ring: evictCursorRing(scan.lines, bound),
348
+ };
349
+ return { entry, lines: scan.lines };
350
+ } finally {
351
+ try {
352
+ fs.closeSync(fd);
353
+ } catch {
354
+ /* ignore */
355
+ }
356
+ }
357
+ }
358
+
359
+ /** Shared answer pipeline (identical to the wide path's): TAIL_EVENT_CAP
360
+ * slice (+ its warning) BEFORE the sinceSeq filter, then the R18/R16-B1
361
+ * archive-tail merge, then the head-cap limit, then total/nextSeq. */
362
+ function cursorResultFromEvents(all: TeamEvent[], eventsPath: string, sinceSeq: number, limit: number | undefined): EventCursorResult {
363
+ let capped = all;
364
+ if (capped.length > TAIL_EVENT_CAP) {
365
+ logInternalError(
366
+ "event-log.cursor-full-read",
367
+ new Error(`readEventsCursor tail read dropped events from a larger log; pass fromByteOffset for incremental reads`),
368
+ `eventsPath=${eventsPath}`,
369
+ );
370
+ capped = capped.slice(-TAIL_EVENT_CAP);
371
+ }
372
+ const filtered = capped.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
373
+ // R18 (Phase 3.6): rotation stranding — prepend archive-tail events (seq >
374
+ // sinceSeq, deduped, seq-sorted) ahead of the live tail slice, so events
375
+ // stranded into an archive by a rotation are still delivered to sinceSeq
376
+ // streaming consumers. No-rotation case: no archives exist → behavior is
377
+ // byte-identical to before (mergeArchiveTailEvents returns liveEvents).
378
+ const merged = mergeArchiveTailEvents(readArchiveTailEvents(eventsPath, sinceSeq), filtered);
379
+ const events = limit !== undefined ? merged.slice(0, limit) : merged;
380
+ const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
381
+ return { events, nextSeq: returnedMaxSeq, total: merged.length };
382
+ }
383
+
384
+ /** Can `ring` (under `entry`'s lineage) answer a read anchored at sinceSeq?
385
+ * Sound per the watermark docblock: either the ring starts at the verified
386
+ * seq watermark that sinceSeq has already passed, or the ring starts at
387
+ * offset 0 and is the whole file. */
388
+ function cursorRingProvable(entry: CursorTailCacheEntry, ring: CursorLineEvent[], sinceSeq: number): boolean {
389
+ if (ring.length === 0) {
390
+ return entry.verifiedOffset === 0 || (entry.verified && entry.lastSeq <= sinceSeq);
391
+ }
392
+ const start = ring[0];
393
+ return start.startOffset === 0 || (entry.verified && (start.event.metadata?.seq ?? 0) <= sinceSeq);
394
+ }
395
+
396
+ /** Perf round 2 / Task 6 (fix round 1): serve a sinceSeq+limit cursor read
397
+ * from the verified watermark cache. `sinceSeq > 0` gating keeps
398
+ * replay-from-zero calls on the wide path (seqs start at 1, so a sinceSeq=0
399
+ * anchor can never be proven passed by a watermark — every call would pay
400
+ * the full parse for nothing). Returns undefined when the file cannot be
401
+ * stat'd/read; the caller then runs the FIND-05 wide path unchanged. */
402
+ function readEventsCursorTailCached(eventsPath: string, sinceSeq: number, limit: number): EventCursorResult | undefined {
403
+ let stat: fs.Stats;
404
+ try {
405
+ stat = fs.statSync(eventsPath);
406
+ } catch {
407
+ return undefined;
408
+ }
409
+ const previous = cursorTailCache.get(eventsPath);
410
+ const bound = Math.max(TAIL_EVENT_CAP, limit) * 2;
411
+
412
+ if (previous && stat.size === previous.size && stat.mtimeMs === previous.mtimeMs && sameInode(stat, previous)) {
413
+ // Unchanged stamps + same inode: serve purely from the ring (no event
414
+ // bytes read). The inode check is what makes "unchanged" trustworthy:
415
+ // size+mtimeMs alone pass for a same-path replacement with recycled
416
+ // stamps (compaction/rotation rewrites).
417
+ const ring = evictCursorRing(previous.ring, bound);
418
+ if (cursorRingProvable(previous, ring, sinceSeq)) {
419
+ if (ring !== previous.ring) cursorTailCache.set(eventsPath, { ...previous, ring });
420
+ return cursorResultFromEvents(
421
+ ring.map((line) => line.event),
422
+ eventsPath,
423
+ sinceSeq,
424
+ limit,
425
+ );
426
+ }
427
+ } else if (
428
+ previous &&
429
+ stat.size > previous.size &&
430
+ stat.mtimeMs >= previous.mtimeMs &&
431
+ sameInode(stat, previous) &&
432
+ previous.verifiedOffset < stat.size
433
+ ) {
434
+ // Append-only growth on the SAME inode: read only [verifiedOffset,
435
+ // size). Provability is checked BEFORE the read — appending only moves
436
+ // the ring start forward, so an unprovable ring can never become
437
+ // provable here. The inode requirement closes rewrite-then-regrow: a
438
+ // replacement (new inode) that shrank and grew back past the old size
439
+ // with mtimeMs >= previous.mtimeMs must NOT be read as a delta from
440
+ // the dead watermark — it falls through to the full parse.
441
+ const preRing = evictCursorRing(previous.ring, bound);
442
+ if (cursorRingProvable(previous, preRing, sinceSeq)) {
443
+ let delta: Buffer | null = null;
444
+ let deltaFd: number | undefined;
445
+ try {
446
+ deltaFd = fs.openSync(eventsPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
447
+ // TOCTOU guard: rotation/compaction can swap the path between
448
+ // the statSync above and this open. The stamps describe the
449
+ // stat'd inode; bytes from a different one must never extend
450
+ // its watermark — re-check identity on the fd actually opened.
451
+ const fdStat = fs.fstatSync(deltaFd);
452
+ if (fdStat.ino === stat.ino && fdStat.dev === stat.dev) {
453
+ delta = readCursorByteRange(deltaFd, previous.verifiedOffset, stat.size);
454
+ }
455
+ } catch {
456
+ delta = null;
457
+ } finally {
458
+ if (deltaFd !== undefined) {
459
+ try {
460
+ fs.closeSync(deltaFd);
461
+ } catch {
462
+ /* ignore */
463
+ }
464
+ }
465
+ }
466
+ if (delta !== null) {
467
+ const scan = scanEventLines(delta, previous.verifiedOffset, previous.lastSeq);
468
+ if (scan.ok) {
469
+ const ring = evictCursorRing([...preRing, ...scan.lines], bound);
470
+ const lastLineSeq = scan.lines.at(-1)?.event.metadata?.seq;
471
+ const entry: CursorTailCacheEntry = {
472
+ size: stat.size,
473
+ mtimeMs: stat.mtimeMs,
474
+ ino: stat.ino,
475
+ dev: stat.dev,
476
+ verifiedOffset: previous.verifiedOffset + scan.verifiedBytes,
477
+ lastSeq: typeof lastLineSeq === "number" ? lastLineSeq : previous.lastSeq,
478
+ // The delta verified against the watermark, but a
479
+ // violation earlier in the file keeps the lineage
480
+ // unverified (it can only answer from offset 0).
481
+ verified: previous.verified,
482
+ ring,
483
+ };
484
+ cursorTailCache.set(eventsPath, entry);
485
+ if (cursorRingProvable(entry, ring, sinceSeq)) {
486
+ return cursorResultFromEvents(
487
+ ring.map((line) => line.event),
488
+ eventsPath,
489
+ sinceSeq,
490
+ limit,
491
+ );
492
+ }
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ // Full parse: cache miss, stamp mismatch/shrink, INODE CHANGE (rewrite,
499
+ // rotation, forget+import), watermark violation, or an anchor older than
500
+ // the ring's provable start. Re-establishes the watermark and answers with
501
+ // TOTAL coverage (exact full-read semantics for files within the wide
502
+ // window; a superset — never fewer — beyond).
503
+ const rebuilt = rebuildCursorTailCache(eventsPath, bound);
504
+ if (rebuilt === undefined) return undefined;
505
+ cursorTailCache.set(eventsPath, rebuilt.entry);
506
+ // Bounded FIFO, not an LRU: Map.set on an existing key does NOT refresh
507
+ // its insertion position, so the oldest-INSERTED entry is the one evicted
508
+ // (the FIND-12 bound, with the comment telling the truth about what it is).
509
+ if (cursorTailCache.size > CURSOR_TAIL_CACHE_MAX_ENTRIES) {
510
+ const oldestKey = cursorTailCache.keys().next().value;
511
+ if (oldestKey !== undefined) cursorTailCache.delete(oldestKey);
512
+ }
513
+ return cursorResultFromEvents(
514
+ rebuilt.lines.map((line) => line.event),
515
+ eventsPath,
516
+ sinceSeq,
517
+ limit,
518
+ );
519
+ }
520
+
521
+ /** Test/invalidation hook: drop the verified watermark cache for one events
522
+ * path (or every path when omitted). */
523
+ export function clearEventsCursorTailCache(eventsPath?: string): void {
524
+ if (!eventsPath) {
525
+ cursorTailCache.clear();
526
+ return;
527
+ }
528
+ cursorTailCache.delete(eventsPath);
529
+ }
530
+
141
531
  export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): EventCursorResult {
142
532
  // Incremental byte-offset path: read only new bytes since last known offset
143
533
  if (options.fromByteOffset !== undefined) {
@@ -188,36 +578,36 @@ export function readEventsCursor(eventsPath: string, options: EventCursorOptions
188
578
  // emitted whenever the file exceeds the 4MB tail budget, signalling
189
579
  // that a prefix was dropped and callers should pass fromByteOffset for
190
580
  // streaming reads.
191
- const TAIL_BYTES = 4 * 1024 * 1024; // 4 MB
192
- const TAIL_EVENT_CAP = 5000;
193
581
  const sinceSeq = positiveInteger(options.sinceSeq) ?? 0;
194
582
  const limit = positiveInteger(options.limit);
195
-
583
+ // Perf round 2 / Task 6 (fix round 1): streaming ticks anchored with
584
+ // sinceSeq and capped with limit (run-event-bus onWithReplay, broker
585
+ // events.since/events.subscribe resync) are served by the verified
586
+ // watermark cache — delta-only reads for append growth, zero event-byte
587
+ // reads when nothing changed, and a full-parse rebuild on any watermark
588
+ // violation, shrink, or stale anchor. Warning suppression there is
589
+ // backed by proof, not by an order assumption: a ring answer happens
590
+ // only when no post-sinceSeq event can have been dropped. sinceSeq=0 /
591
+ // no-limit calls keep this wide path unchanged.
592
+ if (limit !== undefined && sinceSeq > 0) {
593
+ const cached = readEventsCursorTailCached(eventsPath, sinceSeq, limit);
594
+ if (cached !== undefined) return cached;
595
+ }
596
+ // Fix round 2: the wide path never re-validates the watermark cache, so it
597
+ // must DROP the entry instead of leaving its stamps behind. Without this,
598
+ // a rewrite that lands between sinceSeq=0 / no-limit calls keeps stale
599
+ // (size, mtimeMs, ino) stamps in the map until some later cached-path call
600
+ // re-validates them — the entry would outlive its inode up to the point of
601
+ // inode-number reuse. The next sinceSeq+limit call re-establishes the
602
+ // watermark with one full parse.
603
+ cursorTailCache.delete(eventsPath);
196
604
  const tail = readJsonlTail<TeamEvent>(eventsPath, TAIL_BYTES);
197
- let all = tail.items;
198
605
  if (tail.truncated) {
199
606
  logInternalError("event-log.cursor-tail-truncated", {
200
607
  eventsPath,
201
- returned: all.length,
608
+ returned: tail.items.length,
202
609
  tailBytes: TAIL_BYTES,
203
610
  });
204
611
  }
205
- if (all.length > TAIL_EVENT_CAP) {
206
- logInternalError(
207
- "event-log.cursor-full-read",
208
- new Error(`readEventsCursor tail read dropped events from a larger log; pass fromByteOffset for incremental reads`),
209
- `eventsPath=${eventsPath}`,
210
- );
211
- all = all.slice(-TAIL_EVENT_CAP);
212
- }
213
- const filtered = all.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
214
- // R18 (Phase 3.6): rotation stranding — prepend archive-tail events (seq >
215
- // sinceSeq, deduped, seq-sorted) ahead of the live tail slice, so events
216
- // stranded into an archive by a rotation are still delivered to sinceSeq
217
- // streaming consumers. No-rotation case: no archives exist → behavior is
218
- // byte-identical to before (mergeArchiveTailEvents returns liveEvents).
219
- const merged = mergeArchiveTailEvents(readArchiveTailEvents(eventsPath, sinceSeq), filtered);
220
- const events = limit !== undefined ? merged.slice(0, limit) : merged;
221
- const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
222
- return { events, nextSeq: returnedMaxSeq, total: merged.length };
612
+ return cursorResultFromEvents(tail.items, eventsPath, sinceSeq, limit);
223
613
  }