@thehammer/danx-session-telemetry 0.1.14 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activity-observer.d.ts +77 -0
- package/dist/activity-observer.js +73 -0
- package/dist/dispatch-usage.d.ts +209 -0
- package/dist/dispatch-usage.js +430 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +11 -1
- package/dist/internal-logger.js +5 -2
- package/dist/session-log-watcher.d.ts +230 -35
- package/dist/session-log-watcher.js +300 -45
- package/dist/stats-wire.d.ts +227 -0
- package/dist/stats-wire.js +307 -0
- package/dist/usage-ledger.js +1 -1
- package/dist/write-queue.d.ts +9 -0
- package/dist/write-queue.js +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-2963 — a running dispatch's usage, worker side.
|
|
3
|
+
*
|
|
4
|
+
* The worker no longer sums anything. It feeds every transcript line into
|
|
5
|
+
* the ONE shared counter (`UsageLedgerCounter`, per-field maximum per reply)
|
|
6
|
+
* and ships the entries that are new or changed since the last acknowledged
|
|
7
|
+
* write to the server (`applyStats`), which derives every total from the
|
|
8
|
+
* ledger and hands them back. The worker's own view of the totals is what the
|
|
9
|
+
* server last returned — plus, for the token ceiling only, a local estimate of
|
|
10
|
+
* what it has read but the server has not acknowledged yet.
|
|
11
|
+
*
|
|
12
|
+
* Totals are taken from the server ONLY at process start (the first write,
|
|
13
|
+
* sent with no entries if there are none) and from each write's response —
|
|
14
|
+
* never seeded by hand (the reattach hand seeds this replaces).
|
|
15
|
+
*
|
|
16
|
+
* DB-free: runs in the DB-less worker. The sink decides the transport
|
|
17
|
+
* (HTTP on a registered worker, getPool-direct on the dashboard).
|
|
18
|
+
*/
|
|
19
|
+
import { UsageLedgerCounter, ledgerLineFromLogEntry, excessUsage, syntheticMessageKey, } from "./usage-ledger.js";
|
|
20
|
+
import { ACTIVITY_BATCH_CAP, DispatchUsageTerminalError, chunkEntries, collapseActivityChanges, } from "./stats-wire.js";
|
|
21
|
+
import { createUsageWriteQueue } from "./write-queue.js";
|
|
22
|
+
import { createLogger } from "./internal-logger.js";
|
|
23
|
+
const log = createLogger("dispatch-usage");
|
|
24
|
+
export function zeroUsageTotals() {
|
|
25
|
+
return {
|
|
26
|
+
tokensIn: 0,
|
|
27
|
+
tokensOut: 0,
|
|
28
|
+
cacheRead: 0,
|
|
29
|
+
cacheWrite: 0,
|
|
30
|
+
tokensTotal: 0,
|
|
31
|
+
costUsd: 0,
|
|
32
|
+
turnCount: 0,
|
|
33
|
+
toolCallCount: 0,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/*
|
|
37
|
+
* DX-2954 — `isPreCardUsageRow` was DELETED here, along with
|
|
38
|
+
* `src/agent/pre-card-lineage.ts` and `markPreCardLineage` below.
|
|
39
|
+
*
|
|
40
|
+
* It answered "did this row count usage before the ledger existed?", which
|
|
41
|
+
* mattered only because a dispatch had nowhere to record HOW FAR it had read:
|
|
42
|
+
* the choice was between re-reading a file from 0 (double-counting) and skipping
|
|
43
|
+
* to its end (losing everything since the last write). `src/agent/start-cursors.ts`
|
|
44
|
+
* dissolves the question — a row WITH stored cursors resumes from them whatever
|
|
45
|
+
* its lineage, and a row WITHOUT any is exactly the case the start order's rule
|
|
46
|
+
* 4 covers. Nothing writes `usage_pre_card_lineage` any more.
|
|
47
|
+
*/
|
|
48
|
+
export class DispatchUsage {
|
|
49
|
+
counter = new UsageLedgerCounter();
|
|
50
|
+
serverTotals = zeroUsageTotals();
|
|
51
|
+
acked = new Map();
|
|
52
|
+
healthy = true;
|
|
53
|
+
refused = false;
|
|
54
|
+
started = false;
|
|
55
|
+
queue = createUsageWriteQueue();
|
|
56
|
+
// ── DX-2954: the cursor half ──────────────────────────────────────────────
|
|
57
|
+
/** How to read the watcher's live per-source cursor state. */
|
|
58
|
+
readCursorState = null;
|
|
59
|
+
/**
|
|
60
|
+
* Per source, the `delivered` offset as of the last ACKNOWLEDGED write — which
|
|
61
|
+
* is precisely what `acked` means. Seeded from the cursors this process
|
|
62
|
+
* resumed from, so a restart that writes before reading anything re-reports
|
|
63
|
+
* its own resume point instead of regressing it.
|
|
64
|
+
*/
|
|
65
|
+
ackedCursor = new Map();
|
|
66
|
+
/** Past-EOF resets to report once, then forget. */
|
|
67
|
+
pastEof = [];
|
|
68
|
+
/**
|
|
69
|
+
* DX-2954 — replies this process read as REPLAYS. The server already counted
|
|
70
|
+
* them for THIS dispatch (they are below its stored `acked`), so they must not
|
|
71
|
+
* enter the unacknowledged excess the token-ceiling estimate adds on top of the
|
|
72
|
+
* server totals.
|
|
73
|
+
*/
|
|
74
|
+
replayedMessageIds = new Set();
|
|
75
|
+
/** Running-set changes to report on the next write. */
|
|
76
|
+
pendingActivities = [];
|
|
77
|
+
/** The session transcript path — the ONE path field the wire carries. */
|
|
78
|
+
transcriptPath = null;
|
|
79
|
+
transcriptPathSent = false;
|
|
80
|
+
/**
|
|
81
|
+
* DX-2954 — wire the cursor half up at attach.
|
|
82
|
+
*
|
|
83
|
+
* `startCursors` are the cursors this process RESUMED from
|
|
84
|
+
* (`resolveStartCursors`); their `acked` values seed this collector's own
|
|
85
|
+
* acknowledged floor. `readCursorState` reads the watcher's live offsets at
|
|
86
|
+
* each write.
|
|
87
|
+
*/
|
|
88
|
+
attachCursors(readCursorState, startCursors) {
|
|
89
|
+
this.readCursorState = readCursorState;
|
|
90
|
+
for (const [source, cursor] of Object.entries(startCursors)) {
|
|
91
|
+
this.ackedCursor.set(source, cursor.acked);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* DX-2954 — a stored cursor was past its file's end; report the reset on the
|
|
96
|
+
* next write so the server records a `transcript_cursor_past_eof` event and
|
|
97
|
+
* permits the one sanctioned backwards move of `acked`.
|
|
98
|
+
*/
|
|
99
|
+
recordPastEof(info) {
|
|
100
|
+
this.pastEof.push(info);
|
|
101
|
+
// The acknowledged floor MUST drop with the file. Leaving it at the old
|
|
102
|
+
// offset would make the next write report `acked` above its own `delivered`
|
|
103
|
+
// — which `parseStatsPayload` refuses outright ("delivered is behind
|
|
104
|
+
// acked"), and which the getPool-direct sink would store as a cursor pair
|
|
105
|
+
// that can never be satisfied. The bytes we believed acknowledged do not
|
|
106
|
+
// exist any more, so claiming them is simply false.
|
|
107
|
+
this.ackedCursor.set(info.source, 0);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* DX-2954 — queue running-set changes for the next write.
|
|
111
|
+
*
|
|
112
|
+
* Kept COLLAPSED (one entry per `(kind, activityId)`) rather than appended
|
|
113
|
+
* raw, so the queue is bounded by how many activities the dispatch has, not by
|
|
114
|
+
* how much they moved since the last write. A producer reports an activity's
|
|
115
|
+
* full current state every time anything about it changes, and a chatty
|
|
116
|
+
* sub-agent advances its `lastActivityAt` continuously — appending those would
|
|
117
|
+
* grow without bound, eventually exceed `ACTIVITY_BATCH_CAP` and take the
|
|
118
|
+
* WHOLE stats write down (not just its activity part) to describe a handful of
|
|
119
|
+
* activities.
|
|
120
|
+
*
|
|
121
|
+
* `collapseActivityChanges` applies the server's own merge rule, so collapsing
|
|
122
|
+
* here reaches the identical stored row; it is not a lossy summarisation.
|
|
123
|
+
*/
|
|
124
|
+
recordActivities(changes) {
|
|
125
|
+
this.pendingActivities = collapseActivityChanges([
|
|
126
|
+
...this.pendingActivities,
|
|
127
|
+
...changes,
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* DX-2954 — the session's own transcript path, recorded on the dispatch's
|
|
132
|
+
* `jsonl_path`. Sent once, with the first stats write that carries it; the
|
|
133
|
+
* server also keeps only the first path it is given, so a repeat is harmless.
|
|
134
|
+
*/
|
|
135
|
+
setTranscriptPath(path) {
|
|
136
|
+
this.transcriptPath = path;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* DX-2954 — this write's cursor reports, plus the `delivered` value to promote
|
|
140
|
+
* to `acked` per source once the write is acknowledged.
|
|
141
|
+
*
|
|
142
|
+
* **`acked` deliberately LAGS `delivered` by one write.** A write reports the
|
|
143
|
+
* position it has read to (`delivered`) and the position whose entries a
|
|
144
|
+
* PREVIOUS write already had acknowledged (`acked`); only when THIS write
|
|
145
|
+
* succeeds does its own `delivered` become the next write's `acked`.
|
|
146
|
+
*
|
|
147
|
+
* The lag is not slack — it is what makes the pair safe. A reply's ledger entry
|
|
148
|
+
* can be RAISED after it was first reported (its usage grows as the reply
|
|
149
|
+
* streams), and a delivered line need not produce an entry at all (a
|
|
150
|
+
* `tool_result`), so "the server committed my last write" is NOT the same as
|
|
151
|
+
* "every line I have read is finally accounted for". Reporting `acked` one
|
|
152
|
+
* write behind is the conservative claim that is always true.
|
|
153
|
+
*
|
|
154
|
+
* What it costs: a restart re-reads the last write's window. That is exactly
|
|
155
|
+
* the window `EntryMeta.replay` covers — the ledger re-credits nothing (its
|
|
156
|
+
* excess rule) and every side-effecting subscriber skips it — so the cost is
|
|
157
|
+
* one window of re-read, never a double count or a duplicate side effect.
|
|
158
|
+
*
|
|
159
|
+
* ## KNOWN GAP: the process-start write carries no cursors
|
|
160
|
+
*
|
|
161
|
+
* `attachMonitoringStack` deliberately does NOT await `watcher.start()` —
|
|
162
|
+
* file discovery polls for up to 180 s and must not block the spawn — and the
|
|
163
|
+
* 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.
|
|
167
|
+
*
|
|
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.
|
|
178
|
+
*/
|
|
179
|
+
buildCursorReports() {
|
|
180
|
+
const reports = {};
|
|
181
|
+
const deliveredAtSend = new Map();
|
|
182
|
+
if (!this.readCursorState)
|
|
183
|
+
return { reports, deliveredAtSend, reportedResets: [] };
|
|
184
|
+
const resetBySource = new Map(this.pastEof.map((p) => [p.source, p]));
|
|
185
|
+
const reportedResets = [];
|
|
186
|
+
for (const [source, state] of Object.entries(this.readCursorState())) {
|
|
187
|
+
// `acked` floors at the source's START offset: a source opened at EOF
|
|
188
|
+
// under rule 4 must never claim byte 0 as acknowledged, or the next
|
|
189
|
+
// restart would read the whole file and re-sum the history rule 4 skipped.
|
|
190
|
+
const acked = Math.max(this.ackedCursor.get(source) ?? 0, state.startOffset);
|
|
191
|
+
const report = { acked, delivered: state.delivered };
|
|
192
|
+
const reset = resetBySource.get(source);
|
|
193
|
+
if (reset) {
|
|
194
|
+
report.pastEof = { storedOffset: reset.storedOffset, fileSize: reset.fileSize };
|
|
195
|
+
reportedResets.push(reset);
|
|
196
|
+
}
|
|
197
|
+
reports[source] = report;
|
|
198
|
+
deliveredAtSend.set(source, state.delivered);
|
|
199
|
+
}
|
|
200
|
+
// Only the resets that actually reached a report are consumed. A reset for a
|
|
201
|
+
// source that has left `cursorState()` (its tail dropped on ENOENT) would
|
|
202
|
+
// otherwise be taken and forgotten, leaving the stored `acked` at a value the
|
|
203
|
+
// file can never satisfy.
|
|
204
|
+
return { reports, deliveredAtSend, reportedResets };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* DX-2954 — whether the cursor half has anything new to say. An IDLE dispatch
|
|
208
|
+
* (no new lines, no hook events) must still cost zero writes per tick, which
|
|
209
|
+
* is why a write is not forced merely because cursors EXIST.
|
|
210
|
+
*/
|
|
211
|
+
cursorsMoved(reports) {
|
|
212
|
+
for (const [source, report] of Object.entries(reports)) {
|
|
213
|
+
if (report.pastEof !== undefined)
|
|
214
|
+
return true;
|
|
215
|
+
// A source whose `delivered` has not moved past its last acknowledged
|
|
216
|
+
// point has nothing to report. A source seen for the FIRST time has no
|
|
217
|
+
// entry in `ackedCursor` yet, so it always counts as moved — that is how a
|
|
218
|
+
// newly discovered sub-agent file gets its starting cursor persisted.
|
|
219
|
+
if (report.delivered !== this.ackedCursor.get(source))
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Feed one watcher entry. Only assistant lines carry usage / tool ids.
|
|
226
|
+
*
|
|
227
|
+
* `replay` (DX-2954) marks a line re-read after a restart. The line STILL goes
|
|
228
|
+
* to the counter — the ledger is idempotent and dropping it would lose usage a
|
|
229
|
+
* restart is meant to recover — but its id is remembered so the local ceiling
|
|
230
|
+
* estimate does not count it twice (see {@link DispatchUsage.unacknowledged}).
|
|
231
|
+
*/
|
|
232
|
+
observeEntry(entry, replay = false) {
|
|
233
|
+
const line = ledgerLineFromLogEntry(entry);
|
|
234
|
+
if (!line)
|
|
235
|
+
return;
|
|
236
|
+
// The counter's OWN key, not `line.messageId`: a usage line with no
|
|
237
|
+
// `message.id` is keyed by `syntheticMessageKey(source, byteOffset)`
|
|
238
|
+
// (`usage-ledger.ts`), and recording a different key here would leave that
|
|
239
|
+
// reply's replay unrecognised — the exact double-count this set prevents.
|
|
240
|
+
if (replay) {
|
|
241
|
+
this.replayedMessageIds.add(line.messageId ?? syntheticMessageKey(line.source, line.byteOffset));
|
|
242
|
+
}
|
|
243
|
+
this.counter.observe(line);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Ship every changed entry in capped batches, one after another. The first
|
|
247
|
+
* call always writes (even with nothing to send) so the process takes the
|
|
248
|
+
* server totals at start. A failed batch re-queues itself and every later
|
|
249
|
+
* batch; a refusal (the dispatch went terminal) stops all later writes.
|
|
250
|
+
*
|
|
251
|
+
* DX-2954 — the write also carries the cursor half. A write happens when there
|
|
252
|
+
* are entries, OR the cursors moved, OR there are activity changes, OR it is
|
|
253
|
+
* the process-start write; an idle dispatch still writes nothing. Cursors,
|
|
254
|
+
* activity changes and the transcript path ride the LAST batch only, so a
|
|
255
|
+
* multi-batch backlog does not repeat them and the cursor can never be
|
|
256
|
+
* acknowledged ahead of the entries it covers.
|
|
257
|
+
*/
|
|
258
|
+
flush(sink, dispatchId) {
|
|
259
|
+
return this.queue(async () => {
|
|
260
|
+
if (this.refused)
|
|
261
|
+
return "refused";
|
|
262
|
+
const changed = this.counter.takeChanged();
|
|
263
|
+
const batches = chunkEntries(changed);
|
|
264
|
+
const { reports, deliveredAtSend, reportedResets } = this.buildCursorReports();
|
|
265
|
+
// TAKE the queued side data, exactly as `counter.takeChanged()` does, so a
|
|
266
|
+
// hook that fires during the await lands in the NEXT write rather than
|
|
267
|
+
// being silently dropped by a post-write reset of the live array.
|
|
268
|
+
// Take at most one batch's worth and LEAVE the rest queued. The cap is
|
|
269
|
+
// enforced server-side by the strict parser, and an over-cap payload comes
|
|
270
|
+
// back as a 400 — which is not a `DispatchUsageTerminalError`, so `flush`
|
|
271
|
+
// re-queues it and retries the identical over-cap payload forever. That
|
|
272
|
+
// wedges the dispatch's stats writes PERMANENTLY, entries and cursors
|
|
273
|
+
// included, not just its activity rows. The collapse in `recordActivities`
|
|
274
|
+
// makes crossing the cap unlikely (one entry per distinct activity, not per
|
|
275
|
+
// change), but nothing bounds how many distinct activities a dispatch
|
|
276
|
+
// starts, so the cap is respected here rather than merely made improbable.
|
|
277
|
+
// Splitting across writes is lossless: each row is a full-state idempotent
|
|
278
|
+
// upsert.
|
|
279
|
+
const activities = this.pendingActivities.slice(0, ACTIVITY_BATCH_CAP);
|
|
280
|
+
this.pendingActivities = this.pendingActivities.slice(ACTIVITY_BATCH_CAP);
|
|
281
|
+
// Consume only the resets that reached a report (see `buildCursorReports`).
|
|
282
|
+
const pastEofSent = reportedResets;
|
|
283
|
+
const sentResets = new Set(reportedResets);
|
|
284
|
+
this.pastEof = this.pastEof.filter((p) => !sentResets.has(p));
|
|
285
|
+
const haveSideData = this.cursorsMoved(reports) ||
|
|
286
|
+
activities.length > 0 ||
|
|
287
|
+
(this.transcriptPath !== null && !this.transcriptPathSent);
|
|
288
|
+
if (batches.length === 0 && (!this.started || haveSideData))
|
|
289
|
+
batches.push([]);
|
|
290
|
+
for (let i = 0; i < batches.length; i++) {
|
|
291
|
+
const payload = { entries: batches[i] };
|
|
292
|
+
const isLast = i === batches.length - 1;
|
|
293
|
+
if (isLast) {
|
|
294
|
+
if (Object.keys(reports).length > 0)
|
|
295
|
+
payload.sourceCursors = reports;
|
|
296
|
+
if (activities.length > 0)
|
|
297
|
+
payload.activities = activities;
|
|
298
|
+
if (this.transcriptPath !== null && !this.transcriptPathSent) {
|
|
299
|
+
payload.transcriptPath = this.transcriptPath;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
const res = await sink.applyStats(dispatchId, payload);
|
|
304
|
+
this.started = true;
|
|
305
|
+
this.serverTotals = res.totals;
|
|
306
|
+
for (const [id, u] of Object.entries(res.perMessage))
|
|
307
|
+
this.acked.set(id, u);
|
|
308
|
+
if (isLast) {
|
|
309
|
+
// The write landed, so every line up to the `delivered` we SENT is
|
|
310
|
+
// now acknowledged — that is what makes the next restart resume
|
|
311
|
+
// there. A source the server REFUSED as a regression keeps its own
|
|
312
|
+
// stored value, so we must not promote ours past it.
|
|
313
|
+
const refused = new Set(res.cursorRegressions);
|
|
314
|
+
// A source whose file was found truncated WHILE this write was in
|
|
315
|
+
// flight must NOT have its floor promoted. `recordPastEof` dropped
|
|
316
|
+
// that floor to 0 on purpose; re-raising it here would make the next
|
|
317
|
+
// report claim `acked` above its own `delivered`, which
|
|
318
|
+
// `parseStatsPayload` refuses outright — wedging every later write
|
|
319
|
+
// for this dispatch, since nothing lowers `acked` again.
|
|
320
|
+
const resetMidFlight = new Set(this.pastEof.map((p) => p.source));
|
|
321
|
+
for (const [source, delivered] of deliveredAtSend) {
|
|
322
|
+
if (refused.has(source) || resetMidFlight.has(source))
|
|
323
|
+
continue;
|
|
324
|
+
this.ackedCursor.set(source, delivered);
|
|
325
|
+
}
|
|
326
|
+
if (payload.transcriptPath !== undefined)
|
|
327
|
+
this.transcriptPathSent = true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
if (err instanceof DispatchUsageTerminalError) {
|
|
332
|
+
this.refused = true;
|
|
333
|
+
return "refused";
|
|
334
|
+
}
|
|
335
|
+
this.healthy = false;
|
|
336
|
+
this.counter.requeue(batches.slice(i).flat().map((e) => e.messageId));
|
|
337
|
+
// Re-queue the side data at the FRONT, and leave `ackedCursor` where it
|
|
338
|
+
// was: the next write re-reports all three. Advancing an
|
|
339
|
+
// unacknowledged cursor would skip those lines permanently, and
|
|
340
|
+
// dropping a past-EOF reset would leave the server refusing the
|
|
341
|
+
// eventual lower `acked` as a regression forever.
|
|
342
|
+
// Collapsed on the way back in, so the re-queue keeps the invariant
|
|
343
|
+
// `recordActivities` maintains — and so a failed write followed by
|
|
344
|
+
// fresh changes for the same activity does not double the queue.
|
|
345
|
+
this.pendingActivities = collapseActivityChanges([
|
|
346
|
+
...activities,
|
|
347
|
+
...this.pendingActivities,
|
|
348
|
+
]);
|
|
349
|
+
this.pastEof = [...pastEofSent, ...this.pastEof];
|
|
350
|
+
log.error(`[Dispatch ${dispatchId}] stats write failed — entries re-queued for the next write`, err);
|
|
351
|
+
return "failed";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
this.healthy = true;
|
|
355
|
+
return "ok";
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
/** The dispatch's totals as the server last returned them. */
|
|
359
|
+
totals() {
|
|
360
|
+
return this.serverTotals;
|
|
361
|
+
}
|
|
362
|
+
/** Window tokens (input + output + cache writes) of the server totals. */
|
|
363
|
+
serverWindowTokens() {
|
|
364
|
+
const t = this.serverTotals;
|
|
365
|
+
return t.tokensIn + t.tokensOut + t.cacheWrite;
|
|
366
|
+
}
|
|
367
|
+
/** Whether the most recent stats write succeeded. */
|
|
368
|
+
statsHealthy() {
|
|
369
|
+
return this.healthy;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The per-field excess of each local reply's maximum over what the server
|
|
373
|
+
* last acknowledged for it.
|
|
374
|
+
*
|
|
375
|
+
* DX-2954 — REPLAYED replies are excluded. The pre-DX-2954 comment here
|
|
376
|
+
* asserted that "every id this process observed was read at or after its
|
|
377
|
+
* starting cursor … so lines re-read on a restart never enter the estimate",
|
|
378
|
+
* which stopped being true the moment replays became a deliberate input: a
|
|
379
|
+
* replayed reply has no entry in `this.acked` at process start, so its FULL
|
|
380
|
+
* usage would be added on top of `serverTotals` that already contain it. That
|
|
381
|
+
* feeds a fabricated total to the token ceiling (`agent-status.ts` uses this
|
|
382
|
+
* estimate precisely when a write did NOT land) and to the context-overflow
|
|
383
|
+
* decision — either can stop a healthy dispatch.
|
|
384
|
+
*/
|
|
385
|
+
unacknowledged() {
|
|
386
|
+
return this.counter
|
|
387
|
+
.all()
|
|
388
|
+
.filter((e) => !this.replayedMessageIds.has(e.messageId))
|
|
389
|
+
.map((e) => excessUsage(e.usage, this.acked.get(e.messageId) ?? {
|
|
390
|
+
input: 0,
|
|
391
|
+
output: 0,
|
|
392
|
+
cacheRead: 0,
|
|
393
|
+
cacheWrite5m: 0,
|
|
394
|
+
cacheWrite1h: 0,
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The dispatch's usage as far as this process knows it: the server totals
|
|
399
|
+
* plus the per-field excess this process has read but the server has not
|
|
400
|
+
* acknowledged. A replay block adds nothing (its excess over the
|
|
401
|
+
* acknowledged total is zero). For a job with no dispatch row (nothing is
|
|
402
|
+
* ever acknowledged) this is exactly the local counter's total.
|
|
403
|
+
*/
|
|
404
|
+
estimate() {
|
|
405
|
+
const t = this.serverTotals;
|
|
406
|
+
const out = {
|
|
407
|
+
tokensIn: t.tokensIn,
|
|
408
|
+
tokensOut: t.tokensOut,
|
|
409
|
+
cacheRead: t.cacheRead,
|
|
410
|
+
cacheWrite: t.cacheWrite,
|
|
411
|
+
};
|
|
412
|
+
for (const u of this.unacknowledged()) {
|
|
413
|
+
out.tokensIn += u.input;
|
|
414
|
+
out.tokensOut += u.output;
|
|
415
|
+
out.cacheRead += u.cacheRead;
|
|
416
|
+
out.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
/** Token-ceiling input: window tokens (input + output + cache writes) of {@link estimate}. */
|
|
421
|
+
windowTokensEstimate() {
|
|
422
|
+
const e = this.estimate();
|
|
423
|
+
return e.tokensIn + e.tokensOut + e.cacheWrite;
|
|
424
|
+
}
|
|
425
|
+
/** All-field total of {@link estimate}. */
|
|
426
|
+
totalTokensEstimate() {
|
|
427
|
+
const e = this.estimate();
|
|
428
|
+
return e.tokensIn + e.tokensOut + e.cacheRead + e.cacheWrite;
|
|
429
|
+
}
|
|
430
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,9 +14,13 @@
|
|
|
14
14
|
* — the dashboard server, the dashboard SPA, and any future browser-side
|
|
15
15
|
* usage display.
|
|
16
16
|
*/
|
|
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";
|
|
17
|
+
export { SessionLogWatcher, DISPATCH_TAG_PREFIX, encodeClaudeProjectsCwd, deriveSessionDir, sessionUuidExistsInCleanRoom, findNewestJsonlFile, findSessionFileByDispatchId, convertJsonlEntry, skipReplays, } 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";
|
|
22
22
|
export * from "./pricing.js";
|
|
23
|
+
export * from "./stats-wire.js";
|
|
24
|
+
export * from "./dispatch-usage.js";
|
|
25
|
+
export * from "./activity-observer.js";
|
|
26
|
+
export * from "./write-queue.js";
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* — the dashboard server, the dashboard SPA, and any future browser-side
|
|
15
15
|
* usage display.
|
|
16
16
|
*/
|
|
17
|
-
export { SessionLogWatcher, DISPATCH_TAG_PREFIX, encodeClaudeProjectsCwd, deriveSessionDir, sessionUuidExistsInCleanRoom, findNewestJsonlFile, findSessionFileByDispatchId, convertJsonlEntry, } from "./session-log-watcher.js";
|
|
17
|
+
export { SessionLogWatcher, DISPATCH_TAG_PREFIX, encodeClaudeProjectsCwd, deriveSessionDir, sessionUuidExistsInCleanRoom, findNewestJsonlFile, findSessionFileByDispatchId, convertJsonlEntry, skipReplays, } from "./session-log-watcher.js";
|
|
18
18
|
// `truncStr` is a generic, dependency-free string helper that moved here
|
|
19
19
|
// alongside the rest of `tool-summary.ts` (it has no other home now that
|
|
20
20
|
// file is gone from root) — re-exported for danxbot root consumers that
|
|
@@ -25,3 +25,13 @@ export { truncStr, summarizeToolInput, buildAssistantSummary, buildToolResultSum
|
|
|
25
25
|
// browser-safe `./browser` subpath; both are pure arithmetic).
|
|
26
26
|
export * from "./usage-ledger.js";
|
|
27
27
|
export * from "./pricing.js";
|
|
28
|
+
// DX-2954 — the ONE stats collector and its wire, shared by the danxbot worker
|
|
29
|
+
// and the desktop bridge (operator sessions) so both report the same stats the
|
|
30
|
+
// same way (epic DX-2958): the stats payload/response schema and its strict
|
|
31
|
+
// parser, the collector that ships ledger entries + transcript cursors +
|
|
32
|
+
// running-set changes, the transcript-side running-set producer, and the FIFO
|
|
33
|
+
// that orders a dispatch's writes.
|
|
34
|
+
export * from "./stats-wire.js";
|
|
35
|
+
export * from "./dispatch-usage.js";
|
|
36
|
+
export * from "./activity-observer.js";
|
|
37
|
+
export * from "./write-queue.js";
|
package/dist/internal-logger.js
CHANGED
|
@@ -12,8 +12,11 @@ function emit(level, component, message, error) {
|
|
|
12
12
|
else if (error !== undefined) {
|
|
13
13
|
entry.error = String(error);
|
|
14
14
|
}
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// DX-2954 — every level goes to STDERR. The desktop bridge
|
|
16
|
+
// (`danx-dashboard-mcp bridge`) now runs this collector, and its STDOUT is a
|
|
17
|
+
// JSON Lines protocol channel the plugin parses record by record; a log line
|
|
18
|
+
// there would be read as a bridge record. The worker captures both streams.
|
|
19
|
+
console.error(JSON.stringify(entry));
|
|
17
20
|
}
|
|
18
21
|
export function createLogger(component) {
|
|
19
22
|
return {
|