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.
- package/CHANGELOG.md +249 -0
- package/dist/index.mjs +98 -307
- package/package.json +2 -1
- package/schema.json +11 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
- package/skills/real-test-pi-crew/SKILL.md +278 -79
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/types.ts +36 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/team-tool.ts +53 -5
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +19 -0
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/status.ts +7 -0
- package/src/extension/team-tool.ts +35 -2
- package/src/hooks/registry.ts +59 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +13 -7
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +261 -41
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +353 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +4 -0
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +54 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +47 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +101 -0
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/state-store.ts +98 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- 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:
|
|
608
|
+
returned: tail.items.length,
|
|
202
609
|
tailBytes: TAIL_BYTES,
|
|
203
610
|
});
|
|
204
611
|
}
|
|
205
|
-
|
|
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
|
}
|