@thehammer/danx-session-telemetry 0.1.10 → 0.1.12
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/dist/index.d.ts +1 -1
- package/dist/session-log-watcher.d.ts +230 -48
- package/dist/session-log-watcher.js +300 -60
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* usage display.
|
|
16
16
|
*/
|
|
17
17
|
export { SessionLogWatcher, DISPATCH_TAG_PREFIX, encodeClaudeProjectsCwd, deriveSessionDir, sessionUuidExistsInCleanRoom, findNewestJsonlFile, findSessionFileByDispatchId, convertJsonlEntry, } from "./session-log-watcher.js";
|
|
18
|
-
export type { EntryConsumer, SessionLogWatcherOptions, } from "./session-log-watcher.js";
|
|
18
|
+
export type { EntryConsumer, SessionLogWatcherOptions, SourceCursorStart, CursorPastEofInfo, SourceDiscoveredInfo, EntryMeta, } from "./session-log-watcher.js";
|
|
19
19
|
export type { AgentLogEntry, AgentLineage } from "./types.js";
|
|
20
20
|
export { truncStr, summarizeToolInput, buildAssistantSummary, buildToolResultSummary, } from "./tool-summary.js";
|
|
21
21
|
export * from "./usage-ledger.js";
|
|
@@ -28,7 +28,14 @@ import type { AgentLogEntry } from "./types.js";
|
|
|
28
28
|
* wrong file (e.g., an active human session).
|
|
29
29
|
*/
|
|
30
30
|
export declare const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A consumer of transcript lines.
|
|
33
|
+
*
|
|
34
|
+
* DX-2954 added the second argument. A consumer that ignores it behaves exactly
|
|
35
|
+
* as before; a consumer with a SIDE EFFECT must read `meta.replay` and skip a
|
|
36
|
+
* re-read line (see {@link EntryMeta.replay}).
|
|
37
|
+
*/
|
|
38
|
+
export type EntryConsumer = (entry: AgentLogEntry, meta: EntryMeta) => void;
|
|
32
39
|
export interface SessionLogWatcherOptions {
|
|
33
40
|
/** Working directory the agent was launched in. Used to derive the session directory. */
|
|
34
41
|
cwd: string;
|
|
@@ -46,32 +53,118 @@ export interface SessionLogWatcherOptions {
|
|
|
46
53
|
*/
|
|
47
54
|
dispatchId?: string;
|
|
48
55
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
56
|
+
* DX-2954 — where each source resumes: the cursors the SERVER has stored for
|
|
57
|
+
* this dispatch, keyed by source ({@link MAIN_SOURCE} for the main transcript,
|
|
58
|
+
* the sub-agent id for a sub-agent file).
|
|
59
|
+
*
|
|
60
|
+
* An ABSENT source key means no cursor is stored for it — never "start at 0".
|
|
61
|
+
* The two are different: 0 is a legitimate stored cursor meaning "start of
|
|
62
|
+
* file", and CP1 forbids defaulting a missing persisted value. See
|
|
63
|
+
* {@link SessionLogWatcherOptions.startExistingFilesAtEof} and the start-order
|
|
64
|
+
* table on {@link SessionLogWatcher} for what happens when a source has none.
|
|
65
|
+
*
|
|
66
|
+
* This REPLACED the pre-DX-2954 `fromEof` / `existingSubagentFilesFromEof`
|
|
67
|
+
* pair, which skipped straight to a file's end on every reattach and every
|
|
68
|
+
* `--resume` because there was nowhere to record how far a reader had got.
|
|
69
|
+
* That lost every line between the last write and the restart; a cursor does
|
|
70
|
+
* not.
|
|
62
71
|
*/
|
|
63
|
-
|
|
72
|
+
startCursors?: Record<string, SourceCursorStart>;
|
|
64
73
|
/**
|
|
65
|
-
* DX-
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
74
|
+
* DX-2954 — rule 4 of the start order: start every ALREADY-EXISTING file at
|
|
75
|
+
* its current end.
|
|
76
|
+
*
|
|
77
|
+
* Set ONLY by a WORKER dispatch that continues a session and has no cursor
|
|
78
|
+
* for ANY source — a row from before this card shipped, or a `--resume` whose
|
|
79
|
+
* predecessor predates it. Those files hold a prior dispatch's accounting with
|
|
80
|
+
* no ledger rows to dedupe against, so reading them from 0 re-sums the whole
|
|
81
|
+
* session (the 421M-cache-read inflation of DX-2583). A file that appears
|
|
82
|
+
* LATER is genuinely new and still starts at 0.
|
|
83
|
+
*
|
|
84
|
+
* It must never be set alongside cursors for some sources: rule 5 says a
|
|
85
|
+
* sub-agent file that appeared after the last stats write, on a dispatch that
|
|
86
|
+
* HAS cursors, starts at 0 so none of its lines are lost.
|
|
73
87
|
*/
|
|
74
|
-
|
|
88
|
+
startExistingFilesAtEof?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* DX-2954 — a stored cursor was PAST the end of its file (the file was
|
|
91
|
+
* replaced or truncated), so that source restarted at 0.
|
|
92
|
+
*
|
|
93
|
+
* Without this the watcher would silently tail nothing for the rest of the
|
|
94
|
+
* dispatch: its `size <= byteOffset` early return is indistinguishable from
|
|
95
|
+
* "no new bytes". The caller records a visible `transcript_cursor_past_eof`
|
|
96
|
+
* event with both numbers and marks the reset in its next stats write, which
|
|
97
|
+
* is the ONE sanctioned backwards move of a stored `acked`.
|
|
98
|
+
*/
|
|
99
|
+
onCursorPastEof?: (info: CursorPastEofInfo) => void;
|
|
100
|
+
/**
|
|
101
|
+
* DX-2954 — a sub-agent transcript just came under tail for the first time,
|
|
102
|
+
* i.e. a sub-agent of this dispatch is RUNNING.
|
|
103
|
+
*
|
|
104
|
+
* This is the transcript-side producer of the dispatch's running set. It
|
|
105
|
+
* reports the sub-agent's ledger source key, which is the same value its
|
|
106
|
+
* `dispatch_activities.activity_id` and its per-source token total join on —
|
|
107
|
+
* so a hook-side producer reporting the same sub-agent by its
|
|
108
|
+
* `transcript_path` resolves to this same id and upserts the SAME row rather
|
|
109
|
+
* than a duplicate.
|
|
110
|
+
*
|
|
111
|
+
* Fired once per source, from the discovery pass, for sub-agent files only —
|
|
112
|
+
* never for the main transcript, which is the dispatch itself, not an activity
|
|
113
|
+
* within it.
|
|
114
|
+
*/
|
|
115
|
+
onSourceDiscovered?: (info: SourceDiscoveredInfo) => void;
|
|
116
|
+
}
|
|
117
|
+
/** DX-2954 — one source's stored cursor pair, as a collector starts from it. */
|
|
118
|
+
export interface SourceCursorStart {
|
|
119
|
+
/** The resume point: read on from here. */
|
|
120
|
+
acked: number;
|
|
121
|
+
/**
|
|
122
|
+
* The highest byte already handed to subscribers. Lines between `acked` and
|
|
123
|
+
* this are re-read and handed on marked as REPLAYS, so side-effecting
|
|
124
|
+
* subscribers skip them while the ledger and the log shipper still see them.
|
|
125
|
+
*/
|
|
126
|
+
delivered: number;
|
|
127
|
+
}
|
|
128
|
+
/** DX-2954 — reported when a stored cursor was past its file's end. */
|
|
129
|
+
export interface CursorPastEofInfo {
|
|
130
|
+
source: string;
|
|
131
|
+
storedOffset: number;
|
|
132
|
+
fileSize: number;
|
|
133
|
+
}
|
|
134
|
+
/** DX-2954 — reported when a sub-agent's transcript comes under tail. */
|
|
135
|
+
export interface SourceDiscoveredInfo {
|
|
136
|
+
/** The sub-agent's ledger source key (its transcript file's basename). */
|
|
137
|
+
source: string;
|
|
138
|
+
/**
|
|
139
|
+
* The sub-agent's declared type, read from its own transcript. `null` when it
|
|
140
|
+
* has not written a line naming one yet — a hook-side report of the same
|
|
141
|
+
* sub-agent fills it in later without clobbering a value already stored.
|
|
142
|
+
*/
|
|
143
|
+
agentType: string | null;
|
|
144
|
+
/** Epoch ms this watcher first saw the file. */
|
|
145
|
+
discoveredAt: number;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* DX-2954 — what a consumer learns about the line it is being handed, beyond
|
|
149
|
+
* the entry itself.
|
|
150
|
+
*/
|
|
151
|
+
export interface EntryMeta {
|
|
152
|
+
/** {@link MAIN_SOURCE} for the main transcript, else the sub-agent id. */
|
|
153
|
+
source: string;
|
|
154
|
+
/** Byte offset of the line's first byte in its file. */
|
|
155
|
+
byteOffset: number;
|
|
156
|
+
/**
|
|
157
|
+
* This line was already handed to a PREVIOUS incarnation's subscribers (it
|
|
158
|
+
* falls below the source's stored `delivered` cursor) and is being handed
|
|
159
|
+
* again after a restart.
|
|
160
|
+
*
|
|
161
|
+
* Every subscriber with a SIDE EFFECT must skip a replay — re-posting Slack
|
|
162
|
+
* progress, re-forwarding to gpt-manager, or re-running the API-error
|
|
163
|
+
* detector on a predecessor's last lines are all real, visible damage. The
|
|
164
|
+
* ledger and the log shipper DO process replays: both are idempotent, and
|
|
165
|
+
* skipping them is what would lose data.
|
|
166
|
+
*/
|
|
167
|
+
replay: boolean;
|
|
75
168
|
}
|
|
76
169
|
/**
|
|
77
170
|
* Encode a working directory to the subdir name Claude Code writes JSONL
|
|
@@ -90,20 +183,21 @@ export interface SessionLogWatcherOptions {
|
|
|
90
183
|
* The `.`/`_` encoding matters: a hidden-dir segment (leading `.`) or a
|
|
91
184
|
* clean-room key's `__` delimiter that the encoder does NOT collapse produces
|
|
92
185
|
* a directory Claude Code never writes to — silently breaking
|
|
93
|
-
* SessionLogWatcher attachment
|
|
94
|
-
*
|
|
186
|
+
* SessionLogWatcher attachment. The separator-encoding regression tests
|
|
187
|
+
* below pin this.
|
|
95
188
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
189
|
+
* Claude 2.1.104+ (GitHub #51488 — symlinks under `~/.claude/projects/` are
|
|
190
|
+
* no longer followed) writes its transcript to this exact realpath-encoded
|
|
191
|
+
* dir as a REAL directory; the watcher reads the same path, so they converge
|
|
192
|
+
* with no symlink. A danxbot worker container binds `<repo>/claude-projects/`
|
|
193
|
+
* at `/home/danxbot/.claude/projects/` and relies on the same encoding for
|
|
194
|
+
* the bound subdir name; an operator's own interactive session on its
|
|
195
|
+
* machine uses the native dir. Keep the encoding logic centralized here so
|
|
196
|
+
* every caller stays in lockstep.
|
|
103
197
|
*
|
|
104
|
-
* DX-3069 — Windows
|
|
105
|
-
*
|
|
106
|
-
*
|
|
198
|
+
* DX-3069 — a Windows caller (an operator session on a Windows machine)
|
|
199
|
+
* passes an ALREADY-ABSOLUTE Windows cwd (`C:\Users\...`), which never
|
|
200
|
+
* starts with `/`. The prior
|
|
107
201
|
* `cwd.startsWith("/") ? cwd : "/" + cwd` unconditionally prepended `/`,
|
|
108
202
|
* producing a malformed path (`/C:\Users\...`) that `realpathSync` cannot
|
|
109
203
|
* resolve (ENOENT — Windows reads the leading `/C:` as its own root segment),
|
|
@@ -112,7 +206,7 @@ export interface SessionLogWatcherOptions {
|
|
|
112
206
|
* drive-letter colon survived verbatim into the "encoded" dir name, and
|
|
113
207
|
* `join(homedir(), ".claude", "projects", encoded)` then produced a NESTED,
|
|
114
208
|
* DOUBLED path — `C:\Users\...\.claude\projects\-C:\Users\...` — a directory
|
|
115
|
-
* Claude Code never writes to, so every
|
|
209
|
+
* Claude Code never writes to, so every watcher on Windows silently
|
|
116
210
|
* never found its own transcript. Verified empirically against a real Claude
|
|
117
211
|
* Code install on this machine: `C:\Users\newms\projects\danxbot` encodes on
|
|
118
212
|
* disk to `C--Users-newms-projects-danxbot` (`ls ~/.claude/projects`) — no
|
|
@@ -185,6 +279,29 @@ export declare function convertJsonlEntry(raw: Record<string, unknown>, lastTime
|
|
|
185
279
|
entry: AgentLogEntry;
|
|
186
280
|
timestamp: number;
|
|
187
281
|
} | null;
|
|
282
|
+
/**
|
|
283
|
+
* DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
|
|
284
|
+
* {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
|
|
285
|
+
* the right `startCursors` / `startExistingFilesAtEof`.
|
|
286
|
+
*
|
|
287
|
+
* | # | Condition | Start |
|
|
288
|
+
* |---|---|---|
|
|
289
|
+
* | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
|
|
290
|
+
* | 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` |
|
|
291
|
+
* | 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 |
|
|
292
|
+
* | 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 |
|
|
293
|
+
* | 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 |
|
|
294
|
+
*
|
|
295
|
+
* Rules 1 and 2 arrive identically because "where does this source resume" has
|
|
296
|
+
* ONE answer; rule 4 is the only branch that skips content, and it is narrow by
|
|
297
|
+
* construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
|
|
298
|
+
* late sub-agent file's lines).
|
|
299
|
+
*
|
|
300
|
+
* Rules 3 and 5 both read from 0, but for different reasons, and both are
|
|
301
|
+
* correct: a fresh session's full usage SHOULD be counted, and a forked
|
|
302
|
+
* session's copied history is read and credited nothing (the ledger's excess
|
|
303
|
+
* rule already covers it).
|
|
304
|
+
*/
|
|
188
305
|
export declare class SessionLogWatcher {
|
|
189
306
|
private consumers;
|
|
190
307
|
private pollTimer;
|
|
@@ -196,13 +313,62 @@ export declare class SessionLogWatcher {
|
|
|
196
313
|
private sessionId;
|
|
197
314
|
private dispatchId;
|
|
198
315
|
private pollIntervalMs;
|
|
199
|
-
private
|
|
200
|
-
private
|
|
316
|
+
private readonly startCursors;
|
|
317
|
+
private readonly startExistingFilesAtEof;
|
|
318
|
+
private readonly onCursorPastEof;
|
|
319
|
+
private readonly onSourceDiscovered;
|
|
201
320
|
private subagentDiscoveryDone;
|
|
202
321
|
private running;
|
|
203
322
|
private polling;
|
|
204
323
|
private initSynthesized;
|
|
205
324
|
constructor(options: SessionLogWatcherOptions);
|
|
325
|
+
/**
|
|
326
|
+
* DX-2954 — resolve one file's starting offset and replay boundary, per the
|
|
327
|
+
* start order on {@link SessionLogWatcher}.
|
|
328
|
+
*
|
|
329
|
+
* `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
|
|
330
|
+
* attached, on a continuing worker dispatch with no cursor anywhere) from
|
|
331
|
+
* rule 5 (a file that appeared later, which always starts at 0 so none of its
|
|
332
|
+
* lines are lost).
|
|
333
|
+
*/
|
|
334
|
+
private resolveStart;
|
|
335
|
+
/**
|
|
336
|
+
* DX-2954 — every open source's cursor state.
|
|
337
|
+
*
|
|
338
|
+
* `delivered` is the byte up to which this watcher has handed lines to
|
|
339
|
+
* subscribers, which the collector reports as the `delivered` half of its
|
|
340
|
+
* stats write. `startOffset` is where this watcher OPENED the file, and it is
|
|
341
|
+
* the floor for the `acked` half: a source that started at EOF under rule 4
|
|
342
|
+
* must never report `acked: 0`, or the next restart would read the whole file
|
|
343
|
+
* from the start and re-sum the history rule 4 exists to skip.
|
|
344
|
+
*/
|
|
345
|
+
cursorState(): Record<string, {
|
|
346
|
+
startOffset: number;
|
|
347
|
+
delivered: number;
|
|
348
|
+
}>;
|
|
349
|
+
/**
|
|
350
|
+
* DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
|
|
351
|
+
* or 0 when it has none.
|
|
352
|
+
*
|
|
353
|
+
* Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
|
|
354
|
+
* watcher attach mid-session?". The offset alone is not enough: because `acked`
|
|
355
|
+
* lags `delivered` by one write, a crash right after the first cursor-bearing
|
|
356
|
+
* write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
|
|
357
|
+
* every byte up to D is a re-read.
|
|
358
|
+
*/
|
|
359
|
+
mainReplayUntil(): number;
|
|
360
|
+
/**
|
|
361
|
+
* DX-2954 — the main transcript's starting offset once discovery has run, or
|
|
362
|
+
* `null` before that.
|
|
363
|
+
*
|
|
364
|
+
* This is what "is this a mid-session attach?" is derived from now. The
|
|
365
|
+
* declared-loaded observer used to be told `reattached: true` explicitly;
|
|
366
|
+
* a non-zero main start is the same fact stated as an observation rather than
|
|
367
|
+
* a flag, and it is true for every case that matters (a reattach, a
|
|
368
|
+
* `--resume`, a resumed operator session) without a caller having to
|
|
369
|
+
* enumerate them.
|
|
370
|
+
*/
|
|
371
|
+
mainStartOffset(): number | null;
|
|
206
372
|
/** Register a consumer callback that receives each new AgentLogEntry. */
|
|
207
373
|
onEntry(consumer: EntryConsumer): void;
|
|
208
374
|
/** Returns the discovered parent session file path, or null if not yet found. */
|
|
@@ -263,15 +429,10 @@ export declare class SessionLogWatcher {
|
|
|
263
429
|
/**
|
|
264
430
|
* Adopt a parent session file and derive the sub-agent directory.
|
|
265
431
|
*
|
|
266
|
-
*
|
|
267
|
-
* the
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* with a delete that happens between discover and adopt) falls back to
|
|
271
|
-
* 0 so the watcher tails from the start; that is preferable to throwing
|
|
272
|
-
* during boot. `fromEof` does NOT skip sub-agent files: any sub-agent
|
|
273
|
-
* file claude writes AFTER reattach is genuinely new and should be
|
|
274
|
-
* tailed normally.
|
|
432
|
+
* DX-2954 — the starting offset comes from {@link SessionLogWatcher.resolveStart}
|
|
433
|
+
* (the cursor start order), not from a `fromEof` flag: the main source resumes
|
|
434
|
+
* at its stored `acked` cursor when it has one, and only a continuing worker
|
|
435
|
+
* dispatch with no cursor anywhere skips to the file's end.
|
|
275
436
|
*/
|
|
276
437
|
private setParentFile;
|
|
277
438
|
/**
|
|
@@ -296,6 +457,27 @@ export declare class SessionLogWatcher {
|
|
|
296
457
|
* `pollFile` — the inline call is the defense-in-depth that closes the race
|
|
297
458
|
* where bytes appear before `refreshPendingAgentTypes` has a chance to run.
|
|
298
459
|
*/
|
|
460
|
+
/**
|
|
461
|
+
* DX-2954 — report a running sub-agent to the consumer, normalizing the
|
|
462
|
+
* absent-type shape and CONTAINING any throw.
|
|
463
|
+
*
|
|
464
|
+
* The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
|
|
465
|
+
* every consumer for the same reason, and it is load-bearing here because of
|
|
466
|
+
* where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
|
|
467
|
+
* `poll()` has `try/finally` without one, so an escaping throw would:
|
|
468
|
+
*
|
|
469
|
+
* - on the first pass (inside `start()`), kill the watcher for the whole
|
|
470
|
+
* dispatch — the caller only logs `start()`'s rejection;
|
|
471
|
+
* - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
|
|
472
|
+
* turns into process exit — a worker crash caused by a consumer bug;
|
|
473
|
+
* - either way abort the discovery loop mid-list, skipping
|
|
474
|
+
* `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
|
|
475
|
+
* sends later-appearing files down rule 4 instead of rule 5 — losing
|
|
476
|
+
* exactly the lines that rule split exists to preserve.
|
|
477
|
+
*
|
|
478
|
+
* Reporting the running set must never cost the monitoring it rides on.
|
|
479
|
+
*/
|
|
480
|
+
private emitSourceDiscovered;
|
|
299
481
|
private refreshLineage;
|
|
300
482
|
/**
|
|
301
483
|
* Read agentType from the `<subagentId>.meta.json` sidecar. Returns
|
|
@@ -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;
|
|
@@ -58,20 +59,21 @@ export const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
|
|
|
58
59
|
* The `.`/`_` encoding matters: a hidden-dir segment (leading `.`) or a
|
|
59
60
|
* clean-room key's `__` delimiter that the encoder does NOT collapse produces
|
|
60
61
|
* a directory Claude Code never writes to — silently breaking
|
|
61
|
-
* SessionLogWatcher attachment
|
|
62
|
-
*
|
|
62
|
+
* SessionLogWatcher attachment. The separator-encoding regression tests
|
|
63
|
+
* below pin this.
|
|
63
64
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
65
|
+
* Claude 2.1.104+ (GitHub #51488 — symlinks under `~/.claude/projects/` are
|
|
66
|
+
* no longer followed) writes its transcript to this exact realpath-encoded
|
|
67
|
+
* dir as a REAL directory; the watcher reads the same path, so they converge
|
|
68
|
+
* with no symlink. A danxbot worker container binds `<repo>/claude-projects/`
|
|
69
|
+
* at `/home/danxbot/.claude/projects/` and relies on the same encoding for
|
|
70
|
+
* the bound subdir name; an operator's own interactive session on its
|
|
71
|
+
* machine uses the native dir. Keep the encoding logic centralized here so
|
|
72
|
+
* every caller stays in lockstep.
|
|
71
73
|
*
|
|
72
|
-
* DX-3069 — Windows
|
|
73
|
-
*
|
|
74
|
-
*
|
|
74
|
+
* DX-3069 — a Windows caller (an operator session on a Windows machine)
|
|
75
|
+
* passes an ALREADY-ABSOLUTE Windows cwd (`C:\Users\...`), which never
|
|
76
|
+
* starts with `/`. The prior
|
|
75
77
|
* `cwd.startsWith("/") ? cwd : "/" + cwd` unconditionally prepended `/`,
|
|
76
78
|
* producing a malformed path (`/C:\Users\...`) that `realpathSync` cannot
|
|
77
79
|
* resolve (ENOENT — Windows reads the leading `/C:` as its own root segment),
|
|
@@ -80,7 +82,7 @@ export const DISPATCH_TAG_PREFIX = "<!-- danxbot-dispatch:";
|
|
|
80
82
|
* drive-letter colon survived verbatim into the "encoded" dir name, and
|
|
81
83
|
* `join(homedir(), ".claude", "projects", encoded)` then produced a NESTED,
|
|
82
84
|
* DOUBLED path — `C:\Users\...\.claude\projects\-C:\Users\...` — a directory
|
|
83
|
-
* Claude Code never writes to, so every
|
|
85
|
+
* Claude Code never writes to, so every watcher on Windows silently
|
|
84
86
|
* never found its own transcript. Verified empirically against a real Claude
|
|
85
87
|
* Code install on this machine: `C:\Users\newms\projects\danxbot` encodes on
|
|
86
88
|
* disk to `C--Users-newms-projects-danxbot` (`ls ~/.claude/projects`) — no
|
|
@@ -239,8 +241,8 @@ export async function findSessionFileByDispatchId(dir, dispatchId) {
|
|
|
239
241
|
catch (err) {
|
|
240
242
|
// DX-1201 — a single unreadable SIBLING file must NOT abort the
|
|
241
243
|
// whole scan. Dispatched agents share a busy session dir (e.g.
|
|
242
|
-
//
|
|
243
|
-
// `~/.claude/projects
|
|
244
|
+
// every issue-worker dispatch of one repo writing under the same
|
|
245
|
+
// encoded `~/.claude/projects/<cwd>/` dir), so any one of the
|
|
244
246
|
// co-located multi-MB files can momentarily fail an `open`/`stat`/
|
|
245
247
|
// `read`: ENOENT (sibling rotated/deleted mid-scan), ESTALE (a
|
|
246
248
|
// worktree torn down under the agent), EACCES (perms race), EMFILE
|
|
@@ -412,6 +414,29 @@ export function convertJsonlEntry(raw, lastTimestamp) {
|
|
|
412
414
|
return null;
|
|
413
415
|
}
|
|
414
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* DX-2954 — WHERE EACH FILE STARTS. One ordered rule set, applied per source by
|
|
419
|
+
* {@link SessionLogWatcher.resolveStart}; the caller's job is only to hand in
|
|
420
|
+
* the right `startCursors` / `startExistingFilesAtEof`.
|
|
421
|
+
*
|
|
422
|
+
* | # | Condition | Start |
|
|
423
|
+
* |---|---|---|
|
|
424
|
+
* | 1 | the dispatch has a stored cursor for this source | its `acked`, replaying up to its `delivered` |
|
|
425
|
+
* | 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` |
|
|
426
|
+
* | 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 |
|
|
427
|
+
* | 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 |
|
|
428
|
+
* | 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 |
|
|
429
|
+
*
|
|
430
|
+
* Rules 1 and 2 arrive identically because "where does this source resume" has
|
|
431
|
+
* ONE answer; rule 4 is the only branch that skips content, and it is narrow by
|
|
432
|
+
* construction (it cannot fire when ANY source has a cursor, so rule 5 keeps a
|
|
433
|
+
* late sub-agent file's lines).
|
|
434
|
+
*
|
|
435
|
+
* Rules 3 and 5 both read from 0, but for different reasons, and both are
|
|
436
|
+
* correct: a fresh session's full usage SHOULD be counted, and a forked
|
|
437
|
+
* session's copied history is read and credited nothing (the ledger's excess
|
|
438
|
+
* rule already covers it).
|
|
439
|
+
*/
|
|
415
440
|
export class SessionLogWatcher {
|
|
416
441
|
consumers = [];
|
|
417
442
|
pollTimer;
|
|
@@ -423,8 +448,10 @@ export class SessionLogWatcher {
|
|
|
423
448
|
sessionId;
|
|
424
449
|
dispatchId;
|
|
425
450
|
pollIntervalMs;
|
|
426
|
-
|
|
427
|
-
|
|
451
|
+
startCursors;
|
|
452
|
+
startExistingFilesAtEof;
|
|
453
|
+
onCursorPastEof;
|
|
454
|
+
onSourceDiscovered;
|
|
428
455
|
subagentDiscoveryDone = false;
|
|
429
456
|
running = false;
|
|
430
457
|
polling = false;
|
|
@@ -440,9 +467,110 @@ export class SessionLogWatcher {
|
|
|
440
467
|
this.sessionId = options.sessionId;
|
|
441
468
|
this.dispatchId = options.dispatchId;
|
|
442
469
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
443
|
-
this.
|
|
444
|
-
this.
|
|
445
|
-
|
|
470
|
+
this.startCursors = options.startCursors ?? {};
|
|
471
|
+
this.startExistingFilesAtEof = options.startExistingFilesAtEof ?? false;
|
|
472
|
+
this.onCursorPastEof = options.onCursorPastEof;
|
|
473
|
+
this.onSourceDiscovered = options.onSourceDiscovered;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* DX-2954 — resolve one file's starting offset and replay boundary, per the
|
|
477
|
+
* start order on {@link SessionLogWatcher}.
|
|
478
|
+
*
|
|
479
|
+
* `fileAlreadyExisted` distinguishes rule 4 (a file present when this watcher
|
|
480
|
+
* attached, on a continuing worker dispatch with no cursor anywhere) from
|
|
481
|
+
* rule 5 (a file that appeared later, which always starts at 0 so none of its
|
|
482
|
+
* lines are lost).
|
|
483
|
+
*/
|
|
484
|
+
async resolveStart(source, filePath, fileAlreadyExisted) {
|
|
485
|
+
const stored = this.startCursors[source];
|
|
486
|
+
// Rules 1-2: this dispatch's own stored cursor, or the predecessor's that
|
|
487
|
+
// the caller already resolved into `startCursors`. Both arrive here the
|
|
488
|
+
// same way, because "where does this source resume" has ONE answer.
|
|
489
|
+
if (stored !== undefined) {
|
|
490
|
+
return { byteOffset: stored.acked, replayUntil: stored.delivered };
|
|
491
|
+
}
|
|
492
|
+
// Rule 4: no cursor for ANY source on a continuing worker dispatch.
|
|
493
|
+
if (this.startExistingFilesAtEof && fileAlreadyExisted) {
|
|
494
|
+
try {
|
|
495
|
+
const size = (await stat(filePath)).size;
|
|
496
|
+
return { byteOffset: size, replayUntil: 0 };
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
// NOT a fall-back to 0. This branch fires precisely on the rows whose
|
|
500
|
+
// stored totals already count this file, so reading it from the start
|
|
501
|
+
// re-sums the whole session — the 421M-cache-read inflation (DX-2583)
|
|
502
|
+
// that rule 4 exists to prevent. `null` means "do not open this source
|
|
503
|
+
// yet"; the caller leaves it undiscovered and the next poll retries,
|
|
504
|
+
// still treating it as pre-existing.
|
|
505
|
+
log.warn(`startExistingFilesAtEof: stat failed for ${filePath}; NOT opening it this pass (reading from 0 would re-sum the session): ${String(err)}`);
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
// Rules 3 and 5: the first dispatch of a session, and any file that appeared
|
|
510
|
+
// after the last stats write, both read from the start.
|
|
511
|
+
return { byteOffset: 0, replayUntil: 0 };
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* DX-2954 — every open source's cursor state.
|
|
515
|
+
*
|
|
516
|
+
* `delivered` is the byte up to which this watcher has handed lines to
|
|
517
|
+
* subscribers, which the collector reports as the `delivered` half of its
|
|
518
|
+
* stats write. `startOffset` is where this watcher OPENED the file, and it is
|
|
519
|
+
* the floor for the `acked` half: a source that started at EOF under rule 4
|
|
520
|
+
* must never report `acked: 0`, or the next restart would read the whole file
|
|
521
|
+
* from the start and re-sum the history rule 4 exists to skip.
|
|
522
|
+
*/
|
|
523
|
+
cursorState() {
|
|
524
|
+
// `delivered` is `lineBufferStartOffset`, NOT `byteOffset`.
|
|
525
|
+
//
|
|
526
|
+
// `pollFile` advances `byteOffset` by every byte it READ, and `processText`
|
|
527
|
+
// deliberately buffers a trailing newline-less line (the whole reason
|
|
528
|
+
// `lineBuffer` exists — a poll routinely catches a line mid-append). So
|
|
529
|
+
// `byteOffset` can sit PAST the first byte of a line that was never emitted;
|
|
530
|
+
// promoting that to `acked` makes a restart open the file mid-JSON, and the
|
|
531
|
+
// fragment is dropped as a malformed line — losing that reply's usage, its
|
|
532
|
+
// tool-use ids and its side effects permanently.
|
|
533
|
+
//
|
|
534
|
+
// `lineBufferStartOffset` is the start of the next UNCONSUMED line, which is
|
|
535
|
+
// exactly the resume point. It equals `byteOffset` whenever the buffer is
|
|
536
|
+
// empty, so the happy path is unchanged.
|
|
537
|
+
const at = (s) => ({
|
|
538
|
+
startOffset: s.startOffset,
|
|
539
|
+
delivered: s.lineBufferStartOffset,
|
|
540
|
+
});
|
|
541
|
+
const out = {};
|
|
542
|
+
if (this.parentTail)
|
|
543
|
+
out[this.parentTail.source] = at(this.parentTail);
|
|
544
|
+
for (const state of this.subagentTails.values())
|
|
545
|
+
out[state.source] = at(state);
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* DX-2954 — the main source's replay boundary (its stored `delivered` cursor),
|
|
550
|
+
* or 0 when it has none.
|
|
551
|
+
*
|
|
552
|
+
* Read with {@link SessionLogWatcher.mainStartOffset} to answer "did this
|
|
553
|
+
* watcher attach mid-session?". The offset alone is not enough: because `acked`
|
|
554
|
+
* lags `delivered` by one write, a crash right after the first cursor-bearing
|
|
555
|
+
* write leaves `{acked: 0, delivered: D}`, so the next attach starts at 0 while
|
|
556
|
+
* every byte up to D is a re-read.
|
|
557
|
+
*/
|
|
558
|
+
mainReplayUntil() {
|
|
559
|
+
return this.parentTail?.replayUntil ?? 0;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* DX-2954 — the main transcript's starting offset once discovery has run, or
|
|
563
|
+
* `null` before that.
|
|
564
|
+
*
|
|
565
|
+
* This is what "is this a mid-session attach?" is derived from now. The
|
|
566
|
+
* declared-loaded observer used to be told `reattached: true` explicitly;
|
|
567
|
+
* a non-zero main start is the same fact stated as an observation rather than
|
|
568
|
+
* a flag, and it is true for every case that matters (a reattach, a
|
|
569
|
+
* `--resume`, a resumed operator session) without a caller having to
|
|
570
|
+
* enumerate them.
|
|
571
|
+
*/
|
|
572
|
+
mainStartOffset() {
|
|
573
|
+
return this.parentTail?.startOffset ?? null;
|
|
446
574
|
}
|
|
447
575
|
/** Register a consumer callback that receives each new AgentLogEntry. */
|
|
448
576
|
onEntry(consumer) {
|
|
@@ -543,8 +671,7 @@ export class SessionLogWatcher {
|
|
|
543
671
|
const filePath = this.dispatchId
|
|
544
672
|
? await findSessionFileByDispatchId(this.sessionDir, this.dispatchId)
|
|
545
673
|
: await findNewestJsonlFile(this.sessionDir, this.sessionId);
|
|
546
|
-
if (filePath) {
|
|
547
|
-
await this.setParentFile(filePath);
|
|
674
|
+
if (filePath && (await this.setParentFile(filePath))) {
|
|
548
675
|
const tag = this.dispatchId ? `[Dispatch ${this.dispatchId}] ` : "";
|
|
549
676
|
log.info(`${tag}Watching session file: ${filePath}`);
|
|
550
677
|
return;
|
|
@@ -556,35 +683,35 @@ export class SessionLogWatcher {
|
|
|
556
683
|
/**
|
|
557
684
|
* Adopt a parent session file and derive the sub-agent directory.
|
|
558
685
|
*
|
|
559
|
-
*
|
|
560
|
-
* the
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
* with a delete that happens between discover and adopt) falls back to
|
|
564
|
-
* 0 so the watcher tails from the start; that is preferable to throwing
|
|
565
|
-
* during boot. `fromEof` does NOT skip sub-agent files: any sub-agent
|
|
566
|
-
* file claude writes AFTER reattach is genuinely new and should be
|
|
567
|
-
* tailed normally.
|
|
686
|
+
* DX-2954 — the starting offset comes from {@link SessionLogWatcher.resolveStart}
|
|
687
|
+
* (the cursor start order), not from a `fromEof` flag: the main source resumes
|
|
688
|
+
* at its stored `acked` cursor when it has one, and only a continuing worker
|
|
689
|
+
* dispatch with no cursor anywhere skips to the file's end.
|
|
568
690
|
*/
|
|
569
691
|
async setParentFile(filePath) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
}
|
|
692
|
+
// DX-2954 — the main transcript's source key is always MAIN_SOURCE, and it
|
|
693
|
+
// ALWAYS counts as already-existing: by the time discovery found this file
|
|
694
|
+
// it is on disk, which is exactly what rule 4 asks about.
|
|
695
|
+
const start = await this.resolveStart(MAIN_SOURCE, filePath, true);
|
|
696
|
+
// `null` = its start offset could not be established safely (see
|
|
697
|
+
// `resolveStart`). Report the failure so discovery retries rather than
|
|
698
|
+
// adopting the file at a guessed offset.
|
|
699
|
+
if (start === null)
|
|
700
|
+
return false;
|
|
701
|
+
const { byteOffset, replayUntil } = start;
|
|
580
702
|
this.parentTail = {
|
|
581
703
|
filePath,
|
|
582
704
|
byteOffset,
|
|
583
705
|
lineBuffer: "",
|
|
584
706
|
lineBufferStartOffset: byteOffset,
|
|
585
707
|
lastTimestamp: 0,
|
|
708
|
+
source: MAIN_SOURCE,
|
|
709
|
+
startOffset: byteOffset,
|
|
710
|
+
replayUntil,
|
|
711
|
+
discoveredAt: Date.now(),
|
|
586
712
|
};
|
|
587
713
|
this.subagentsDir = join(dirname(filePath), basename(filePath, ".jsonl"), "subagents");
|
|
714
|
+
return true;
|
|
588
715
|
}
|
|
589
716
|
/**
|
|
590
717
|
* Poll all known files: the parent session, then re-enumerate + tail sub-agent files.
|
|
@@ -626,8 +753,41 @@ export class SessionLogWatcher {
|
|
|
626
753
|
const fileStats = await stat(state.filePath);
|
|
627
754
|
if (!this.running)
|
|
628
755
|
return;
|
|
629
|
-
|
|
756
|
+
// DX-2954 — a cursor PAST the end of the file means the file was replaced
|
|
757
|
+
// or truncated under us. `size === byteOffset` is the ordinary "no new
|
|
758
|
+
// bytes" case; only a STRICTLY smaller file is the pathological one, and
|
|
759
|
+
// before this card it fell into the same early return and silently tailed
|
|
760
|
+
// nothing for the rest of the dispatch. Restart that source at 0 and
|
|
761
|
+
// report it, so the caller records a visible event and marks the reset in
|
|
762
|
+
// its next stats write (the one sanctioned backwards move of `acked`).
|
|
763
|
+
if (fileStats.size < state.byteOffset) {
|
|
764
|
+
this.onCursorPastEof?.({
|
|
765
|
+
source: state.source,
|
|
766
|
+
storedOffset: state.byteOffset,
|
|
767
|
+
fileSize: fileStats.size,
|
|
768
|
+
});
|
|
769
|
+
state.byteOffset = 0;
|
|
770
|
+
state.lineBufferStartOffset = 0;
|
|
771
|
+
state.lineBuffer = "";
|
|
772
|
+
// `startOffset` moves to 0 with it: it is the FLOOR the collector uses
|
|
773
|
+
// for this source's reported `acked`, so leaving it at the old (now
|
|
774
|
+
// impossible) offset would make the next write claim to have
|
|
775
|
+
// acknowledged bytes the file no longer has.
|
|
776
|
+
state.startOffset = 0;
|
|
777
|
+
// Nothing below the new position was ever delivered from THIS file's
|
|
778
|
+
// current content, so nothing in it is a replay.
|
|
779
|
+
state.replayUntil = 0;
|
|
780
|
+
// The replacement file's first entry must not have its `delta_ms`
|
|
781
|
+
// computed against the OLD file's last timestamp (which can produce a
|
|
782
|
+
// negative or absurd gap). This is a different file; its timeline starts
|
|
783
|
+
// fresh.
|
|
784
|
+
state.lastTimestamp = 0;
|
|
785
|
+
if (fileStats.size === 0)
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
else if (fileStats.size === state.byteOffset) {
|
|
630
789
|
return;
|
|
790
|
+
}
|
|
631
791
|
const fd = await open(state.filePath, "r");
|
|
632
792
|
try {
|
|
633
793
|
if (!this.running)
|
|
@@ -685,40 +845,62 @@ export class SessionLogWatcher {
|
|
|
685
845
|
log.error("Failed to enumerate sub-agent directory:", err);
|
|
686
846
|
return;
|
|
687
847
|
}
|
|
688
|
-
// DX-
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
|
|
848
|
+
// DX-2954 — the FIRST discovery pass sees exactly the sub-agent files that
|
|
849
|
+
// existed when this watcher attached. Only those can satisfy rule 4's
|
|
850
|
+
// "already existing"; a file found on a LATER pass appeared after the attach
|
|
851
|
+
// and always reads from 0 (rule 5), so none of its lines are lost.
|
|
852
|
+
const firstPass = !this.subagentDiscoveryDone;
|
|
853
|
+
// Marked done only if this pass established a start offset for EVERY file it
|
|
854
|
+
// saw. A file skipped by `resolveStart` must still count as PRE-EXISTING on
|
|
855
|
+
// the retry — treating it as "appeared later" would send it to rule 5 (read
|
|
856
|
+
// from 0) and re-sum exactly the history rule 4 skipped.
|
|
857
|
+
let allResolved = true;
|
|
692
858
|
for (const file of files) {
|
|
693
859
|
if (!file.endsWith(".jsonl"))
|
|
694
860
|
continue;
|
|
695
861
|
const fullPath = join(this.subagentsDir, file);
|
|
696
862
|
if (this.subagentTails.has(fullPath))
|
|
697
863
|
continue;
|
|
864
|
+
// A sub-agent's ledger source key IS its file's basename (`agent-<hash>`),
|
|
865
|
+
// the same value `usage-ledger.ts` credits usage under and the same value
|
|
866
|
+
// `dispatch_activities.activity_id` holds — so a cursor, a per-source
|
|
867
|
+
// token total and a running-set row all join on one key.
|
|
698
868
|
const subagentId = basename(file, ".jsonl");
|
|
699
869
|
const agentType = await this.readAgentType(subagentId);
|
|
700
|
-
|
|
701
|
-
if (
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
705
|
-
catch (err) {
|
|
706
|
-
log.warn(`existingSubagentFilesFromEof: stat failed for ${fullPath}; tailing from 0: ${String(err)}`);
|
|
707
|
-
}
|
|
870
|
+
const start = await this.resolveStart(subagentId, fullPath, firstPass);
|
|
871
|
+
if (start === null) {
|
|
872
|
+
allResolved = false;
|
|
873
|
+
continue;
|
|
708
874
|
}
|
|
875
|
+
const { byteOffset, replayUntil } = start;
|
|
876
|
+
const discoveredAt = Date.now();
|
|
709
877
|
this.subagentTails.set(fullPath, {
|
|
710
878
|
filePath: fullPath,
|
|
711
|
-
byteOffset
|
|
712
|
-
lineBufferStartOffset:
|
|
879
|
+
byteOffset,
|
|
880
|
+
lineBufferStartOffset: byteOffset,
|
|
713
881
|
lineBuffer: "",
|
|
714
882
|
lastTimestamp: 0,
|
|
883
|
+
source: subagentId,
|
|
884
|
+
startOffset: byteOffset,
|
|
885
|
+
replayUntil,
|
|
886
|
+
discoveredAt,
|
|
715
887
|
lineage: {
|
|
716
888
|
subagent_id: subagentId,
|
|
717
889
|
parent_session_id: this.parentSessionId,
|
|
718
890
|
agent_type: agentType,
|
|
719
891
|
},
|
|
720
892
|
});
|
|
893
|
+
// DX-2954 — fired AFTER the tail is registered, so a consumer that throws
|
|
894
|
+
// cannot leave a half-registered source, and the `subagentTails.has()`
|
|
895
|
+
// guard above makes it exactly-once per source for this watcher's life. A
|
|
896
|
+
// re-discovery by a RESTARTED watcher fires again by design: the row it
|
|
897
|
+
// reports is an idempotent upsert keyed on this same id, and re-reporting
|
|
898
|
+
// is how a reattached collector re-learns the sub-agents that were already
|
|
899
|
+
// running before it existed.
|
|
900
|
+
this.emitSourceDiscovered(subagentId, agentType, discoveredAt);
|
|
721
901
|
}
|
|
902
|
+
if (allResolved)
|
|
903
|
+
this.subagentDiscoveryDone = true;
|
|
722
904
|
}
|
|
723
905
|
/** Centralized lineage refresh — see `refreshLineage` for the per-state body. */
|
|
724
906
|
async refreshPendingAgentTypes() {
|
|
@@ -734,13 +916,62 @@ export class SessionLogWatcher {
|
|
|
734
916
|
* `pollFile` — the inline call is the defense-in-depth that closes the race
|
|
735
917
|
* where bytes appear before `refreshPendingAgentTypes` has a chance to run.
|
|
736
918
|
*/
|
|
919
|
+
/**
|
|
920
|
+
* DX-2954 — report a running sub-agent to the consumer, normalizing the
|
|
921
|
+
* absent-type shape and CONTAINING any throw.
|
|
922
|
+
*
|
|
923
|
+
* The try/catch is not defensive padding; it mirrors `emitEntry`, which wraps
|
|
924
|
+
* every consumer for the same reason, and it is load-bearing here because of
|
|
925
|
+
* where this fires. `discoverNewSubagentFiles` has no `catch` of its own and
|
|
926
|
+
* `poll()` has `try/finally` without one, so an escaping throw would:
|
|
927
|
+
*
|
|
928
|
+
* - on the first pass (inside `start()`), kill the watcher for the whole
|
|
929
|
+
* dispatch — the caller only logs `start()`'s rejection;
|
|
930
|
+
* - on a later interval tick, become an UNHANDLED REJECTION, which Node 20
|
|
931
|
+
* turns into process exit — a worker crash caused by a consumer bug;
|
|
932
|
+
* - either way abort the discovery loop mid-list, skipping
|
|
933
|
+
* `subagentDiscoveryDone = true`, which leaves `firstPass` true forever and
|
|
934
|
+
* sends later-appearing files down rule 4 instead of rule 5 — losing
|
|
935
|
+
* exactly the lines that rule split exists to preserve.
|
|
936
|
+
*
|
|
937
|
+
* Reporting the running set must never cost the monitoring it rides on.
|
|
938
|
+
*/
|
|
939
|
+
emitSourceDiscovered(source, agentType, discoveredAt) {
|
|
940
|
+
try {
|
|
941
|
+
this.onSourceDiscovered?.({
|
|
942
|
+
source,
|
|
943
|
+
// ONE absent-shape on the wire, never two: this class's own reader
|
|
944
|
+
// answers `undefined` for "no meta file yet", while the stats payload and
|
|
945
|
+
// the `dispatch_activities.agent_type` column both spell absent as
|
|
946
|
+
// `null`. Normalizing here keeps every consumer from accepting both.
|
|
947
|
+
agentType: agentType ?? null,
|
|
948
|
+
discoveredAt,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
log.error("onSourceDiscovered consumer error:", err);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
737
955
|
async refreshLineage(state) {
|
|
738
956
|
if (!state.lineage)
|
|
739
957
|
return;
|
|
740
958
|
let { agent_type, parent_session_id } = state.lineage;
|
|
959
|
+
// DX-2954 — remember whether the type was still unknown coming in, so the
|
|
960
|
+
// `undefined -> string` transition can be reported. Discovery reads the
|
|
961
|
+
// sidecar the instant the `.jsonl` appears, and claude writes the `.jsonl`
|
|
962
|
+
// BEFORE the `.meta.json` (the race this method exists for), so "no type
|
|
963
|
+
// yet" at discovery is the COMMON case, not an edge. Without a report here
|
|
964
|
+
// the running-set row's `agent_type` would stay NULL for the whole dispatch
|
|
965
|
+
// even though the watcher learned the real value one poll later.
|
|
966
|
+
const typeWasUnknown = agent_type === undefined;
|
|
741
967
|
if (agent_type === undefined) {
|
|
742
968
|
agent_type = await this.readAgentType(state.lineage.subagent_id);
|
|
743
969
|
}
|
|
970
|
+
if (typeWasUnknown && agent_type !== undefined && state.source !== MAIN_SOURCE) {
|
|
971
|
+
// The ORIGINAL discovery stamp, never a fresh `Date.now()`: this is the
|
|
972
|
+
// same activity, learning its type late, not a new one starting now.
|
|
973
|
+
this.emitSourceDiscovered(state.source, agent_type, state.discoveredAt);
|
|
974
|
+
}
|
|
744
975
|
if (parent_session_id === null && this.parentSessionId !== null) {
|
|
745
976
|
parent_session_id = this.parentSessionId;
|
|
746
977
|
}
|
|
@@ -841,7 +1072,7 @@ export class SessionLogWatcher {
|
|
|
841
1072
|
synthesized: true,
|
|
842
1073
|
},
|
|
843
1074
|
};
|
|
844
|
-
this.emitEntry(initEntry, state);
|
|
1075
|
+
this.emitEntry(initEntry, state, lineStart);
|
|
845
1076
|
this.initSynthesized = true;
|
|
846
1077
|
}
|
|
847
1078
|
}
|
|
@@ -859,7 +1090,7 @@ export class SessionLogWatcher {
|
|
|
859
1090
|
state.lastTimestamp = result.timestamp;
|
|
860
1091
|
// DX-2963 — the line's own byte offset (see `TailState.lineBufferStartOffset`).
|
|
861
1092
|
result.entry.data = { ...result.entry.data, byte_offset: lineStart };
|
|
862
|
-
this.emitEntry(result.entry, state);
|
|
1093
|
+
this.emitEntry(result.entry, state, lineStart);
|
|
863
1094
|
}
|
|
864
1095
|
/**
|
|
865
1096
|
* Emit an entry to registered consumers. For sub-agent streams,
|
|
@@ -872,7 +1103,7 @@ export class SessionLogWatcher {
|
|
|
872
1103
|
* tests; nothing else read it. Every real consumer subscribes via
|
|
873
1104
|
* `onEntry` instead.
|
|
874
1105
|
*/
|
|
875
|
-
emitEntry(entry, state) {
|
|
1106
|
+
emitEntry(entry, state, byteOffset) {
|
|
876
1107
|
if (state.lineage) {
|
|
877
1108
|
entry.data = {
|
|
878
1109
|
...entry.data,
|
|
@@ -881,9 +1112,18 @@ export class SessionLogWatcher {
|
|
|
881
1112
|
agent_type: state.lineage.agent_type,
|
|
882
1113
|
};
|
|
883
1114
|
}
|
|
1115
|
+
// DX-2954 — a line whose FIRST byte lies below the source's stored
|
|
1116
|
+
// `delivered` cursor was already handed to a previous incarnation's
|
|
1117
|
+
// subscribers. Strictly `<`: `delivered` is an exclusive end (the next
|
|
1118
|
+
// unread byte), so a line starting exactly AT it is new.
|
|
1119
|
+
const meta = {
|
|
1120
|
+
source: state.source,
|
|
1121
|
+
byteOffset,
|
|
1122
|
+
replay: byteOffset < state.replayUntil,
|
|
1123
|
+
};
|
|
884
1124
|
for (const consumer of this.consumers) {
|
|
885
1125
|
try {
|
|
886
|
-
consumer(entry);
|
|
1126
|
+
consumer(entry, meta);
|
|
887
1127
|
}
|
|
888
1128
|
catch (err) {
|
|
889
1129
|
log.error("Consumer error:", err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-session-telemetry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
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",
|