@50arms/session-telemetry 0.0.0-stage → 0.1.66

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.
Files changed (45) hide show
  1. package/README.md +68 -2
  2. package/dist/.source-hash +1 -0
  3. package/dist/activity-observer.d.ts +186 -0
  4. package/dist/activity-observer.js +917 -0
  5. package/dist/api-tool-names.d.ts +12 -0
  6. package/dist/api-tool-names.js +19 -0
  7. package/dist/browser.d.ts +29 -0
  8. package/dist/browser.js +10 -0
  9. package/dist/catalog-prices.d.ts +27 -0
  10. package/dist/catalog-prices.js +31 -0
  11. package/dist/claim-tracker.d.ts +53 -0
  12. package/dist/claim-tracker.js +213 -0
  13. package/dist/dispatch-usage.d.ts +321 -0
  14. package/dist/dispatch-usage.js +657 -0
  15. package/dist/git-head.d.ts +1 -0
  16. package/dist/git-head.js +19 -0
  17. package/dist/index.d.ts +35 -0
  18. package/dist/index.js +49 -0
  19. package/dist/internal-logger.d.ts +16 -0
  20. package/dist/internal-logger.js +27 -0
  21. package/dist/ledger-timing.d.ts +26 -0
  22. package/dist/ledger-timing.js +31 -0
  23. package/dist/pricing.d.ts +93 -0
  24. package/dist/pricing.js +77 -0
  25. package/dist/real-work.d.ts +13 -0
  26. package/dist/real-work.js +57 -0
  27. package/dist/session-log-watcher.d.ts +616 -0
  28. package/dist/session-log-watcher.js +1285 -0
  29. package/dist/session-title-marks.d.ts +2 -0
  30. package/dist/session-title-marks.js +16 -0
  31. package/dist/stats-wire.d.ts +345 -0
  32. package/dist/stats-wire.js +636 -0
  33. package/dist/subagent-live.d.ts +36 -0
  34. package/dist/subagent-live.js +153 -0
  35. package/dist/tool-summary.d.ts +19 -0
  36. package/dist/tool-summary.js +58 -0
  37. package/dist/transcript-timing.d.ts +100 -0
  38. package/dist/transcript-timing.js +231 -0
  39. package/dist/types.d.ts +25 -0
  40. package/dist/types.js +1 -0
  41. package/dist/usage-ledger.d.ts +256 -0
  42. package/dist/usage-ledger.js +581 -0
  43. package/dist/write-queue.d.ts +9 -0
  44. package/dist/write-queue.js +16 -0
  45. package/package.json +43 -4
