@thehammer/danx-session-telemetry 0.1.15 → 0.1.17

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.
@@ -7,16 +7,17 @@
7
7
  * signal danxbot owns end-to-end: the session-log watcher discovering a
8
8
  * sub-agent's transcript file and then tailing it.
9
9
  *
10
- * ## Why the transcript, not a hook
10
+ * ## Why the transcript, and where the hooks come in
11
11
  *
12
12
  * Claude Code's `SubagentStart` / `SubagentStop` hooks carry strictly more (an
13
- * `agent_type` before the sub-agent has written anything; and, for a background
14
- * `Bash` shell, the ONLY finish signal that exists at all). But those hooks ship
15
- * from the plugin, which lives in a repo danxbot has no board for — so they
16
- * cannot land in this card (see DX-3284). Keying the running set solely on them
17
- * would mean shipping a set that is permanently empty: `subagent_count` would read 0 on
18
- * every dispatch, which is a REGRESSION against the transcript-derived counter
19
- * this card replaced, not a deferral.
13
+ * `agent_type` before the sub-agent has written anything, and — the part this
14
+ * module cannot supply — a FINISH). Those hooks ship from the danxbot plugin in
15
+ * the claude-plugins repo, and DX-3284 builds them, next. They were not built in
16
+ * DX-2954 itself, but not because a plugin change cannot ship from a danxbot
17
+ * card: DX-2954's own `transcript_path` change shipped as claude-plugins
18
+ * `c0c134d`. Keying the running set solely on them before they exist would ship
19
+ * a set that is permanently empty (`subagent_count` 0 on every dispatch, a
20
+ * REGRESSION against the transcript-derived counter this replaced).
20
21
  *
21
22
  * So the transcript is the producer, and the hooks are an ENRICHMENT layered on
22
23
  * the same rows:
@@ -24,7 +25,13 @@
24
25
  * | Signal | Sub-agent start | Sub-agent finish | Background script |
25
26
  * |---|---|---|---|
26
27
  * | Transcript (here) | file discovered | dispatch terminal (`finishOpenActivities`) | not visible |
27
- * | Hooks (DX-3284) | `SubagentStart` | `SubagentStop` | `PostToolUse` + its own stop |
28
+ * | Hooks (DX-3284) | `SubagentStart` | `SubagentStop` | `PostToolUse` start; end at dispatch terminal (no documented completion event) |
29
+ *
30
+ * **Until DX-3284 lands, "running" over-counts.** With no finish signal, every
31
+ * sub-agent this dispatch ever started stays in the running set until the
32
+ * dispatch itself ends, and the silent-too-long warning fires
33
+ * `RUNNING_ACTIVITY_SILENT_AFTER_MS` after a sub-agent that has in fact
34
+ * finished. `SubagentStop` is what closes that gap.
28
35
  *
29
36
  * ## Why the two producers cannot double-count
30
37
  *
@@ -41,9 +48,8 @@
41
48
  * signal — a finished sub-agent's file simply stops growing, which is
42
49
  * indistinguishable from a slow one. Guessing a finish from an idle gap would
43
50
  * mark a long-thinking sub-agent as complete. So open rows are closed exactly
44
- * once, at the dispatch's own terminal transition (`finishOpenActivities`), and
45
- * until DX-3284 lands the honest reading of a long-idle running row is what
46
- * `quietest_running_at` is for.
51
+ * once, at the dispatch's own terminal transition (`finishOpenActivities`),
52
+ * unless DX-3284's `SubagentStop` hook closes a row first.
47
53
  */
48
54
  import type { ActivityChange } from "./stats-wire.js";
49
55
  import type { EntryMeta, SourceDiscoveredInfo } from "./session-log-watcher.js";
