@thehammer/danx-session-telemetry 0.1.20 → 0.1.21
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/claim-tracker.d.ts +47 -0
- package/dist/claim-tracker.js +205 -0
- package/dist/dispatch-usage.d.ts +26 -0
- package/dist/dispatch-usage.js +50 -20
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/stats-wire.d.ts +7 -0
- package/dist/stats-wire.js +57 -2
- package/dist/usage-ledger.d.ts +9 -0
- package/dist/usage-ledger.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** The one tool a sub-agent reaches the dashboard API through (the MCP server key is `danx-dashboard`). */
|
|
2
|
+
export declare const DASHBOARD_API_TOOL_NAME = "mcp__danx-dashboard__danxbot_api";
|
|
3
|
+
/** A card id as the issue routes name it, e.g. `DX-3962`. Also the wire's validation for every card id a claim carries. */
|
|
4
|
+
export declare const ISSUE_ID_PATTERN: RegExp;
|
|
5
|
+
/** Longest card id a claim may carry (`dispatches.issue_id` is `VARCHAR(32)`). */
|
|
6
|
+
export declare const ISSUE_ID_MAX_LENGTH = 32;
|
|
7
|
+
/**
|
|
8
|
+
* Bounds that keep one source's persisted state, and the stats write that carries
|
|
9
|
+
* it, bounded. A real sub-agent holds one or two cards, so neither is reachable;
|
|
10
|
+
* the OLDEST entry is the one dropped, because the newest claim is what the next
|
|
11
|
+
* reply is credited to.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MAX_HELD_CLAIMS = 50;
|
|
14
|
+
export declare const MAX_PENDING_TRANSITIONS = 50;
|
|
15
|
+
/** One transition whose `tool_use` has been read and whose `tool_result` has not. */
|
|
16
|
+
export interface PendingTransition {
|
|
17
|
+
op: "claim" | "release";
|
|
18
|
+
issueId: string;
|
|
19
|
+
}
|
|
20
|
+
/** One source's claim state at one transcript position. */
|
|
21
|
+
export interface ClaimState {
|
|
22
|
+
/** Claimed cards, oldest first. */
|
|
23
|
+
held: string[];
|
|
24
|
+
/** In-flight transitions keyed by their `tool_use_id`. */
|
|
25
|
+
pending: Record<string, PendingTransition>;
|
|
26
|
+
}
|
|
27
|
+
export declare function emptyClaimState(): ClaimState;
|
|
28
|
+
/**
|
|
29
|
+
* The per-process claim tracker: one state per sub-agent source. Fed every
|
|
30
|
+
* transcript line by `DispatchUsage` (operator-bridge mode only — the worker never
|
|
31
|
+
* constructs one), seeded from the stored cursor at attach, and snapshotted per
|
|
32
|
+
* source for the stats write.
|
|
33
|
+
*/
|
|
34
|
+
export declare class ClaimTracker {
|
|
35
|
+
private readonly states;
|
|
36
|
+
/** Replace a source's state — at attach (the stored cursor's claims) and on a past-EOF reset (empty). */
|
|
37
|
+
seed(source: string, state: ClaimState): void;
|
|
38
|
+
/** A copy of the source's state as of the last line fed; empty for a source that holds nothing. */
|
|
39
|
+
snapshot(source: string): ClaimState;
|
|
40
|
+
/** The card a reply whose first line is read NOW is credited to; `null` when the source holds none. */
|
|
41
|
+
issueFor(source: string): string | null;
|
|
42
|
+
/** An assistant line's content blocks: remember every claim / release the line asks for until its result arrives. */
|
|
43
|
+
observeAssistantBlocks(source: string, blocks: readonly unknown[]): void;
|
|
44
|
+
/** A user line's content blocks: settle every remembered transition whose result is here. */
|
|
45
|
+
observeResultBlocks(source: string, blocks: readonly unknown[]): void;
|
|
46
|
+
private stateOf;
|
|
47
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DX-3962 — which card a sub-agent is working, read from the sub-agent's OWN
|
|
3
|
+
* transcript.
|
|
4
|
+
*
|
|
5
|
+
* An operator session builds most cards through sub-agents that claim the card
|
|
6
|
+
* with a manual `pickup`, which creates no dispatch row. Every such claim is in
|
|
7
|
+
* the sub-agent's transcript: a `danxbot_api` `tool_use`
|
|
8
|
+
* `POST /api/issues/<ID>/transition {action: "pickup"}` and, later, the
|
|
9
|
+
* `tool_result` carrying the server's answer. This tracker turns those into the
|
|
10
|
+
* card id every one of the sub-agent's replies is credited to.
|
|
11
|
+
*
|
|
12
|
+
* ## The rule (DX-3962 plan-architecture finding 5)
|
|
13
|
+
*
|
|
14
|
+
* Per source, an ORDERED set of claimed cards.
|
|
15
|
+
* - a successful `pickup` appends the card, or moves it to the end when it is
|
|
16
|
+
* already held;
|
|
17
|
+
* - a successful `complete` / `cancel` / `block` / `rollback_pickup` of a held
|
|
18
|
+
* card BY THE SAME SOURCE removes it. (`review` is not a transition action —
|
|
19
|
+
* Review is a derived status, `issues/derive/status.ts` — so the card's list
|
|
20
|
+
* of releases names only the four that exist.)
|
|
21
|
+
* - a reply's card is the LAST element of the set at the reply's first line, by
|
|
22
|
+
* transcript order, never wall clock. An empty set is `null`.
|
|
23
|
+
*
|
|
24
|
+
* Success is read from the RESULT, never the request: a claim or release takes
|
|
25
|
+
* effect only on the `tool_result` for its own `tool_use_id` whose body is
|
|
26
|
+
* `{"ok": true, ...}`. A refused pickup (409) never attributes and a refused
|
|
27
|
+
* release never releases.
|
|
28
|
+
*
|
|
29
|
+
* `main` never attributes (epic decision): its replies roll up to the plan or the
|
|
30
|
+
* board. Quality-gate verdicts, comments and edits are not transitions, so they
|
|
31
|
+
* never set a claim — a reviewer touching several cards stays unattributed.
|
|
32
|
+
* Documented unattributed cases: a nested sub-agent with no pickup of its own, and
|
|
33
|
+
* the reply that issued the pickup call itself (its first line precedes the
|
|
34
|
+
* successful result).
|
|
35
|
+
*
|
|
36
|
+
* ## The state belongs to a transcript position
|
|
37
|
+
*
|
|
38
|
+
* The state is the held set PLUS the transitions whose `tool_use` has been read
|
|
39
|
+
* and whose `tool_result` has not (`pending`). Both are persisted beside the
|
|
40
|
+
* source's read cursor (`dispatch_source_cursors.claims`): a bridge that restarts
|
|
41
|
+
* resumes at its stored `acked` offset, and a pickup that sits before it is never
|
|
42
|
+
* re-read, so without the state every later reply would go unattributed. The
|
|
43
|
+
* pending half closes the narrow window where the restart lands between a pickup's
|
|
44
|
+
* `tool_use` and its `tool_result`.
|
|
45
|
+
*
|
|
46
|
+
* Pure bookkeeping — no `node:` imports, so it rides the browser-safe subpath too.
|
|
47
|
+
*/
|
|
48
|
+
import { MAIN_SOURCE } from "./usage-ledger.js";
|
|
49
|
+
/** The one tool a sub-agent reaches the dashboard API through (the MCP server key is `danx-dashboard`). */
|
|
50
|
+
export const DASHBOARD_API_TOOL_NAME = "mcp__danx-dashboard__danxbot_api";
|
|
51
|
+
/** A card id as the issue routes name it, e.g. `DX-3962`. Also the wire's validation for every card id a claim carries. */
|
|
52
|
+
export const ISSUE_ID_PATTERN = /^[A-Za-z][A-Za-z0-9]*-[0-9]+$/;
|
|
53
|
+
/** Longest card id a claim may carry (`dispatches.issue_id` is `VARCHAR(32)`). */
|
|
54
|
+
export const ISSUE_ID_MAX_LENGTH = 32;
|
|
55
|
+
/** The transition that claims a card. */
|
|
56
|
+
const CLAIM_ACTION = "pickup";
|
|
57
|
+
/** The transitions that end a sub-agent's hold on a card. */
|
|
58
|
+
const RELEASE_ACTIONS = new Set(["complete", "cancel", "block", "rollback_pickup"]);
|
|
59
|
+
const TRANSITION_PATH = /^\/api\/issues\/([A-Za-z][A-Za-z0-9]*-[0-9]+)\/transition$/;
|
|
60
|
+
/**
|
|
61
|
+
* Bounds that keep one source's persisted state, and the stats write that carries
|
|
62
|
+
* it, bounded. A real sub-agent holds one or two cards, so neither is reachable;
|
|
63
|
+
* the OLDEST entry is the one dropped, because the newest claim is what the next
|
|
64
|
+
* reply is credited to.
|
|
65
|
+
*/
|
|
66
|
+
export const MAX_HELD_CLAIMS = 50;
|
|
67
|
+
export const MAX_PENDING_TRANSITIONS = 50;
|
|
68
|
+
export function emptyClaimState() {
|
|
69
|
+
return { held: [], pending: {} };
|
|
70
|
+
}
|
|
71
|
+
function cloneState(s) {
|
|
72
|
+
return {
|
|
73
|
+
held: [...s.held],
|
|
74
|
+
pending: Object.fromEntries(Object.entries(s.pending).map(([id, p]) => [id, { ...p }])),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** The server's answer to a `danxbot_api` call is the JSON text `{"ok": true|false, "status": ...}`; only a leading `ok:true` is a success. */
|
|
78
|
+
const OK_TRUE = /^\s*\{\s*"ok"\s*:\s*true\b/;
|
|
79
|
+
function resultText(content) {
|
|
80
|
+
if (typeof content === "string")
|
|
81
|
+
return content;
|
|
82
|
+
if (!Array.isArray(content))
|
|
83
|
+
return "";
|
|
84
|
+
return content
|
|
85
|
+
.map((part) => {
|
|
86
|
+
const p = part;
|
|
87
|
+
return p !== null && typeof p === "object" && p.type === "text" && typeof p.text === "string" ? p.text : "";
|
|
88
|
+
})
|
|
89
|
+
.join("");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Whether a `tool_result` block reports a successful `danxbot_api` call. Anything
|
|
93
|
+
* that is not a leading `{"ok": true` is NOT a success: an errored tool call, a
|
|
94
|
+
* 409's `{"ok": false, ...}`, a refusal, text the tool never produces. A truncated
|
|
95
|
+
* body still starts with its `ok`, so a large answer reads correctly.
|
|
96
|
+
*/
|
|
97
|
+
function resultSucceeded(block) {
|
|
98
|
+
if (block.is_error === true)
|
|
99
|
+
return false;
|
|
100
|
+
return OK_TRUE.test(resultText(block.content));
|
|
101
|
+
}
|
|
102
|
+
/** The transition a `tool_use` block asks for, or `null` when it is not a claim or release of a card. */
|
|
103
|
+
function transitionOf(block) {
|
|
104
|
+
if (block.type !== "tool_use" || block.name !== DASHBOARD_API_TOOL_NAME)
|
|
105
|
+
return null;
|
|
106
|
+
const input = block.input;
|
|
107
|
+
if (input === null || typeof input !== "object")
|
|
108
|
+
return null;
|
|
109
|
+
if (typeof input.method !== "string" || input.method.toUpperCase() !== "POST")
|
|
110
|
+
return null;
|
|
111
|
+
if (typeof input.path !== "string")
|
|
112
|
+
return null;
|
|
113
|
+
const match = TRANSITION_PATH.exec(input.path);
|
|
114
|
+
if (match === null)
|
|
115
|
+
return null;
|
|
116
|
+
const body = input.body;
|
|
117
|
+
if (body === null || typeof body !== "object" || typeof body.action !== "string")
|
|
118
|
+
return null;
|
|
119
|
+
if (match[1].length > ISSUE_ID_MAX_LENGTH)
|
|
120
|
+
return null;
|
|
121
|
+
if (body.action === CLAIM_ACTION)
|
|
122
|
+
return { op: "claim", issueId: match[1] };
|
|
123
|
+
if (RELEASE_ACTIONS.has(body.action))
|
|
124
|
+
return { op: "release", issueId: match[1] };
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The per-process claim tracker: one state per sub-agent source. Fed every
|
|
129
|
+
* transcript line by `DispatchUsage` (operator-bridge mode only — the worker never
|
|
130
|
+
* constructs one), seeded from the stored cursor at attach, and snapshotted per
|
|
131
|
+
* source for the stats write.
|
|
132
|
+
*/
|
|
133
|
+
export class ClaimTracker {
|
|
134
|
+
states = new Map();
|
|
135
|
+
/** Replace a source's state — at attach (the stored cursor's claims) and on a past-EOF reset (empty). */
|
|
136
|
+
seed(source, state) {
|
|
137
|
+
if (source === MAIN_SOURCE)
|
|
138
|
+
return;
|
|
139
|
+
this.states.set(source, cloneState(state));
|
|
140
|
+
}
|
|
141
|
+
/** A copy of the source's state as of the last line fed; empty for a source that holds nothing. */
|
|
142
|
+
snapshot(source) {
|
|
143
|
+
const state = this.states.get(source);
|
|
144
|
+
return state === undefined ? emptyClaimState() : cloneState(state);
|
|
145
|
+
}
|
|
146
|
+
/** The card a reply whose first line is read NOW is credited to; `null` when the source holds none. */
|
|
147
|
+
issueFor(source) {
|
|
148
|
+
const held = this.states.get(source)?.held;
|
|
149
|
+
return held === undefined || held.length === 0 ? null : held[held.length - 1];
|
|
150
|
+
}
|
|
151
|
+
/** An assistant line's content blocks: remember every claim / release the line asks for until its result arrives. */
|
|
152
|
+
observeAssistantBlocks(source, blocks) {
|
|
153
|
+
if (source === MAIN_SOURCE)
|
|
154
|
+
return;
|
|
155
|
+
for (const raw of blocks) {
|
|
156
|
+
const block = raw;
|
|
157
|
+
if (block === null || typeof block !== "object" || typeof block.id !== "string")
|
|
158
|
+
continue;
|
|
159
|
+
const transition = transitionOf(block);
|
|
160
|
+
if (transition === null)
|
|
161
|
+
continue;
|
|
162
|
+
const state = this.stateOf(source);
|
|
163
|
+
delete state.pending[block.id];
|
|
164
|
+
state.pending[block.id] = transition;
|
|
165
|
+
const ids = Object.keys(state.pending);
|
|
166
|
+
for (const stale of ids.slice(0, Math.max(0, ids.length - MAX_PENDING_TRANSITIONS)))
|
|
167
|
+
delete state.pending[stale];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** A user line's content blocks: settle every remembered transition whose result is here. */
|
|
171
|
+
observeResultBlocks(source, blocks) {
|
|
172
|
+
if (source === MAIN_SOURCE)
|
|
173
|
+
return;
|
|
174
|
+
const state = this.states.get(source);
|
|
175
|
+
if (state === undefined)
|
|
176
|
+
return;
|
|
177
|
+
for (const raw of blocks) {
|
|
178
|
+
const block = raw;
|
|
179
|
+
if (block === null || typeof block !== "object" || block.type !== "tool_result")
|
|
180
|
+
continue;
|
|
181
|
+
if (typeof block.tool_use_id !== "string")
|
|
182
|
+
continue;
|
|
183
|
+
const pending = state.pending[block.tool_use_id];
|
|
184
|
+
if (pending === undefined)
|
|
185
|
+
continue;
|
|
186
|
+
delete state.pending[block.tool_use_id];
|
|
187
|
+
if (!resultSucceeded(block))
|
|
188
|
+
continue;
|
|
189
|
+
state.held = state.held.filter((id) => id !== pending.issueId);
|
|
190
|
+
if (pending.op === "claim") {
|
|
191
|
+
state.held.push(pending.issueId);
|
|
192
|
+
if (state.held.length > MAX_HELD_CLAIMS)
|
|
193
|
+
state.held = state.held.slice(state.held.length - MAX_HELD_CLAIMS);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
stateOf(source) {
|
|
198
|
+
let state = this.states.get(source);
|
|
199
|
+
if (state === undefined) {
|
|
200
|
+
state = emptyClaimState();
|
|
201
|
+
this.states.set(source, state);
|
|
202
|
+
}
|
|
203
|
+
return state;
|
|
204
|
+
}
|
|
205
|
+
}
|
package/dist/dispatch-usage.d.ts
CHANGED
|
@@ -26,14 +26,39 @@ export interface StatsSink {
|
|
|
26
26
|
}
|
|
27
27
|
export declare function zeroUsageTotals(): UsageTotals;
|
|
28
28
|
export type FlushOutcome = "ok" | "failed" | "refused";
|
|
29
|
+
/**
|
|
30
|
+
* DX-3962 - which transcript owner a collector is built for. It decides whether
|
|
31
|
+
* replies are attributed to cards, and it has no default: every construction names
|
|
32
|
+
* its path.
|
|
33
|
+
*
|
|
34
|
+
* - `"worker"` - a dispatched worker. NO claim tracker, and every entry's
|
|
35
|
+
* `issueId` is `null` whatever the transcript contains (a worker picks its card
|
|
36
|
+
* up server-side, its transcript has no pickup, and its dispatch row already
|
|
37
|
+
* carries the card). The server refuses a non-null `issueId` from a non-operator
|
|
38
|
+
* dispatch by refusing the WHOLE batch, which would stall the worker's stats,
|
|
39
|
+
* token ceiling and spend alert on every resend - so a worker is structurally
|
|
40
|
+
* unable to send one.
|
|
41
|
+
* - `"operator-bridge"` - the desktop bridge of an operator session
|
|
42
|
+
* (`danx-dashboard-mcp`'s `session-stats.ts`), the only path that attributes.
|
|
43
|
+
*/
|
|
44
|
+
export type DispatchUsageMode = "worker" | "operator-bridge";
|
|
29
45
|
export declare class DispatchUsage {
|
|
30
46
|
readonly counter: UsageLedgerCounter;
|
|
47
|
+
/** DX-3962 - `null` in `"worker"` mode: the worker can never create a claim, so it can never send an `issueId`. */
|
|
48
|
+
private readonly claims;
|
|
49
|
+
/**
|
|
50
|
+
* DX-3962 - per source, the claim state as of its `acked` offset: what the next
|
|
51
|
+
* write reports with `acked`, promoted together with it. The state the tracker
|
|
52
|
+
* holds NOW belongs to `delivered`, which runs ahead.
|
|
53
|
+
*/
|
|
54
|
+
private readonly ackedClaims;
|
|
31
55
|
private serverTotals;
|
|
32
56
|
private readonly acked;
|
|
33
57
|
private healthy;
|
|
34
58
|
private refused;
|
|
35
59
|
private started;
|
|
36
60
|
private readonly queue;
|
|
61
|
+
constructor(mode: DispatchUsageMode);
|
|
37
62
|
/** How to read the watcher's live per-source cursor state. */
|
|
38
63
|
private readCursorState;
|
|
39
64
|
/**
|
|
@@ -179,6 +204,7 @@ export declare class DispatchUsage {
|
|
|
179
204
|
* estimate does not count it twice (see {@link DispatchUsage.unacknowledged}).
|
|
180
205
|
*/
|
|
181
206
|
observeEntry(entry: AgentLogEntry, replay?: boolean): void;
|
|
207
|
+
private observeReply;
|
|
182
208
|
/**
|
|
183
209
|
* Ship every changed entry in capped batches, one after another. The first
|
|
184
210
|
* call always writes (even with nothing to send) so the process takes the
|
package/dist/dispatch-usage.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* DB-free: runs in the DB-less worker. The sink decides the transport
|
|
17
17
|
* (HTTP on a registered worker, getPool-direct on the dashboard).
|
|
18
18
|
*/
|
|
19
|
-
import { UsageLedgerCounter, ledgerLineFromLogEntry, excessUsage, syntheticMessageKey, } from "./usage-ledger.js";
|
|
19
|
+
import { MAIN_SOURCE, UsageLedgerCounter, ledgerLineFromLogEntry, excessUsage, syntheticMessageKey, } from "./usage-ledger.js";
|
|
20
|
+
import { ClaimTracker, emptyClaimState } from "./claim-tracker.js";
|
|
20
21
|
import { ACTIVITY_BATCH_CAP, DispatchUsageTerminalError, chunkEntries, collapseActivityChanges, } from "./stats-wire.js";
|
|
21
22
|
import { createUsageWriteQueue } from "./write-queue.js";
|
|
22
23
|
import { createLogger } from "./internal-logger.js";
|
|
@@ -33,26 +34,25 @@ export function zeroUsageTotals() {
|
|
|
33
34
|
toolCallCount: 0,
|
|
34
35
|
};
|
|
35
36
|
}
|
|
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
37
|
export class DispatchUsage {
|
|
49
38
|
counter = new UsageLedgerCounter();
|
|
39
|
+
/** DX-3962 - `null` in `"worker"` mode: the worker can never create a claim, so it can never send an `issueId`. */
|
|
40
|
+
claims;
|
|
41
|
+
/**
|
|
42
|
+
* DX-3962 - per source, the claim state as of its `acked` offset: what the next
|
|
43
|
+
* write reports with `acked`, promoted together with it. The state the tracker
|
|
44
|
+
* holds NOW belongs to `delivered`, which runs ahead.
|
|
45
|
+
*/
|
|
46
|
+
ackedClaims = new Map();
|
|
50
47
|
serverTotals = zeroUsageTotals();
|
|
51
48
|
acked = new Map();
|
|
52
49
|
healthy = true;
|
|
53
50
|
refused = false;
|
|
54
51
|
started = false;
|
|
55
52
|
queue = createUsageWriteQueue();
|
|
53
|
+
constructor(mode) {
|
|
54
|
+
this.claims = mode === "operator-bridge" ? new ClaimTracker() : null;
|
|
55
|
+
}
|
|
56
56
|
// ── DX-2954: the cursor half ──────────────────────────────────────────────
|
|
57
57
|
/** How to read the watcher's live per-source cursor state. */
|
|
58
58
|
readCursorState = null;
|
|
@@ -109,6 +109,10 @@ export class DispatchUsage {
|
|
|
109
109
|
this.startCursors = startCursors;
|
|
110
110
|
for (const [source, cursor] of Object.entries(startCursors)) {
|
|
111
111
|
this.ackedCursor.set(source, cursor.acked);
|
|
112
|
+
// DX-3962 - the watcher reads each source from its stored `acked`, so the tracker starts from the claims
|
|
113
|
+
// stored with that offset: a pickup before it is never read again.
|
|
114
|
+
this.ackedClaims.set(source, cursor.claims);
|
|
115
|
+
this.claims?.seed(source, cursor.claims);
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
/**
|
|
@@ -125,6 +129,10 @@ export class DispatchUsage {
|
|
|
125
129
|
// that can never be satisfied. The bytes we believed acknowledged do not
|
|
126
130
|
// exist any more, so claiming them is simply false.
|
|
127
131
|
this.ackedCursor.set(info.source, 0);
|
|
132
|
+
// DX-3962 - the claims recorded against the old file name a transcript that is gone; the source re-earns them
|
|
133
|
+
// from the lines it re-reads from 0 (the server clears the stored claims in the same statement as the cursor).
|
|
134
|
+
this.ackedClaims.set(info.source, emptyClaimState());
|
|
135
|
+
this.claims?.seed(info.source, emptyClaimState());
|
|
128
136
|
}
|
|
129
137
|
/**
|
|
130
138
|
* DX-2954 — queue running-set changes for the next write.
|
|
@@ -210,8 +218,9 @@ export class DispatchUsage {
|
|
|
210
218
|
buildCursorReports() {
|
|
211
219
|
const reports = {};
|
|
212
220
|
const deliveredAtSend = new Map();
|
|
221
|
+
const claimsAtSend = new Map();
|
|
213
222
|
if (!this.readCursorState) {
|
|
214
|
-
return { reports, deliveredAtSend, reportedResets: [], carriesStartCursors: false };
|
|
223
|
+
return { reports, deliveredAtSend, claimsAtSend, reportedResets: [], carriesStartCursors: false };
|
|
215
224
|
}
|
|
216
225
|
const resetBySource = new Map(this.pastEof.map((p) => [p.source, p]));
|
|
217
226
|
const reportedResets = [];
|
|
@@ -221,7 +230,7 @@ export class DispatchUsage {
|
|
|
221
230
|
for (const [source, start] of Object.entries(this.startCursors)) {
|
|
222
231
|
if (source in live)
|
|
223
232
|
continue; // the live state supersedes it once opened
|
|
224
|
-
reports[source] = { acked: start.acked, delivered: start.delivered };
|
|
233
|
+
reports[source] = { acked: start.acked, delivered: start.delivered, claims: start.claims };
|
|
225
234
|
carriesStartCursors = true;
|
|
226
235
|
}
|
|
227
236
|
}
|
|
@@ -230,7 +239,13 @@ export class DispatchUsage {
|
|
|
230
239
|
// under rule 4 must never claim byte 0 as acknowledged, or the next
|
|
231
240
|
// restart would read the whole file and re-sum the history rule 4 skipped.
|
|
232
241
|
const acked = Math.max(this.ackedCursor.get(source) ?? 0, state.startOffset);
|
|
233
|
-
|
|
242
|
+
// The claims reported with `acked` are the state AS OF `acked`, never the tracker's current state (which
|
|
243
|
+
// belongs to `delivered`): a restart resumes at `acked`, so that is the position the claims must describe.
|
|
244
|
+
const report = {
|
|
245
|
+
acked,
|
|
246
|
+
delivered: state.delivered,
|
|
247
|
+
claims: this.ackedClaims.get(source) ?? emptyClaimState(),
|
|
248
|
+
};
|
|
234
249
|
const reset = resetBySource.get(source);
|
|
235
250
|
if (reset) {
|
|
236
251
|
report.pastEof = { storedOffset: reset.storedOffset, fileSize: reset.fileSize };
|
|
@@ -238,12 +253,13 @@ export class DispatchUsage {
|
|
|
238
253
|
}
|
|
239
254
|
reports[source] = report;
|
|
240
255
|
deliveredAtSend.set(source, state.delivered);
|
|
256
|
+
claimsAtSend.set(source, this.claims?.snapshot(source) ?? emptyClaimState());
|
|
241
257
|
}
|
|
242
258
|
// Only the resets that actually reached a report are consumed. A reset for a
|
|
243
259
|
// source that has left `cursorState()` (its tail dropped on ENOENT) would
|
|
244
260
|
// otherwise be taken and forgotten, leaving the stored `acked` at a value the
|
|
245
261
|
// file can never satisfy.
|
|
246
|
-
return { reports, deliveredAtSend, reportedResets, carriesStartCursors };
|
|
262
|
+
return { reports, deliveredAtSend, claimsAtSend, reportedResets, carriesStartCursors };
|
|
247
263
|
}
|
|
248
264
|
/**
|
|
249
265
|
* DX-2954 — whether the cursor half has anything new to say. An IDLE dispatch
|
|
@@ -272,9 +288,21 @@ export class DispatchUsage {
|
|
|
272
288
|
* estimate does not count it twice (see {@link DispatchUsage.unacknowledged}).
|
|
273
289
|
*/
|
|
274
290
|
observeEntry(entry, replay = false) {
|
|
291
|
+
const content = Array.isArray(entry.data.content) ? entry.data.content : [];
|
|
292
|
+
const source = typeof entry.data.subagent_id === "string" ? entry.data.subagent_id : MAIN_SOURCE;
|
|
293
|
+
// DX-3962 - a `tool_result` settles a pickup / release the source asked for earlier.
|
|
294
|
+
if (entry.type === "user")
|
|
295
|
+
this.claims?.observeResultBlocks(source, content);
|
|
275
296
|
const line = ledgerLineFromLogEntry(entry);
|
|
276
|
-
if (
|
|
277
|
-
|
|
297
|
+
if (line)
|
|
298
|
+
this.observeReply(line, replay);
|
|
299
|
+
// AFTER the reply is counted: a reply is credited to the card held at its FIRST line, so the pickup this line
|
|
300
|
+
// itself asks for takes effect only on its result, and never on the reply that issued it.
|
|
301
|
+
if (entry.type === "assistant")
|
|
302
|
+
this.claims?.observeAssistantBlocks(source, content);
|
|
303
|
+
}
|
|
304
|
+
observeReply(line, replay) {
|
|
305
|
+
line.issueId = this.claims?.issueFor(line.source) ?? null;
|
|
278
306
|
// The counter's OWN key, not `line.messageId`: a usage line with no
|
|
279
307
|
// `message.id` is keyed by `syntheticMessageKey(source, byteOffset)`
|
|
280
308
|
// (`usage-ledger.ts`), and recording a different key here would leave that
|
|
@@ -304,7 +332,7 @@ export class DispatchUsage {
|
|
|
304
332
|
this.flushBegan = true;
|
|
305
333
|
const changed = this.counter.takeChanged();
|
|
306
334
|
const batches = chunkEntries(changed);
|
|
307
|
-
const { reports, deliveredAtSend, reportedResets, carriesStartCursors } = this.buildCursorReports();
|
|
335
|
+
const { reports, deliveredAtSend, claimsAtSend, reportedResets, carriesStartCursors } = this.buildCursorReports();
|
|
308
336
|
// TAKE the queued side data, exactly as `counter.takeChanged()` does, so a
|
|
309
337
|
// hook that fires during the await lands in the NEXT write rather than
|
|
310
338
|
// being silently dropped by a post-write reset of the live array.
|
|
@@ -370,6 +398,8 @@ export class DispatchUsage {
|
|
|
370
398
|
if (refused.has(source) || resetMidFlight.has(source))
|
|
371
399
|
continue;
|
|
372
400
|
this.ackedCursor.set(source, delivered);
|
|
401
|
+
// DX-3962 - the claims advance with the offset they describe, never ahead of it.
|
|
402
|
+
this.ackedClaims.set(source, claimsAtSend.get(source));
|
|
373
403
|
}
|
|
374
404
|
if (payload.transcriptPath !== undefined)
|
|
375
405
|
this.transcriptPathSent = true;
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { truncStr, summarizeToolInput, buildAssistantSummary, buildToolResultSum
|
|
|
21
21
|
export * from "./usage-ledger.js";
|
|
22
22
|
export * from "./pricing.js";
|
|
23
23
|
export * from "./stats-wire.js";
|
|
24
|
+
export * from "./claim-tracker.js";
|
|
24
25
|
export * from "./dispatch-usage.js";
|
|
25
26
|
export * from "./activity-observer.js";
|
|
26
27
|
export * from "./write-queue.js";
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,7 @@ export * from "./pricing.js";
|
|
|
32
32
|
// running-set changes, the transcript-side running-set producer, and the FIFO
|
|
33
33
|
// that orders a dispatch's writes.
|
|
34
34
|
export * from "./stats-wire.js";
|
|
35
|
+
export * from "./claim-tracker.js";
|
|
35
36
|
export * from "./dispatch-usage.js";
|
|
36
37
|
export * from "./activity-observer.js";
|
|
37
38
|
export * from "./write-queue.js";
|
package/dist/stats-wire.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* server, and the response hands them back.
|
|
11
11
|
*/
|
|
12
12
|
import type { LedgerEntry, LedgerUsage } from "./usage-ledger.js";
|
|
13
|
+
import { type ClaimState } from "./claim-tracker.js";
|
|
13
14
|
/**
|
|
14
15
|
* DX-2963 — cap on entries per stats write. A from-0 backlog (a reattach, a
|
|
15
16
|
* session read from the start) is sent as consecutive capped batches, so one
|
|
@@ -27,6 +28,11 @@ export declare const STATS_BATCH_CAP = 500;
|
|
|
27
28
|
*
|
|
28
29
|
* Lines between them are re-read on a restart and handed on as REPLAYS.
|
|
29
30
|
*
|
|
31
|
+
* `claims` (DX-3962) is the source's claim state AS OF `acked` - the card(s) the
|
|
32
|
+
* sub-agent holds, so a collector that resumes at `acked` attributes the next
|
|
33
|
+
* reply to the right card without re-reading the pickup that sits before it.
|
|
34
|
+
* A worker's is always empty: it has no claim tracker.
|
|
35
|
+
*
|
|
30
36
|
* "None stored" is the ABSENCE of a source's key, never `{acked: 0}` — 0 is a
|
|
31
37
|
* legitimate stored cursor meaning "start of file", and CP1 forbids a reader
|
|
32
38
|
* defaulting a missing persisted value.
|
|
@@ -34,6 +40,7 @@ export declare const STATS_BATCH_CAP = 500;
|
|
|
34
40
|
export interface SourceCursor {
|
|
35
41
|
acked: number;
|
|
36
42
|
delivered: number;
|
|
43
|
+
claims: ClaimState;
|
|
37
44
|
}
|
|
38
45
|
/** DX-2954 — per-source cursors. An absent key means none stored (never 0). */
|
|
39
46
|
export type SourceCursors = Record<string, SourceCursor>;
|
package/dist/stats-wire.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ISSUE_ID_MAX_LENGTH, ISSUE_ID_PATTERN, MAX_HELD_CLAIMS, MAX_PENDING_TRANSITIONS, } from "./claim-tracker.js";
|
|
1
2
|
/**
|
|
2
3
|
* DX-2963 — cap on entries per stats write. A from-0 backlog (a reattach, a
|
|
3
4
|
* session read from the start) is sent as consecutive capped batches, so one
|
|
@@ -109,7 +110,52 @@ export function collapseActivityChanges(changes) {
|
|
|
109
110
|
}
|
|
110
111
|
return [...byKey.values()];
|
|
111
112
|
}
|
|
112
|
-
const CURSOR_KEYS = new Set(["acked", "delivered", "pastEof"]);
|
|
113
|
+
const CURSOR_KEYS = new Set(["acked", "delivered", "claims", "pastEof"]);
|
|
114
|
+
const CLAIM_STATE_KEYS = new Set(["held", "pending"]);
|
|
115
|
+
const PENDING_KEYS = new Set(["op", "issueId"]);
|
|
116
|
+
/** DX-3962 - a card id as a claim or a ledger entry names it: the issue routes' own shape, within the column's width. */
|
|
117
|
+
function isIssueId(v) {
|
|
118
|
+
return typeof v === "string" && v.length <= ISSUE_ID_MAX_LENGTH && ISSUE_ID_PATTERN.test(v);
|
|
119
|
+
}
|
|
120
|
+
/** DX-3962 - strict parse of one source's claim state (a collector bug, never coerced). */
|
|
121
|
+
function parseClaimState(raw, where) {
|
|
122
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
123
|
+
throw new StatsPayloadError(where);
|
|
124
|
+
const c = raw;
|
|
125
|
+
for (const k of Object.keys(c)) {
|
|
126
|
+
if (!CLAIM_STATE_KEYS.has(k))
|
|
127
|
+
throw new StatsPayloadError(`${where}.${k} is not a claim-state field`);
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(c.held) || c.held.length > MAX_HELD_CLAIMS || !c.held.every(isIssueId)) {
|
|
130
|
+
throw new StatsPayloadError(`${where}.held must be at most ${MAX_HELD_CLAIMS} card ids`);
|
|
131
|
+
}
|
|
132
|
+
if (!c.pending || typeof c.pending !== "object" || Array.isArray(c.pending)) {
|
|
133
|
+
throw new StatsPayloadError(`${where}.pending must be an object`);
|
|
134
|
+
}
|
|
135
|
+
const pendingEntries = Object.entries(c.pending);
|
|
136
|
+
if (pendingEntries.length > MAX_PENDING_TRANSITIONS) {
|
|
137
|
+
throw new StatsPayloadError(`${where}.pending exceeds the cap of ${MAX_PENDING_TRANSITIONS}`);
|
|
138
|
+
}
|
|
139
|
+
const pending = {};
|
|
140
|
+
for (const [toolUseId, value] of pendingEntries) {
|
|
141
|
+
const w = `${where}.pending[${toolUseId}]`;
|
|
142
|
+
if (!toolUseId)
|
|
143
|
+
throw new StatsPayloadError(`${where}.pending key must be a non-empty tool-use id`);
|
|
144
|
+
if (!value || typeof value !== "object")
|
|
145
|
+
throw new StatsPayloadError(w);
|
|
146
|
+
const p = value;
|
|
147
|
+
for (const k of Object.keys(p)) {
|
|
148
|
+
if (!PENDING_KEYS.has(k))
|
|
149
|
+
throw new StatsPayloadError(`${w}.${k} is not a pending-transition field`);
|
|
150
|
+
}
|
|
151
|
+
if (p.op !== "claim" && p.op !== "release")
|
|
152
|
+
throw new StatsPayloadError(`${w}.op must be claim or release`);
|
|
153
|
+
if (!isIssueId(p.issueId))
|
|
154
|
+
throw new StatsPayloadError(`${w}.issueId`);
|
|
155
|
+
pending[toolUseId] = { op: p.op, issueId: p.issueId };
|
|
156
|
+
}
|
|
157
|
+
return { held: [...c.held], pending };
|
|
158
|
+
}
|
|
113
159
|
const PAST_EOF_KEYS = new Set(["storedOffset", "fileSize"]);
|
|
114
160
|
function parsePastEof(raw, where) {
|
|
115
161
|
if (!raw || typeof raw !== "object")
|
|
@@ -156,7 +202,11 @@ function parseSourceCursors(raw) {
|
|
|
156
202
|
if (c.delivered < c.acked) {
|
|
157
203
|
throw new StatsPayloadError(`sourceCursors[${source}]: delivered (${c.delivered}) is behind acked (${c.acked})`);
|
|
158
204
|
}
|
|
159
|
-
const report = {
|
|
205
|
+
const report = {
|
|
206
|
+
acked: c.acked,
|
|
207
|
+
delivered: c.delivered,
|
|
208
|
+
claims: parseClaimState(c.claims, `sourceCursors[${source}].claims`),
|
|
209
|
+
};
|
|
160
210
|
if (c.pastEof !== undefined) {
|
|
161
211
|
report.pastEof = parsePastEof(c.pastEof, `sourceCursors[${source}].pastEof`);
|
|
162
212
|
}
|
|
@@ -265,6 +315,10 @@ export function parseStatsPayload(body) {
|
|
|
265
315
|
if (!Array.isArray(e.toolUseIds) || e.toolUseIds.some((t) => typeof t !== "string")) {
|
|
266
316
|
throw new StatsPayloadError(`${where}.toolUseIds`);
|
|
267
317
|
}
|
|
318
|
+
// DX-3962 - REQUIRED, `null` or a card id: an entry that omits it comes from a collector that predates
|
|
319
|
+
// attribution, which the MCP version floor refuses rather than this parser tolerating.
|
|
320
|
+
if (e.issueId !== null && !isIssueId(e.issueId))
|
|
321
|
+
throw new StatsPayloadError(`${where}.issueId`);
|
|
268
322
|
return {
|
|
269
323
|
messageId: e.messageId,
|
|
270
324
|
source: e.source,
|
|
@@ -279,6 +333,7 @@ export function parseStatsPayload(body) {
|
|
|
279
333
|
cacheWrite1h: u.cacheWrite1h,
|
|
280
334
|
},
|
|
281
335
|
toolUseIds: e.toolUseIds,
|
|
336
|
+
issueId: e.issueId,
|
|
282
337
|
};
|
|
283
338
|
});
|
|
284
339
|
const payload = { entries };
|
package/dist/usage-ledger.d.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface LedgerEntry {
|
|
|
39
39
|
/** The reply's own transcript timestamp (its FIRST line's), epoch ms. */
|
|
40
40
|
repliedAt: number;
|
|
41
41
|
toolUseIds: string[];
|
|
42
|
+
/**
|
|
43
|
+
* DX-3962 - the card this reply is credited to, or `null`. Fixed by the
|
|
44
|
+
* reply's FIRST line and never changed by a later one. Only an operator-bridge
|
|
45
|
+
* collector (`DispatchUsage`, `"operator-bridge"` mode) ever sets it; every
|
|
46
|
+
* other producer of an entry carries `null`.
|
|
47
|
+
*/
|
|
48
|
+
issueId: string | null;
|
|
42
49
|
}
|
|
43
50
|
/** The fields of one assistant line the counter needs. */
|
|
44
51
|
export interface LedgerLine {
|
|
@@ -53,6 +60,8 @@ export interface LedgerLine {
|
|
|
53
60
|
timestamp: number;
|
|
54
61
|
/** `tool_use` block ids on this line. */
|
|
55
62
|
toolUseIds: readonly string[];
|
|
63
|
+
/** DX-3962 - the card the line's reply is credited to when this is its first line; see {@link LedgerEntry.issueId}. */
|
|
64
|
+
issueId: string | null;
|
|
56
65
|
}
|
|
57
66
|
export declare const MAIN_SOURCE = "main";
|
|
58
67
|
/**
|
package/dist/usage-ledger.js
CHANGED
|
@@ -140,6 +140,7 @@ export class UsageLedgerCounter {
|
|
|
140
140
|
usage,
|
|
141
141
|
repliedAt: line.timestamp,
|
|
142
142
|
toolUseIds: [...new Set(line.toolUseIds)],
|
|
143
|
+
issueId: line.issueId,
|
|
143
144
|
});
|
|
144
145
|
this.changed.add(key);
|
|
145
146
|
return true;
|
|
@@ -247,6 +248,7 @@ export function ledgerLineFromRaw(raw, source, byteOffset) {
|
|
|
247
248
|
toolUseIds: content
|
|
248
249
|
.filter((b) => b.type === "tool_use" && typeof b.id === "string")
|
|
249
250
|
.map((b) => b.id),
|
|
251
|
+
issueId: null,
|
|
250
252
|
};
|
|
251
253
|
}
|
|
252
254
|
/**
|
|
@@ -318,5 +320,6 @@ export function ledgerLineFromLogEntry(entry) {
|
|
|
318
320
|
usage: hasUsage ? d.usage : undefined,
|
|
319
321
|
timestamp: entry.timestamp,
|
|
320
322
|
toolUseIds,
|
|
323
|
+
issueId: null,
|
|
321
324
|
};
|
|
322
325
|
}
|
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.21",
|
|
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",
|