@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.
@@ -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
- for (const file of jsonlFiles) {
223
- const filePath = join(dir, file);
224
- let fd;
225
- try {
226
- fd = await open(filePath, "r");
227
- const fileStats = await stat(filePath);
228
- if (await windowIncludesTag(fd, 0, fileStats.size, SCAN_BYTES, tag)) {
229
- return filePath;
230
- }
231
- // Resume: the new tag is at the tail of a large prior-session file.
232
- // Skip when the file fits in one head window (already fully read).
233
- if (fileStats.size > SCAN_BYTES) {
234
- const tailStart = fileStats.size - SCAN_BYTES;
235
- if (await windowIncludesTag(fd, tailStart, fileStats.size, SCAN_BYTES, tag)) {
236
- return filePath;
237
- }
238
- }
239
- }
240
- catch (err) {
241
- // DX-1201 — a single unreadable SIBLING file must NOT abort the
242
- // whole scan. Dispatched agents share a busy session dir (e.g.
243
- // every issue-worker dispatch of one repo writing under the same
244
- // encoded `~/.claude/projects/<cwd>/` dir), so any one of the
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
- fromEof;
428
- existingSubagentFilesFromEof;
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.fromEof = options.fromEof ?? false;
445
- this.existingSubagentFilesFromEof =
446
- options.existingSubagentFilesFromEof ?? false;
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
- * When `this.fromEof` is set (Phase 2c reattach OR a DX-2583 resume — see
561
- * the option's doc), seed `byteOffset` to the file's current size so the
562
- * next poll reads strictly forward — historical entries are NOT re-emitted.
563
- * A fresh `stat` failure (race
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
- let byteOffset = 0;
572
- if (this.fromEof) {
573
- try {
574
- const fileStats = await stat(filePath);
575
- byteOffset = fileStats.size;
576
- }
577
- catch (err) {
578
- log.warn(`fromEof: stat failed for ${filePath}; tailing from 0: ${String(err)}`);
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
- if (fileStats.size <= state.byteOffset)
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-2963 — the FIRST discovery pass sees exactly the sub-agent files
690
- // that existed when this watcher attached; only those may open at EOF.
691
- const openExistingAtEof = this.existingSubagentFilesFromEof && !this.subagentDiscoveryDone;
692
- this.subagentDiscoveryDone = true;
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
- let startOffset = 0;
702
- if (openExistingAtEof) {
703
- try {
704
- startOffset = (await stat(fullPath)).size;
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: startOffset,
713
- lineBufferStartOffset: startOffset,
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);