@@ -43,6 +43,20 @@ export declare class DispatchUsage {
43
43
  * its own resume point instead of regressing it.
44
44
  */
45
45
  private readonly ackedCursor;
46
+ /**
47
+ * DX-2954 (AC 34324) — the cursors this process STARTED from, reported
48
+ * verbatim for every source the watcher has not opened yet until one write
49
+ * carrying them has landed. See {@link DispatchUsage.buildCursorReports}.
50
+ */
51
+ private startCursors;
52
+ private startCursorsPersisted;
53
+ /**
54
+ * DX-2954 — set the moment the first flush begins. `attachCursors` after that
55
+ * point would set the starting cursors AFTER a write that carried none, and
56
+ * `startCursorsPersisted` (set by any landed write) would then keep them from
57
+ * ever being carried — the exact AC 34324 loss, silently. So it fails loud.
58
+ */
59
+ private flushBegan;
46
60
  /** Past-EOF resets to report once, then forget. */
47
61
  private pastEof;
48
62
  /**
@@ -118,25 +132,36 @@ export declare class DispatchUsage {
118
132
  * excess rule) and every side-effecting subscriber skips it — so the cost is
119
133
  * one window of re-read, never a double count or a duplicate side effect.
120
134
  *
121
- * ## KNOWN GAP: the process-start write carries no cursors
135
+ * ## The process-start write carries the STARTING cursors (AC 34324)
122
136
  *
123
137
  * `attachMonitoringStack` deliberately does NOT await `watcher.start()` —
124
138
  * file discovery polls for up to 180 s and must not block the spawn — and the
125
139
  * process-start stats write fires immediately after it. At that moment the
126
- * watcher has opened no file, so `cursorState()` is empty and this returns no
127
- * reports. Cursors first reach the server on the write AFTER discovery, one
128
- * stats tick (15-30 s) later.
140
+ * watcher has opened no file, so its live `cursorState()` is empty.
141
+ *
142
+ * So every source in the cursors this process STARTED from that the watcher
143
+ * has not opened yet is reported VERBATIM — the exact `{acked, delivered}`
144
+ * pair it resumed from — until a write that carries them has landed. That is
145
+ * what makes "every attach persists its starting cursors" true on the FIRST
146
+ * write rather than one stats tick (15-30 s) later: a reattach of this row
147
+ * inside its first interval reads them back (start-order rule 1) and starts
148
+ * exactly where this process did, instead of finding no cursor, resolving
149
+ * rule 4 and skipping every line written since spawn. A fresh session's
150
+ * starting cursor is explicit too (`resolveStartCursors` rule 3 returns the
151
+ * main source at 0), and a `--resume`'s is its predecessor's (rule 2).
152
+ *
153
+ * Verbatim is the only honest claim for a source this process has not read:
154
+ * a higher `acked` would let a restart skip the replay window it has not
155
+ * re-credited, a lower one is refused as a regression. For the same reason
156
+ * the start write does not PROMOTE such a source's acknowledged floor — it
157
+ * delivered nothing there — so it stays out of `deliveredAtSend`.
129
158
  *
130
- * The bound this puts on the card's "every attach persists its starting
131
- * cursors in its first stats write": a dispatch that dies inside its first
132
- * stats interval stores no cursor at all, so a later dispatch continuing its
133
- * session resolves start-order rule 4 (existing files at EOF) and does not
134
- * count that first interval's usage. The ledger keeps this from ever
135
- * DOUBLE-counting; the cost is the same bounded loss the card already accepts
136
- * for the deploy transition. Closing it properly means persisting the starting
137
- * cursor on discovery rather than on the next tick — a watcher-discovery
138
- * callback wired into the ordered stats channel — which is a separate change,
139
- * not something to bolt onto the flush path.
159
+ * The one case with nothing to carry is rule 4 (a continuing dispatch with no
160
+ * cursor for ANY source): its start is "each existing file's end at
161
+ * discovery", which does not exist until discovery runs. Its cursors reach
162
+ * the server on the first write after discovery. Rule 4 is the pre-card
163
+ * deploy transition only — every dispatch since this card stores cursors on
164
+ * its first write — so that bounded window is the one the card accepts.
140
165
  */
141
166
  private buildCursorReports;
142
167
  /**
@@ -63,6 +63,20 @@ export class DispatchUsage {
63
63
  * its own resume point instead of regressing it.
64
64
  */
65
65
  ackedCursor = new Map();
66
+ /**
67
+ * DX-2954 (AC 34324) — the cursors this process STARTED from, reported
68
+ * verbatim for every source the watcher has not opened yet until one write
69
+ * carrying them has landed. See {@link DispatchUsage.buildCursorReports}.
70
+ */
71
+ startCursors = {};
72
+ startCursorsPersisted = false;
73
+ /**
74
+ * DX-2954 — set the moment the first flush begins. `attachCursors` after that
75
+ * point would set the starting cursors AFTER a write that carried none, and
76
+ * `startCursorsPersisted` (set by any landed write) would then keep them from
77
+ * ever being carried — the exact AC 34324 loss, silently. So it fails loud.
78
+ */
79
+ flushBegan = false;
66
80
  /** Past-EOF resets to report once, then forget. */
67
81
  pastEof = [];
68
82
  /**
@@ -86,7 +100,13 @@ export class DispatchUsage {
86
100
  * each write.
87
101
  */
88
102
  attachCursors(readCursorState, startCursors) {
103
+ // DX-2954 (AC 34324): the starting cursors ride the process-start write, so
104
+ // they must be attached BEFORE any write is attempted.
105
+ if (this.flushBegan) {
106
+ throw new Error("DispatchUsage.attachCursors: called after the first stats write began — the starting cursors would never be persisted");
107
+ }
89
108
  this.readCursorState = readCursorState;
109
+ this.startCursors = startCursors;
90
110
  for (const [source, cursor] of Object.entries(startCursors)) {
91
111
  this.ackedCursor.set(source, cursor.acked);
92
112
  }
@@ -156,34 +176,56 @@ export class DispatchUsage {
156
176
  * excess rule) and every side-effecting subscriber skips it — so the cost is
157
177
  * one window of re-read, never a double count or a duplicate side effect.
158
178
  *
159
- * ## KNOWN GAP: the process-start write carries no cursors
179
+ * ## The process-start write carries the STARTING cursors (AC 34324)
160
180
  *
161
181
  * `attachMonitoringStack` deliberately does NOT await `watcher.start()` —
162
182
  * file discovery polls for up to 180 s and must not block the spawn — and the
163
183
  * process-start stats write fires immediately after it. At that moment the
164
- * watcher has opened no file, so `cursorState()` is empty and this returns no
165
- * reports. Cursors first reach the server on the write AFTER discovery, one
166
- * stats tick (15-30 s) later.
184
+ * watcher has opened no file, so its live `cursorState()` is empty.
167
185
  *
168
- * The bound this puts on the card's "every attach persists its starting
169
- * cursors in its first stats write": a dispatch that dies inside its first
170
- * stats interval stores no cursor at all, so a later dispatch continuing its
171
- * session resolves start-order rule 4 (existing files at EOF) and does not
172
- * count that first interval's usage. The ledger keeps this from ever
173
- * DOUBLE-counting; the cost is the same bounded loss the card already accepts
174
- * for the deploy transition. Closing it properly means persisting the starting
175
- * cursor on discovery rather than on the next tick — a watcher-discovery
176
- * callback wired into the ordered stats channel — which is a separate change,
177
- * not something to bolt onto the flush path.
186
+ * So every source in the cursors this process STARTED from that the watcher
187
+ * has not opened yet is reported VERBATIM — the exact `{acked, delivered}`
188
+ * pair it resumed from — until a write that carries them has landed. That is
189
+ * what makes "every attach persists its starting cursors" true on the FIRST
190
+ * write rather than one stats tick (15-30 s) later: a reattach of this row
191
+ * inside its first interval reads them back (start-order rule 1) and starts
192
+ * exactly where this process did, instead of finding no cursor, resolving
193
+ * rule 4 and skipping every line written since spawn. A fresh session's
194
+ * starting cursor is explicit too (`resolveStartCursors` rule 3 returns the
195
+ * main source at 0), and a `--resume`'s is its predecessor's (rule 2).
196
+ *
197
+ * Verbatim is the only honest claim for a source this process has not read:
198
+ * a higher `acked` would let a restart skip the replay window it has not
199
+ * re-credited, a lower one is refused as a regression. For the same reason
200
+ * the start write does not PROMOTE such a source's acknowledged floor — it
201
+ * delivered nothing there — so it stays out of `deliveredAtSend`.
202
+ *
203
+ * The one case with nothing to carry is rule 4 (a continuing dispatch with no
204
+ * cursor for ANY source): its start is "each existing file's end at
205
+ * discovery", which does not exist until discovery runs. Its cursors reach
206
+ * the server on the first write after discovery. Rule 4 is the pre-card
207
+ * deploy transition only — every dispatch since this card stores cursors on
208
+ * its first write — so that bounded window is the one the card accepts.
178
209
  */
179
210
  buildCursorReports() {
180
211
  const reports = {};
181
212
  const deliveredAtSend = new Map();
182
- if (!this.readCursorState)
183
- return { reports, deliveredAtSend, reportedResets: [] };
213
+ if (!this.readCursorState) {
214
+ return { reports, deliveredAtSend, reportedResets: [], carriesStartCursors: false };
215
+ }
184
216
  const resetBySource = new Map(this.pastEof.map((p) => [p.source, p]));
185
217
  const reportedResets = [];
186
- for (const [source, state] of Object.entries(this.readCursorState())) {
218
+ const live = this.readCursorState();
219
+ let carriesStartCursors = false;
220
+ if (!this.startCursorsPersisted) {
221
+ for (const [source, start] of Object.entries(this.startCursors)) {
222
+ if (source in live)
223
+ continue; // the live state supersedes it once opened
224
+ reports[source] = { acked: start.acked, delivered: start.delivered };
225
+ carriesStartCursors = true;
226
+ }
227
+ }
228
+ for (const [source, state] of Object.entries(live)) {
187
229
  // `acked` floors at the source's START offset: a source opened at EOF
188
230
  // under rule 4 must never claim byte 0 as acknowledged, or the next
189
231
  // restart would read the whole file and re-sum the history rule 4 skipped.
@@ -201,7 +243,7 @@ export class DispatchUsage {
201
243
  // source that has left `cursorState()` (its tail dropped on ENOENT) would
202
244
  // otherwise be taken and forgotten, leaving the stored `acked` at a value the
203
245
  // file can never satisfy.
204
- return { reports, deliveredAtSend, reportedResets };
246
+ return { reports, deliveredAtSend, reportedResets, carriesStartCursors };
205
247
  }
206
248
  /**
207
249
  * DX-2954 — whether the cursor half has anything new to say. An IDLE dispatch
@@ -259,9 +301,10 @@ export class DispatchUsage {
259
301
  return this.queue(async () => {
260
302
  if (this.refused)
261
303
  return "refused";
304
+ this.flushBegan = true;
262
305
  const changed = this.counter.takeChanged();
263
306
  const batches = chunkEntries(changed);
264
- const { reports, deliveredAtSend, reportedResets } = this.buildCursorReports();
307
+ const { reports, deliveredAtSend, reportedResets, carriesStartCursors } = this.buildCursorReports();
265
308
  // TAKE the queued side data, exactly as `counter.takeChanged()` does, so a
266
309
  // hook that fires during the await lands in the NEXT write rather than
267
310
  // being silently dropped by a post-write reset of the live array.
@@ -282,7 +325,12 @@ export class DispatchUsage {
282
325
  const pastEofSent = reportedResets;
283
326
  const sentResets = new Set(reportedResets);
284
327
  this.pastEof = this.pastEof.filter((p) => !sentResets.has(p));
285
- const haveSideData = this.cursorsMoved(reports) ||
328
+ // `carriesStartCursors` forces the write on its own: a starting pair like
329
+ // `{acked: 0, delivered: 0}` never looks "moved", and a process-start write
330
+ // whose last batch failed must still deliver it on a later, otherwise idle
331
+ // tick.
332
+ const haveSideData = carriesStartCursors ||
333
+ this.cursorsMoved(reports) ||
286
334
  activities.length > 0 ||
287
335
  (this.transcriptPath !== null && !this.transcriptPathSent);
288
336
  if (batches.length === 0 && (!this.started || haveSideData))
@@ -325,6 +373,10 @@ export class DispatchUsage {
325
373
  }
326
374
  if (payload.transcriptPath !== undefined)
327
375
  this.transcriptPathSent = true;
376
+ // Safe on ANY landed write: `attachCursors` refuses to run after the
377
+ // first flush began, so every starting cursor was either carried by
378
+ // this write or is superseded by a live one that this write reported.
379
+ this.startCursorsPersisted = true;
328
380
  }
329
381
  }
330
382
  catch (err) {
@@ -76,7 +76,7 @@ export interface SessionLogWatcherOptions {
76
76
  * That lost every line between the last write and the restart; a cursor does
77
77
  * not.
78
78
  */
79
- startCursors?: Record<string, SourceCursorStart>;
79
+ startCursors: Record<string, SourceCursorStart>;
80
80
  /**
81
81
  * DX-2954 — rule 4 of the start order: start every ALREADY-EXISTING file at
82
82
  * its current end.
@@ -507,7 +507,7 @@ export class SessionLogWatcher {
507
507
  this.sessionId = options.sessionId;
508
508
  this.dispatchId = options.dispatchId;
509
509
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
510
- this.startCursors = options.startCursors ?? {};
510
+ this.startCursors = options.startCursors;
511
511
  this.startExistingFilesAtEof = options.startExistingFilesAtEof ?? false;
512
512
  this.onCursorPastEof = options.onCursorPastEof;
513
513
  this.onSourceDiscovered = options.onSourceDiscovered;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Shared Claude Code transcript collector (DX-2962) — incremental JSONL tailing for the danxbot worker and the danx-dashboard-mcp desktop bridge, so both run identical collector code.",
5
5
  "license": "MIT",
6
6
  "type": "module",