@bongos/core 1.19.317 → 1.19.318
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 +28 -18
- package/modules/economy/cost.js +84 -19
- package/modules/lifecycle/publish-reconciler.js +7 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/scripts/gds/session-cost.js +16 -0
- package/scripts/gds/session-digest-build.js +261 -0
- package/scripts/gds/ship.js +7 -198
- package/src/module-api.js +3 -1
- package/tests/publish_reconciler.mjs +38 -0
- package/tests/session_cost.mjs +64 -0
- package/tests/session_cost_row_raise.mjs +139 -0
- package/tests/session_records.mjs +72 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
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.318",
|
|
6
|
+
"core_contract": "1.19.318",
|
|
7
|
+
"source_commit": "01d0a25617f7ca9212b310552724d7eb8c183a32",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-23T22:
|
|
9
|
+
"built_at": "2026-08-23T22:11:19.148Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 323,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1438,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1783,
|
|
20
|
+
"tree_sha256": "24fa1a6a45d9a8ad89550dcdf06b82e5aeffa56776dcd6666a1be6524d9d4a6e",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2987,7 +2987,7 @@
|
|
|
2987
2987
|
{
|
|
2988
2988
|
"path": "modules/economy/cost.js",
|
|
2989
2989
|
"mode": "0000644",
|
|
2990
|
-
"sha256": "
|
|
2990
|
+
"sha256": "01bc7725fe16388971fd4def2544d0de9af6b3a522973df7ef86386737933d40"
|
|
2991
2991
|
},
|
|
2992
2992
|
{
|
|
2993
2993
|
"path": "modules/economy/credits.js",
|
|
@@ -3972,7 +3972,7 @@
|
|
|
3972
3972
|
{
|
|
3973
3973
|
"path": "modules/lifecycle/publish-reconciler.js",
|
|
3974
3974
|
"mode": "0000644",
|
|
3975
|
-
"sha256": "
|
|
3975
|
+
"sha256": "28c61d55fe2b492f0fce4c99b60e88f0cbeb542c47c04482558818d65628e0d7"
|
|
3976
3976
|
},
|
|
3977
3977
|
{
|
|
3978
3978
|
"path": "modules/lifecycle/routes/analytics.js",
|
|
@@ -4642,12 +4642,12 @@
|
|
|
4642
4642
|
{
|
|
4643
4643
|
"path": "package-lock.json",
|
|
4644
4644
|
"mode": "0000644",
|
|
4645
|
-
"sha256": "
|
|
4645
|
+
"sha256": "509e4a76d5eb6f21fd8c926c6bf7ec9f1938d62ed4055558e2d3911858166b38"
|
|
4646
4646
|
},
|
|
4647
4647
|
{
|
|
4648
4648
|
"path": "package.json",
|
|
4649
4649
|
"mode": "0000644",
|
|
4650
|
-
"sha256": "
|
|
4650
|
+
"sha256": "5e3c53bb9627f4dcf747e5e7bc8fa7fb0e136554f04d96081137ca1bc05654ad"
|
|
4651
4651
|
},
|
|
4652
4652
|
{
|
|
4653
4653
|
"path": "public-docs/index.html",
|
|
@@ -5672,7 +5672,7 @@
|
|
|
5672
5672
|
{
|
|
5673
5673
|
"path": "scripts/gds/run-unit-tests.js",
|
|
5674
5674
|
"mode": "0000644",
|
|
5675
|
-
"sha256": "
|
|
5675
|
+
"sha256": "c96e50e533f4e20dba91c6a26ac6efd2c6ad33ffcdc856eeb46af21a17e67975"
|
|
5676
5676
|
},
|
|
5677
5677
|
{
|
|
5678
5678
|
"path": "scripts/gds/sandbox-stage.js",
|
|
@@ -5832,7 +5832,12 @@
|
|
|
5832
5832
|
{
|
|
5833
5833
|
"path": "scripts/gds/session-cost.js",
|
|
5834
5834
|
"mode": "0000644",
|
|
5835
|
-
"sha256": "
|
|
5835
|
+
"sha256": "fdf3b9d8e0089078da550ac65d0c012583cc21103a668deecf481383a73c9da1"
|
|
5836
|
+
},
|
|
5837
|
+
{
|
|
5838
|
+
"path": "scripts/gds/session-digest-build.js",
|
|
5839
|
+
"mode": "0000644",
|
|
5840
|
+
"sha256": "95e5c21533f67588fd02fae311cef8985d3eee58ad3217fcb6e9998e1f4da965"
|
|
5836
5841
|
},
|
|
5837
5842
|
{
|
|
5838
5843
|
"path": "scripts/gds/session-digest.js",
|
|
@@ -5867,7 +5872,7 @@
|
|
|
5867
5872
|
{
|
|
5868
5873
|
"path": "scripts/gds/ship.js",
|
|
5869
5874
|
"mode": "0000644",
|
|
5870
|
-
"sha256": "
|
|
5875
|
+
"sha256": "8f2c0ca63abae081cccfa96059ae07b008bd108f85866a248075cb4a1b7e025e"
|
|
5871
5876
|
},
|
|
5872
5877
|
{
|
|
5873
5878
|
"path": "scripts/gds/skill-preflight.js",
|
|
@@ -6327,7 +6332,7 @@
|
|
|
6327
6332
|
{
|
|
6328
6333
|
"path": "src/module-api.js",
|
|
6329
6334
|
"mode": "0000644",
|
|
6330
|
-
"sha256": "
|
|
6335
|
+
"sha256": "af9d10cbaf1d6320df892f4d0e1403c0f5f1bb6e8c314695e89477690ba7fc85"
|
|
6331
6336
|
},
|
|
6332
6337
|
{
|
|
6333
6338
|
"path": "src/module-loader/catalog.js",
|
|
@@ -8287,7 +8292,7 @@
|
|
|
8287
8292
|
{
|
|
8288
8293
|
"path": "tests/publish_reconciler.mjs",
|
|
8289
8294
|
"mode": "0000644",
|
|
8290
|
-
"sha256": "
|
|
8295
|
+
"sha256": "5a9ea0bf7b4d776a6f9036b8bd060fef76335533274ed748e9b9d742da6960d3"
|
|
8291
8296
|
},
|
|
8292
8297
|
{
|
|
8293
8298
|
"path": "tests/rank_gate.mjs",
|
|
@@ -8507,7 +8512,12 @@
|
|
|
8507
8512
|
{
|
|
8508
8513
|
"path": "tests/session_cost.mjs",
|
|
8509
8514
|
"mode": "0000644",
|
|
8510
|
-
"sha256": "
|
|
8515
|
+
"sha256": "5f330f79eee8c39dc589d892ba5fa6835906e7b61ff5fe52aec3285721e84876"
|
|
8516
|
+
},
|
|
8517
|
+
{
|
|
8518
|
+
"path": "tests/session_cost_row_raise.mjs",
|
|
8519
|
+
"mode": "0000644",
|
|
8520
|
+
"sha256": "74a106432222df7d8e845ec409bd2e1d49aff18bf80cdeff032cfe91b1d6dc3f"
|
|
8511
8521
|
},
|
|
8512
8522
|
{
|
|
8513
8523
|
"path": "tests/session_digest.mjs",
|
|
@@ -8527,7 +8537,7 @@
|
|
|
8527
8537
|
{
|
|
8528
8538
|
"path": "tests/session_records.mjs",
|
|
8529
8539
|
"mode": "0000644",
|
|
8530
|
-
"sha256": "
|
|
8540
|
+
"sha256": "8c62fe0bec07aea4c30f84d296eb7b40c9550606aacf6cb63f9644ca34f12069"
|
|
8531
8541
|
},
|
|
8532
8542
|
{
|
|
8533
8543
|
"path": "tests/session_reward_on_land.mjs",
|
package/modules/economy/cost.js
CHANGED
|
@@ -105,23 +105,51 @@ async function logCost({
|
|
|
105
105
|
outputUsd = null,
|
|
106
106
|
// task #385 / migration 181: which skill invocation incurred this cost.
|
|
107
107
|
skillName = null,
|
|
108
|
+
// task 1003217 — opt-in MONOTONIC RAISE on (source, source_ref) conflict.
|
|
109
|
+
//
|
|
110
|
+
// Default false, so every existing caller keeps the plain insert-once
|
|
111
|
+
// idempotency it relies on. The session reconciler below sets it true: a
|
|
112
|
+
// long session's digest GROWS on every ship, and a cost row written from an
|
|
113
|
+
// early digest must be allowed to catch up, or the session is billed at its
|
|
114
|
+
// first hour forever (the bug this task fixes).
|
|
115
|
+
//
|
|
116
|
+
// Safe because the update SETS an absolute amount rather than adding to one:
|
|
117
|
+
// re-running with the same figure is a no-op, so there is no double-count
|
|
118
|
+
// path, and the WHERE guard means a row can only ever move UP.
|
|
119
|
+
raiseOnConflict = false,
|
|
108
120
|
}) {
|
|
109
121
|
// V3.R11 (#91): builder_id is the per-builder attribution column added by
|
|
110
122
|
// migration 046. Routes default it to the authed builder, so the only
|
|
111
123
|
// legitimately-null builder_id is for historical rows or system-injected
|
|
112
124
|
// costs (infra, domain, etc.) that don't belong to any one person.
|
|
125
|
+
// Monotonic raise (opt-in) vs the historical insert-once. `recorded_at` moves
|
|
126
|
+
// with the amount so the reconciler's "digest is newer than the priced row"
|
|
127
|
+
// predicate settles instead of re-firing every sweep.
|
|
128
|
+
const conflictAction = raiseOnConflict
|
|
129
|
+
? `DO UPDATE SET
|
|
130
|
+
amount_usd = EXCLUDED.amount_usd,
|
|
131
|
+
description = EXCLUDED.description,
|
|
132
|
+
fresh_input_usd = EXCLUDED.fresh_input_usd,
|
|
133
|
+
cache_read_usd = EXCLUDED.cache_read_usd,
|
|
134
|
+
cache_write_usd = EXCLUDED.cache_write_usd,
|
|
135
|
+
output_usd = EXCLUDED.output_usd,
|
|
136
|
+
recorded_at = EXCLUDED.recorded_at
|
|
137
|
+
WHERE EXCLUDED.amount_usd > cost_log.amount_usd`
|
|
138
|
+
: 'DO NOTHING';
|
|
113
139
|
const { rows } = await pool.query(
|
|
114
140
|
`INSERT INTO cost_log (task_id, amount_usd, category, description, source, source_ref, recorded_at, builder_id,
|
|
115
141
|
fresh_input_usd, cache_read_usd, cache_write_usd, output_usd, skill_name)
|
|
116
142
|
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, now()), $8, $9, $10, $11, $12, $13)
|
|
117
143
|
ON CONFLICT (source, source_ref)
|
|
118
144
|
WHERE source IS NOT NULL AND source_ref IS NOT NULL
|
|
119
|
-
|
|
145
|
+
${conflictAction}
|
|
120
146
|
RETURNING id, recorded_at`,
|
|
121
147
|
[taskId, amountUsd, category, description, source, sourceRef, recordedAt, builderId,
|
|
122
148
|
freshInputUsd, cacheReadUsd, cacheWriteUsd, outputUsd, skillName || null]
|
|
123
149
|
);
|
|
124
|
-
|
|
150
|
+
// null when the conflict clause skipped the write (DO NOTHING, or a raise that
|
|
151
|
+
// would not have moved the row upward).
|
|
152
|
+
return rows[0] ?? null;
|
|
125
153
|
}
|
|
126
154
|
|
|
127
155
|
// task 1366 (System 1) — server-side session-cost reconciler.
|
|
@@ -144,25 +172,45 @@ async function logCost({
|
|
|
144
172
|
// first crack at posting (avoids racing it). 15m default.
|
|
145
173
|
// - limit : per-sweep cap (runaway guard; there are normally few).
|
|
146
174
|
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
175
|
+
// A session the CLIENT split into interactive + subagent rows (session_id ||
|
|
176
|
+
// ':sub', task 1344) is skipped entirely — we hold only the combined
|
|
177
|
+
// model_usage, so we cannot reproduce that split and should defer to it.
|
|
178
|
+
//
|
|
179
|
+
// task 1003217 — WHY THIS NO LONGER SKIPS EVERY PRICED SESSION. It used to
|
|
180
|
+
// exclude any session that had a cost row at all. But `session_records` is
|
|
181
|
+
// upserted on EVERY ship, so a session that ships twelve tasks has a digest
|
|
182
|
+
// from its FIRST ship, and this sweep priced that partial digest ~15 minutes
|
|
183
|
+
// later and then never looked again — logCost's DO NOTHING silently dropped
|
|
184
|
+
// every subsequent, larger figure. Measured on prod: a 9.3-hour session was
|
|
185
|
+
// billed at its first 56 minutes, and across four audited sessions the ledger
|
|
186
|
+
// captured 14%, 14%, 16% and 54% of real spend. Now a session is revisited
|
|
187
|
+
// while its digest is NEWER than the row we priced from it, and logCost raises
|
|
188
|
+
// the row monotonically. `recorded_at` advances on each raise, so a settled
|
|
189
|
+
// session stops matching and the sweep converges.
|
|
190
|
+
//
|
|
191
|
+
// Historical rows are NOT re-priced downward: llm-pricing.js is explicit that a
|
|
192
|
+
// paid row records what was charged, and ADR 0054 paid credits from it. The
|
|
193
|
+
// raise is upward-only, so pre-fix inflated rows stay as they are — expect a
|
|
194
|
+
// visible step change in cost-per-ship at deploy rather than a rewritten past.
|
|
195
|
+
// Returns { scanned, reconciled, raised, totalUsd, unknownModels } for logging.
|
|
153
196
|
async function reconcileSessionCosts({ olderThanMinutes = 15, limit = 500 } = {}) {
|
|
154
197
|
const mins = Number.isFinite(Number(olderThanMinutes)) ? Number(olderThanMinutes) : 15;
|
|
155
198
|
const lim = Number.isFinite(Number(limit)) ? Number(limit) : 500;
|
|
156
199
|
const { rows } = await pool.query(
|
|
157
|
-
`SELECT sr.session_id, sr.builder_id, sr.model_usage
|
|
200
|
+
`SELECT sr.session_id, sr.builder_id, sr.model_usage,
|
|
201
|
+
c.amount_usd AS prior_usd
|
|
158
202
|
FROM session_records sr
|
|
203
|
+
LEFT JOIN cost_log c
|
|
204
|
+
ON c.source = 'session:interactive'
|
|
205
|
+
AND c.source_ref = sr.session_id
|
|
159
206
|
WHERE sr.model_usage IS NOT NULL
|
|
160
207
|
AND sr.uploaded_at < now() - ($1 || ' minutes')::interval
|
|
161
208
|
AND NOT EXISTS (
|
|
162
|
-
SELECT 1 FROM cost_log
|
|
163
|
-
WHERE
|
|
164
|
-
OR c.source_ref = sr.session_id || ':sub'
|
|
209
|
+
SELECT 1 FROM cost_log s
|
|
210
|
+
WHERE s.source_ref = sr.session_id || ':sub'
|
|
165
211
|
)
|
|
212
|
+
-- Never priced, or the digest has grown since we priced it.
|
|
213
|
+
AND (c.id IS NULL OR c.recorded_at < sr.uploaded_at)
|
|
166
214
|
ORDER BY sr.uploaded_at DESC
|
|
167
215
|
LIMIT $2`,
|
|
168
216
|
[String(mins), lim]
|
|
@@ -170,6 +218,7 @@ async function reconcileSessionCosts({ olderThanMinutes = 15, limit = 500 } = {}
|
|
|
170
218
|
|
|
171
219
|
const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
|
|
172
220
|
let reconciled = 0;
|
|
221
|
+
let raised = 0;
|
|
173
222
|
let totalUsd = 0;
|
|
174
223
|
const unknownModels = new Set();
|
|
175
224
|
for (const r of rows) {
|
|
@@ -177,34 +226,50 @@ async function reconcileSessionCosts({ olderThanMinutes = 15, limit = 500 } = {}
|
|
|
177
226
|
for (const m of comp.unknown_models) unknownModels.add(m);
|
|
178
227
|
const amount = round4(comp.total_usd);
|
|
179
228
|
if (!(amount > 0)) continue; // nothing priceable on this row — skip
|
|
229
|
+
const prior = round4(r.prior_usd);
|
|
230
|
+
// A row already at or above this figure needs no write: the digest grew but
|
|
231
|
+
// the price did not (or the stored row predates a pricing fix and is higher,
|
|
232
|
+
// which we deliberately leave alone).
|
|
233
|
+
if (prior > 0 && amount <= prior) continue;
|
|
180
234
|
const id8 = String(r.session_id).slice(0, 8);
|
|
181
235
|
const unkNote = comp.unknown_models.length
|
|
182
236
|
? ` [unpriced model(s) priced as opus: ${comp.unknown_models.join(',')}]`
|
|
183
237
|
: '';
|
|
238
|
+
const provenance = prior > 0
|
|
239
|
+
? `raised from $${prior.toFixed(4)} as the session grew`
|
|
240
|
+
: 'reconciled from digest; client cost-hook did not post';
|
|
184
241
|
const inserted = await logCost({
|
|
185
242
|
amountUsd: amount,
|
|
186
243
|
category: 'api',
|
|
187
244
|
source: 'session:interactive',
|
|
188
|
-
// SAME key the client would use
|
|
189
|
-
//
|
|
245
|
+
// SAME key the client would use, so a client post and this sweep can
|
|
246
|
+
// never both land as separate rows.
|
|
190
247
|
sourceRef: r.session_id,
|
|
191
248
|
builderId: r.builder_id,
|
|
192
|
-
|
|
249
|
+
// Upward-only: catches a long session up as it ships more tasks, and can
|
|
250
|
+
// never lower a row that has already been paid from.
|
|
251
|
+
raiseOnConflict: true,
|
|
252
|
+
description: `session ${id8} — $${amount.toFixed(4)} (${provenance})${unkNote}`.slice(0, 1000),
|
|
193
253
|
freshInputUsd: round4(comp.fresh_input_usd),
|
|
194
254
|
cacheReadUsd: round4(comp.cache_read_usd),
|
|
195
255
|
cacheWriteUsd: round4(comp.cache_write_usd),
|
|
196
256
|
outputUsd: round4(comp.output_usd),
|
|
197
257
|
});
|
|
198
|
-
// inserted === null means
|
|
199
|
-
// SELECT and the INSERT
|
|
258
|
+
// inserted === null means the conflict clause skipped the write (the client
|
|
259
|
+
// raced in between the SELECT and the INSERT, or the raise would not have
|
|
260
|
+
// moved the row up) — expected + idempotent, not counted.
|
|
200
261
|
if (inserted) {
|
|
201
|
-
|
|
202
|
-
|
|
262
|
+
if (prior > 0) raised++;
|
|
263
|
+
else reconciled++;
|
|
264
|
+
// Bill only the INCREMENT on a raise, so this figure stays a true
|
|
265
|
+
// "spend recorded by this sweep" rather than re-counting the whole row.
|
|
266
|
+
totalUsd += amount - prior;
|
|
203
267
|
}
|
|
204
268
|
}
|
|
205
269
|
return {
|
|
206
270
|
scanned: rows.length,
|
|
207
271
|
reconciled,
|
|
272
|
+
raised,
|
|
208
273
|
totalUsd: round4(totalUsd),
|
|
209
274
|
unknownModels: Array.from(unknownModels),
|
|
210
275
|
};
|
|
@@ -804,12 +804,17 @@ async function runCostSweep(deps = {}) {
|
|
|
804
804
|
if (typeof reconciler.reconcileSessionCosts !== 'function') return { skipped: 'unsupported' };
|
|
805
805
|
try {
|
|
806
806
|
const cost = await reconciler.reconcileSessionCosts();
|
|
807
|
-
|
|
807
|
+
// task 1003217: `raised` counts long sessions caught up as their digest grew.
|
|
808
|
+
// It is reported alongside `reconciled` — a sweep that only raised rows did
|
|
809
|
+
// real ledger work and must not log silence.
|
|
810
|
+
const raised = Number(cost && cost.raised) || 0;
|
|
811
|
+
if (cost && (cost.reconciled > 0 || raised > 0)) {
|
|
808
812
|
const unk = cost.unknownModels && cost.unknownModels.length
|
|
809
813
|
? ` [unpriced→opus: ${cost.unknownModels.join(',')}]`
|
|
810
814
|
: '';
|
|
815
|
+
const raisedNote = raised > 0 ? `, raised ${raised}` : '';
|
|
811
816
|
logFn(
|
|
812
|
-
`[publish-reconciler] session-cost: scanned ${cost.scanned}, reconciled ${cost.reconciled} cost_log row(s) totalling $${Number(cost.totalUsd || 0).toFixed(4)}${unk}`
|
|
817
|
+
`[publish-reconciler] session-cost: scanned ${cost.scanned}, reconciled ${cost.reconciled}${raisedNote} cost_log row(s) totalling $${Number(cost.totalUsd || 0).toFixed(4)}${unk}`
|
|
813
818
|
);
|
|
814
819
|
}
|
|
815
820
|
return cost;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.318",
|
|
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.318",
|
|
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.318",
|
|
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",
|
|
@@ -104,7 +104,14 @@ const INTEGRATION = new Set([
|
|
|
104
104
|
'government_board_membership_db',
|
|
105
105
|
'government_fold_bench', 'government_live_proof', 'linkify_refs', 'memory_isolation',
|
|
106
106
|
'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent', 'reward_mode_cost_plus_only',
|
|
107
|
-
'scouting_rollup_fanout', 'session_records',
|
|
107
|
+
'scouting_rollup_fanout', 'session_records',
|
|
108
|
+
// task 1003217: the cost-row RAISE against real SQL. Every claim in it IS the
|
|
109
|
+
// SQL — an ON CONFLICT DO UPDATE guarded to move a row only upward, and a
|
|
110
|
+
// revisit predicate comparing cost_log.recorded_at to the digest upload. The
|
|
111
|
+
// DB-free siblings (session_records, session_cost) cover the transcript
|
|
112
|
+
// de-duplication half.
|
|
113
|
+
'session_cost_row_raise',
|
|
114
|
+
'setup_smoke',
|
|
108
115
|
'status-probe',
|
|
109
116
|
]);
|
|
110
117
|
|
|
@@ -146,9 +146,19 @@ function roundTiers(t) {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
// Sum usage per model across the transcript, then price each model's total.
|
|
149
|
+
//
|
|
150
|
+
// task 1003217 — usage is deduped by `message.id`. Claude Code writes one
|
|
151
|
+
// transcript line per content block of an assistant message (thinking / text /
|
|
152
|
+
// tool_use) and repeats the SAME `message.usage` on every one of them, so
|
|
153
|
+
// summing per line priced a single API call two or three times (measured 2.22x
|
|
154
|
+
// inflation over 12 real transcripts). The evidence — including the cache-chain
|
|
155
|
+
// identity that proves a line-group is one call — is written up above
|
|
156
|
+
// buildSessionDigest in ./session-digest-build.js. A transcript whose messages
|
|
157
|
+
// carry no `id` degrades to the old per-line behaviour.
|
|
149
158
|
function priceTranscript(transcriptPath) {
|
|
150
159
|
const raw = fs.readFileSync(transcriptPath, 'utf8');
|
|
151
160
|
const perModel = new Map(); // model -> {input, output, read, c5, c1h}
|
|
161
|
+
const seenMessageIds = new Set();
|
|
152
162
|
for (const line of raw.split('\n')) {
|
|
153
163
|
if (!line.trim()) continue;
|
|
154
164
|
let o;
|
|
@@ -159,6 +169,12 @@ function priceTranscript(transcriptPath) {
|
|
|
159
169
|
// generated messages (compaction, interrupts) — no real API call, usage is
|
|
160
170
|
// a zero placeholder. Pricing them would mislabel them as an unknown model.
|
|
161
171
|
if (!usage || !msg.model || msg.model === '<synthetic>') continue;
|
|
172
|
+
// One API call, many lines — price it once (task 1003217).
|
|
173
|
+
const msgId = typeof msg.id === 'string' && msg.id ? msg.id : null;
|
|
174
|
+
if (msgId !== null) {
|
|
175
|
+
if (seenMessageIds.has(msgId)) continue;
|
|
176
|
+
seenMessageIds.add(msgId);
|
|
177
|
+
}
|
|
162
178
|
const m = perModel.get(msg.model) || { input: 0, output: 0, read: 0, c5: 0, c1h: 0 };
|
|
163
179
|
m.input += Number(usage.input_tokens) || 0;
|
|
164
180
|
m.output += Number(usage.output_tokens) || 0;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// scripts/gds/session-digest-build.js — the PURE transcript→digest builder.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from scripts/gds/ship.js (task 1003217, serving the de-monolith goal
|
|
4
|
+
// of task 1003203: nothing over 1,500 lines). ship.js was 4,646 lines and this
|
|
5
|
+
// block — snippetOf + buildSessionDigest and their two constants — is pure,
|
|
6
|
+
// separately tested, and shared: ship.js uploads a digest mid-ship and
|
|
7
|
+
// session-digest.js uploads the final one on the SessionEnd seam.
|
|
8
|
+
//
|
|
9
|
+
// It has NO dependency on ship.js, which is what makes the split safe: ship.js
|
|
10
|
+
// imports and re-exports this, so its public surface (and tests/session_records.mjs)
|
|
11
|
+
// is unchanged, and the existing session-digest.js -> ship.js import cannot become
|
|
12
|
+
// a cycle.
|
|
13
|
+
//
|
|
14
|
+
// NB the prose above deliberately says "imports", not the r-e-q-u-i-r-e call
|
|
15
|
+
// spelled with './ship' — the fitness require-graph (scripts/gds/fitness-ratchets.js
|
|
16
|
+
// requireGraph) scans raw file text and does NOT strip comments, so naming that
|
|
17
|
+
// call here invented a phantom session-digest-build <-> ship cycle and tripped the
|
|
18
|
+
// require_cycle_count ratchet. Same trap the curated-set parser in run-unit-tests.js
|
|
19
|
+
// warns about for quoted words in ITS comments.
|
|
20
|
+
//
|
|
21
|
+
// The transcript-grade secret scrubber + its ADR-0022 internals live in
|
|
22
|
+
// modules/security/secret-scrub.js — extracted (task 1211 / GDS-V4 R72) so the
|
|
23
|
+
// public-mirror export pipeline can reuse the ONE copy of the secret shapes
|
|
24
|
+
// without requiring ship.js (whose grader / Discord / sound / session deps are
|
|
25
|
+
// absent in a bare CI job). See that module for the recall-first design (a missed
|
|
26
|
+
// secret is a compromise; over-redaction is cosmetic).
|
|
27
|
+
const { scrubSecrets } = require('../../modules/security/secret-scrub');
|
|
28
|
+
|
|
29
|
+
// snippetOf — short, single-line, scrubbed excerpt for footgun context.
|
|
30
|
+
function snippetOf(text, extraSecrets, max = 160) {
|
|
31
|
+
if (typeof text !== 'string' || text.length === 0) return undefined;
|
|
32
|
+
// Drop NULs first: Postgres rejects a literal NUL in jsonb, so ONE of them anywhere in
|
|
33
|
+
// ONE snippet made the whole digest unstorable — POST /sessions answered 500 and
|
|
34
|
+
// the session never paid. UTF-16LE tool output (`wsl --list` is the observed
|
|
35
|
+
// case) is full of them, and \s below does NOT match NUL, so it must go here.
|
|
36
|
+
const flat = text.replace(/\0/g, '').replace(/\s+/g, ' ').trim();
|
|
37
|
+
if (!flat) return undefined;
|
|
38
|
+
const cut = flat.length > max ? flat.slice(0, max) : flat;
|
|
39
|
+
const scrubbed = scrubSecrets(cut, extraSecrets);
|
|
40
|
+
return scrubbed || undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// buildSessionDigest — turn parsed Claude Code transcript entries into
|
|
44
|
+
// { startedAt, endedAt, durationSeconds, totalTokens, metrics, detail }.
|
|
45
|
+
// Pure (no I/O) + exported for unit tests. `detail` is per-turn metadata + short
|
|
46
|
+
// scrubbed snippets, capped at maxTurns (head+tail kept when over).
|
|
47
|
+
// Harness guardrail retries (task 959) — an is_error tool_result that is NOT real
|
|
48
|
+
// friction but the agent harness rejecting a tool call: an Edit/Write before the
|
|
49
|
+
// file was Read ("File has not been read yet"), or a foreground sleep ("Blocked:
|
|
50
|
+
// sleep"). These were counted in tool_error_count, inflating it and skewing the
|
|
51
|
+
// BFG's order=tool_errors ranking (top sessions ranked on benign retries). They
|
|
52
|
+
// now go into a separate harness_retry_count so tool_error_count stays NET — and
|
|
53
|
+
// because the sessions/search ordering reads metrics->>'tool_error_count'
|
|
54
|
+
// (src/bongos/db.js), that ordering automatically uses the net figure with no route
|
|
55
|
+
// change. (ADR 0027 lineage — the 6D session evaluator.)
|
|
56
|
+
const HARNESS_RETRY_RE =
|
|
57
|
+
/File has not been read yet|Read it first before (?:writing|editing)|Blocked:\s*sleep|sleep is blocked in (?:the )?foreground/i;
|
|
58
|
+
|
|
59
|
+
// task 1676 — the family bucket unlabeled token-bearing turns are folded into so
|
|
60
|
+
// the cost-plus reward (ADR 0054) prices the real spend instead of rounding to $0.
|
|
61
|
+
// A concrete, priceable model id (llm-pricing.js maps it to the opus family by
|
|
62
|
+
// substring) — NOT a fabricated dollar figure; it carries the REAL token counts
|
|
63
|
+
// ship.js already parsed, priced server-side like any other bucket.
|
|
64
|
+
const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
|
|
65
|
+
|
|
66
|
+
// task 1003217 — ONE API CALL IS MANY TRANSCRIPT LINES.
|
|
67
|
+
//
|
|
68
|
+
// Claude Code writes a separate transcript line per content block of a single
|
|
69
|
+
// assistant message (thinking / text / tool_use), and EVERY one of those lines
|
|
70
|
+
// repeats the SAME `message.usage` object. Summing usage per line therefore
|
|
71
|
+
// counted one API call's tokens two or three times: measured 2.22x inflation
|
|
72
|
+
// across 12 real transcripts in this project (2,357 usage-bearing lines,
|
|
73
|
+
// naive cache_read 551,095,738 vs deduped 248,555,962).
|
|
74
|
+
//
|
|
75
|
+
// `message.id` is the API call's identity — the usage tuples are byte-identical
|
|
76
|
+
// across lines sharing an id. The proof that a line-GROUP is one call is the
|
|
77
|
+
// cache chain: cache_read[n] == cache_read[n-1] + cache_creation[n-1] closes on
|
|
78
|
+
// 99.1% of links after de-duplication (the residue is compaction boundaries)
|
|
79
|
+
// and holds for none of them before it.
|
|
80
|
+
//
|
|
81
|
+
// This mattered because ADR 0054 pays round(true_cost_usd x 1.20) from these
|
|
82
|
+
// very numbers, so the inflation flowed into cost-plus rewards.
|
|
83
|
+
//
|
|
84
|
+
// `assistant_turns` is deliberately left as the LINE count (existing consumers
|
|
85
|
+
// order on it); `api_calls` is the new honest step count. A transcript whose
|
|
86
|
+
// messages carry no `id` degrades to exactly the old behaviour.
|
|
87
|
+
function buildSessionDigest(entries, opts = {}) {
|
|
88
|
+
const extra = opts.extraSecrets || [];
|
|
89
|
+
const MAX_TURNS = opts.maxTurns || 4000;
|
|
90
|
+
const seenMessageIds = new Set();
|
|
91
|
+
const m = {
|
|
92
|
+
assistant_turns: 0,
|
|
93
|
+
api_calls: 0, // distinct message.id — the real number of API round-trips
|
|
94
|
+
user_turns: 0,
|
|
95
|
+
tool_use_count: 0,
|
|
96
|
+
tool_result_count: 0,
|
|
97
|
+
tool_error_count: 0, // NET real errors (harness guardrail retries excluded)
|
|
98
|
+
harness_retry_count: 0, // benign harness guardrail retries (task 959)
|
|
99
|
+
tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
|
|
100
|
+
model_usage: {}, // per-model token buckets for the cost-plus reward (task 1005)
|
|
101
|
+
// task 1676: tokens from turns with NO model id (not '<synthetic>'). Folded
|
|
102
|
+
// into a priceable bucket after the loop, then stripped from the digest.
|
|
103
|
+
_unlabeled: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
|
|
104
|
+
};
|
|
105
|
+
const detail = [];
|
|
106
|
+
let firstTs = null;
|
|
107
|
+
let lastTs = null;
|
|
108
|
+
let i = 0;
|
|
109
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
110
|
+
if (!e || typeof e !== 'object') continue;
|
|
111
|
+
const ts = typeof e.timestamp === 'string' ? e.timestamp : null;
|
|
112
|
+
if (ts) {
|
|
113
|
+
if (!firstTs) firstTs = ts;
|
|
114
|
+
lastTs = ts;
|
|
115
|
+
}
|
|
116
|
+
const msg = e.message;
|
|
117
|
+
if (e.type === 'assistant' && msg) {
|
|
118
|
+
m.assistant_turns++;
|
|
119
|
+
// Dedup usage per API call (see the note above buildSessionDigest). A
|
|
120
|
+
// repeat line contributes ZERO tokens but still gets its detail row, so
|
|
121
|
+
// the per-turn record stays faithful and summing detail stays correct.
|
|
122
|
+
const msgId = typeof msg.id === 'string' && msg.id ? msg.id : null;
|
|
123
|
+
const dupUsage = msgId !== null && seenMessageIds.has(msgId);
|
|
124
|
+
if (msgId !== null) seenMessageIds.add(msgId);
|
|
125
|
+
if (!dupUsage) m.api_calls++;
|
|
126
|
+
const u = dupUsage ? {} : (msg.usage || {});
|
|
127
|
+
const tin = u.input_tokens || 0;
|
|
128
|
+
const tout = u.output_tokens || 0;
|
|
129
|
+
const tcr = u.cache_read_input_tokens || 0;
|
|
130
|
+
const tcc = u.cache_creation_input_tokens || 0;
|
|
131
|
+
m.tokens.input += tin;
|
|
132
|
+
m.tokens.output += tout;
|
|
133
|
+
m.tokens.cache_read += tcr;
|
|
134
|
+
m.tokens.cache_creation += tcc;
|
|
135
|
+
// task 1005: per-model bucket for the cost-plus token reward, reusing the
|
|
136
|
+
// tin/tout/tcr/tcc already parsed above (no second pass). Skip synthetic
|
|
137
|
+
// turns (a grader 429 etc. emits no real model id AND no real spend).
|
|
138
|
+
const model = msg.model;
|
|
139
|
+
if (model === '<synthetic>') {
|
|
140
|
+
// synthetic: no real spend — exclude from BOTH model_usage and the
|
|
141
|
+
// task-1676 unlabeled-remainder fold below (don't price phantom tokens).
|
|
142
|
+
} else if (model) {
|
|
143
|
+
const mu = m.model_usage[model] || (m.model_usage[model] =
|
|
144
|
+
{ input: 0, output: 0, cache_read: 0, cache_creation: 0 });
|
|
145
|
+
mu.input += tin; mu.output += tout; mu.cache_read += tcr; mu.cache_creation += tcc;
|
|
146
|
+
} else {
|
|
147
|
+
// task 1676: a token-bearing turn with NO model id. Previously dropped
|
|
148
|
+
// from model_usage (→ total_tokens>0 but priced $0 on the dev-box/CI
|
|
149
|
+
// path; ADR 0097). Accumulate the unlabeled tokens so the digest can
|
|
150
|
+
// attribute them to a real, priceable family bucket after the loop.
|
|
151
|
+
m._unlabeled.input += tin; m._unlabeled.output += tout;
|
|
152
|
+
m._unlabeled.cache_read += tcr; m._unlabeled.cache_creation += tcc;
|
|
153
|
+
}
|
|
154
|
+
const tools = [];
|
|
155
|
+
let textLen = 0;
|
|
156
|
+
let firstText = '';
|
|
157
|
+
if (Array.isArray(msg.content)) {
|
|
158
|
+
for (const c of msg.content) {
|
|
159
|
+
if (c && c.type === 'tool_use') {
|
|
160
|
+
m.tool_use_count++;
|
|
161
|
+
tools.push(String(c.name || '?'));
|
|
162
|
+
} else if (c && c.type === 'text' && typeof c.text === 'string') {
|
|
163
|
+
textLen += c.text.length;
|
|
164
|
+
if (!firstText) firstText = c.text;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
|
|
169
|
+
// Marks a line that shares its API call with an earlier one, so a reader
|
|
170
|
+
// can tell a genuinely-zero-usage turn from a de-duplicated repeat.
|
|
171
|
+
if (dupUsage) row.dup = 1;
|
|
172
|
+
if (tools.length) row.tools = tools;
|
|
173
|
+
if (textLen) row.text_len = textLen;
|
|
174
|
+
const snip = snippetOf(firstText, extra);
|
|
175
|
+
if (snip) row.snippet = snip;
|
|
176
|
+
detail.push(row);
|
|
177
|
+
i++;
|
|
178
|
+
} else if (e.type === 'user' && msg) {
|
|
179
|
+
m.user_turns++;
|
|
180
|
+
let results = 0;
|
|
181
|
+
let errors = 0;
|
|
182
|
+
let harnessRetries = 0;
|
|
183
|
+
let firstResult = '';
|
|
184
|
+
if (Array.isArray(msg.content)) {
|
|
185
|
+
for (const c of msg.content) {
|
|
186
|
+
if (c && c.type === 'tool_result') {
|
|
187
|
+
results++;
|
|
188
|
+
m.tool_result_count++;
|
|
189
|
+
const text = typeof c.content === 'string'
|
|
190
|
+
? c.content
|
|
191
|
+
: Array.isArray(c.content)
|
|
192
|
+
? c.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ')
|
|
193
|
+
: '';
|
|
194
|
+
if (c.is_error) {
|
|
195
|
+
// Split benign harness guardrail retries out of the net error count
|
|
196
|
+
// so order=tool_errors ranks on real friction (task 959).
|
|
197
|
+
if (HARNESS_RETRY_RE.test(text)) {
|
|
198
|
+
harnessRetries++;
|
|
199
|
+
m.harness_retry_count++;
|
|
200
|
+
} else {
|
|
201
|
+
errors++;
|
|
202
|
+
m.tool_error_count++;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!firstResult) firstResult = text;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const row = { i, role: 'user', ts };
|
|
210
|
+
if (results) row.tool_results = results;
|
|
211
|
+
if (errors) row.tool_errors = errors;
|
|
212
|
+
if (harnessRetries) row.harness_retries = harnessRetries;
|
|
213
|
+
const snip = snippetOf(firstResult, extra);
|
|
214
|
+
if (snip) row.snippet = snip;
|
|
215
|
+
detail.push(row);
|
|
216
|
+
i++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
m.total_tokens = m.tokens.input + m.tokens.output + m.tokens.cache_read + m.tokens.cache_creation;
|
|
220
|
+
// task 1676 — cost-plus reward zero-out fix. The cost-plus session reward
|
|
221
|
+
// (ADR 0054) prices `model_usage` server-side. model_usage is keyed on msg.model
|
|
222
|
+
// and SKIPS any token-bearing turn whose model id is missing. On the dev-box/CI
|
|
223
|
+
// agent path that gap was the difference between total_tokens>0 (so the digest
|
|
224
|
+
// passes the route's empty-session guard + gets stored) and model_usage={} (so
|
|
225
|
+
// priceModelUsage returns $0 → 0 drachmae) — ADR 0097's "upload no priced model
|
|
226
|
+
// usage" symptom. Fold the unlabeled remainder (NOT synthetic turns) into a real,
|
|
227
|
+
// priceable family bucket so the cost-plus reward reflects the true spend rather
|
|
228
|
+
// than silently dropping to $0. opus is the conservative family for an unlabeled
|
|
229
|
+
// turn (matches llm-pricing.js's unknown-model fallback) — this instance runs
|
|
230
|
+
// opus by default, and pricing an unlabeled turn as opus never UNDER-pays.
|
|
231
|
+
const un = m._unlabeled;
|
|
232
|
+
if (un.input + un.output + un.cache_read + un.cache_creation > 0) {
|
|
233
|
+
const fb = m.model_usage[UNATTRIBUTED_MODEL] || (m.model_usage[UNATTRIBUTED_MODEL] =
|
|
234
|
+
{ input: 0, output: 0, cache_read: 0, cache_creation: 0 });
|
|
235
|
+
fb.input += un.input; fb.output += un.output;
|
|
236
|
+
fb.cache_read += un.cache_read; fb.cache_creation += un.cache_creation;
|
|
237
|
+
}
|
|
238
|
+
delete m._unlabeled; // scratch — never persisted to the digest
|
|
239
|
+
let boundedDetail = detail;
|
|
240
|
+
if (detail.length > MAX_TURNS) {
|
|
241
|
+
const head = Math.floor(MAX_TURNS * 0.7);
|
|
242
|
+
const tail = MAX_TURNS - head;
|
|
243
|
+
boundedDetail = detail.slice(0, head).concat(detail.slice(detail.length - tail));
|
|
244
|
+
m.detail_truncated = true;
|
|
245
|
+
m.detail_total_turns = detail.length;
|
|
246
|
+
}
|
|
247
|
+
let durationSeconds = null;
|
|
248
|
+
if (firstTs && lastTs) {
|
|
249
|
+
const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 1000;
|
|
250
|
+
if (Number.isFinite(d) && d >= 0) durationSeconds = Math.round(d);
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
startedAt: firstTs,
|
|
254
|
+
endedAt: lastTs,
|
|
255
|
+
durationSeconds,
|
|
256
|
+
totalTokens: m.total_tokens,
|
|
257
|
+
metrics: m,
|
|
258
|
+
detail: boundedDetail,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
module.exports = { snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL };
|
package/scripts/gds/ship.js
CHANGED
|
@@ -386,205 +386,14 @@ const {
|
|
|
386
386
|
redactHighEntropy, collectEnvSecrets,
|
|
387
387
|
} = require('../../modules/security/secret-scrub');
|
|
388
388
|
|
|
389
|
-
// snippetOf
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const flat = text.replace(/\0/g, '').replace(/\s+/g, ' ').trim();
|
|
397
|
-
if (!flat) return undefined;
|
|
398
|
-
const cut = flat.length > max ? flat.slice(0, max) : flat;
|
|
399
|
-
const scrubbed = scrubSecrets(cut, extraSecrets);
|
|
400
|
-
return scrubbed || undefined;
|
|
401
|
-
}
|
|
389
|
+
// snippetOf + buildSessionDigest moved to ./session-digest-build (task 1003217,
|
|
390
|
+
// serving the de-monolith goal of task 1003203) — pure, separately tested, and
|
|
391
|
+
// shared with session-digest.js. Re-exported below so this file's public surface
|
|
392
|
+
// and tests/session_records.mjs are unchanged.
|
|
393
|
+
const {
|
|
394
|
+
snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
|
|
395
|
+
} = require('./session-digest-build');
|
|
402
396
|
|
|
403
|
-
// buildSessionDigest — turn parsed Claude Code transcript entries into
|
|
404
|
-
// { startedAt, endedAt, durationSeconds, totalTokens, metrics, detail }.
|
|
405
|
-
// Pure (no I/O) + exported for unit tests. `detail` is per-turn metadata + short
|
|
406
|
-
// scrubbed snippets, capped at maxTurns (head+tail kept when over).
|
|
407
|
-
// Harness guardrail retries (task 959) — an is_error tool_result that is NOT real
|
|
408
|
-
// friction but the agent harness rejecting a tool call: an Edit/Write before the
|
|
409
|
-
// file was Read ("File has not been read yet"), or a foreground sleep ("Blocked:
|
|
410
|
-
// sleep"). These were counted in tool_error_count, inflating it and skewing the
|
|
411
|
-
// BFG's order=tool_errors ranking (top sessions ranked on benign retries). They
|
|
412
|
-
// now go into a separate harness_retry_count so tool_error_count stays NET — and
|
|
413
|
-
// because the sessions/search ordering reads metrics->>'tool_error_count'
|
|
414
|
-
// (src/bongos/db.js), that ordering automatically uses the net figure with no route
|
|
415
|
-
// change. (ADR 0027 lineage — the 6D session evaluator.)
|
|
416
|
-
const HARNESS_RETRY_RE =
|
|
417
|
-
/File has not been read yet|Read it first before (?:writing|editing)|Blocked:\s*sleep|sleep is blocked in (?:the )?foreground/i;
|
|
418
|
-
|
|
419
|
-
// task 1676 — the family bucket unlabeled token-bearing turns are folded into so
|
|
420
|
-
// the cost-plus reward (ADR 0054) prices the real spend instead of rounding to $0.
|
|
421
|
-
// A concrete, priceable model id (llm-pricing.js maps it to the opus family by
|
|
422
|
-
// substring) — NOT a fabricated dollar figure; it carries the REAL token counts
|
|
423
|
-
// ship.js already parsed, priced server-side like any other bucket.
|
|
424
|
-
const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
|
|
425
|
-
|
|
426
|
-
function buildSessionDigest(entries, opts = {}) {
|
|
427
|
-
const extra = opts.extraSecrets || [];
|
|
428
|
-
const MAX_TURNS = opts.maxTurns || 4000;
|
|
429
|
-
const m = {
|
|
430
|
-
assistant_turns: 0,
|
|
431
|
-
user_turns: 0,
|
|
432
|
-
tool_use_count: 0,
|
|
433
|
-
tool_result_count: 0,
|
|
434
|
-
tool_error_count: 0, // NET real errors (harness guardrail retries excluded)
|
|
435
|
-
harness_retry_count: 0, // benign harness guardrail retries (task 959)
|
|
436
|
-
tokens: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
|
|
437
|
-
model_usage: {}, // per-model token buckets for the cost-plus reward (task 1005)
|
|
438
|
-
// task 1676: tokens from turns with NO model id (not '<synthetic>'). Folded
|
|
439
|
-
// into a priceable bucket after the loop, then stripped from the digest.
|
|
440
|
-
_unlabeled: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
|
|
441
|
-
};
|
|
442
|
-
const detail = [];
|
|
443
|
-
let firstTs = null;
|
|
444
|
-
let lastTs = null;
|
|
445
|
-
let i = 0;
|
|
446
|
-
for (const e of Array.isArray(entries) ? entries : []) {
|
|
447
|
-
if (!e || typeof e !== 'object') continue;
|
|
448
|
-
const ts = typeof e.timestamp === 'string' ? e.timestamp : null;
|
|
449
|
-
if (ts) {
|
|
450
|
-
if (!firstTs) firstTs = ts;
|
|
451
|
-
lastTs = ts;
|
|
452
|
-
}
|
|
453
|
-
const msg = e.message;
|
|
454
|
-
if (e.type === 'assistant' && msg) {
|
|
455
|
-
m.assistant_turns++;
|
|
456
|
-
const u = msg.usage || {};
|
|
457
|
-
const tin = u.input_tokens || 0;
|
|
458
|
-
const tout = u.output_tokens || 0;
|
|
459
|
-
const tcr = u.cache_read_input_tokens || 0;
|
|
460
|
-
const tcc = u.cache_creation_input_tokens || 0;
|
|
461
|
-
m.tokens.input += tin;
|
|
462
|
-
m.tokens.output += tout;
|
|
463
|
-
m.tokens.cache_read += tcr;
|
|
464
|
-
m.tokens.cache_creation += tcc;
|
|
465
|
-
// task 1005: per-model bucket for the cost-plus token reward, reusing the
|
|
466
|
-
// tin/tout/tcr/tcc already parsed above (no second pass). Skip synthetic
|
|
467
|
-
// turns (a grader 429 etc. emits no real model id AND no real spend).
|
|
468
|
-
const model = msg.model;
|
|
469
|
-
if (model === '<synthetic>') {
|
|
470
|
-
// synthetic: no real spend — exclude from BOTH model_usage and the
|
|
471
|
-
// task-1676 unlabeled-remainder fold below (don't price phantom tokens).
|
|
472
|
-
} else if (model) {
|
|
473
|
-
const mu = m.model_usage[model] || (m.model_usage[model] =
|
|
474
|
-
{ input: 0, output: 0, cache_read: 0, cache_creation: 0 });
|
|
475
|
-
mu.input += tin; mu.output += tout; mu.cache_read += tcr; mu.cache_creation += tcc;
|
|
476
|
-
} else {
|
|
477
|
-
// task 1676: a token-bearing turn with NO model id. Previously dropped
|
|
478
|
-
// from model_usage (→ total_tokens>0 but priced $0 on the dev-box/CI
|
|
479
|
-
// path; ADR 0097). Accumulate the unlabeled tokens so the digest can
|
|
480
|
-
// attribute them to a real, priceable family bucket after the loop.
|
|
481
|
-
m._unlabeled.input += tin; m._unlabeled.output += tout;
|
|
482
|
-
m._unlabeled.cache_read += tcr; m._unlabeled.cache_creation += tcc;
|
|
483
|
-
}
|
|
484
|
-
const tools = [];
|
|
485
|
-
let textLen = 0;
|
|
486
|
-
let firstText = '';
|
|
487
|
-
if (Array.isArray(msg.content)) {
|
|
488
|
-
for (const c of msg.content) {
|
|
489
|
-
if (c && c.type === 'tool_use') {
|
|
490
|
-
m.tool_use_count++;
|
|
491
|
-
tools.push(String(c.name || '?'));
|
|
492
|
-
} else if (c && c.type === 'text' && typeof c.text === 'string') {
|
|
493
|
-
textLen += c.text.length;
|
|
494
|
-
if (!firstText) firstText = c.text;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
|
|
499
|
-
if (tools.length) row.tools = tools;
|
|
500
|
-
if (textLen) row.text_len = textLen;
|
|
501
|
-
const snip = snippetOf(firstText, extra);
|
|
502
|
-
if (snip) row.snippet = snip;
|
|
503
|
-
detail.push(row);
|
|
504
|
-
i++;
|
|
505
|
-
} else if (e.type === 'user' && msg) {
|
|
506
|
-
m.user_turns++;
|
|
507
|
-
let results = 0;
|
|
508
|
-
let errors = 0;
|
|
509
|
-
let harnessRetries = 0;
|
|
510
|
-
let firstResult = '';
|
|
511
|
-
if (Array.isArray(msg.content)) {
|
|
512
|
-
for (const c of msg.content) {
|
|
513
|
-
if (c && c.type === 'tool_result') {
|
|
514
|
-
results++;
|
|
515
|
-
m.tool_result_count++;
|
|
516
|
-
const text = typeof c.content === 'string'
|
|
517
|
-
? c.content
|
|
518
|
-
: Array.isArray(c.content)
|
|
519
|
-
? c.content.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ')
|
|
520
|
-
: '';
|
|
521
|
-
if (c.is_error) {
|
|
522
|
-
// Split benign harness guardrail retries out of the net error count
|
|
523
|
-
// so order=tool_errors ranks on real friction (task 959).
|
|
524
|
-
if (HARNESS_RETRY_RE.test(text)) {
|
|
525
|
-
harnessRetries++;
|
|
526
|
-
m.harness_retry_count++;
|
|
527
|
-
} else {
|
|
528
|
-
errors++;
|
|
529
|
-
m.tool_error_count++;
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
if (!firstResult) firstResult = text;
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
const row = { i, role: 'user', ts };
|
|
537
|
-
if (results) row.tool_results = results;
|
|
538
|
-
if (errors) row.tool_errors = errors;
|
|
539
|
-
if (harnessRetries) row.harness_retries = harnessRetries;
|
|
540
|
-
const snip = snippetOf(firstResult, extra);
|
|
541
|
-
if (snip) row.snippet = snip;
|
|
542
|
-
detail.push(row);
|
|
543
|
-
i++;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
m.total_tokens = m.tokens.input + m.tokens.output + m.tokens.cache_read + m.tokens.cache_creation;
|
|
547
|
-
// task 1676 — cost-plus reward zero-out fix. The cost-plus session reward
|
|
548
|
-
// (ADR 0054) prices `model_usage` server-side. model_usage is keyed on msg.model
|
|
549
|
-
// and SKIPS any token-bearing turn whose model id is missing. On the dev-box/CI
|
|
550
|
-
// agent path that gap was the difference between total_tokens>0 (so the digest
|
|
551
|
-
// passes the route's empty-session guard + gets stored) and model_usage={} (so
|
|
552
|
-
// priceModelUsage returns $0 → 0 drachmae) — ADR 0097's "upload no priced model
|
|
553
|
-
// usage" symptom. Fold the unlabeled remainder (NOT synthetic turns) into a real,
|
|
554
|
-
// priceable family bucket so the cost-plus reward reflects the true spend rather
|
|
555
|
-
// than silently dropping to $0. opus is the conservative family for an unlabeled
|
|
556
|
-
// turn (matches llm-pricing.js's unknown-model fallback) — this instance runs
|
|
557
|
-
// opus by default, and pricing an unlabeled turn as opus never UNDER-pays.
|
|
558
|
-
const un = m._unlabeled;
|
|
559
|
-
if (un.input + un.output + un.cache_read + un.cache_creation > 0) {
|
|
560
|
-
const fb = m.model_usage[UNATTRIBUTED_MODEL] || (m.model_usage[UNATTRIBUTED_MODEL] =
|
|
561
|
-
{ input: 0, output: 0, cache_read: 0, cache_creation: 0 });
|
|
562
|
-
fb.input += un.input; fb.output += un.output;
|
|
563
|
-
fb.cache_read += un.cache_read; fb.cache_creation += un.cache_creation;
|
|
564
|
-
}
|
|
565
|
-
delete m._unlabeled; // scratch — never persisted to the digest
|
|
566
|
-
let boundedDetail = detail;
|
|
567
|
-
if (detail.length > MAX_TURNS) {
|
|
568
|
-
const head = Math.floor(MAX_TURNS * 0.7);
|
|
569
|
-
const tail = MAX_TURNS - head;
|
|
570
|
-
boundedDetail = detail.slice(0, head).concat(detail.slice(detail.length - tail));
|
|
571
|
-
m.detail_truncated = true;
|
|
572
|
-
m.detail_total_turns = detail.length;
|
|
573
|
-
}
|
|
574
|
-
let durationSeconds = null;
|
|
575
|
-
if (firstTs && lastTs) {
|
|
576
|
-
const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 1000;
|
|
577
|
-
if (Number.isFinite(d) && d >= 0) durationSeconds = Math.round(d);
|
|
578
|
-
}
|
|
579
|
-
return {
|
|
580
|
-
startedAt: firstTs,
|
|
581
|
-
endedAt: lastTs,
|
|
582
|
-
durationSeconds,
|
|
583
|
-
totalTokens: m.total_tokens,
|
|
584
|
-
metrics: m,
|
|
585
|
-
detail: boundedDetail,
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
397
|
|
|
589
398
|
// classifyUploadSkip — PURE decision for whether the session upload must skip,
|
|
590
399
|
// and the clear, actionable reason why. Separated from I/O so it is unit-tested
|
package/src/module-api.js
CHANGED
|
@@ -1061,8 +1061,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1061
1061
|
// landed since 1.19.315 with no explicit bump. run 32669347711. (task 1002620)
|
|
1062
1062
|
// 1.19.317 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1063
1063
|
// landed since 1.19.316 with no explicit bump. run 32669571610. (task 1002620)
|
|
1064
|
+
// 1.19.318 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1065
|
+
// landed since 1.19.317 with no explicit bump. run 32669798745. (task 1002620)
|
|
1064
1066
|
// ---------------------------------------------------------------------------
|
|
1065
|
-
const CORE_VERSION = '1.19.
|
|
1067
|
+
const CORE_VERSION = '1.19.318'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1066
1068
|
|
|
1067
1069
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1068
1070
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -1695,3 +1695,41 @@ setTimeout(() => {
|
|
|
1695
1695
|
console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);
|
|
1696
1696
|
process.exit(failed > 0 ? 1 : 0);
|
|
1697
1697
|
}, 100);
|
|
1698
|
+
|
|
1699
|
+
// --- task 1003217: a sweep that only RAISED rows must not log silence --------
|
|
1700
|
+
|
|
1701
|
+
test('1003217 runCostSweep reports raised rows (a raise-only sweep still logs)', async () => {
|
|
1702
|
+
const logs = [];
|
|
1703
|
+
const deps = {
|
|
1704
|
+
db: { reconcileSessionCosts: async () => ({ scanned: 12, reconciled: 0, raised: 3, totalUsd: 45, unknownModels: [] }) },
|
|
1705
|
+
log: (m) => logs.push(m),
|
|
1706
|
+
error: () => {},
|
|
1707
|
+
};
|
|
1708
|
+
const r = await reconciler.runCostSweep(deps);
|
|
1709
|
+
assert.equal(r.raised, 3, 'the raised count passes through');
|
|
1710
|
+
assert.equal(logs.length, 1, 'a sweep that raised 3 rows logged — the old guard was reconciled>0 only');
|
|
1711
|
+
assert.match(logs[0], /raised 3/, 'and the raise is named in the line');
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
test('1003217 a sweep that did nothing at all still logs nothing', async () => {
|
|
1715
|
+
const logs = [];
|
|
1716
|
+
const deps = {
|
|
1717
|
+
db: { reconcileSessionCosts: async () => ({ scanned: 12, reconciled: 0, raised: 0, totalUsd: 0, unknownModels: [] }) },
|
|
1718
|
+
log: (m) => logs.push(m),
|
|
1719
|
+
error: () => {},
|
|
1720
|
+
};
|
|
1721
|
+
await reconciler.runCostSweep(deps);
|
|
1722
|
+
assert.equal(logs.length, 0, 'no ledger work → no line, as before');
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
test('1003217 an older deps bundle with no `raised` key still logs on reconciled', async () => {
|
|
1726
|
+
const logs = [];
|
|
1727
|
+
const deps = {
|
|
1728
|
+
db: { reconcileSessionCosts: async () => ({ scanned: 2, reconciled: 2, totalUsd: 3, unknownModels: [] }) },
|
|
1729
|
+
log: (m) => logs.push(m),
|
|
1730
|
+
error: () => {},
|
|
1731
|
+
};
|
|
1732
|
+
await reconciler.runCostSweep(deps);
|
|
1733
|
+
assert.equal(logs.length, 1);
|
|
1734
|
+
assert.doesNotMatch(logs[0], /raised/, 'no phantom "raised 0" noise when the key is absent');
|
|
1735
|
+
});
|
package/tests/session_cost.mjs
CHANGED
|
@@ -131,3 +131,67 @@ test('1344 addComponents / addTiers / roundTiers are pure and additive', () => {
|
|
|
131
131
|
const rounded = sc.roundTiers({ fresh_input_usd: 1.000049, cache_read_usd: 0, cache_write_usd: 0, output_usd: 0 });
|
|
132
132
|
assert.equal(rounded.fresh_input_usd, 1.0); // round to 4dp
|
|
133
133
|
});
|
|
134
|
+
|
|
135
|
+
// ---------- task 1003217: price one API call once, not once per line ----------
|
|
136
|
+
//
|
|
137
|
+
// A single assistant message is written to the transcript as several lines (one
|
|
138
|
+
// per content block: thinking / text / tool_use), each repeating the SAME
|
|
139
|
+
// message.usage. priceTranscript must dedup on message.id. Measured 2.22x
|
|
140
|
+
// over-pricing on 12 real transcripts before this fix — and ADR 0054 pays
|
|
141
|
+
// round(true_cost_usd x 1.20) off this number.
|
|
142
|
+
function splitLine(id, model, usage, block) {
|
|
143
|
+
return JSON.stringify({ message: { id, role: 'assistant', model, usage, content: [block] } });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
test('1003217 priceTranscript charges a split assistant message ONCE', () => {
|
|
147
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup-'));
|
|
148
|
+
const p = path.join(dir, 'split.jsonl');
|
|
149
|
+
const usage = { output_tokens: M }; // opus-5 output = $25/M
|
|
150
|
+
fs.writeFileSync(p, [
|
|
151
|
+
splitLine('msg_1', 'claude-opus-5', usage, { type: 'thinking', thinking: 'hmm' }),
|
|
152
|
+
splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'ok' }),
|
|
153
|
+
splitLine('msg_1', 'claude-opus-5', usage, { type: 'tool_use', name: 'Bash' }),
|
|
154
|
+
].join('\n'));
|
|
155
|
+
const r = sc.priceTranscript(p);
|
|
156
|
+
assert.equal(r.total, 25, 'one call at 1M opus output = $25, NOT $75');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('1003217 priceTranscript still charges distinct message ids separately', () => {
|
|
160
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup2-'));
|
|
161
|
+
const p = path.join(dir, 'three.jsonl');
|
|
162
|
+
const usage = { output_tokens: M };
|
|
163
|
+
fs.writeFileSync(p, [
|
|
164
|
+
splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'a' }),
|
|
165
|
+
splitLine('msg_2', 'claude-opus-5', usage, { type: 'text', text: 'b' }),
|
|
166
|
+
splitLine('msg_3', 'claude-opus-5', usage, { type: 'text', text: 'c' }),
|
|
167
|
+
].join('\n'));
|
|
168
|
+
assert.equal(sc.priceTranscript(p).total, 75, 'three real calls = $75');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('1003217 priceTranscript keeps the tier split consistent with the deduped total', () => {
|
|
172
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup3-'));
|
|
173
|
+
const p = path.join(dir, 'tiers.jsonl');
|
|
174
|
+
const usage = { output_tokens: M, cache_read_input_tokens: M };
|
|
175
|
+
fs.writeFileSync(p, [
|
|
176
|
+
splitLine('msg_1', 'claude-opus-5', usage, { type: 'text', text: 'a' }),
|
|
177
|
+
splitLine('msg_1', 'claude-opus-5', usage, { type: 'tool_use', name: 'Bash' }),
|
|
178
|
+
].join('\n'));
|
|
179
|
+
const r = sc.priceTranscript(p);
|
|
180
|
+
// opus-5: output $25/M, cache_read $0.50/M → $25.50 for ONE call.
|
|
181
|
+
assert.equal(r.total, 25.5);
|
|
182
|
+
assert.equal(r.tiers.output_usd, 25);
|
|
183
|
+
assert.equal(r.tiers.cache_read_usd, 0.5);
|
|
184
|
+
const tierSum = r.tiers.fresh_input_usd + r.tiers.cache_read_usd
|
|
185
|
+
+ r.tiers.cache_write_usd + r.tiers.output_usd;
|
|
186
|
+
assert.ok(Math.abs(tierSum - r.total) < 1e-9, 'the four tiers must still sum to total');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('1003217 a transcript with no message.id prices per line (unchanged legacy path)', () => {
|
|
190
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-dedup4-'));
|
|
191
|
+
const p = path.join(dir, 'noid.jsonl');
|
|
192
|
+
fs.writeFileSync(p, [
|
|
193
|
+
JSON.stringify({ message: { role: 'assistant', model: 'claude-opus-5', usage: { output_tokens: M } } }),
|
|
194
|
+
JSON.stringify({ message: { role: 'assistant', model: 'claude-opus-5', usage: { output_tokens: M } } }),
|
|
195
|
+
].join('\n'));
|
|
196
|
+
assert.equal(sc.priceTranscript(p).total, 50, 'no id to dedup on → both lines charged');
|
|
197
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// tests/session_cost_row_raise.mjs — task 1003217 (real DB).
|
|
2
|
+
//
|
|
3
|
+
// BUG A: a long session's cost row froze at the FIRST ship. `session_records`
|
|
4
|
+
// is upserted on every ship, so a twelve-task session has a digest from its
|
|
5
|
+
// first ship; the reconciler priced that partial digest ~15 min later, and
|
|
6
|
+
// logCost's `ON CONFLICT DO NOTHING` then silently dropped every later, larger
|
|
7
|
+
// figure. Prod evidence: a 9.3-hour session billed at its first 56 minutes;
|
|
8
|
+
// across four audited sessions the ledger held 14%, 14%, 16% and 54% of real
|
|
9
|
+
// spend.
|
|
10
|
+
//
|
|
11
|
+
// What this pins, against real SQL because every claim here IS the SQL:
|
|
12
|
+
// • a never-priced session still gets its row (the original task-1366 job),
|
|
13
|
+
// • a session whose digest GREW is revisited and its row RAISED,
|
|
14
|
+
// • the raise is monotonic — a lower figure never lowers a paid row,
|
|
15
|
+
// • the sweep CONVERGES: once settled, a second run books nothing,
|
|
16
|
+
// • totalUsd bills only the INCREMENT on a raise, never the whole row again,
|
|
17
|
+
// • a client-split session (source_ref || ':sub') is still left alone.
|
|
18
|
+
//
|
|
19
|
+
// Real-DB integration test: self-skips without a reachable Postgres.
|
|
20
|
+
// Run: DATABASE_URL=postgres:///bongos_test node --test tests/session_cost_row_raise.mjs
|
|
21
|
+
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { Pool } = require('pg');
|
|
27
|
+
|
|
28
|
+
const probe = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
29
|
+
let live = true;
|
|
30
|
+
try { await probe.query('SELECT 1 FROM cost_log LIMIT 1'); } catch (e) {
|
|
31
|
+
console.log(`session_cost_row_raise.mjs SKIPPED — no reachable Postgres with the GDS schema (${e.code || e.message}).`);
|
|
32
|
+
live = false;
|
|
33
|
+
}
|
|
34
|
+
await probe.end().catch(() => {});
|
|
35
|
+
|
|
36
|
+
const B = 90000411; // fixture builder
|
|
37
|
+
const S1 = 'sess-raise-0000000000000001'; // grows across ships
|
|
38
|
+
const S2 = 'sess-raise-0000000000000002'; // client-split, must be untouched
|
|
39
|
+
const M = 1_000_000;
|
|
40
|
+
|
|
41
|
+
// opus-5 cache_read is $0.50/M, so cache_read tokens make a clean, exact figure.
|
|
42
|
+
const digest = (cacheReadM) => ({ 'claude-opus-5': { input: 0, output: 0, cache_read: cacheReadM * M, cache_creation: 0 } });
|
|
43
|
+
|
|
44
|
+
if (live) {
|
|
45
|
+
const cost = require('../modules/economy/cost.js');
|
|
46
|
+
const pool = cost.pool || new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
47
|
+
const q = (sql, p) => pool.query(sql, p).then((r) => r.rows);
|
|
48
|
+
|
|
49
|
+
const seedDigest = async (sid, cacheReadM, uploadedAgoMin) => q(
|
|
50
|
+
`INSERT INTO session_records (session_id, builder_id, model_usage, uploaded_at)
|
|
51
|
+
VALUES ($1,$2,$3::jsonb, now() - ($4 || ' minutes')::interval)
|
|
52
|
+
ON CONFLICT (session_id) DO UPDATE SET
|
|
53
|
+
model_usage = EXCLUDED.model_usage, uploaded_at = EXCLUDED.uploaded_at`,
|
|
54
|
+
[sid, B, JSON.stringify(digest(cacheReadM)), String(uploadedAgoMin)]
|
|
55
|
+
);
|
|
56
|
+
const row = async (sid) => (await q(
|
|
57
|
+
`SELECT amount_usd, cache_read_usd, description, recorded_at FROM cost_log
|
|
58
|
+
WHERE source='session:interactive' AND source_ref=$1`, [sid]))[0];
|
|
59
|
+
const rowCount = async (sid) => Number((await q(
|
|
60
|
+
`SELECT count(*) n FROM cost_log WHERE source_ref LIKE $1 || '%'`, [sid]))[0].n);
|
|
61
|
+
// Backdate the priced row, modelling production: the sweep prices an early
|
|
62
|
+
// partial digest, and the session keeps shipping for hours afterwards. The
|
|
63
|
+
// revisit predicate is `cost_log.recorded_at < session_records.uploaded_at`,
|
|
64
|
+
// so the row must genuinely predate the digest growth for a raise to be due.
|
|
65
|
+
const backdateRow = async (sid, minsAgo) => q(
|
|
66
|
+
`UPDATE cost_log SET recorded_at = now() - ($2 || ' minutes')::interval
|
|
67
|
+
WHERE source='session:interactive' AND source_ref=$1`, [sid, String(minsAgo)]);
|
|
68
|
+
|
|
69
|
+
const cleanup = async () => {
|
|
70
|
+
await q(`DELETE FROM cost_log WHERE source_ref LIKE 'sess-raise-%'`);
|
|
71
|
+
await q(`DELETE FROM session_records WHERE session_id LIKE 'sess-raise-%'`);
|
|
72
|
+
await q(`DELETE FROM builders WHERE id=$1`, [B]);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await cleanup();
|
|
76
|
+
await q(`INSERT INTO builders (id, github_id, github_login, display_name, rank)
|
|
77
|
+
VALUES ($1, 990000411, 'fixture-raise','fixture-raise','xenos')
|
|
78
|
+
ON CONFLICT (id) DO NOTHING`, [B]);
|
|
79
|
+
|
|
80
|
+
test('1003217 a never-priced session gets its row (the original task-1366 job)', async () => {
|
|
81
|
+
await seedDigest(S1, 10, 30); // 10M cache_read = $5.00
|
|
82
|
+
const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
|
|
83
|
+
assert.ok(r.scanned >= 1, 'the unpriced session is scanned');
|
|
84
|
+
const got = await row(S1);
|
|
85
|
+
assert.equal(Number(got.amount_usd), 5, 'priced at $5.00');
|
|
86
|
+
assert.match(got.description, /client cost-hook did not post/, 'first write keeps its provenance wording');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('1003217 the row is RAISED when the digest grows (the frozen-row bug)', async () => {
|
|
90
|
+
// Priced an hour ago, off the first ship's partial digest...
|
|
91
|
+
await backdateRow(S1, 60);
|
|
92
|
+
// ...then the session shipped more tasks: the digest is replaced with the
|
|
93
|
+
// fuller transcript figure and uploaded_at moves forward.
|
|
94
|
+
await seedDigest(S1, 100, 20); // 100M cache_read = $50.00
|
|
95
|
+
const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
|
|
96
|
+
assert.equal(r.raised, 1, 'exactly one row raised');
|
|
97
|
+
const got = await row(S1);
|
|
98
|
+
assert.equal(Number(got.amount_usd), 50, 'row caught up from $5 to $50');
|
|
99
|
+
assert.equal(Number(got.cache_read_usd), 50, 'the tier split is rewritten too, not left stale');
|
|
100
|
+
assert.match(got.description, /raised from \$5\.0000 as the session grew/, 'the raise is legible in the ledger');
|
|
101
|
+
// Only the increment is billed as this sweep's spend.
|
|
102
|
+
assert.ok(Math.abs(r.totalUsd - 45) < 1e-6, `totalUsd should be the $45 increment, got ${r.totalUsd}`);
|
|
103
|
+
assert.equal(await rowCount(S1), 1, 'still ONE row — a raise must never fork a second');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('1003217 the sweep converges: a settled session books nothing on the next run', async () => {
|
|
107
|
+
const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
|
|
108
|
+
assert.equal(r.raised, 0, 'nothing left to raise');
|
|
109
|
+
assert.equal(r.reconciled, 0, 'nothing left to insert');
|
|
110
|
+
assert.equal(Number((await row(S1)).amount_usd), 50, 'the row is unchanged');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('1003217 the raise is MONOTONIC — a smaller figure never lowers a paid row', async () => {
|
|
114
|
+
// This is the "do not re-price history" guarantee: after the dedup fix
|
|
115
|
+
// (bug B) a re-priced old session computes LOWER, and llm-pricing.js is
|
|
116
|
+
// explicit that a paid row records what was charged. It must hold.
|
|
117
|
+
await backdateRow(S1, 60);
|
|
118
|
+
await seedDigest(S1, 4, 20); // would be $2.00 — far below the $50 on file
|
|
119
|
+
const r = await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
|
|
120
|
+
assert.equal(r.raised, 0, 'a downward move is not a raise');
|
|
121
|
+
assert.equal(Number((await row(S1)).amount_usd), 50, 'the row held at $50');
|
|
122
|
+
assert.ok(Math.abs(r.totalUsd) < 1e-9, 'and nothing was billed');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('1003217 a client-split session (:sub) is still left entirely alone', async () => {
|
|
126
|
+
await seedDigest(S2, 10, 30);
|
|
127
|
+
await q(`INSERT INTO cost_log (amount_usd, category, source, source_ref, builder_id)
|
|
128
|
+
VALUES (1.23,'api','session:subagent',$1 || ':sub',$2)`, [S2, B]);
|
|
129
|
+
const before = await rowCount(S2);
|
|
130
|
+
await cost.reconcileSessionCosts({ olderThanMinutes: 15 });
|
|
131
|
+
assert.equal(await rowCount(S2), before, 'no interactive row invented alongside the client split');
|
|
132
|
+
assert.equal(await row(S2), undefined, 'specifically: no session:interactive row for it');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('cleanup', async () => {
|
|
136
|
+
await cleanup();
|
|
137
|
+
if (!cost.pool) await pool.end().catch(() => {});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -405,3 +405,75 @@ test('a failed/empty subagent spawn digests to 0 tokens despite 1 assistant turn
|
|
|
405
405
|
console.log('');
|
|
406
406
|
console.log(`Session-records (6D.1) tests: ${passed} passed, ${failed} failed`);
|
|
407
407
|
if (failed > 0) process.exit(1);
|
|
408
|
+
|
|
409
|
+
// ---------- task 1003217: one API call is many transcript lines ----------
|
|
410
|
+
//
|
|
411
|
+
// Claude Code writes a separate line per content block of one assistant message
|
|
412
|
+
// (thinking / text / tool_use) and repeats the SAME message.usage on each. The
|
|
413
|
+
// digest must price the call ONCE. Measured 2.22x inflation on real transcripts
|
|
414
|
+
// before this fix.
|
|
415
|
+
function splitCallFixture() {
|
|
416
|
+
const usage = {
|
|
417
|
+
input_tokens: 2,
|
|
418
|
+
output_tokens: 799,
|
|
419
|
+
cache_read_input_tokens: 63182,
|
|
420
|
+
cache_creation_input_tokens: 3227,
|
|
421
|
+
};
|
|
422
|
+
const mk = (content) => ({
|
|
423
|
+
type: 'assistant',
|
|
424
|
+
timestamp: '2026-08-21T19:23:47.000Z',
|
|
425
|
+
message: { id: 'msg_01SPLIT', model: 'claude-opus-5', usage, content },
|
|
426
|
+
});
|
|
427
|
+
return [
|
|
428
|
+
mk([{ type: 'thinking', thinking: 'weighing it up' }]),
|
|
429
|
+
mk([{ type: 'text', text: 'Here is the plan.' }]),
|
|
430
|
+
mk([{ type: 'tool_use', name: 'Bash', input: { command: 'ls' } }]),
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
test('1003217 buildSessionDigest prices one split assistant message ONCE, not per line', () => {
|
|
435
|
+
const d = buildSessionDigest(splitCallFixture());
|
|
436
|
+
// 3 transcript lines, 1 API call.
|
|
437
|
+
assert.equal(d.metrics.assistant_turns, 3, 'assistant_turns stays the LINE count for existing consumers');
|
|
438
|
+
assert.equal(d.metrics.api_calls, 1, 'api_calls is the honest round-trip count');
|
|
439
|
+
assert.equal(d.metrics.tokens.output, 799, 'output counted once, not 3x');
|
|
440
|
+
assert.equal(d.metrics.tokens.cache_read, 63182, 'cache_read counted once, not 3x');
|
|
441
|
+
assert.equal(d.metrics.tokens.cache_creation, 3227);
|
|
442
|
+
assert.equal(d.metrics.tokens.input, 2);
|
|
443
|
+
assert.equal(d.totalTokens, 2 + 799 + 63182 + 3227);
|
|
444
|
+
assert.deepEqual(d.metrics.model_usage['claude-opus-5'], {
|
|
445
|
+
input: 2, output: 799, cache_read: 63182, cache_creation: 3227,
|
|
446
|
+
}, 'model_usage — the cost-plus reward basis (ADR 0054) — must not be inflated');
|
|
447
|
+
// The tool_use on the third line is still seen.
|
|
448
|
+
assert.equal(d.metrics.tool_use_count, 1);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('1003217 the detail rows still sum to the true usage (repeats zeroed + flagged)', () => {
|
|
452
|
+
const d = buildSessionDigest(splitCallFixture());
|
|
453
|
+
const asst = d.detail.filter((r) => r.role === 'assistant');
|
|
454
|
+
assert.equal(asst.length, 3, 'every line keeps its detail row');
|
|
455
|
+
const sum = (k) => asst.reduce((a, r) => a + (r[k] || 0), 0);
|
|
456
|
+
assert.equal(sum('cache_read'), 63182, 'summing detail must not double-count');
|
|
457
|
+
assert.equal(sum('out'), 799);
|
|
458
|
+
assert.equal(asst.filter((r) => r.dup === 1).length, 2, 'the two repeat lines are flagged dup');
|
|
459
|
+
assert.equal(asst[0].dup, undefined, 'the first line of the call is not a dup');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('1003217 distinct message ids are still counted separately', () => {
|
|
463
|
+
const usage = { input_tokens: 0, output_tokens: 10, cache_read_input_tokens: 100, cache_creation_input_tokens: 0 };
|
|
464
|
+
const mk = (id) => ({
|
|
465
|
+
type: 'assistant',
|
|
466
|
+
timestamp: '2026-08-21T19:23:47.000Z',
|
|
467
|
+
message: { id, model: 'claude-opus-5', usage, content: [{ type: 'text', text: 'x' }] },
|
|
468
|
+
});
|
|
469
|
+
const d = buildSessionDigest([mk('msg_A'), mk('msg_B'), mk('msg_C')]);
|
|
470
|
+
assert.equal(d.metrics.api_calls, 3);
|
|
471
|
+
assert.equal(d.metrics.tokens.cache_read, 300, 'three real calls = three times the usage');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('1003217 a transcript with no message.id degrades to the old per-line behaviour', () => {
|
|
475
|
+
const d = buildSessionDigest(fixture()); // fixture() messages carry no id
|
|
476
|
+
assert.equal(d.metrics.tokens.input, 300, 'unchanged from the pre-fix expectation');
|
|
477
|
+
assert.equal(d.metrics.tokens.output, 130);
|
|
478
|
+
assert.equal(d.metrics.api_calls, 2, 'with no id to dedup on, every line is its own call');
|
|
479
|
+
});
|