@@ -0,0 +1,657 @@
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, ledgerLineFromRaw, reportCompactionsOf, excessUsage, } from "./usage-ledger.js";
20
+ import { ClaimTracker, emptyClaimState } from "./claim-tracker.js";
21
+ import { ACTIVITY_BATCH_CAP, ACTIVITY_MESSAGE_BATCH_CAP, ACTIVITY_PROBLEM_BATCH_CAP, COMPACTION_BATCH_CAP, DispatchUsageTerminalError, chunkEntries, collapseActivityChanges, } from "./stats-wire.js";
22
+ import { createUsageWriteQueue } from "./write-queue.js";
23
+ import { createLogger } from "./internal-logger.js";
24
+ const log = createLogger("dispatch-usage");
25
+ export function zeroUsageTotals() {
26
+ return {
27
+ tokensIn: 0,
28
+ tokensOut: 0,
29
+ cacheRead: 0,
30
+ cacheWrite: 0,
31
+ tokensTotal: 0,
32
+ costUsd: 0,
33
+ turnCount: 0,
34
+ toolCallCount: 0,
35
+ };
36
+ }
37
+ /** DX-4922 - the compactions as wire-sized chunks. */
38
+ function chunkCompactions(compactions) {
39
+ const out = [];
40
+ for (let i = 0; i < compactions.length; i += COMPACTION_BATCH_CAP)
41
+ out.push(compactions.slice(i, i + COMPACTION_BATCH_CAP));
42
+ return out;
43
+ }
44
+ /** DX-5333 — the key a queued message is held under (one per activity and transcript line, so a re-read replaces rather than repeats). */
45
+ const messageQueueKey = (m) => `${m.activityId}\u0000${m.messageKey}`;
46
+ /** DX-5333 — the key a queued problem link is held under (one per activity and problem). */
47
+ const problemLinkQueueKey = (l) => `${l.activityId}\u0000${l.problemRefNum}`;
48
+ /** DX-5333 - a queue as wire-sized chunks. */
49
+ function chunksOf(items, cap) {
50
+ const out = [];
51
+ for (let i = 0; i < items.length; i += cap)
52
+ out.push(items.slice(i, i + cap));
53
+ return out;
54
+ }
55
+ export class DispatchUsage {
56
+ counter = new UsageLedgerCounter();
57
+ /** DX-3962 - `null` in `"worker"` mode: the worker can never create a claim, so it can never send an `issueId`. */
58
+ claims;
59
+ /**
60
+ * DX-3962 - per source, the claim state as of its `acked` offset: what the next
61
+ * write reports with `acked`, promoted together with it. The state the tracker
62
+ * holds NOW belongs to `delivered`, which runs ahead.
63
+ */
64
+ ackedClaims = new Map();
65
+ serverTotals = zeroUsageTotals();
66
+ acked = new Map();
67
+ healthy = true;
68
+ refused = false;
69
+ started = false;
70
+ queue = createUsageWriteQueue();
71
+ constructor(mode) {
72
+ this.claims = mode === "operator-bridge" ? new ClaimTracker() : null;
73
+ }
74
+ // ── DX-2954: the cursor half ──────────────────────────────────────────────
75
+ /** How to read the watcher's live per-source cursor state. */
76
+ readCursorState = null;
77
+ /**
78
+ * Per source, the `delivered` offset as of the last ACKNOWLEDGED write — which
79
+ * is precisely what `acked` means. Seeded from the cursors this process
80
+ * resumed from, so a restart that writes before reading anything re-reports
81
+ * its own resume point instead of regressing it.
82
+ */
83
+ ackedCursor = new Map();
84
+ /**
85
+ * DX-3972 - per source, the `acked` the server holds for this dispatch: the `acked` of the last ACKNOWLEDGED
86
+ * report that carried the source. It is NOT {@link DispatchUsage.ackedCursor}: that is what the NEXT report
87
+ * claims (one write behind `delivered`), this is what the LAST one claimed. The terminal catch-up
88
+ * ({@link DispatchUsage.flushFinal}) is needed exactly while this lags the watcher's `delivered`.
89
+ */
90
+ storedAcked = new Map();
91
+ /**
92
+ * DX-2954 (AC 34324) — the cursors this process STARTED from, reported
93
+ * verbatim for every source the watcher has not opened yet until one write
94
+ * carrying them has landed. See {@link DispatchUsage.buildCursorReports}.
95
+ */
96
+ startCursors = {};
97
+ startCursorsPersisted = false;
98
+ /**
99
+ * DX-2954 — set the moment the first flush begins. `attachCursors` after that
100
+ * point would set the starting cursors AFTER a write that carried none, and
101
+ * `startCursorsPersisted` (set by any landed write) would then keep them from
102
+ * ever being carried — the exact AC 34324 loss, silently. So it fails loud.
103
+ */
104
+ flushBegan = false;
105
+ /** Past-EOF resets to report once, then forget. */
106
+ pastEof = [];
107
+ /**
108
+ * DX-2954 — replies this process read as REPLAYS. The server already counted
109
+ * them for THIS dispatch (they are below its stored `acked`), so they must not
110
+ * enter the unacknowledged excess the token-ceiling estimate adds on top of the
111
+ * server totals.
112
+ */
113
+ replayedMessageIds = new Set();
114
+ /** Running-set changes to report on the next write. */
115
+ pendingActivities = [];
116
+ /** DX-5333 - sub-agent messages to report on the next write, one per `(activityId, messageKey)`. */
117
+ pendingMessages = new Map();
118
+ /** DX-5333 - card problems sub-agents created, to report on the next write, one per `(activityId, problemRefNum)`. */
119
+ pendingProblemLinks = new Map();
120
+ /** The session transcript path — the ONE path field the wire carries. */
121
+ transcriptPath = null;
122
+ transcriptPathSent = false;
123
+ /**
124
+ * DX-2954 — wire the cursor half up at attach.
125
+ *
126
+ * `startCursors` are the cursors this process RESUMED from
127
+ * (`resolveStartCursors`); their `acked` values seed this collector's own
128
+ * acknowledged floor. `readCursorState` reads the watcher's live offsets at
129
+ * each write.
130
+ */
131
+ attachCursors(readCursorState, startCursors) {
132
+ // DX-2954 (AC 34324): the starting cursors ride the process-start write, so
133
+ // they must be attached BEFORE any write is attempted.
134
+ if (this.flushBegan) {
135
+ throw new Error("DispatchUsage.attachCursors: called after the first stats write began — the starting cursors would never be persisted");
136
+ }
137
+ this.readCursorState = readCursorState;
138
+ this.startCursors = startCursors;
139
+ for (const [source, cursor] of Object.entries(startCursors)) {
140
+ this.ackedCursor.set(source, cursor.acked);
141
+ // DX-3962 - the watcher reads each source from its stored `acked`, so the tracker starts from the claims
142
+ // stored with that offset: a pickup before it is never read again.
143
+ this.ackedClaims.set(source, cursor.claims);
144
+ this.claims?.seed(source, cursor.claims);
145
+ }
146
+ }
147
+ /**
148
+ * DX-2954 — a stored cursor was past its file's end; report the reset on the
149
+ * next write so the server records a `transcript_cursor_past_eof` event and
150
+ * permits the one sanctioned backwards move of `acked`.
151
+ */
152
+ recordPastEof(info) {
153
+ this.pastEof.push(info);
154
+ // The acknowledged floor MUST drop with the file. Leaving it at the old
155
+ // offset would make the next write report `acked` above its own `delivered`
156
+ // — which `parseStatsPayload` refuses outright ("delivered is behind
157
+ // acked"), and which the getPool-direct sink would store as a cursor pair
158
+ // that can never be satisfied. The bytes we believed acknowledged do not
159
+ // exist any more, so claiming them is simply false.
160
+ this.ackedCursor.set(info.source, 0);
161
+ // DX-3964 - the source is read again from 0: its timing reducer starts fresh, first-line-anchored.
162
+ this.counter.resetSource(info.source);
163
+ // DX-3962 - the claims recorded against the old file name a transcript that is gone; the source re-earns them
164
+ // from the lines it re-reads from 0 (the server clears the stored claims in the same statement as the cursor).
165
+ this.ackedClaims.set(info.source, emptyClaimState());
166
+ this.claims?.seed(info.source, emptyClaimState());
167
+ }
168
+ /**
169
+ * DX-2954 — queue running-set changes for the next write.
170
+ *
171
+ * Kept COLLAPSED (one entry per `(kind, activityId)`) rather than appended
172
+ * raw, so the queue is bounded by how many activities the dispatch has, not by
173
+ * how much they moved since the last write. A producer reports an activity's
174
+ * full current state every time anything about it changes, and a chatty
175
+ * sub-agent advances its `lastActivityAt` continuously — appending those would
176
+ * grow without bound, eventually exceed `ACTIVITY_BATCH_CAP` and take the
177
+ * WHOLE stats write down (not just its activity part) to describe a handful of
178
+ * activities.
179
+ *
180
+ * `collapseActivityChanges` applies the server's own merge rule, so collapsing
181
+ * here reaches the identical stored row; it is not a lossy summarisation.
182
+ */
183
+ recordActivities(changes) {
184
+ this.pendingActivities = collapseActivityChanges([
185
+ ...this.pendingActivities,
186
+ ...changes,
187
+ ]);
188
+ }
189
+ /**
190
+ * DX-5333 - queue sub-agent messages for the next write. One entry per `(activityId, messageKey)`: the server's insert is
191
+ * idempotent on that key, so a message read twice (a collector that re-reads a file) is sent once.
192
+ */
193
+ recordMessages(messages) {
194
+ for (const m of messages)
195
+ this.pendingMessages.set(messageQueueKey(m), m);
196
+ }
197
+ /** DX-5333 - queue the card problems a sub-agent created for the next write (idempotent on the pair, like {@link DispatchUsage.recordMessages}). */
198
+ recordProblemLinks(links) {
199
+ for (const l of links)
200
+ this.pendingProblemLinks.set(problemLinkQueueKey(l), l);
201
+ }
202
+ /**
203
+ * DX-2954 — the session's own transcript path, recorded on the dispatch's
204
+ * `jsonl_path`. Sent once, with the first stats write that carries it; the
205
+ * server also keeps only the first path it is given, so a repeat is harmless.
206
+ */
207
+ setTranscriptPath(path) {
208
+ this.transcriptPath = path;
209
+ }
210
+ /**
211
+ * DX-2954 — this write's cursor reports, plus the `delivered` value to promote
212
+ * to `acked` per source once the write is acknowledged.
213
+ *
214
+ * **`acked` deliberately LAGS `delivered` by one write.** A write reports the
215
+ * position it has read to (`delivered`) and the position whose entries a
216
+ * PREVIOUS write already had acknowledged (`acked`); only when THIS write
217
+ * succeeds does its own `delivered` become the next write's `acked`.
218
+ *
219
+ * The lag is not slack — it is what makes the pair safe. A reply's ledger entry
220
+ * can be RAISED after it was first reported (its usage grows as the reply
221
+ * streams), and a delivered line need not produce an entry at all (a
222
+ * `tool_result`), so "the server committed my last write" is NOT the same as
223
+ * "every line I have read is finally accounted for". Reporting `acked` one
224
+ * write behind is the conservative claim that is always true.
225
+ *
226
+ * What it costs: a restart re-reads the last write's window. That is exactly
227
+ * the window `EntryMeta.replay` covers — the ledger re-credits nothing (its
228
+ * excess rule) and every side-effecting subscriber skips it — so the cost is
229
+ * one window of re-read, never a double count or a duplicate side effect.
230
+ *
231
+ * ## The terminal write closes the lag (DX-3972)
232
+ *
233
+ * A restart is a crash, and a crash's window is the cost above. A dispatch that
234
+ * ENDS is not one: its watcher is drained and stopped, so every line it read has been
235
+ * fed to this collector and nothing will raise a reply any more. The terminal
236
+ * {@link DispatchUsage.flushFinal} therefore reports `acked` equal to `delivered` for
237
+ * every source it has opened (see {@link DispatchUsage.catchUpReports}), and a
238
+ * `--resume` of the session starts at the end of what this dispatch read instead of
239
+ * replaying the whole file. Without it a dispatch with ONE line-bearing write ended at
240
+ * `{acked: 0, delivered: EOF}`, and every resume of the session re-read the whole
241
+ * transcript and re-shipped it into its own `dispatch_logs`.
242
+ *
243
+ * ## The process-start write carries the STARTING cursors (AC 34324)
244
+ *
245
+ * `attachMonitoringStack` deliberately does NOT await `watcher.start()` —
246
+ * file discovery polls for up to 180 s and must not block the spawn — and the
247
+ * process-start stats write fires immediately after it. At that moment the
248
+ * watcher has opened no file, so its live `cursorState()` is empty.
249
+ *
250
+ * So every source in the cursors this process STARTED from that the watcher
251
+ * has not opened yet is reported VERBATIM — the exact `{acked, delivered}`
252
+ * pair it resumed from — until a write that carries them has landed. That is
253
+ * what makes "every attach persists its starting cursors" true on the FIRST
254
+ * write rather than one stats tick (15-30 s) later: a reattach of this row
255
+ * inside its first interval reads them back (start-order rule 1) and starts
256
+ * exactly where this process did, instead of finding no cursor, resolving
257
+ * rule 4 and skipping every line written since spawn. A fresh session's
258
+ * starting cursor is explicit too (`resolveStartCursors` rule 3 returns the
259
+ * main source at 0), and a `--resume`'s is its predecessor's (rule 2).
260
+ *
261
+ * Verbatim is the only honest claim for a source this process has not read:
262
+ * a higher `acked` would let a restart skip the replay window it has not
263
+ * re-credited, a lower one is refused as a regression. For the same reason
264
+ * the start write does not PROMOTE such a source's acknowledged floor — it
265
+ * delivered nothing there — so it stays out of `deliveredAtSend`.
266
+ *
267
+ * The one case with nothing to carry is rule 4 (a continuing dispatch with no
268
+ * cursor for ANY source): its start is "each existing file's end at
269
+ * discovery", which does not exist until discovery runs. Its cursors reach
270
+ * the server on the first write after discovery. Rule 4 is the pre-card
271
+ * deploy transition only — every dispatch since this card stores cursors on
272
+ * its first write — so that bounded window is the one the card accepts.
273
+ */
274
+ buildCursorReports() {
275
+ const reports = {};
276
+ const deliveredAtSend = new Map();
277
+ const claimsAtSend = new Map();
278
+ if (!this.readCursorState) {
279
+ return { reports, deliveredAtSend, claimsAtSend, reportedResets: [], carriesStartCursors: false };
280
+ }
281
+ const resetBySource = new Map(this.pastEof.map((p) => [p.source, p]));
282
+ const reportedResets = [];
283
+ const live = this.readCursorState();
284
+ let carriesStartCursors = false;
285
+ if (!this.startCursorsPersisted) {
286
+ for (const [source, start] of Object.entries(this.startCursors)) {
287
+ if (source in live)
288
+ continue; // the live state supersedes it once opened
289
+ reports[source] = { acked: start.acked, delivered: start.delivered, claims: start.claims };
290
+ carriesStartCursors = true;
291
+ }
292
+ }
293
+ for (const [source, state] of Object.entries(live)) {
294
+ // `acked` floors at the source's START offset: a source opened at EOF
295
+ // under rule 4 must never claim byte 0 as acknowledged, or the next
296
+ // restart would read the whole file and re-sum the history rule 4 skipped.
297
+ const acked = Math.max(this.ackedCursor.get(source) ?? 0, state.startOffset);
298
+ // The claims reported with `acked` are the state AS OF `acked`, never the tracker's current state (which
299
+ // belongs to `delivered`): a restart resumes at `acked`, so that is the position the claims must describe.
300
+ const report = {
301
+ acked,
302
+ delivered: state.delivered,
303
+ claims: this.ackedClaims.get(source) ?? emptyClaimState(),
304
+ };
305
+ const reset = resetBySource.get(source);
306
+ if (reset) {
307
+ report.pastEof = { storedOffset: reset.storedOffset, fileSize: reset.fileSize };
308
+ reportedResets.push(reset);
309
+ }
310
+ reports[source] = report;
311
+ deliveredAtSend.set(source, state.delivered);
312
+ claimsAtSend.set(source, this.claims?.snapshot(source) ?? emptyClaimState());
313
+ }
314
+ // Only the resets that actually reached a report are consumed. A reset for a
315
+ // source that has left `cursorState()` (its tail dropped on ENOENT) would
316
+ // otherwise be taken and forgotten, leaving the stored `acked` at a value the
317
+ // file can never satisfy.
318
+ return { reports, deliveredAtSend, claimsAtSend, reportedResets, carriesStartCursors };
319
+ }
320
+ /**
321
+ * DX-2954 — whether the cursor half has anything new to say. An IDLE dispatch
322
+ * (no new lines, no hook events) must still cost zero writes per tick, which
323
+ * is why a write is not forced merely because cursors EXIST.
324
+ */
325
+ cursorsMoved(reports) {
326
+ for (const [source, report] of Object.entries(reports)) {
327
+ if (report.pastEof !== undefined)
328
+ return true;
329
+ // A source whose `delivered` has not moved past its last acknowledged
330
+ // point has nothing to report. A source seen for the FIRST time has no
331
+ // entry in `ackedCursor` yet, so it always counts as moved — that is how a
332
+ // newly discovered sub-agent file gets its starting cursor persisted.
333
+ if (report.delivered !== this.ackedCursor.get(source))
334
+ return true;
335
+ }
336
+ return false;
337
+ }
338
+ /**
339
+ * DX-3972 - the TERMINAL write's reports: the tick's reports with every source this process has read claiming its
340
+ * whole `delivered` as `acked`, and the claim state AS OF that offset (the tracker's current state belongs to
341
+ * `delivered`, so it describes the new `acked` exactly, never a later position). Sources the watcher has not
342
+ * opened (a start cursor reported verbatim) are untouched: this process read nothing there to claim.
343
+ */
344
+ catchUpReports(reports, deliveredAtSend, claimsAtSend) {
345
+ const caughtUp = { ...reports };
346
+ for (const [source, delivered] of deliveredAtSend) {
347
+ caughtUp[source] = { ...reports[source], acked: delivered, claims: claimsAtSend.get(source) };
348
+ }
349
+ return caughtUp;
350
+ }
351
+ /**
352
+ * DX-3972 - whether the terminal write has anything to say: the server's `acked` still lags what the catch-up now
353
+ * claims. `ackedCursor` (the NEXT tick's claim) cannot tell, because a dispatch whose last write reported
354
+ * `delivered` with an older `acked` has `ackedCursor === delivered` and still lags; {@link DispatchUsage.storedAcked}
355
+ * is what the server holds. A source never reported at all has no entry, so it counts as lagging.
356
+ */
357
+ storedAckedLags(reports) {
358
+ for (const [source, report] of Object.entries(reports)) {
359
+ if (report.pastEof !== undefined || report.acked !== this.storedAcked.get(source))
360
+ return true;
361
+ }
362
+ return false;
363
+ }
364
+ /**
365
+ * Feed one raw transcript line of the usage feed set (assistant, user, attachment, system), in file order.
366
+ * DX-3964 - the usage collector is fed from the watcher's collector-only `onUsageLine` subscription, never from
367
+ * `onEntry`: `convertJsonlEntry` drops human prompts, attachments and system lines, and widening it would hand
368
+ * every other `onEntry` subscriber a new volume of entries.
369
+ *
370
+ * `meta.replay` (DX-2954) marks a line re-read after a restart. The line STILL goes to the counter - the
371
+ * ledger is idempotent and dropping it would lose usage a restart is meant to recover - but a replayed
372
+ * assistant reply's id is remembered so the local ceiling estimate does not count it twice (see
373
+ * {@link DispatchUsage.unacknowledged}).
374
+ */
375
+ observeRawLine(raw, meta) {
376
+ // DX-4922: a compaction boundary is judged against the session the file belongs to.
377
+ const line = ledgerLineFromRaw(raw, meta.source, meta.byteOffset, reportCompactionsOf(meta.sessionId));
378
+ if (!line)
379
+ return;
380
+ const message = raw.message;
381
+ const content = Array.isArray(message?.content) ? message.content : [];
382
+ // DX-3962 - a `tool_result` settles a pickup / release the source asked for earlier.
383
+ if (line.kind === "user")
384
+ this.claims?.observeResultBlocks(meta.source, content);
385
+ if (line.kind === "assistant") {
386
+ line.line.issueId = this.claims?.issueFor(meta.source) ?? null;
387
+ // The counter's OWN key, not `line.messageId`: a usage line with no `message.id` is keyed by
388
+ // `syntheticMessageKey(source, byteOffset)`, and recording a different key here would leave that reply's
389
+ // replay unrecognised - the exact double-count this set prevents.
390
+ if (meta.replay)
391
+ this.replayedMessageIds.add(line.replyKey);
392
+ }
393
+ this.counter.observeLine(line);
394
+ // AFTER the reply is counted: a reply is credited to the card held at its FIRST line, so the pickup this line
395
+ // itself asks for takes effect only on its result, and never on the reply that issued it.
396
+ if (line.kind === "assistant")
397
+ this.claims?.observeAssistantBlocks(meta.source, content);
398
+ }
399
+ /**
400
+ * Ship every changed entry in capped batches, one after another. The first
401
+ * call always writes (even with nothing to send) so the process takes the
402
+ * server totals at start. A failed batch re-queues itself and every later
403
+ * batch; a refusal (the dispatch went terminal) stops all later writes.
404
+ *
405
+ * DX-2954 — the write also carries the cursor half. A write happens when there
406
+ * are entries, OR the cursors moved, OR there are activity changes, OR it is
407
+ * the process-start write; an idle dispatch still writes nothing. Cursors,
408
+ * activity changes and the transcript path ride the LAST batch only, so a
409
+ * multi-batch backlog does not repeat them and the cursor can never be
410
+ * acknowledged ahead of the entries it covers.
411
+ */
412
+ flush(sink, dispatchId) {
413
+ return this.write(sink, dispatchId, false);
414
+ }
415
+ /**
416
+ * DX-3972 - the TERMINAL write: {@link DispatchUsage.flush} that also catches the stored `acked` up to
417
+ * `delivered` for every source (see {@link DispatchUsage.catchUpReports}). Call it once, when the dispatch ends,
418
+ * after the watcher is drained and stopped (that is the whole precondition: every line it read has reached this
419
+ * collector, so `delivered` is covered by the entries this very write carries), and BEFORE the row goes terminal
420
+ * (the server refuses a terminal dispatch's stats, which would drop the catch-up silently). A write that does not
421
+ * land promotes nothing, exactly as in a tick; a dispatch already caught up sends no write.
422
+ *
423
+ * It does NOT wait for the dispatch's log shipper: the spawn path flushes it before this call, the reattach path
424
+ * after (`reattach.ts` `_cleanup`), so on a reattach `acked` can claim a line whose `dispatch_logs` row is still in
425
+ * the shipper's buffer. The shipper is best-effort today and a resume's shipper skips replays either way, so
426
+ * nothing is lost that was not already; acking only after the log line is acknowledged is DX-2956's change.
427
+ */
428
+ flushFinal(sink, dispatchId) {
429
+ return this.write(sink, dispatchId, true);
430
+ }
431
+ write(sink, dispatchId, catchUp) {
432
+ return this.queue(async () => {
433
+ if (this.refused)
434
+ return "refused";
435
+ this.flushBegan = true;
436
+ const changed = this.counter.takeChanged();
437
+ const batches = chunkEntries(changed);
438
+ const built = this.buildCursorReports();
439
+ const { deliveredAtSend, claimsAtSend, reportedResets, carriesStartCursors } = built;
440
+ const reports = catchUp
441
+ ? this.catchUpReports(built.reports, deliveredAtSend, claimsAtSend)
442
+ : built.reports;
443
+ // TAKE the queued side data, exactly as `counter.takeChanged()` does, so a
444
+ // hook that fires during the await lands in the NEXT write rather than
445
+ // being silently dropped by a post-write reset of the live array.
446
+ // Take at most one batch's worth and LEAVE the rest queued. The cap is
447
+ // enforced server-side by the strict parser, and an over-cap payload comes
448
+ // back as a 400 — which is not a `DispatchUsageTerminalError`, so `flush`
449
+ // re-queues it and retries the identical over-cap payload forever. That
450
+ // wedges the dispatch's stats writes PERMANENTLY, entries and cursors
451
+ // included, not just its activity rows. The collapse in `recordActivities`
452
+ // makes crossing the cap unlikely (one entry per distinct activity, not per
453
+ // change), but nothing bounds how many distinct activities a dispatch
454
+ // starts, so the cap is respected here rather than merely made improbable.
455
+ // Splitting across writes is lossless: each row is a full-state idempotent
456
+ // upsert.
457
+ // DX-4922 - every queued compaction, in chunks of the wire's cap: chunk i rides payload i, so a backlog is
458
+ // drained by this one write (the terminal one included) and never waits for a later tick; a failed payload puts
459
+ // its chunk and every later one back on the counter's queue.
460
+ const compactions = this.counter.takeChangedCompactions();
461
+ const compactionChunks = chunkCompactions(compactions);
462
+ const activities = this.pendingActivities.slice(0, ACTIVITY_BATCH_CAP);
463
+ this.pendingActivities = this.pendingActivities.slice(ACTIVITY_BATCH_CAP);
464
+ // DX-5333 - like the compactions, every queued sub-agent message and problem link rides this write in wire-sized chunks
465
+ // (chunk i on payload i), so the terminal write drains a backlog instead of leaving it for a dispatch that no longer
466
+ // accepts writes. A failed payload puts its chunk and every later one back.
467
+ const messageChunks = chunksOf([...this.pendingMessages.values()], ACTIVITY_MESSAGE_BATCH_CAP);
468
+ const problemChunks = chunksOf([...this.pendingProblemLinks.values()], ACTIVITY_PROBLEM_BATCH_CAP);
469
+ this.pendingMessages = new Map();
470
+ this.pendingProblemLinks = new Map();
471
+ // Consume only the resets that reached a report (see `buildCursorReports`).
472
+ const pastEofSent = reportedResets;
473
+ const sentResets = new Set(reportedResets);
474
+ this.pastEof = this.pastEof.filter((p) => !sentResets.has(p));
475
+ // `carriesStartCursors` forces the write on its own: a starting pair like
476
+ // `{acked: 0, delivered: 0}` never looks "moved", and a process-start write
477
+ // whose last batch failed must still deliver it on a later, otherwise idle
478
+ // tick.
479
+ const haveSideData = carriesStartCursors ||
480
+ (catchUp ? this.storedAckedLags(reports) : this.cursorsMoved(reports)) ||
481
+ activities.length > 0 ||
482
+ messageChunks.length > 0 ||
483
+ problemChunks.length > 0 ||
484
+ compactions.length > 0 ||
485
+ (this.transcriptPath !== null && !this.transcriptPathSent);
486
+ if (batches.length === 0 && (!this.started || haveSideData))
487
+ batches.push([]);
488
+ while (batches.length < Math.max(compactionChunks.length, messageChunks.length, problemChunks.length))
489
+ batches.push([]);
490
+ for (let i = 0; i < batches.length; i++) {
491
+ const payload = { entries: batches[i] };
492
+ if (i < compactionChunks.length)
493
+ payload.compactions = compactionChunks[i];
494
+ if (i < messageChunks.length)
495
+ payload.activityMessages = messageChunks[i];
496
+ if (i < problemChunks.length)
497
+ payload.activityProblems = problemChunks[i];
498
+ const isLast = i === batches.length - 1;
499
+ if (isLast) {
500
+ if (Object.keys(reports).length > 0)
501
+ payload.sourceCursors = reports;
502
+ if (activities.length > 0)
503
+ payload.activities = activities;
504
+ if (this.transcriptPath !== null && !this.transcriptPathSent) {
505
+ payload.transcriptPath = this.transcriptPath;
506
+ }
507
+ }
508
+ try {
509
+ const res = await sink.applyStats(dispatchId, payload);
510
+ this.started = true;
511
+ this.serverTotals = res.totals;
512
+ for (const [id, u] of Object.entries(res.perMessage))
513
+ this.acked.set(id, u);
514
+ if (isLast) {
515
+ // The write landed, so every line up to the `delivered` we SENT is
516
+ // now acknowledged — that is what makes the next restart resume
517
+ // there. A source the server REFUSED as a regression keeps its own
518
+ // stored value, so we must not promote ours past it.
519
+ const refused = new Set(res.cursorRegressions);
520
+ // DX-3972 - what the server now holds as each reported source's `acked` (a refused source kept its own).
521
+ for (const [source, report] of Object.entries(reports)) {
522
+ if (!refused.has(source))
523
+ this.storedAcked.set(source, report.acked);
524
+ }
525
+ // A source whose file was found truncated WHILE this write was in
526
+ // flight must NOT have its floor promoted. `recordPastEof` dropped
527
+ // that floor to 0 on purpose; re-raising it here would make the next
528
+ // report claim `acked` above its own `delivered`, which
529
+ // `parseStatsPayload` refuses outright — wedging every later write
530
+ // for this dispatch, since nothing lowers `acked` again.
531
+ const resetMidFlight = new Set(this.pastEof.map((p) => p.source));
532
+ for (const [source, delivered] of deliveredAtSend) {
533
+ if (refused.has(source) || resetMidFlight.has(source))
534
+ continue;
535
+ this.ackedCursor.set(source, delivered);
536
+ // DX-3962 - the claims advance with the offset they describe, never ahead of it.
537
+ this.ackedClaims.set(source, claimsAtSend.get(source));
538
+ }
539
+ if (payload.transcriptPath !== undefined)
540
+ this.transcriptPathSent = true;
541
+ // Safe on ANY landed write: `attachCursors` refuses to run after the
542
+ // first flush began, so every starting cursor was either carried by
543
+ // this write or is superseded by a live one that this write reported.
544
+ this.startCursorsPersisted = true;
545
+ }
546
+ }
547
+ catch (err) {
548
+ if (err instanceof DispatchUsageTerminalError) {
549
+ this.refused = true;
550
+ return "refused";
551
+ }
552
+ this.healthy = false;
553
+ this.counter.requeue(batches.slice(i).flat().map((e) => e.messageId));
554
+ this.counter.requeueCompactions(compactionChunks.slice(i).flat().map((c) => c.id));
555
+ // DX-5333 - back ahead of anything recorded during the failed write; a re-recorded message is the same entry.
556
+ this.pendingMessages = new Map([
557
+ ...messageChunks.slice(i).flat().map((m) => [messageQueueKey(m), m]),
558
+ ...this.pendingMessages,
559
+ ]);
560
+ this.pendingProblemLinks = new Map([
561
+ ...problemChunks.slice(i).flat().map((l) => [problemLinkQueueKey(l), l]),
562
+ ...this.pendingProblemLinks,
563
+ ]);
564
+ // Re-queue the side data at the FRONT, and leave `ackedCursor` where it
565
+ // was: the next write re-reports all three. Advancing an
566
+ // unacknowledged cursor would skip those lines permanently, and
567
+ // dropping a past-EOF reset would leave the server refusing the
568
+ // eventual lower `acked` as a regression forever.
569
+ // Collapsed on the way back in, so the re-queue keeps the invariant
570
+ // `recordActivities` maintains — and so a failed write followed by
571
+ // fresh changes for the same activity does not double the queue.
572
+ this.pendingActivities = collapseActivityChanges([
573
+ ...activities,
574
+ ...this.pendingActivities,
575
+ ]);
576
+ this.pastEof = [...pastEofSent, ...this.pastEof];
577
+ log.error(`[Dispatch ${dispatchId}] stats write failed — entries re-queued for the next write`, err);
578
+ return "failed";
579
+ }
580
+ }
581
+ this.healthy = true;
582
+ return "ok";
583
+ });
584
+ }
585
+ /** The dispatch's totals as the server last returned them. */
586
+ totals() {
587
+ return this.serverTotals;
588
+ }
589
+ /** Window tokens (input + output + cache writes) of the server totals. */
590
+ serverWindowTokens() {
591
+ const t = this.serverTotals;
592
+ return t.tokensIn + t.tokensOut + t.cacheWrite;
593
+ }
594
+ /** Whether the most recent stats write succeeded. */
595
+ statsHealthy() {
596
+ return this.healthy;
597
+ }
598
+ /**
599
+ * The per-field excess of each local reply's maximum over what the server
600
+ * last acknowledged for it.
601
+ *
602
+ * DX-2954 — REPLAYED replies are excluded. The pre-DX-2954 comment here
603
+ * asserted that "every id this process observed was read at or after its
604
+ * starting cursor … so lines re-read on a restart never enter the estimate",
605
+ * which stopped being true the moment replays became a deliberate input: a
606
+ * replayed reply has no entry in `this.acked` at process start, so its FULL
607
+ * usage would be added on top of `serverTotals` that already contain it. That
608
+ * feeds a fabricated total to the token ceiling (`agent-status.ts` uses this
609
+ * estimate precisely when a write did NOT land) and to the context-overflow
610
+ * decision — either can stop a healthy dispatch.
611
+ */
612
+ unacknowledged() {
613
+ return this.counter
614
+ .all()
615
+ .filter((e) => !this.replayedMessageIds.has(e.messageId))
616
+ .map((e) => excessUsage(e.usage, this.acked.get(e.messageId) ?? {
617
+ input: 0,
618
+ output: 0,
619
+ cacheRead: 0,
620
+ cacheWrite5m: 0,
621
+ cacheWrite1h: 0,
622
+ }));
623
+ }
624
+ /**
625
+ * The dispatch's usage as far as this process knows it: the server totals
626
+ * plus the per-field excess this process has read but the server has not
627
+ * acknowledged. A replay block adds nothing (its excess over the
628
+ * acknowledged total is zero). For a job with no dispatch row (nothing is
629
+ * ever acknowledged) this is exactly the local counter's total.
630
+ */
631
+ estimate() {
632
+ const t = this.serverTotals;
633
+ const out = {
634
+ tokensIn: t.tokensIn,
635
+ tokensOut: t.tokensOut,
636
+ cacheRead: t.cacheRead,
637
+ cacheWrite: t.cacheWrite,
638
+ };
639
+ for (const u of this.unacknowledged()) {
640
+ out.tokensIn += u.input;
641
+ out.tokensOut += u.output;
642
+ out.cacheRead += u.cacheRead;
643
+ out.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
644
+ }
645
+ return out;
646
+ }
647
+ /** Token-ceiling input: window tokens (input + output + cache writes) of {@link estimate}. */
648
+ windowTokensEstimate() {
649
+ const e = this.estimate();
650
+ return e.tokensIn + e.tokensOut + e.cacheWrite;
651
+ }
652
+ /** All-field total of {@link estimate}. */
653
+ totalTokensEstimate() {
654
+ const e = this.estimate();
655
+ return e.tokensIn + e.tokensOut + e.cacheRead + e.cacheWrite;
656
+ }
657
+ }
@@ -0,0 +1 @@
1
+ export declare function readGitHead(cwd: string): Promise<string | null>;