@bongos/core 1.19.443 → 1.19.444
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/.bongos-core.json +15 -15
- package/docs/adr/0054-session-token-reward-cost-plus.md +14 -0
- package/docs/module-api-changelog.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/session-digest-build.js +49 -17
- package/scripts/gds/session-digest.js +11 -1
- package/scripts/gds/ship-session-upload.js +102 -12
- package/scripts/gds/ship.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/credit_grant.mjs +236 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.444",
|
|
6
|
+
"core_contract": "1.19.444",
|
|
7
|
+
"source_commit": "6a56c81be62cf4638f6b5b72bd88cf7523135092",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-30T20:54:19.941Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 370,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2147,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "7285bb9b1aa117e2737ae06222ec335d9d10c8437c1d89036eeb141d1fa0a46a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -717,7 +717,7 @@
|
|
|
717
717
|
{
|
|
718
718
|
"path": "docs/adr/0054-session-token-reward-cost-plus.md",
|
|
719
719
|
"mode": "0000644",
|
|
720
|
-
"sha256": "
|
|
720
|
+
"sha256": "63457c6355aa510ca0840f8cd783e69a5d9ce9a6764e90896f5efcf117e557bb"
|
|
721
721
|
},
|
|
722
722
|
{
|
|
723
723
|
"path": "docs/adr/0055-server-mediated-branch-publish.md",
|
|
@@ -2002,7 +2002,7 @@
|
|
|
2002
2002
|
{
|
|
2003
2003
|
"path": "docs/module-api-changelog.md",
|
|
2004
2004
|
"mode": "0000644",
|
|
2005
|
-
"sha256": "
|
|
2005
|
+
"sha256": "b600ec9449f02043cdd38525642177270148161abd142830c308d5949eb9ef24"
|
|
2006
2006
|
},
|
|
2007
2007
|
{
|
|
2008
2008
|
"path": "docs/modules-contract.md",
|
|
@@ -6152,12 +6152,12 @@
|
|
|
6152
6152
|
{
|
|
6153
6153
|
"path": "package-lock.json",
|
|
6154
6154
|
"mode": "0000644",
|
|
6155
|
-
"sha256": "
|
|
6155
|
+
"sha256": "d98226fae4927f559b5317bdf43b5a10d5274b899afba24f98863c8bb8b52865"
|
|
6156
6156
|
},
|
|
6157
6157
|
{
|
|
6158
6158
|
"path": "package.json",
|
|
6159
6159
|
"mode": "0000644",
|
|
6160
|
-
"sha256": "
|
|
6160
|
+
"sha256": "ba49db7e69feb40ac43df19e0b38bde6fd4dc553d77533a7e0253233efc899fc"
|
|
6161
6161
|
},
|
|
6162
6162
|
{
|
|
6163
6163
|
"path": "public-docs/index.html",
|
|
@@ -7167,12 +7167,12 @@
|
|
|
7167
7167
|
{
|
|
7168
7168
|
"path": "scripts/gds/session-digest-build.js",
|
|
7169
7169
|
"mode": "0000644",
|
|
7170
|
-
"sha256": "
|
|
7170
|
+
"sha256": "2409e6e4bb4edbe67336ebffb63832ba2c07830b8af858f5c372e4ba093cf192"
|
|
7171
7171
|
},
|
|
7172
7172
|
{
|
|
7173
7173
|
"path": "scripts/gds/session-digest.js",
|
|
7174
7174
|
"mode": "0000644",
|
|
7175
|
-
"sha256": "
|
|
7175
|
+
"sha256": "81500cc2955e7159720a5cf260fa73e3790c0b4fd0c2e50cb64dbc481b5882ae"
|
|
7176
7176
|
},
|
|
7177
7177
|
{
|
|
7178
7178
|
"path": "scripts/gds/session-eval.js",
|
|
@@ -7267,7 +7267,7 @@
|
|
|
7267
7267
|
{
|
|
7268
7268
|
"path": "scripts/gds/ship-session-upload.js",
|
|
7269
7269
|
"mode": "0000644",
|
|
7270
|
-
"sha256": "
|
|
7270
|
+
"sha256": "46749850c4ccd88797978282d0345ff73a34f10e670922e52a8dbf75664ca3a7"
|
|
7271
7271
|
},
|
|
7272
7272
|
{
|
|
7273
7273
|
"path": "scripts/gds/ship-state.js",
|
|
@@ -7287,7 +7287,7 @@
|
|
|
7287
7287
|
{
|
|
7288
7288
|
"path": "scripts/gds/ship.js",
|
|
7289
7289
|
"mode": "0000644",
|
|
7290
|
-
"sha256": "
|
|
7290
|
+
"sha256": "7264827d7dc6a5d8babf5b0f6aa8384fa9c14fca71ba07a649226ce98dd84e0d"
|
|
7291
7291
|
},
|
|
7292
7292
|
{
|
|
7293
7293
|
"path": "scripts/gds/skill-preflight.js",
|
|
@@ -7802,7 +7802,7 @@
|
|
|
7802
7802
|
{
|
|
7803
7803
|
"path": "src/module-api.js",
|
|
7804
7804
|
"mode": "0000644",
|
|
7805
|
-
"sha256": "
|
|
7805
|
+
"sha256": "cf2b8751ec0c1b79dce16d5117243cf917b15c62b798b66a68c8f857f1762557"
|
|
7806
7806
|
},
|
|
7807
7807
|
{
|
|
7808
7808
|
"path": "src/module-loader/catalog.js",
|
|
@@ -8452,7 +8452,7 @@
|
|
|
8452
8452
|
{
|
|
8453
8453
|
"path": "tests/credit_grant.mjs",
|
|
8454
8454
|
"mode": "0000644",
|
|
8455
|
-
"sha256": "
|
|
8455
|
+
"sha256": "dcb35adea926027490b7ca20a59fd596d2db097cc84ec004a372e922602098c8"
|
|
8456
8456
|
},
|
|
8457
8457
|
{
|
|
8458
8458
|
"path": "tests/criterion_authoring_gate.mjs",
|
|
@@ -61,3 +61,17 @@ Revisit when: (a) per-task token attribution lands (the unit becomes the task, n
|
|
|
61
61
|
- `computeSessionTokenReward` in `src/bongos/db.js` changed accordingly; `tests/credit_grant.mjs` updated; `llm-pricing.js` (the token→USD table) is **unchanged** — only the cents→dollars scale factor moved.
|
|
62
62
|
- **History was re-denominated, not left mixed.** Per the "append-only, reversible" rule above, existing `session.token_reward` rows were **not mutated**; a single compensating `credit_log` row (`reason='redenominate.token_reward_dollars'`, `task_id` → [#1138](https://example.com/builders#/task/1138)) restates the running total from cents to dollars, and the `total_credits` trigger nets it automatically. The original cents rows remain as historical record; the `session_records.token_reward_example` display mirror was divided by 100 to match. At amendment time builder `#1` (example-owner) was the only holder of token rewards, so this touched one builder.
|
|
63
63
|
- **The per-task `credits_reward` path (ADR 0023) is untouched** — it stays a unitless author estimate. The two-conventions tension (§"Two drachma conventions, one ledger") is now *narrower* (both paths read on a ~dollar scale) but not formally reconciled; trigger (c) remains open for a full reconciliation.
|
|
64
|
+
|
|
65
|
+
## Amendment — 2026-08-30 (task 1003435): the priced unit is the SESSION, subagents included
|
|
66
|
+
|
|
67
|
+
**A gap in the Decision's premise, not a change to its formula.** The formula above prices `session_records.model_usage`, and that map was built from exactly one file — the interactive transcript. Claude Code persists Agent/Task-tool spawns in a sibling subtree (`<project-dir>/<session-id>/subagents/*.jsonl`), so a session's subagent tokens were invisible to the reward. The spend ledger had already closed this gap on its side: [ADR 0028](<redacted>.md)'s `session-cost.js` prices that subtree into its own `session:<redacted> cost row. Only the reward still read one file.
|
|
68
|
+
|
|
69
|
+
That inverted the methodology's own instruction. CLAUDE.md tells builders to delegate freely and to route most subagent work to the top model tier; following it moved real, productive spend into a bucket the reward could not see, so the same work paid less when it was parallelized. This is **not** the accepted waste risk recorded in §"The residual rewards waste risk" — that section knowingly accepts paying for tokens that turned out wasteful. This was the opposite failure: guidance-following spend that earned nothing.
|
|
70
|
+
|
|
71
|
+
**Change:** `model_usage` now describes the whole session — the interactive thread plus its subagents subtree. Grader worker transcripts are excluded **by signature**, not by location, reusing `looksLikeGraderTranscript()`, because a grader run can nest inside a `subagents/` dir and its cost is already captured through the worker envelope; re-pricing it would double-count, which is the ADR 0028 invariant.
|
|
72
|
+
|
|
73
|
+
**The deliberate asymmetry, recorded because it looks like a bug otherwise.** Only `model_usage` grew. `detail`, `metrics.tokens`, `assistant_turns` and `total_tokens` remain the **interactive thread's** record. Those feed the BFG session corpus and the inefficiency evaluator ([ADR 0027](<redacted>.md)), which reason about one agent's turn-by-turn behaviour; splicing N subagent transcripts into that per-turn story would distort the very signal it exists to measure, and bloat the payload. So `total_tokens` is deliberately **less than** the sum of `model_usage`, and three counters — `subagent_files`, `subagent_tokens`, `subagent_grader_skipped` — carry the difference explicitly rather than leaving a reader to infer it from a mismatch.
|
|
74
|
+
|
|
75
|
+
**The acceptance test is a reconciliation, not a fixture.** `tests/credit_grant.mjs` runs both real pricing paths over one on-disk fixture and asserts the reward's `true_cost_usd` equals the sum of that session's `session:<redacted> and `session:<redacted> cost rows within independent-rounding epsilon — so a change made to one side and not the other fails the build. Idempotency is unchanged: the award is still a per-session watermark, so a re-ship pays only the increment and never twice.
|
|
76
|
+
|
|
77
|
+
**Not done here, and an owner decision:** past parallel sessions were underpaid. History stays append-only; a backfill would be its own task, with the 2026-06-15 re-denomination above as the worked precedent for the compensating-row shape.
|
|
@@ -1323,5 +1323,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1323
1323
|
landed since 1.19.441 with no explicit bump. run 33326781131. (task 1002620)
|
|
1324
1324
|
1.19.443 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1325
1325
|
landed since 1.19.442 with no explicit bump. run 33330337830. (task 1002620)
|
|
1326
|
+
1.19.444 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1327
|
+
landed since 1.19.443 with no explicit bump. run 33334908383. (task 1002620)
|
|
1326
1328
|
---------------------------------------------------------------------------
|
|
1327
1329
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.444",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.444",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.444",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -87,6 +87,16 @@ const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
|
|
|
87
87
|
function buildSessionDigest(entries, opts = {}) {
|
|
88
88
|
const extra = opts.extraSecrets || [];
|
|
89
89
|
const MAX_TURNS = opts.maxTurns || 4000;
|
|
90
|
+
// usageOnly — build the token metrics but no `detail`. For the callers that
|
|
91
|
+
// want ONLY a transcript's model_usage (the subagents-subtree fold, task
|
|
92
|
+
// 1003435), where the per-turn rows would be discarded anyway and the snippet
|
|
93
|
+
// scrubbing is the expensive part. It exists so that fold can reuse THIS
|
|
94
|
+
// function — the one place the token semantics live — instead of a second copy
|
|
95
|
+
// that could drift, without paying for output nobody reads. The waste is worth
|
|
96
|
+
// avoiding on both upload paths, and on the SessionEnd one it is load-bearing:
|
|
97
|
+
// that hook runs under a 6-second watchdog, so a slow pass there doesn't just
|
|
98
|
+
// cost time, it drops the upload.
|
|
99
|
+
const usageOnly = opts.usageOnly === true;
|
|
90
100
|
const seenMessageIds = new Set();
|
|
91
101
|
const m = {
|
|
92
102
|
assistant_turns: 0,
|
|
@@ -165,15 +175,17 @@ function buildSessionDigest(entries, opts = {}) {
|
|
|
165
175
|
}
|
|
166
176
|
}
|
|
167
177
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
if (!usageOnly) {
|
|
179
|
+
const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
|
|
180
|
+
// Marks a line that shares its API call with an earlier one, so a reader
|
|
181
|
+
// can tell a genuinely-zero-usage turn from a de-duplicated repeat.
|
|
182
|
+
if (dupUsage) row.dup = 1;
|
|
183
|
+
if (tools.length) row.tools = tools;
|
|
184
|
+
if (textLen) row.text_len = textLen;
|
|
185
|
+
const snip = snippetOf(firstText, extra);
|
|
186
|
+
if (snip) row.snippet = snip;
|
|
187
|
+
detail.push(row);
|
|
188
|
+
}
|
|
177
189
|
i++;
|
|
178
190
|
} else if (e.type === 'user' && msg) {
|
|
179
191
|
m.user_turns++;
|
|
@@ -206,13 +218,15 @@ function buildSessionDigest(entries, opts = {}) {
|
|
|
206
218
|
}
|
|
207
219
|
}
|
|
208
220
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
if (!usageOnly) {
|
|
222
|
+
const row = { i, role: 'user', ts };
|
|
223
|
+
if (results) row.tool_results = results;
|
|
224
|
+
if (errors) row.tool_errors = errors;
|
|
225
|
+
if (harnessRetries) row.harness_retries = harnessRetries;
|
|
226
|
+
const snip = snippetOf(firstResult, extra);
|
|
227
|
+
if (snip) row.snippet = snip;
|
|
228
|
+
detail.push(row);
|
|
229
|
+
}
|
|
216
230
|
i++;
|
|
217
231
|
}
|
|
218
232
|
}
|
|
@@ -258,4 +272,22 @@ function buildSessionDigest(entries, opts = {}) {
|
|
|
258
272
|
detail: boundedDetail,
|
|
259
273
|
};
|
|
260
274
|
}
|
|
261
|
-
|
|
275
|
+
// mergeModelUsage — fold one model_usage map into another, in place, so a
|
|
276
|
+
// session's tokens can be summed across the several transcripts one session
|
|
277
|
+
// actually writes (the interactive thread + its subagent subtree — task
|
|
278
|
+
// 1003435). PURE apart from mutating `target`, which the caller owns. The four
|
|
279
|
+
// buckets are the session_records model_usage shape, NOT the transcript
|
|
280
|
+
// *_input_tokens shape; keep them in step with buildSessionDigest above.
|
|
281
|
+
function mergeModelUsage(target, addition) {
|
|
282
|
+
for (const [model, u] of Object.entries(addition || {})) {
|
|
283
|
+
const bucket = target[model] || (target[model] =
|
|
284
|
+
{ input: 0, output: 0, cache_read: 0, cache_creation: 0 });
|
|
285
|
+
bucket.input += Number(u && u.input) || 0;
|
|
286
|
+
bucket.output += Number(u && u.output) || 0;
|
|
287
|
+
bucket.cache_read += Number(u && u.cache_read) || 0;
|
|
288
|
+
bucket.cache_creation += Number(u && u.cache_creation) || 0;
|
|
289
|
+
}
|
|
290
|
+
return target;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = { snippetOf, buildSessionDigest, mergeModelUsage, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL };
|
|
@@ -34,7 +34,7 @@ const fs = require('fs');
|
|
|
34
34
|
const os = require('os');
|
|
35
35
|
const path = require('path');
|
|
36
36
|
const { cliClient, loadSession, arg } = require('./cli-lib');
|
|
37
|
-
const { buildSessionDigest, collectEnvSecrets } = require('./ship');
|
|
37
|
+
const { buildSessionDigest, collectEnvSecrets, foldSubagentUsage } = require('./ship');
|
|
38
38
|
|
|
39
39
|
|
|
40
40
|
// Claude Code encodes a project transcript dir by replacing every non-alnum
|
|
@@ -144,6 +144,10 @@ async function main() {
|
|
|
144
144
|
if (session.token) extraSecrets.push(session.token);
|
|
145
145
|
|
|
146
146
|
const digest = buildSessionDigest(parseTranscript(transcriptPath), { extraSecrets });
|
|
147
|
+
// task 1003435: fold this session's subagents subtree into model_usage, the
|
|
148
|
+
// same way the on-ship upload does — the whole session's tokens, not just the
|
|
149
|
+
// interactive thread's.
|
|
150
|
+
foldSubagentUsage(digest, transcriptPath, sessionId);
|
|
147
151
|
|
|
148
152
|
// task_ids:[] — the server upsert UNIONs task_ids, so the on-ship task
|
|
149
153
|
// attribution is preserved; this end-of-session upload's job is to refresh
|
|
@@ -157,6 +161,12 @@ async function main() {
|
|
|
157
161
|
ended_at: digest.endedAt,
|
|
158
162
|
detail: digest.detail,
|
|
159
163
|
metrics: digest.metrics,
|
|
164
|
+
// model_usage is its OWN column, and the upsert overwrites it from the
|
|
165
|
+
// body — so omitting it here (as this hook did until task 1003435) wiped
|
|
166
|
+
// the map the on-ship upload had just stored, leaving the reward with
|
|
167
|
+
// nothing to price. It is the LAST upload of the session, so it must carry
|
|
168
|
+
// the fullest picture, not the emptiest.
|
|
169
|
+
model_usage: digest.metrics.model_usage || {},
|
|
160
170
|
};
|
|
161
171
|
const api = await cliClient({ allowUnauthenticated: true });
|
|
162
172
|
const r = await api.sessions.postSessions({ body: payload });
|
|
@@ -43,9 +43,16 @@ const {
|
|
|
43
43
|
// shared with session-digest.js. Re-exported below so this file's public surface
|
|
44
44
|
// and tests/session_records.mjs are unchanged.
|
|
45
45
|
const {
|
|
46
|
-
snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
|
|
46
|
+
snippetOf, buildSessionDigest, mergeModelUsage, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
|
|
47
47
|
} = require('./session-digest-build');
|
|
48
48
|
|
|
49
|
+
// The subagent subtree walker + the grader-transcript signature live in
|
|
50
|
+
// session-cost.js, which already prices that subtree for the SPEND ledger. The
|
|
51
|
+
// reward reads the same tokens, so it must use the SAME two definitions or the
|
|
52
|
+
// two ledgers describe different sessions (task 1003435). session-cost.js guards
|
|
53
|
+
// its own entry point behind require.main, so importing it is side-effect free.
|
|
54
|
+
const { listJsonlRecursive, looksLikeGraderTranscript } = require('./session-cost.js');
|
|
55
|
+
|
|
49
56
|
|
|
50
57
|
// classifyUploadSkip — PURE decision for whether the session upload must skip,
|
|
51
58
|
// and the clear, actionable reason why. Separated from I/O so it is unit-tested
|
|
@@ -109,6 +116,94 @@ function recordUploadSkip(taskId, { code, reason }, opts = {}) {
|
|
|
109
116
|
}
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
// parseJsonl — one transcript file's text into parsed entries, skipping any
|
|
120
|
+
// malformed line (best-effort, exactly like the rest of this path).
|
|
121
|
+
function parseJsonl(raw) {
|
|
122
|
+
const entries = [];
|
|
123
|
+
for (const line of String(raw || '').split('\n')) {
|
|
124
|
+
const t = line.trim();
|
|
125
|
+
if (!t) continue;
|
|
126
|
+
try {
|
|
127
|
+
entries.push(JSON.parse(t));
|
|
128
|
+
} catch (_) {
|
|
129
|
+
/* skip a malformed line — best-effort */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return entries;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// subagentModelUsage — the per-model TOKEN counts this session's ad-hoc
|
|
136
|
+
// subagents burned, so the cost-plus reward (ADR 0054) can see them.
|
|
137
|
+
//
|
|
138
|
+
// WHY (task 1003435): Agent/Task-tool spawns persist in a sibling subtree,
|
|
139
|
+
// <project-dir>/<session-id>/subagents/*.jsonl — never in the main transcript.
|
|
140
|
+
// session-cost.js already prices that subtree into a `session:subagent` cost_log
|
|
141
|
+
// row, so the SPEND ledger sees it; the digest read one file, so the REWARD did
|
|
142
|
+
// not. That paid a builder who follows CLAUDE.md's "delegate freely" less than
|
|
143
|
+
// one who did identical work in the main thread, and made the two ledgers
|
|
144
|
+
// disagree about the same session.
|
|
145
|
+
//
|
|
146
|
+
// Grader transcripts are excluded BY SIGNATURE, not by location: a grader run
|
|
147
|
+
// can nest inside a subagents/ dir, and its cost is already captured via the
|
|
148
|
+
// worker envelope, so pricing it here would double-count (the ADR 0028
|
|
149
|
+
// invariant). Each file goes through buildSessionDigest so the token semantics —
|
|
150
|
+
// dedup by message.id, skip `<synthetic>`, fold unlabeled turns into a priceable
|
|
151
|
+
// bucket — are the ONE implementation, never a second one that can drift.
|
|
152
|
+
function subagentModelUsage(sessionDir) {
|
|
153
|
+
const modelUsage = {};
|
|
154
|
+
let files = 0;
|
|
155
|
+
let tokens = 0;
|
|
156
|
+
let graderSkipped = 0;
|
|
157
|
+
for (const f of listJsonlRecursive(sessionDir)) {
|
|
158
|
+
if (looksLikeGraderTranscript(f)) { graderSkipped++; continue; }
|
|
159
|
+
let digest;
|
|
160
|
+
try {
|
|
161
|
+
digest = buildSessionDigest(parseJsonl(fs.readFileSync(f, 'utf8')), { usageOnly: true });
|
|
162
|
+
} catch (_) {
|
|
163
|
+
continue; // an unreadable subagent file must never fail a ship
|
|
164
|
+
}
|
|
165
|
+
if (!(digest.totalTokens > 0)) continue;
|
|
166
|
+
files++;
|
|
167
|
+
tokens += digest.totalTokens;
|
|
168
|
+
mergeModelUsage(modelUsage, digest.metrics.model_usage);
|
|
169
|
+
}
|
|
170
|
+
return { modelUsage, files, tokens, graderSkipped };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// foldSubagentUsage — add the session's subagent tokens to `digest`'s
|
|
174
|
+
// model_usage, IN PLACE, and record what was folded.
|
|
175
|
+
//
|
|
176
|
+
// model_usage ONLY, deliberately (task 1003435). model_usage is the reward's
|
|
177
|
+
// input, so it must describe the whole session. `detail`, `metrics.tokens`,
|
|
178
|
+
// `assistant_turns` and `total_tokens` stay the INTERACTIVE thread's record:
|
|
179
|
+
// they feed the BFG corpus + the inefficiency evaluator, which reason about one
|
|
180
|
+
// agent's turn-by-turn behaviour, and splicing N subagent transcripts into that
|
|
181
|
+
// per-turn story would distort the very signal it exists to measure (and bloat
|
|
182
|
+
// the payload). The three subagent_* counters are how a reader tells the two
|
|
183
|
+
// apart instead of inferring a discrepancy from a mismatch.
|
|
184
|
+
function foldSubagentUsage(digest, transcriptPath, sessionId) {
|
|
185
|
+
if (!digest || !digest.metrics || !transcriptPath || !sessionId) return digest;
|
|
186
|
+
// Path-traversal guard. The session id is interpolated into a filesystem path
|
|
187
|
+
// below, and what comes out of that path is priced into a payout — so a value
|
|
188
|
+
// carrying `../` could aim the walk at an arbitrary directory and fold
|
|
189
|
+
// fabricated token counts into the reward's input. Guarded HERE rather than at
|
|
190
|
+
// each call site: uploadSessionOnShip already screens the id before resolving
|
|
191
|
+
// the transcript, but the SessionEnd hook takes it straight off stdin, and any
|
|
192
|
+
// future caller would inherit the same hole. Same shape as the check in
|
|
193
|
+
// uploadSessionOnShip — a plain UUID-ish token, nothing else.
|
|
194
|
+
if (!/^[A-Za-z0-9_-]+$/.test(String(sessionId))) return digest;
|
|
195
|
+
const sessionDir = path.join(path.dirname(transcriptPath), sessionId);
|
|
196
|
+
const sub = subagentModelUsage(sessionDir);
|
|
197
|
+
if (sub.files === 0 && sub.graderSkipped === 0) return digest;
|
|
198
|
+
digest.metrics.model_usage = mergeModelUsage(
|
|
199
|
+
digest.metrics.model_usage || {}, sub.modelUsage
|
|
200
|
+
);
|
|
201
|
+
digest.metrics.subagent_files = sub.files;
|
|
202
|
+
digest.metrics.subagent_tokens = sub.tokens;
|
|
203
|
+
digest.metrics.subagent_grader_skipped = sub.graderSkipped;
|
|
204
|
+
return digest;
|
|
205
|
+
}
|
|
206
|
+
|
|
112
207
|
async function uploadSessionOnShip(taskId) {
|
|
113
208
|
try {
|
|
114
209
|
const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
|
|
@@ -135,17 +230,7 @@ async function uploadSessionOnShip(taskId) {
|
|
|
135
230
|
recordUploadSkip(taskId, decision);
|
|
136
231
|
return null;
|
|
137
232
|
}
|
|
138
|
-
const
|
|
139
|
-
const entries = [];
|
|
140
|
-
for (const line of raw.split('\n')) {
|
|
141
|
-
const t = line.trim();
|
|
142
|
-
if (!t) continue;
|
|
143
|
-
try {
|
|
144
|
-
entries.push(JSON.parse(t));
|
|
145
|
-
} catch (_) {
|
|
146
|
-
/* skip a malformed line — best-effort */
|
|
147
|
-
}
|
|
148
|
-
}
|
|
233
|
+
const entries = parseJsonl(fs.readFileSync(transcriptPath, 'utf8'));
|
|
149
234
|
// Dynamic secrets to scrub: every secret-shaped env VALUE (Apple/MAC_* CI
|
|
150
235
|
// creds, a baked GDS/DO token, GITHUB_TOKEN, ANTHROPIC_API_KEY, …) plus this
|
|
151
236
|
// run's live GDS session token. Defense in depth on top of the pattern +
|
|
@@ -157,6 +242,10 @@ async function uploadSessionOnShip(taskId) {
|
|
|
157
242
|
const s = await loadSession();
|
|
158
243
|
if (s && s.token) extraSecrets.push(s.token);
|
|
159
244
|
const digest = buildSessionDigest(entries, { extraSecrets });
|
|
245
|
+
// task 1003435: the interactive transcript is only part of what this session
|
|
246
|
+
// spent — fold the subagents subtree into model_usage so the cost-plus reward
|
|
247
|
+
// prices the same tokens the cost ledger already bills.
|
|
248
|
+
foldSubagentUsage(digest, transcriptPath, sessionId);
|
|
160
249
|
// task 1270: surface the session usage so the completion card can show
|
|
161
250
|
// Claude tokens + an estimated cost. Returned even when the POST below fails
|
|
162
251
|
// (we already have the digest) — the card is best-effort either way.
|
|
@@ -215,4 +304,5 @@ module.exports = {
|
|
|
215
304
|
classifyUploadSkip,
|
|
216
305
|
recordUploadSkip,
|
|
217
306
|
uploadSessionOnShip,
|
|
307
|
+
foldSubagentUsage,
|
|
218
308
|
};
|
package/scripts/gds/ship.js
CHANGED
|
@@ -99,7 +99,9 @@ const {
|
|
|
99
99
|
resolveInstanceBase,
|
|
100
100
|
shouldBailOnFetchFailure,
|
|
101
101
|
} = require('./ship-deploy-target.js');
|
|
102
|
-
const {
|
|
102
|
+
const {
|
|
103
|
+
classifyUploadSkip, recordUploadSkip, foldSubagentUsage,
|
|
104
|
+
} = require('./ship-session-upload.js');
|
|
103
105
|
const {
|
|
104
106
|
classifyLandOutcome,
|
|
105
107
|
formatPublishFailure,
|
|
@@ -176,6 +178,10 @@ module.exports = {
|
|
|
176
178
|
// secret corpus test (tests/scrubber_corpus.mjs) can exercise each layer + tune.
|
|
177
179
|
shannonEntropy, looksHighEntropySecret, redactHighEntropy, collectEnvSecrets,
|
|
178
180
|
classifyUploadSkip, recordUploadSkip,
|
|
181
|
+
// task 1003435: the subagents-subtree fold, shared with the SessionEnd hook —
|
|
182
|
+
// both uploads must send the SAME whole-session model_usage or whichever runs
|
|
183
|
+
// last decides what the builder is paid for.
|
|
184
|
+
foldSubagentUsage,
|
|
179
185
|
// #678: re-grade path + the grade-step helpers it shares with shipMain.
|
|
180
186
|
// Exporting these formalizes learning #43 (which had to "replicate ~10
|
|
181
187
|
// lines" because runSubagentGrader wasn't reachable).
|
package/src/module-api.js
CHANGED
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.444'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/credit_grant.mjs
CHANGED
|
@@ -588,6 +588,242 @@ await test('cost-plus-only cascade: a 0 per-task credit → idea-promotion slice
|
|
|
588
588
|
assert.equal(client.queries.length, 0, 'no lineage lookup, no bonus INSERT when the base credit is 0');
|
|
589
589
|
});
|
|
590
590
|
|
|
591
|
+
// =========================================================================
|
|
592
|
+
// (7) task 1003435 — a PARALLEL session (one claim, several subagents).
|
|
593
|
+
//
|
|
594
|
+
// The cost ledger has always billed a session's subagents subtree in its own
|
|
595
|
+
// `session:subagent` cost_log row, but model_usage — the reward's only input —
|
|
596
|
+
// was built from ONE file, the interactive transcript. So a builder who followed
|
|
597
|
+
// CLAUDE.md's "delegate freely" moved most of a session's real spend into a
|
|
598
|
+
// bucket the reward could not see, and the two ledgers described different
|
|
599
|
+
// sessions.
|
|
600
|
+
//
|
|
601
|
+
// The acceptance test is that RECONCILIATION: what the reward prices must equal
|
|
602
|
+
// what the ledger bills, within independent-rounding epsilon. It runs both real
|
|
603
|
+
// pricing paths over the same on-disk fixture rather than asserting a hand-typed
|
|
604
|
+
// number on either side, so a change to one that is not made to the other reds.
|
|
605
|
+
// =========================================================================
|
|
606
|
+
|
|
607
|
+
const fs = require('node:fs');
|
|
608
|
+
const os = require('node:os');
|
|
609
|
+
const nodePath = require('node:path');
|
|
610
|
+
const { priceTranscript, priceSubagents, buildCostRows } = require('../scripts/gds/session-cost.js');
|
|
611
|
+
const { buildSessionDigest } = require('../scripts/gds/session-digest-build.js');
|
|
612
|
+
const { foldSubagentUsage } = require('../scripts/gds/ship-session-upload.js');
|
|
613
|
+
const { priceModelUsage } = require('../src/bongos/llm-pricing.js');
|
|
614
|
+
|
|
615
|
+
const PARALLEL_SID = 'sess-parallel';
|
|
616
|
+
|
|
617
|
+
// One assistant transcript line. `id` is the API call's identity — two lines
|
|
618
|
+
// sharing one are ONE call, which is what makes the dedup assertion below real.
|
|
619
|
+
function asstLine(id, model, usage) {
|
|
620
|
+
return JSON.stringify({
|
|
621
|
+
type: 'assistant',
|
|
622
|
+
timestamp: '2026-08-30T00:00:00.000Z',
|
|
623
|
+
message: { id, model, usage, content: [{ type: 'text', text: 'work' }] },
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
function userLine(text) {
|
|
627
|
+
return JSON.stringify({
|
|
628
|
+
type: 'user',
|
|
629
|
+
timestamp: '2026-08-30T00:00:00.000Z',
|
|
630
|
+
message: { role: 'user', content: text },
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
function parseJsonlText(raw) {
|
|
634
|
+
return raw.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// A session that spawned three subagents, one of them a grader. Mirrors the real
|
|
638
|
+
// on-disk layout: the interactive transcript is a FILE, and the subagents live in
|
|
639
|
+
// a sibling DIRECTORY named for the session id.
|
|
640
|
+
function makeParallelFixture() {
|
|
641
|
+
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'otb-parallel-'));
|
|
642
|
+
const mainPath = nodePath.join(dir, `${PARALLEL_SID}.jsonl`);
|
|
643
|
+
// opus: 150k in / 30k out / 1.5M read / 40k write = $0.75+$0.75+$0.75+$0.25 = $2.50.
|
|
644
|
+
// m1 is written twice (one API call, two content blocks) — priced ONCE.
|
|
645
|
+
const dup = asstLine('m1', 'claude-opus-4-8', {
|
|
646
|
+
input_tokens: 100_000, output_tokens: 20_000,
|
|
647
|
+
cache_read_input_tokens: 1_000_000, cache_creation_input_tokens: 40_000,
|
|
648
|
+
});
|
|
649
|
+
fs.writeFileSync(mainPath, [
|
|
650
|
+
userLine('ship task 1003435'),
|
|
651
|
+
dup,
|
|
652
|
+
dup,
|
|
653
|
+
asstLine('m2', 'claude-opus-4-8', {
|
|
654
|
+
input_tokens: 50_000, output_tokens: 10_000,
|
|
655
|
+
cache_read_input_tokens: 500_000, cache_creation_input_tokens: 0,
|
|
656
|
+
}),
|
|
657
|
+
].join('\n'));
|
|
658
|
+
|
|
659
|
+
const subagents = nodePath.join(dir, PARALLEL_SID, 'subagents');
|
|
660
|
+
const nested = nodePath.join(subagents, 'nested');
|
|
661
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
662
|
+
// opus subagent: 200k in / 40k out / 2M read = $1.00+$1.00+$1.00 = $3.00
|
|
663
|
+
fs.writeFileSync(nodePath.join(subagents, 'agent-a.jsonl'), [
|
|
664
|
+
userLine('audit the pricing path'),
|
|
665
|
+
asstLine('a1', 'claude-opus-4-8', {
|
|
666
|
+
input_tokens: 200_000, output_tokens: 40_000, cache_read_input_tokens: 2_000_000,
|
|
667
|
+
}),
|
|
668
|
+
].join('\n'));
|
|
669
|
+
// sonnet subagent, one level deeper — the walk is recursive:
|
|
670
|
+
// 100k in / 20k out / 1M read = $0.20+$0.20+$0.20 = $0.60
|
|
671
|
+
fs.writeFileSync(nodePath.join(nested, 'agent-b.jsonl'), [
|
|
672
|
+
userLine('read the ledger rows'),
|
|
673
|
+
asstLine('b1', 'claude-sonnet-5', {
|
|
674
|
+
input_tokens: 100_000, output_tokens: 20_000, cache_read_input_tokens: 1_000_000,
|
|
675
|
+
}),
|
|
676
|
+
].join('\n'));
|
|
677
|
+
// A GRADER worker nested in the subagents dir. Its $5.00 is already captured via
|
|
678
|
+
// the worker envelope, so pricing it here would double-count (ADR 0028).
|
|
679
|
+
fs.writeFileSync(nodePath.join(nested, 'grader-worker.jsonl'), [
|
|
680
|
+
userLine('You are the **Hacker** — a specialist adversarial subagent. Find the holes.'),
|
|
681
|
+
asstLine('g1', 'claude-opus-4-8', { input_tokens: 1_000_000 }),
|
|
682
|
+
].join('\n'));
|
|
683
|
+
|
|
684
|
+
return { dir, mainPath, sessionDir: nodePath.join(dir, PARALLEL_SID) };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const fixture = makeParallelFixture();
|
|
688
|
+
const INTERACTIVE_USD = 2.50;
|
|
689
|
+
const SUBAGENT_USD = 3.60;
|
|
690
|
+
const SESSION_USD = INTERACTIVE_USD + SUBAGENT_USD; // $6.10
|
|
691
|
+
// Each cost_log row is rounded to 4dp independently and priceModelUsage rounds
|
|
692
|
+
// its own total, so the two sides may disagree by a rounding step per row.
|
|
693
|
+
const EPSILON = 0.0002;
|
|
694
|
+
|
|
695
|
+
// The digest the ship uploads for this session, subagents folded in.
|
|
696
|
+
function parallelDigest() {
|
|
697
|
+
const digest = buildSessionDigest(parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8')));
|
|
698
|
+
foldSubagentUsage(digest, fixture.mainPath, PARALLEL_SID);
|
|
699
|
+
return digest;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
await test('1003435 the cost ledger bills interactive + subagent, grader excluded', () => {
|
|
703
|
+
const inter = priceTranscript(fixture.mainPath);
|
|
704
|
+
const sub = priceSubagents(fixture.sessionDir);
|
|
705
|
+
assert.equal(Math.round(inter.total * 100) / 100, INTERACTIVE_USD, 'm1 priced once, not twice');
|
|
706
|
+
assert.equal(Math.round(sub.subagentTotal * 100) / 100, SUBAGENT_USD);
|
|
707
|
+
assert.equal(sub.subagentFiles, 2);
|
|
708
|
+
assert.equal(sub.graderSkipped, 1, 'the grader worker is skipped by signature');
|
|
709
|
+
const built = buildCostRows({ sessionId: PARALLEL_SID, inter, sub });
|
|
710
|
+
assert.equal(built.rows.length, 2, 'one :interactive row + one :subagent row');
|
|
711
|
+
assert.equal(Math.round(built.rowsTotal * 100) / 100, SESSION_USD);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
await test('1003435 model_usage now carries the subagents subtree, grader excluded', () => {
|
|
715
|
+
const digest = parallelDigest();
|
|
716
|
+
const models = Object.keys(digest.metrics.model_usage).sort();
|
|
717
|
+
assert.deepEqual(models, ['claude-opus-4-8', 'claude-sonnet-5'],
|
|
718
|
+
'the sonnet subagent reached model_usage, which only the fold can do');
|
|
719
|
+
assert.equal(digest.metrics.subagent_files, 2);
|
|
720
|
+
assert.equal(digest.metrics.subagent_grader_skipped, 1);
|
|
721
|
+
// The grader's 1M opus input tokens must be in NEITHER ledger.
|
|
722
|
+
assert.equal(digest.metrics.model_usage['claude-opus-4-8'].input, 350_000,
|
|
723
|
+
'150k interactive + 200k subagent — the grader\'s 1M is not counted');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
await test('1003435 detail + total_tokens stay the INTERACTIVE thread\'s record', () => {
|
|
727
|
+
const interactiveOnly = buildSessionDigest(
|
|
728
|
+
parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8'))
|
|
729
|
+
);
|
|
730
|
+
const digest = parallelDigest();
|
|
731
|
+
// The fold is model_usage ONLY: detail and the turn/token metrics feed the BFG
|
|
732
|
+
// corpus + inefficiency evaluator, which reason about ONE agent's turn-by-turn
|
|
733
|
+
// behaviour. Splicing N subagent transcripts into that would distort the very
|
|
734
|
+
// signal they measure, so the subagent_* counters carry the difference instead.
|
|
735
|
+
assert.equal(digest.detail.length, interactiveOnly.detail.length);
|
|
736
|
+
assert.equal(digest.totalTokens, interactiveOnly.totalTokens);
|
|
737
|
+
assert.equal(digest.metrics.assistant_turns, interactiveOnly.metrics.assistant_turns);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
await test('1003435 ACCEPTANCE: the reward reconciles with the session\'s cost_log rows', () => {
|
|
741
|
+
const inter = priceTranscript(fixture.mainPath);
|
|
742
|
+
const sub = priceSubagents(fixture.sessionDir);
|
|
743
|
+
const ledgerUsd = buildCostRows({ sessionId: PARALLEL_SID, inter, sub }).rowsTotal;
|
|
744
|
+
const rewardUsd = priceModelUsage(parallelDigest().metrics.model_usage).true_cost_usd;
|
|
745
|
+
assert.ok(Math.abs(rewardUsd - ledgerUsd) <= EPSILON,
|
|
746
|
+
`reward priced $${rewardUsd} but the ledger billed $${ledgerUsd}`);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
await test('1003435 a parallel session is PAID for its subagents', async () => {
|
|
750
|
+
const client = fakeClient();
|
|
751
|
+
client.enqueue(NO_PRIOR);
|
|
752
|
+
client.enqueue([{ id: 99 }]);
|
|
753
|
+
const digest = parallelDigest();
|
|
754
|
+
const res = await awardSessionTokenReward(
|
|
755
|
+
client, 7, PARALLEL_SID, digest.metrics.model_usage, digest.totalTokens
|
|
756
|
+
);
|
|
757
|
+
assert.equal(res.awarded, true);
|
|
758
|
+
assert.equal(res.trueCostUsd, SESSION_USD);
|
|
759
|
+
assert.equal(res.drachmae, Math.round(SESSION_USD * 1.20)); // $6.10 × 1.20 = 7.32 → 7
|
|
760
|
+
// The bug this closes: interactive-only priced the same session at $2.50 → 3.
|
|
761
|
+
const before = computeSessionTokenReward(INTERACTIVE_USD).drachmae;
|
|
762
|
+
assert.ok(res.drachmae > before, `subagent-blind reward was ${before}, now ${res.drachmae}`);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
await test('1003435 a re-ship of the same session is still a no-op (one award per session)', async () => {
|
|
766
|
+
const digest = parallelDigest();
|
|
767
|
+
const client = fakeClient();
|
|
768
|
+
// The watermark already holds everything this session is worth.
|
|
769
|
+
client.enqueue(ledger(Math.round(SESSION_USD * 1.20), 0));
|
|
770
|
+
const res = await awardSessionTokenReward(
|
|
771
|
+
client, 7, PARALLEL_SID, digest.metrics.model_usage, digest.totalTokens
|
|
772
|
+
);
|
|
773
|
+
assert.equal(res.awarded, false);
|
|
774
|
+
assert.equal(res.drachmae, 0);
|
|
775
|
+
assert.equal(client.queries.length, 1, 'SELECT only — a second upload must not pay again');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await test('1003435 a traversing session id folds NOTHING (the walk feeds a payout)', () => {
|
|
779
|
+
// The id is interpolated into a filesystem path whose contents get priced, so
|
|
780
|
+
// a `../` value could aim the walk at another directory and fold fabricated
|
|
781
|
+
// tokens into the reward. The on-ship caller screens the id before resolving
|
|
782
|
+
// the transcript; the SessionEnd hook takes it straight off stdin — so the
|
|
783
|
+
// guard lives in foldSubagentUsage, where every caller gets it.
|
|
784
|
+
const entries = parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8'));
|
|
785
|
+
const clean = buildSessionDigest(entries).metrics.model_usage;
|
|
786
|
+
for (const hostile of ['../' + PARALLEL_SID, `${PARALLEL_SID}/..`, 'a/../../etc', '']) {
|
|
787
|
+
const digest = foldSubagentUsage(buildSessionDigest(entries), fixture.mainPath, hostile);
|
|
788
|
+
assert.deepEqual(digest.metrics.model_usage, clean, `folded for id ${JSON.stringify(hostile)}`);
|
|
789
|
+
assert.equal(digest.metrics.subagent_files, undefined);
|
|
790
|
+
}
|
|
791
|
+
// …and the legitimate id still folds, so the guard is not just refusing everything.
|
|
792
|
+
const ok = foldSubagentUsage(buildSessionDigest(entries), fixture.mainPath, PARALLEL_SID);
|
|
793
|
+
assert.equal(ok.metrics.subagent_files, 2);
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
await test('1003435 usageOnly changes what is BUILT, never what is COUNTED', () => {
|
|
797
|
+
// The fold reuses buildSessionDigest so the token semantics — dedup by
|
|
798
|
+
// message.id, skip <synthetic>, fold unlabeled turns — have ONE home. usageOnly
|
|
799
|
+
// only drops the per-turn rows it would discard (and their snippet scrubbing,
|
|
800
|
+
// which the SessionEnd watchdog gives a hard time budget). If it ever changed a
|
|
801
|
+
// count, the reward and the ledger would quietly diverge again.
|
|
802
|
+
const entries = parseJsonlText(fs.readFileSync(fixture.mainPath, 'utf8'));
|
|
803
|
+
const full = buildSessionDigest(entries);
|
|
804
|
+
const lean = buildSessionDigest(entries, { usageOnly: true });
|
|
805
|
+
assert.deepEqual(lean.metrics.model_usage, full.metrics.model_usage);
|
|
806
|
+
assert.equal(lean.totalTokens, full.totalTokens);
|
|
807
|
+
assert.equal(lean.metrics.assistant_turns, full.metrics.assistant_turns);
|
|
808
|
+
assert.equal(lean.metrics.api_calls, full.metrics.api_calls);
|
|
809
|
+
assert.deepEqual(lean.detail, [], 'the detail rows are what usageOnly skips');
|
|
810
|
+
assert.ok(full.detail.length > 0);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
await test('1003435 a session with no subagents is untouched (no regression)', () => {
|
|
814
|
+
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'otb-solo-'));
|
|
815
|
+
const mainPath = nodePath.join(dir, 'sess-solo.jsonl');
|
|
816
|
+
fs.writeFileSync(mainPath, asstLine('s1', 'claude-opus-4-8', { input_tokens: 110_000 }));
|
|
817
|
+
const entries = parseJsonlText(fs.readFileSync(mainPath, 'utf8'));
|
|
818
|
+
const before = buildSessionDigest(entries);
|
|
819
|
+
const after = foldSubagentUsage(buildSessionDigest(entries), mainPath, 'sess-solo');
|
|
820
|
+
assert.deepEqual(after.metrics.model_usage, before.metrics.model_usage);
|
|
821
|
+
assert.equal(after.metrics.subagent_files, undefined, 'no counters when there is nothing to count');
|
|
822
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
fs.rmSync(fixture.dir, { recursive: true, force: true });
|
|
826
|
+
|
|
591
827
|
// -------------------------------------------------------------------------
|
|
592
828
|
// runner summary
|
|
593
829
|
// -------------------------------------------------------------------------
|