@thehammer/danx-session-telemetry 0.1.13 → 0.1.15
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/README.md +1 -1
- package/dist/activity-observer.d.ts +77 -0
- package/dist/activity-observer.js +73 -0
- package/dist/dispatch-usage.d.ts +209 -0
- package/dist/dispatch-usage.js +430 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +11 -1
- package/dist/internal-logger.js +5 -2
- package/dist/session-log-watcher.d.ts +230 -35
- package/dist/session-log-watcher.js +373 -91
- package/dist/stats-wire.d.ts +227 -0
- package/dist/stats-wire.js +307 -0
- package/dist/usage-ledger.js +1 -1
- package/dist/write-queue.d.ts +9 -0
- package/dist/write-queue.js +16 -0
- package/package.json +1 -1
|
@@ -26,6 +26,7 @@ import { homedir } from "node:os";
|
|
|
26
26
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
27
27
|
import { createLogger } from "./internal-logger.js";
|
|
28
28
|
import { buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
|
|
29
|
+
import { MAIN_SOURCE } from "./usage-ledger.js";
|
|
29
30
|
const log = createLogger("session-log-watcher");
|
|
30
31
|
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
31
32
|
const FILE_DISCOVERY_POLL_MS = 1_000;
|
|
@@ -41,6 +42,19 @@ const MAX_FILE_DISCOVERY_ATTEMPTS = 180;
|
|
|
41
42
|
* wrong file (e.g., an active human session).
|
|
42
43
|
*/
|
|
43
44
|
export const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
|
|
45
|
+
/**
|
|
46
|
+
* DX-2954 — the ONE replay gate every side-effecting transcript subscriber goes
|
|
47
|
+
* through (Slack progress, the gpt-manager forwarder): it hands on only lines
|
|
48
|
+
* read for the FIRST time. A replay was already handed to the previous
|
|
49
|
+
* incarnation's subscribers, so acting on it again is a duplicate post/event.
|
|
50
|
+
*/
|
|
51
|
+
export function skipReplays(consumer) {
|
|
52
|
+
return (entry, meta) => {
|
|
53
|
+
if (meta.replay)
|
|
54
|
+
return;
|
|
55
|
+
consumer(entry);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
44
58
|
/**
|
|
45
59
|
* Encode a working directory to the subdir name Claude Code writes JSONL
|
|
46
60
|
* under. Replaces `/`, `.`, AND `_` with `-`. Verified empirically against
|
|
@@ -219,52 +233,29 @@ export async function findSessionFileByDispatchId(dir, dispatchId) {
|
|
|
219
233
|
try {
|
|
220
234
|
const files = await readdir(dir);
|
|
221
235
|
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// co-located multi-MB files can momentarily fail an `open`/`stat`/
|
|
246
|
-
// `read`: ENOENT (sibling rotated/deleted mid-scan), ESTALE (a
|
|
247
|
-
// worktree torn down under the agent), EACCES (perms race), EMFILE
|
|
248
|
-
// (fd pressure across N concurrent watchers). Before this guard the
|
|
249
|
-
// throw propagated out of the per-file body and rejected the entire
|
|
250
|
-
// `findSessionFileByDispatchId` call → `discoverSessionFile` →
|
|
251
|
-
// `start()` (fire-and-forget, no `.catch`) → the watcher silently
|
|
252
|
-
// never attached → null `session_uuid` + zero usage/tool-call
|
|
253
|
-
// counters on ~20% of host dispatches (intermittent because the
|
|
254
|
-
// outcome hinged on `readdir` ordering vs the failing sibling).
|
|
255
|
-
// Skip this file and keep scanning the rest — the tagged file the
|
|
256
|
-
// caller actually wants may be later in the directory.
|
|
257
|
-
const code = err.code;
|
|
258
|
-
if (code !== "ENOENT") {
|
|
259
|
-
log.warn(`Skipping unreadable session file during dispatch-tag scan: ${filePath} (${code ?? String(err)})`);
|
|
260
|
-
}
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
finally {
|
|
264
|
-
await fd?.close();
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return null;
|
|
236
|
+
// DX-3395 — scan every sibling CONCURRENTLY instead of one at a time.
|
|
237
|
+
// A dispatched agent's cwd is a busy, SHARED session dir (DX-1201's own
|
|
238
|
+
// comment below: every issue-worker dispatch of one card writes under
|
|
239
|
+
// the same encoded `~/.claude/projects/<cwd>/` dir), so this directory
|
|
240
|
+
// accumulates one multi-MB `.jsonl` per distinct session ever launched
|
|
241
|
+
// there. The previous `for...of` paid each sibling's bounded
|
|
242
|
+
// open+stat+read (<=2*SCAN_BYTES) back-to-back even though they are
|
|
243
|
+
// independent filesystem operations the OS can service in parallel —
|
|
244
|
+
// this is the ONE stage DX-3395's resume-latency investigation found
|
|
245
|
+
// that is both real, sequential, and entirely danxbot-owned (unlike the
|
|
246
|
+
// dominant cost, `claude --resume`'s own session reload, which lives
|
|
247
|
+
// outside this codebase — see the card for the measured comparison).
|
|
248
|
+
// `Promise.all` lets every sibling's I/O overlap; each check keeps its
|
|
249
|
+
// own try/catch (unchanged from the DX-1201 per-file isolation below),
|
|
250
|
+
// so one unreadable sibling still can never abort the others. At most
|
|
251
|
+
// ONE sibling can ever contain a given dispatch's unique tag, so which
|
|
252
|
+
// promise settles first is never ambiguous — the result is looked up by
|
|
253
|
+
// `files`' ORIGINAL index, so the return value stays deterministic
|
|
254
|
+
// regardless of resolution order (proven by the multi-sibling test in
|
|
255
|
+
// `session-log-watcher.test.ts`).
|
|
256
|
+
const hits = await Promise.all(jsonlFiles.map((file) => fileContainsTag(dir, file, tag, SCAN_BYTES)));
|
|
257
|
+
const index = hits.findIndex(Boolean);
|
|
258
|
+
return index === -1 ? null : join(dir, jsonlFiles[index]);
|
|
268
259
|
}
|
|
269
260
|
catch (err) {
|
|
270
261
|
if (err.code === "ENOENT")
|
|
@@ -272,6 +263,56 @@ export async function findSessionFileByDispatchId(dir, dispatchId) {
|
|
|
272
263
|
throw err;
|
|
273
264
|
}
|
|
274
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* One sibling's head+tail scan for `findSessionFileByDispatchId`'s
|
|
268
|
+
* concurrent `Promise.all` — factored out so each file's try/catch/finally
|
|
269
|
+
* is self-contained and never affects any other sibling's check.
|
|
270
|
+
*/
|
|
271
|
+
async function fileContainsTag(dir, file, tag, scanBytes) {
|
|
272
|
+
const filePath = join(dir, file);
|
|
273
|
+
let fd;
|
|
274
|
+
try {
|
|
275
|
+
fd = await open(filePath, "r");
|
|
276
|
+
const fileStats = await stat(filePath);
|
|
277
|
+
if (await windowIncludesTag(fd, 0, fileStats.size, scanBytes, tag)) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
// Resume: the new tag is at the tail of a large prior-session file.
|
|
281
|
+
// Skip when the file fits in one head window (already fully read).
|
|
282
|
+
if (fileStats.size > scanBytes) {
|
|
283
|
+
const tailStart = fileStats.size - scanBytes;
|
|
284
|
+
if (await windowIncludesTag(fd, tailStart, fileStats.size, scanBytes, tag)) {
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
// DX-1201 — a single unreadable SIBLING file must NOT abort the
|
|
292
|
+
// whole scan. Dispatched agents share a busy session dir (e.g.
|
|
293
|
+
// every issue-worker dispatch of one repo writing under the same
|
|
294
|
+
// encoded `~/.claude/projects/<cwd>/` dir), so any one of the
|
|
295
|
+
// co-located multi-MB files can momentarily fail an `open`/`stat`/
|
|
296
|
+
// `read`: ENOENT (sibling rotated/deleted mid-scan), ESTALE (a
|
|
297
|
+
// worktree torn down under the agent), EACCES (perms race), EMFILE
|
|
298
|
+
// (fd pressure across N concurrent watchers). Before this guard the
|
|
299
|
+
// throw propagated out of the per-file body and rejected the entire
|
|
300
|
+
// `findSessionFileByDispatchId` call → `discoverSessionFile` →
|
|
301
|
+
// `start()` (fire-and-forget, no `.catch`) → the watcher silently
|
|
302
|
+
// never attached → null `session_uuid` + zero usage/tool-call
|
|
303
|
+
// counters on ~20% of host dispatches (intermittent because the
|
|
304
|
+
// outcome hinged on `readdir` ordering vs the failing sibling).
|
|
305
|
+
// Report this sibling as a non-match and let the others resolve.
|
|
306
|
+
const code = err.code;
|
|
307
|
+
if (code !== "ENOENT") {
|
|
308
|
+
log.warn(`Skipping unreadable session file during dispatch-tag scan: ${filePath} (${code ?? String(err)})`);
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
await fd?.close();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
275
316
|
/**
|
|
276
317
|
* Read a `<= maxBytes` window starting at `from` (clamped to the file) and
|
|
277
318
|
* report whether `tag` appears in it. Factored out so the head + tail scans
|
|
@@ -413,6 +454,29 @@ export function convertJsonlEntry(raw, lastTimestamp) {
|
|
|
413
454
|
return null;
|
|
414
455
|
}
|
|
415
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
|
|
459
|
+
* {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
|
|
460
|
+
* the right `startCursors` / `startExistingFilesAtEof`.
|
|
461
|
+
*
|
|
462
|
+
* | # | Condition | Start |
|
|
463
|
+
* |---|---|---|
|
|
464
|
+
* | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
|
|
465
|
+
* | 2 | a worker dispatch continuing a session: its predecessor's cursor for this source (the caller resolves it INTO `startCursors`, so it arrives as rule 1) | the predecessor's final `acked`, replaying to its final `delivered` |
|
|
466
|
+
* | 3 | the FIRST dispatch of its Claude session — a new worker session, or any operator dispatch (always the only one for its session id, including one opened by a mid-session `plan_connect` and a forked session's new id) | 0 |
|
|
467
|
+
* | 4 | a WORKER dispatch continuing a session with NO cursor for ANY source (a row from before this card; a `--resume` whose predecessor predates it) — `startExistingFilesAtEof` | every ALREADY-EXISTING file at its current end |
|
|
468
|
+
* | 5 | otherwise — in particular a sub-agent file that appeared AFTER the last stats write, on a dispatch that has cursors for other sources | 0 |
|
|
469
|
+
*
|
|
470
|
+
* Rules 1 and 2 arrive identically because "where does this source resume" has
|
|
471
|
+
* ONE answer; rule 4 is the only branch that skips content, and it is narrow by
|
|
472
|
+
* construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
|
|
473
|
+
* late sub-agent file's lines).
|
|
474
|
+
*
|
|
475
|
+
* Rules 3 and 5 both read from 0, but for different reasons, and both are
|
|
476
|
+
* correct: a fresh session's full usage SHOULD be counted, and a forked
|
|
477
|
+
* session's copied history is read and credited nothing (the ledger's excess
|
|
478
|
+
* rule already covers it).
|
|
479
|
+
*/
|
|
416
480
|
export class SessionLogWatcher {
|
|
417
481
|
consumers = [];
|
|
418
482
|
pollTimer;
|
|
@@ -424,8 +488,10 @@ export class SessionLogWatcher {
|
|
|
424
488
|
sessionId;
|
|
425
489
|
dispatchId;
|
|
426
490
|
pollIntervalMs;
|
|
427
|
-
|
|
428
|
-
|
|
491
|
+
startCursors;
|
|
492
|
+
startExistingFilesAtEof;
|
|
493
|
+
onCursorPastEof;
|
|
494
|
+
onSourceDiscovered;
|
|
429
495
|
subagentDiscoveryDone = false;
|
|
430
496
|
running = false;
|
|
431
497
|
polling = false;
|
|
@@ -441,9 +507,110 @@ export class SessionLogWatcher {
|
|
|
441
507
|
this.sessionId = options.sessionId;
|
|
442
508
|
this.dispatchId = options.dispatchId;
|
|
443
509
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
444
|
-
this.
|
|
445
|
-
this.
|
|
446
|
-
|
|
510
|
+
this.startCursors = options.startCursors ?? {};
|
|
511
|
+
this.startExistingFilesAtEof = options.startExistingFilesAtEof ?? false;
|
|
512
|
+
this.onCursorPastEof = options.onCursorPastEof;
|
|
513
|
+
this.onSourceDiscovered = options.onSourceDiscovered;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* DX-2954 — resolve one file's starting offset and replay boundary, per the
|
|
517
|
+
* start order on {@link SessionLogWatcher}.
|
|
518
|
+
*
|
|
519
|
+
* `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
|
|
520
|
+
* attached, on a continuing worker dispatch with no cursor anywhere) from
|
|
521
|
+
* rule 5 (a file that appeared later, which always starts at 0 so none of its
|
|
522
|
+
* lines are lost).
|
|
523
|
+
*/
|
|
524
|
+
async resolveStart(source, filePath, fileAlreadyExisted) {
|
|
525
|
+
const stored = this.startCursors[source];
|
|
526
|
+
// Rules 1-2: this dispatch's own stored cursor, or the predecessor's that
|
|
527
|
+
// the caller already resolved into `startCursors`. Both arrive here the
|
|
528
|
+
// same way, because "where does this source resume" has ONE answer.
|
|
529
|
+
if (stored !== undefined) {
|
|
530
|
+
return { byteOffset: stored.acked, replayUntil: stored.delivered };
|
|
531
|
+
}
|
|
532
|
+
// Rule 4: no cursor for ANY source on a continuing worker dispatch.
|
|
533
|
+
if (this.startExistingFilesAtEof && fileAlreadyExisted) {
|
|
534
|
+
try {
|
|
535
|
+
const size = (await stat(filePath)).size;
|
|
536
|
+
return { byteOffset: size, replayUntil: 0 };
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
// NOT a fall-back to 0. This branch fires precisely on the rows whose
|
|
540
|
+
// stored totals already count this file, so reading it from the start
|
|
541
|
+
// re-sums the whole session — the 421M-cache-read inflation (DX-2583)
|
|
542
|
+
// that rule 4 exists to prevent. `null` means "do not open this source
|
|
543
|
+
// yet"; the caller leaves it undiscovered and the next poll retries,
|
|
544
|
+
// still treating it as pre-existing.
|
|
545
|
+
log.warn(`startExistingFilesAtEof: stat failed for ${filePath}; NOT opening it this pass (reading from 0 would re-sum the session): ${String(err)}`);
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// Rules 3 and 5: the first dispatch of a session, and any file that appeared
|
|
550
|
+
// after the last stats write, both read from the start.
|
|
551
|
+
return { byteOffset: 0, replayUntil: 0 };
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* DX-2954 — every open source's cursor state.
|
|
555
|
+
*
|
|
556
|
+
* `delivered` is the byte up to which this watcher has handed lines to
|
|
557
|
+
* subscribers, which the collector reports as the `delivered` half of its
|
|
558
|
+
* stats write. `startOffset` is where this watcher OPENED the file, and it is
|
|
559
|
+
* the floor for the `acked` half: a source that started at EOF under rule 4
|
|
560
|
+
* must never report `acked: 0`, or the next restart would read the whole file
|
|
561
|
+
* from the start and re-sum the history rule 4 exists to skip.
|
|
562
|
+
*/
|
|
563
|
+
cursorState() {
|
|
564
|
+
// `delivered` is `lineBufferStartOffset`, NOT `byteOffset`.
|
|
565
|
+
//
|
|
566
|
+
// `pollFile` advances `byteOffset` by every byte it READ, and `processText`
|
|
567
|
+
// deliberately buffers a trailing newline-less line (the whole reason
|
|
568
|
+
// `lineBuffer` exists — a poll routinely catches a line mid-append). So
|
|
569
|
+
// `byteOffset` can sit PAST the first byte of a line that was never emitted;
|
|
570
|
+
// promoting that to `acked` makes a restart open the file mid-JSON, and the
|
|
571
|
+
// fragment is dropped as a malformed line — losing that reply's usage, its
|
|
572
|
+
// tool-use ids and its side effects permanently.
|
|
573
|
+
//
|
|
574
|
+
// `lineBufferStartOffset` is the start of the next UNCONSUMED line, which is
|
|
575
|
+
// exactly the resume point. It equals `byteOffset` whenever the buffer is
|
|
576
|
+
// empty, so the happy path is unchanged.
|
|
577
|
+
const at = (s) => ({
|
|
578
|
+
startOffset: s.startOffset,
|
|
579
|
+
delivered: s.lineBufferStartOffset,
|
|
580
|
+
});
|
|
581
|
+
const out = {};
|
|
582
|
+
if (this.parentTail)
|
|
583
|
+
out[this.parentTail.source] = at(this.parentTail);
|
|
584
|
+
for (const state of this.subagentTails.values())
|
|
585
|
+
out[state.source] = at(state);
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
|
|
590
|
+
* or 0 when it has none.
|
|
591
|
+
*
|
|
592
|
+
* Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
|
|
593
|
+
* watcher attach mid-session?". The offset alone is not enough: because `acked`
|
|
594
|
+
* lags `delivered` by one write, a crash right after the first cursor-bearing
|
|
595
|
+
* write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
|
|
596
|
+
* every byte up to D is a re-read.
|
|
597
|
+
*/
|
|
598
|
+
mainReplayUntil() {
|
|
599
|
+
return this.parentTail?.replayUntil ?? 0;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* DX-2954 — the main transcript's starting offset once discovery has run, or
|
|
603
|
+
* `null` before that.
|
|
604
|
+
*
|
|
605
|
+
* This is what "is this a mid-session attach?" is derived from now. The
|
|
606
|
+
* declared-loaded observer used to be told `reattached: true` explicitly;
|
|
607
|
+
* a non-zero main start is the same fact stated as an observation rather than
|
|
608
|
+
* a flag, and it is true for every case that matters (a reattach, a
|
|
609
|
+
* `--resume`, a resumed operator session) without a caller having to
|
|
610
|
+
* enumerate them.
|
|
611
|
+
*/
|
|
612
|
+
mainStartOffset() {
|
|
613
|
+
return this.parentTail?.startOffset ?? null;
|
|
447
614
|
}
|
|
448
615
|
/** Register a consumer callback that receives each new AgentLogEntry. */
|
|
449
616
|
onEntry(consumer) {
|
|
@@ -544,8 +711,7 @@ export class SessionLogWatcher {
|
|
|
544
711
|
const filePath = this.dispatchId
|
|
545
712
|
? await findSessionFileByDispatchId(this.sessionDir, this.dispatchId)
|
|
546
713
|
: await findNewestJsonlFile(this.sessionDir, this.sessionId);
|
|
547
|
-
if (filePath) {
|
|
548
|
-
await this.setParentFile(filePath);
|
|
714
|
+
if (filePath && (await this.setParentFile(filePath))) {
|
|
549
715
|
const tag = this.dispatchId ? `[Dispatch ${this.dispatchId}] ` : "";
|
|
550
716
|
log.info(`${tag}Watching session file: ${filePath}`);
|
|
551
717
|
return;
|
|
@@ -557,35 +723,36 @@ export class SessionLogWatcher {
|
|
|
557
723
|
/**
|
|
558
724
|
* Adopt a parent session file and derive the sub-agent directory.
|
|
559
725
|
*
|
|
560
|
-
*
|
|
561
|
-
* the
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
* with a delete that happens between discover and adopt) falls back to
|
|
565
|
-
* 0 so the watcher tails from the start; that is preferable to throwing
|
|
566
|
-
* during boot. `fromEof` does NOT skip sub-agent files: any sub-agent
|
|
567
|
-
* file claude writes AFTER reattach is genuinely new and should be
|
|
568
|
-
* tailed normally.
|
|
726
|
+
* DX-2954 — the starting offset comes from {@link SessionLogWatcher.resolveStart}
|
|
727
|
+
* (the cursor start order), not from a `fromEof` flag: the main source resumes
|
|
728
|
+
* at its stored `acked` cursor when it has one, and only a continuing worker
|
|
729
|
+
* dispatch with no cursor anywhere skips to the file's end.
|
|
569
730
|
*/
|
|
570
731
|
async setParentFile(filePath) {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
}
|
|
732
|
+
// DX-2954 — the main transcript's source key is always MAIN_SOURCE, and it
|
|
733
|
+
// ALWAYS counts as already-existing: by the time discovery found this file
|
|
734
|
+
// it is on disk, which is exactly what rule 4 asks about.
|
|
735
|
+
const start = await this.resolveStart(MAIN_SOURCE, filePath, true);
|
|
736
|
+
// `null` = its start offset could not be established safely (see
|
|
737
|
+
// `resolveStart`). Report the failure so discovery retries rather than
|
|
738
|
+
// adopting the file at a guessed offset.
|
|
739
|
+
if (start === null)
|
|
740
|
+
return false;
|
|
741
|
+
const { byteOffset, replayUntil } = start;
|
|
581
742
|
this.parentTail = {
|
|
582
743
|
filePath,
|
|
583
744
|
byteOffset,
|
|
584
745
|
lineBuffer: "",
|
|
585
746
|
lineBufferStartOffset: byteOffset,
|
|
586
747
|
lastTimestamp: 0,
|
|
748
|
+
source: MAIN_SOURCE,
|
|
749
|
+
startOffset: byteOffset,
|
|
750
|
+
replayUntil,
|
|
751
|
+
discoveredAt: Date.now(),
|
|
752
|
+
preexisting: false,
|
|
587
753
|
};
|
|
588
754
|
this.subagentsDir = join(dirname(filePath), basename(filePath, ".jsonl"), "subagents");
|
|
755
|
+
return true;
|
|
589
756
|
}
|
|
590
757
|
/**
|
|
591
758
|
* Poll all known files: the parent session, then re-enumerate + tail sub-agent files.
|
|
@@ -627,8 +794,41 @@ export class SessionLogWatcher {
|
|
|
627
794
|
const fileStats = await stat(state.filePath);
|
|
628
795
|
if (!this.running)
|
|
629
796
|
return;
|
|
630
|
-
|
|
797
|
+
// DX-2954 — a cursor PAST the end of the file means the file was replaced
|
|
798
|
+
// or truncated under us. `size === byteOffset` is the ordinary "no new
|
|
799
|
+
// bytes" case; only a STRICTLY smaller file is the pathological one, and
|
|
800
|
+
// before this card it fell into the same early return and silently tailed
|
|
801
|
+
// nothing for the rest of the dispatch. Restart that source at 0 and
|
|
802
|
+
// report it, so the caller records a visible event and marks the reset in
|
|
803
|
+
// its next stats write (the one sanctioned backwards move of `acked`).
|
|
804
|
+
if (fileStats.size < state.byteOffset) {
|
|
805
|
+
this.onCursorPastEof?.({
|
|
806
|
+
source: state.source,
|
|
807
|
+
storedOffset: state.byteOffset,
|
|
808
|
+
fileSize: fileStats.size,
|
|
809
|
+
});
|
|
810
|
+
state.byteOffset = 0;
|
|
811
|
+
state.lineBufferStartOffset = 0;
|
|
812
|
+
state.lineBuffer = "";
|
|
813
|
+
// `startOffset` moves to 0 with it: it is the FLOOR the collector uses
|
|
814
|
+
// for this source's reported `acked`, so leaving it at the old (now
|
|
815
|
+
// impossible) offset would make the next write claim to have
|
|
816
|
+
// acknowledged bytes the file no longer has.
|
|
817
|
+
state.startOffset = 0;
|
|
818
|
+
// Nothing below the new position was ever delivered from THIS file's
|
|
819
|
+
// current content, so nothing in it is a replay.
|
|
820
|
+
state.replayUntil = 0;
|
|
821
|
+
// The replacement file's first entry must not have its `delta_ms`
|
|
822
|
+
// computed against the OLD file's last timestamp (which can produce a
|
|
823
|
+
// negative or absurd gap). This is a different file; its timeline starts
|
|
824
|
+
// fresh.
|
|
825
|
+
state.lastTimestamp = 0;
|
|
826
|
+
if (fileStats.size === 0)
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
else if (fileStats.size === state.byteOffset) {
|
|
631
830
|
return;
|
|
831
|
+
}
|
|
632
832
|
const fd = await open(state.filePath, "r");
|
|
633
833
|
try {
|
|
634
834
|
if (!this.running)
|
|
@@ -686,40 +886,63 @@ export class SessionLogWatcher {
|
|
|
686
886
|
log.error("Failed to enumerate sub-agent directory:", err);
|
|
687
887
|
return;
|
|
688
888
|
}
|
|
689
|
-
// DX-
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
|
|
889
|
+
// DX-2954 — the FIRST discovery pass sees exactly the sub-agent files that
|
|
890
|
+
// existed when this watcher attached. Only those can satisfy rule 4's
|
|
891
|
+
// "already existing"; a file found on a LATER pass appeared after the attach
|
|
892
|
+
// and always reads from 0 (rule 5), so none of its lines are lost.
|
|
893
|
+
const firstPass = !this.subagentDiscoveryDone;
|
|
894
|
+
// Marked done only if this pass established a start offset for EVERY file it
|
|
895
|
+
// saw. A file skipped by `resolveStart` must still count as PRE-EXISTING on
|
|
896
|
+
// the retry — treating it as "appeared later" would send it to rule 5 (read
|
|
897
|
+
// from 0) and re-sum exactly the history rule 4 skipped.
|
|
898
|
+
let allResolved = true;
|
|
693
899
|
for (const file of files) {
|
|
694
900
|
if (!file.endsWith(".jsonl"))
|
|
695
901
|
continue;
|
|
696
902
|
const fullPath = join(this.subagentsDir, file);
|
|
697
903
|
if (this.subagentTails.has(fullPath))
|
|
698
904
|
continue;
|
|
905
|
+
// A sub-agent's ledger source key IS its file's basename (`agent-<hash>`),
|
|
906
|
+
// the same value `usage-ledger.ts` credits usage under and the same value
|
|
907
|
+
// `dispatch_activities.activity_id` holds — so a cursor, a per-source
|
|
908
|
+
// token total and a running-set row all join on one key.
|
|
699
909
|
const subagentId = basename(file, ".jsonl");
|
|
700
910
|
const agentType = await this.readAgentType(subagentId);
|
|
701
|
-
|
|
702
|
-
if (
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
}
|
|
706
|
-
catch (err) {
|
|
707
|
-
log.warn(`existingSubagentFilesFromEof: stat failed for ${fullPath}; tailing from 0: ${String(err)}`);
|
|
708
|
-
}
|
|
911
|
+
const start = await this.resolveStart(subagentId, fullPath, firstPass);
|
|
912
|
+
if (start === null) {
|
|
913
|
+
allResolved = false;
|
|
914
|
+
continue;
|
|
709
915
|
}
|
|
916
|
+
const { byteOffset, replayUntil } = start;
|
|
917
|
+
const discoveredAt = Date.now();
|
|
710
918
|
this.subagentTails.set(fullPath, {
|
|
711
919
|
filePath: fullPath,
|
|
712
|
-
byteOffset
|
|
713
|
-
lineBufferStartOffset:
|
|
920
|
+
byteOffset,
|
|
921
|
+
lineBufferStartOffset: byteOffset,
|
|
714
922
|
lineBuffer: "",
|
|
715
923
|
lastTimestamp: 0,
|
|
924
|
+
source: subagentId,
|
|
925
|
+
startOffset: byteOffset,
|
|
926
|
+
replayUntil,
|
|
927
|
+
discoveredAt,
|
|
928
|
+
preexisting: firstPass,
|
|
716
929
|
lineage: {
|
|
717
930
|
subagent_id: subagentId,
|
|
718
931
|
parent_session_id: this.parentSessionId,
|
|
719
932
|
agent_type: agentType,
|
|
720
933
|
},
|
|
721
934
|
});
|
|
935
|
+
// DX-2954 — fired AFTER the tail is registered, so a consumer that throws
|
|
936
|
+
// cannot leave a half-registered source, and the `subagentTails.has()`
|
|
937
|
+
// guard above makes it exactly-once per source for this watcher's life. A
|
|
938
|
+
// re-discovery by a RESTARTED watcher fires again by design: the row it
|
|
939
|
+
// reports is an idempotent upsert keyed on this same id, and re-reporting
|
|
940
|
+
// is how a reattached collector re-learns the sub-agents that were already
|
|
941
|
+
// running before it existed.
|
|
942
|
+
this.emitSourceDiscovered(subagentId, agentType, discoveredAt, firstPass);
|
|
722
943
|
}
|
|
944
|
+
if (allResolved)
|
|
945
|
+
this.subagentDiscoveryDone = true;
|
|
723
946
|
}
|
|
724
947
|
/** Centralized lineage refresh — see `refreshLineage` for the per-state body. */
|
|
725
948
|
async refreshPendingAgentTypes() {
|
|
@@ -735,13 +958,63 @@ export class SessionLogWatcher {
|
|
|
735
958
|
* `pollFile` — the inline call is the defense-in-depth that closes the race
|
|
736
959
|
* where bytes appear before `refreshPendingAgentTypes` has a chance to run.
|
|
737
960
|
*/
|
|
961
|
+
/**
|
|
962
|
+
* DX-2954 — report a running sub-agent to the consumer, normalizing the
|
|
963
|
+
* absent-type shape and CONTAINING any throw.
|
|
964
|
+
*
|
|
965
|
+
* The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
|
|
966
|
+
* every consumer for the same reason, and it is load-bearing here because of
|
|
967
|
+
* where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
|
|
968
|
+
* `poll()` has `try/finally` without one, so an escaping throw would:
|
|
969
|
+
*
|
|
970
|
+
* - on the first pass (inside `start()`), kill the watcher for the whole
|
|
971
|
+
* dispatch — the caller only logs `start()`'s rejection;
|
|
972
|
+
* - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
|
|
973
|
+
* turns into process exit — a worker crash caused by a consumer bug;
|
|
974
|
+
* - either way abort the discovery loop mid-list, skipping
|
|
975
|
+
* `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
|
|
976
|
+
* sends later-appearing files down rule 4 instead of rule 5 — losing
|
|
977
|
+
* exactly the lines that rule split exists to preserve.
|
|
978
|
+
*
|
|
979
|
+
* Reporting the running set must never cost the monitoring it rides on.
|
|
980
|
+
*/
|
|
981
|
+
emitSourceDiscovered(source, agentType, discoveredAt, preexisting) {
|
|
982
|
+
try {
|
|
983
|
+
this.onSourceDiscovered?.({
|
|
984
|
+
source,
|
|
985
|
+
// ONE absent-shape on the wire, never two: this class's own reader
|
|
986
|
+
// answers `undefined` for "no meta file yet", while the stats payload and
|
|
987
|
+
// the `dispatch_activities.agent_type` column both spell absent as
|
|
988
|
+
// `null`. Normalizing here keeps every consumer from accepting both.
|
|
989
|
+
agentType: agentType ?? null,
|
|
990
|
+
discoveredAt,
|
|
991
|
+
preexisting,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
log.error("onSourceDiscovered consumer error:", err);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
738
998
|
async refreshLineage(state) {
|
|
739
999
|
if (!state.lineage)
|
|
740
1000
|
return;
|
|
741
1001
|
let { agent_type, parent_session_id } = state.lineage;
|
|
1002
|
+
// DX-2954 — remember whether the type was still unknown coming in, so the
|
|
1003
|
+
// `undefined -> string` transition can be reported. Discovery reads the
|
|
1004
|
+
// sidecar the instant the `.jsonl` appears, and claude writes the `.jsonl`
|
|
1005
|
+
// BEFORE the `.meta.json` (the race this method exists for), so "no type
|
|
1006
|
+
// yet" at discovery is the COMMON case, not an edge. Without a report here
|
|
1007
|
+
// the running-set row's `agent_type` would stay NULL for the whole dispatch
|
|
1008
|
+
// even though the watcher learned the real value one poll later.
|
|
1009
|
+
const typeWasUnknown = agent_type === undefined;
|
|
742
1010
|
if (agent_type === undefined) {
|
|
743
1011
|
agent_type = await this.readAgentType(state.lineage.subagent_id);
|
|
744
1012
|
}
|
|
1013
|
+
if (typeWasUnknown && agent_type !== undefined && state.source !== MAIN_SOURCE) {
|
|
1014
|
+
// The ORIGINAL discovery stamp, never a fresh `Date.now()`: this is the
|
|
1015
|
+
// same activity, learning its type late, not a new one starting now.
|
|
1016
|
+
this.emitSourceDiscovered(state.source, agent_type, state.discoveredAt, state.preexisting);
|
|
1017
|
+
}
|
|
745
1018
|
if (parent_session_id === null && this.parentSessionId !== null) {
|
|
746
1019
|
parent_session_id = this.parentSessionId;
|
|
747
1020
|
}
|
|
@@ -842,7 +1115,7 @@ export class SessionLogWatcher {
|
|
|
842
1115
|
synthesized: true,
|
|
843
1116
|
},
|
|
844
1117
|
};
|
|
845
|
-
this.emitEntry(initEntry, state);
|
|
1118
|
+
this.emitEntry(initEntry, state, lineStart);
|
|
846
1119
|
this.initSynthesized = true;
|
|
847
1120
|
}
|
|
848
1121
|
}
|
|
@@ -860,7 +1133,7 @@ export class SessionLogWatcher {
|
|
|
860
1133
|
state.lastTimestamp = result.timestamp;
|
|
861
1134
|
// DX-2963 — the line's own byte offset (see `TailState.lineBufferStartOffset`).
|
|
862
1135
|
result.entry.data = { ...result.entry.data, byte_offset: lineStart };
|
|
863
|
-
this.emitEntry(result.entry, state);
|
|
1136
|
+
this.emitEntry(result.entry, state, lineStart);
|
|
864
1137
|
}
|
|
865
1138
|
/**
|
|
866
1139
|
* Emit an entry to registered consumers. For sub-agent streams,
|
|
@@ -873,7 +1146,7 @@ export class SessionLogWatcher {
|
|
|
873
1146
|
* tests; nothing else read it. Every real consumer subscribes via
|
|
874
1147
|
* `onEntry` instead.
|
|
875
1148
|
*/
|
|
876
|
-
emitEntry(entry, state) {
|
|
1149
|
+
emitEntry(entry, state, byteOffset) {
|
|
877
1150
|
if (state.lineage) {
|
|
878
1151
|
entry.data = {
|
|
879
1152
|
...entry.data,
|
|
@@ -882,9 +1155,18 @@ export class SessionLogWatcher {
|
|
|
882
1155
|
agent_type: state.lineage.agent_type,
|
|
883
1156
|
};
|
|
884
1157
|
}
|
|
1158
|
+
// DX-2954 — a line whose FIRST byte lies below the source's stored
|
|
1159
|
+
// `delivered` cursor was already handed to a previous incarnation's
|
|
1160
|
+
// subscribers. Strictly `<`: `delivered` is an exclusive end (the next
|
|
1161
|
+
// unread byte), so a line starting exactly AT it is new.
|
|
1162
|
+
const meta = {
|
|
1163
|
+
source: state.source,
|
|
1164
|
+
byteOffset,
|
|
1165
|
+
replay: byteOffset < state.replayUntil,
|
|
1166
|
+
};
|
|
885
1167
|
for (const consumer of this.consumers) {
|
|
886
1168
|
try {
|
|
887
|
-
consumer(entry);
|
|
1169
|
+
consumer(entry, meta);
|
|
888
1170
|
}
|
|
889
1171
|
catch (err) {
|
|
890
1172
|
log.error("Consumer error:", err);
|