@bongos/core 1.19.443 → 1.19.445
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 +120 -20
- package/docs/adr/0054-session-token-reward-cost-plus.md +31 -0
- package/docs/design/hall-direction-v2.md +295 -0
- package/docs/design/mocks/hall/_tokens.css +370 -0
- package/docs/design/mocks/hall/board/v1.html +846 -0
- package/docs/design/mocks/hall/board/v2.html +858 -0
- package/docs/design/mocks/hall/board/v3.html +884 -0
- package/docs/design/mocks/hall/goals/v1.html +1212 -0
- package/docs/design/mocks/hall/goals/v2.html +1192 -0
- package/docs/design/mocks/hall/goals/v3.html +1205 -0
- package/docs/design/mocks/hall/hall-sheet.html +244 -0
- package/docs/design/mocks/hall/reading/v1.html +1125 -0
- package/docs/design/mocks/hall/reading/v2.html +1177 -0
- package/docs/design/mocks/hall/reading/v3.html +1150 -0
- package/docs/design/mocks/hall/settings/v1.html +1238 -0
- package/docs/design/mocks/hall/settings/v2.html +1235 -0
- package/docs/design/mocks/hall/settings/v3.html +1299 -0
- package/docs/design/mocks/hall/task/v1.html +637 -0
- package/docs/design/mocks/hall/task/v2.html +646 -0
- package/docs/design/mocks/hall/task/v3.html +651 -0
- package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/credits.js +46 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-subagent-rewards.js +485 -0
- 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/backfill_subagent_rewards.mjs +308 -0
- package/tests/credit_grant.mjs +236 -0
|
@@ -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.445'; // 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');
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// tests/backfill_subagent_rewards.mjs
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for the subagent cost-plus backfill planner (task 1003436, ADR 0054).
|
|
4
|
+
//
|
|
5
|
+
// This script decides who gets paid real credits, so the whole decision table is
|
|
6
|
+
// pure and pinned here with no DB: which sessions are payable, which are refused
|
|
7
|
+
// and WHY, and — the part that actually protects the ledger — that the classes
|
|
8
|
+
// which cannot be trusted (pre-de-dup-fix inflated rows, unrecoverable sessions,
|
|
9
|
+
// the ADR 0173 legacy class) never reach the mint.
|
|
10
|
+
//
|
|
11
|
+
// Run: node tests/backfill_subagent_rewards.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
import { makeRunner } from './helpers.mjs';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const {
|
|
19
|
+
CLASSES, DEDUP_FIX_AT, classifySession, buildBackfillPlan, renderReport,
|
|
20
|
+
} = require('../scripts/gds/backfill-subagent-rewards.js');
|
|
21
|
+
const {
|
|
22
|
+
computeSessionTokenReward, sessionTrueCostUsd, awardSessionTokenReward,
|
|
23
|
+
} = require('../modules/economy/credits.js');
|
|
24
|
+
|
|
25
|
+
const { test, summary } = makeRunner();
|
|
26
|
+
|
|
27
|
+
const AFTER = '2026-08-25T00:00:00Z'; // safely after the de-dup cutoff
|
|
28
|
+
const BEFORE = '2026-08-20T00:00:00Z'; // safely before it
|
|
29
|
+
const OPTS = { cutoffMs: Date.parse(DEDUP_FIX_AT), marginPct: 20, usdPeg: 1 };
|
|
30
|
+
|
|
31
|
+
// 1M opus input tokens = $5.00 interactive (PRICES.opus.input = $5/M).
|
|
32
|
+
const OPUS_1M = { 'claude-opus-4-8': { input: 1_000_000 } };
|
|
33
|
+
|
|
34
|
+
function row(over = {}) {
|
|
35
|
+
return {
|
|
36
|
+
session_id: 'sess-aaaaaaaa',
|
|
37
|
+
builder_id: 7,
|
|
38
|
+
model_usage: OPUS_1M,
|
|
39
|
+
total_tokens: 1_000_000,
|
|
40
|
+
subagent_usd: 3.0,
|
|
41
|
+
subagent_recorded_at: AFTER,
|
|
42
|
+
has_pre_split_cost_row: false,
|
|
43
|
+
has_folded_usage: false, // pre-1003435 digest: model_usage is interactive-only
|
|
44
|
+
paid: 6, // round(5.00 x 1.20) — what the subagent-blind reward paid
|
|
45
|
+
legacy_rows: 0,
|
|
46
|
+
...over,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// =========================================================================
|
|
51
|
+
// classifySession — the decision table
|
|
52
|
+
// =========================================================================
|
|
53
|
+
|
|
54
|
+
await test('payable: owed is priced on interactive + subagent, minus what was paid', () => {
|
|
55
|
+
const r = classifySession(row(), OPTS);
|
|
56
|
+
assert.equal(r.klass, CLASSES.PAYABLE);
|
|
57
|
+
assert.equal(r.interactiveUsd, 5.0);
|
|
58
|
+
assert.equal(r.subagentUsd, 3.0);
|
|
59
|
+
assert.equal(r.trueCostUsd, 8.0);
|
|
60
|
+
assert.equal(r.owed, 10); // round(8.00 x 1.20) = 9.6 -> 10
|
|
61
|
+
assert.equal(r.paid, 6);
|
|
62
|
+
assert.equal(r.delta, 4);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await test('payable: the interactive side comes from the SAME function the reward books from', () => {
|
|
66
|
+
// If these ever diverge the backfill would silently re-price what was already
|
|
67
|
+
// paid instead of only adding the missing leg.
|
|
68
|
+
const r = classifySession(row(), OPTS);
|
|
69
|
+
const { trueCostUsd } = sessionTrueCostUsd(OPUS_1M, 1_000_000);
|
|
70
|
+
assert.equal(r.interactiveUsd, trueCostUsd);
|
|
71
|
+
assert.equal(r.owed, computeSessionTokenReward(trueCostUsd + 3.0, 20, 1).drachmae);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await test('up_to_date: a session already paid its full worth mints nothing', () => {
|
|
75
|
+
const r = classifySession(row({ paid: 10 }), OPTS);
|
|
76
|
+
assert.equal(r.klass, CLASSES.UP_TO_DATE);
|
|
77
|
+
assert.equal(r.delta, 0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('overpaid: paid ABOVE its worth is reported, never clawed back', () => {
|
|
81
|
+
const r = classifySession(row({ paid: 25 }), OPTS);
|
|
82
|
+
assert.equal(r.klass, CLASSES.OVERPAID);
|
|
83
|
+
assert.equal(r.excess, 15);
|
|
84
|
+
assert.equal(r.delta, 0, 'a negative delta must never become a mint');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('pre_dedup_fix: an inflated cost row is NEVER paid from', () => {
|
|
88
|
+
// Rows written before task 1003217 price one API call once per transcript line
|
|
89
|
+
// (~2.22x). Paying from them would overpay, so they are refused by DATE.
|
|
90
|
+
const r = classifySession(row({ subagent_recorded_at: BEFORE }), OPTS);
|
|
91
|
+
assert.equal(r.klass, CLASSES.PRE_DEDUP_FIX);
|
|
92
|
+
assert.equal(r.delta, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('--since moves the trust cutoff (a machine on an older core keeps writing inflated rows)', () => {
|
|
96
|
+
const late = { ...OPTS, cutoffMs: Date.parse('2026-08-28T00:00:00Z') };
|
|
97
|
+
assert.equal(classifySession(row(), late).klass, CLASSES.PRE_DEDUP_FIX);
|
|
98
|
+
assert.equal(classifySession(row(), OPTS).klass, CLASSES.PAYABLE);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test('no_subagent_record: no cost row means unrecoverable — never guessed at', () => {
|
|
102
|
+
const r = classifySession(row({ subagent_usd: null, subagent_recorded_at: null }), OPTS);
|
|
103
|
+
assert.equal(r.klass, CLASSES.NO_SUBAGENT_RECORD);
|
|
104
|
+
assert.equal(r.delta, 0);
|
|
105
|
+
assert.equal(r.subagentUsd, undefined, 'no invented figure');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await test('pre_split: a legacy single `session` row lumps both legs and cannot be separated', () => {
|
|
109
|
+
const r = classifySession(
|
|
110
|
+
row({ subagent_usd: null, subagent_recorded_at: null, has_pre_split_cost_row: true }), OPTS
|
|
111
|
+
);
|
|
112
|
+
assert.equal(r.klass, CLASSES.PRE_SPLIT);
|
|
113
|
+
assert.equal(r.delta, 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
// The double-count guard. This is the one that would actually mint bad money:
|
|
118
|
+
// after task 1003435 the digest folds the subagents subtree INTO model_usage, so
|
|
119
|
+
// adding the cost_log subagent row on top pays the same leg twice.
|
|
120
|
+
// -------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
await test('already_folded: a post-1003435 digest already prices the subagent leg — never topped up', () => {
|
|
123
|
+
const r = classifySession(row({ has_folded_usage: true }), OPTS);
|
|
124
|
+
assert.equal(r.klass, CLASSES.ALREADY_FOLDED);
|
|
125
|
+
assert.equal(r.delta, 0, 'adding the cost row here would DOUBLE-COUNT and overpay');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('already_folded is decided BEFORE any money is computed', () => {
|
|
129
|
+
// A large subagent row must not be able to leak a figure into the plan via a
|
|
130
|
+
// field that later code reads.
|
|
131
|
+
const r = classifySession(row({ has_folded_usage: true, subagent_usd: 500 }), OPTS);
|
|
132
|
+
assert.equal(r.klass, CLASSES.ALREADY_FOLDED);
|
|
133
|
+
assert.equal(r.owed, undefined);
|
|
134
|
+
assert.equal(r.subagentUsd, undefined);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await test('the guard keys on the DATA, not a date — a re-uploaded old session is caught too', () => {
|
|
138
|
+
// A session that shipped before the fix but whose SessionEnd digest was
|
|
139
|
+
// re-uploaded after it has folded model_usage AND an old subagent cost row.
|
|
140
|
+
// The date would say "payable"; the counters say "already whole".
|
|
141
|
+
const r = classifySession(
|
|
142
|
+
row({ has_folded_usage: true, subagent_recorded_at: BEFORE }), OPTS
|
|
143
|
+
);
|
|
144
|
+
assert.equal(r.klass, CLASSES.ALREADY_FOLDED);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await test('a folded session never reaches the mint, and is reported', () => {
|
|
148
|
+
const plan = buildBackfillPlan([
|
|
149
|
+
row({ session_id: 's1' }), // +4
|
|
150
|
+
row({ session_id: 's2', has_folded_usage: true }), // excluded
|
|
151
|
+
], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
|
|
152
|
+
assert.equal(plan.grandTotal, 4, 'only the un-folded session is paid');
|
|
153
|
+
assert.equal(plan.counts[CLASSES.ALREADY_FOLDED], 1);
|
|
154
|
+
assert.match(renderReport(plan).text, /already_folded/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await test('legacy: the reward_basis=0 class ADR 0173 declined is never folded in here', () => {
|
|
158
|
+
const r = classifySession(row({ legacy_rows: 2 }), OPTS);
|
|
159
|
+
assert.equal(r.klass, CLASSES.LEGACY);
|
|
160
|
+
assert.equal(r.delta, 0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await test('legacy wins over payable — the class is decided before any money is computed', () => {
|
|
164
|
+
const r = classifySession(row({ legacy_rows: 1, subagent_usd: 100 }), OPTS);
|
|
165
|
+
assert.equal(r.klass, CLASSES.LEGACY);
|
|
166
|
+
assert.equal(r.owed, undefined);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// =========================================================================
|
|
170
|
+
// buildBackfillPlan — grouping + the grand total the owner signs off on
|
|
171
|
+
// =========================================================================
|
|
172
|
+
|
|
173
|
+
await test('the plan groups payables by builder and totals them', () => {
|
|
174
|
+
const plan = buildBackfillPlan([
|
|
175
|
+
row({ session_id: 's1', builder_id: 7 }), // +4
|
|
176
|
+
row({ session_id: 's2', builder_id: 7, subagent_usd: 10 }), // owed round(15x1.2)=18, -6 => +12
|
|
177
|
+
row({ session_id: 's3', builder_id: 9 }), // +4
|
|
178
|
+
row({ session_id: 's4', builder_id: 9, legacy_rows: 1 }), // not paid
|
|
179
|
+
row({ session_id: 's5', builder_id: 9, subagent_recorded_at: BEFORE }), // not paid
|
|
180
|
+
], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
|
|
181
|
+
|
|
182
|
+
assert.equal(plan.totalCandidates, 5);
|
|
183
|
+
assert.equal(plan.plans.length, 2);
|
|
184
|
+
assert.deepEqual(plan.plans.map((p) => [p.builderId, p.toMint]), [[7, 16], [9, 4]]);
|
|
185
|
+
assert.equal(plan.grandTotal, 20);
|
|
186
|
+
assert.equal(plan.counts[CLASSES.PAYABLE], 3);
|
|
187
|
+
assert.equal(plan.counts[CLASSES.LEGACY], 1);
|
|
188
|
+
assert.equal(plan.counts[CLASSES.PRE_DEDUP_FIX], 1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await test('the plan reports what it does NOT settle (ADR 0173 objected to a silent partial)', () => {
|
|
192
|
+
const plan = buildBackfillPlan([
|
|
193
|
+
row({ session_id: 's1', subagent_usd: 7.5, subagent_recorded_at: BEFORE }),
|
|
194
|
+
row({ session_id: 's2', paid: 25 }),
|
|
195
|
+
], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
|
|
196
|
+
assert.equal(plan.unpaidSubagentUsd, 7.5, 'inflated-but-real spend is surfaced, not hidden');
|
|
197
|
+
assert.equal(plan.overpaidExcess, 15, 'correcting in one direction only is stated');
|
|
198
|
+
assert.equal(plan.grandTotal, 0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test('an empty ledger plans nothing and totals zero', () => {
|
|
202
|
+
const plan = buildBackfillPlan([], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
|
|
203
|
+
assert.equal(plan.grandTotal, 0);
|
|
204
|
+
assert.equal(plan.plans.length, 0);
|
|
205
|
+
assert.match(renderReport(plan).text, /NOTHING PAYABLE/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await test('the report leads with the grand total and names every refusal reason', () => {
|
|
209
|
+
const plan = buildBackfillPlan([row(), row({ session_id: 's2', legacy_rows: 1 })],
|
|
210
|
+
{ since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
|
|
211
|
+
const { text, total } = renderReport(plan, { loginFor: (id) => (id === 7 ? 'jaxri' : null) });
|
|
212
|
+
assert.equal(total, 4);
|
|
213
|
+
assert.match(text, /GRAND TOTAL to mint: 4 credits/);
|
|
214
|
+
assert.match(text, /jaxri: \+4/);
|
|
215
|
+
for (const k of [CLASSES.PRE_DEDUP_FIX, CLASSES.NO_SUBAGENT_RECORD, CLASSES.PRE_SPLIT, CLASSES.LEGACY]) {
|
|
216
|
+
assert.ok(text.includes(k), `the report must name the ${k} class`);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// =========================================================================
|
|
221
|
+
// The mint path — idempotency + the forward-path invariant.
|
|
222
|
+
//
|
|
223
|
+
// These are the properties that keep a backfill from corrupting the ledger, so
|
|
224
|
+
// they are asserted against the REAL writer with a fake client, not described.
|
|
225
|
+
// =========================================================================
|
|
226
|
+
|
|
227
|
+
function fakeClient(ledgerRows) {
|
|
228
|
+
const queries = [];
|
|
229
|
+
const responses = [...ledgerRows];
|
|
230
|
+
return {
|
|
231
|
+
queries,
|
|
232
|
+
async query(sql, params) {
|
|
233
|
+
queries.push({ sql, params });
|
|
234
|
+
return { rows: responses.shift() || [] };
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const ledger = (paid = 0, legacy = 0) => [{ paid, legacy }];
|
|
239
|
+
|
|
240
|
+
await test('the mint goes through the real reward writer, so extraCostUsd raises the basis', async () => {
|
|
241
|
+
const client = fakeClient([ledger(6, 0), [{ id: 1 }]]);
|
|
242
|
+
const res = await awardSessionTokenReward(client, 7, 'sess-aaaaaaaa', OPUS_1M, 1_000_000, 3.0);
|
|
243
|
+
assert.equal(res.awarded, true);
|
|
244
|
+
assert.equal(res.trueCostUsd, 8.0, 'interactive $5 + recovered subagent $3');
|
|
245
|
+
assert.equal(res.drachmae, 4, 'books only the INCREMENT over what was paid');
|
|
246
|
+
const insert = client.queries[1];
|
|
247
|
+
assert.match(insert.sql, /session\.token_reward/);
|
|
248
|
+
assert.match(insert.sql, /ON CONFLICT \(session_id, reward_basis\)/);
|
|
249
|
+
// reward_basis is the CUMULATIVE owed total — the last positional.
|
|
250
|
+
assert.equal(insert.params[insert.params.length - 1], 10);
|
|
251
|
+
assert.equal(insert.params[1], 4, 'delta booked = owed - paid');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await test('re-running the backfill is a no-op — the watermark, not a private reason key', async () => {
|
|
255
|
+
// Second run: the ledger now shows the topped-up total, so owed - paid = 0.
|
|
256
|
+
const client = fakeClient([ledger(10, 0)]);
|
|
257
|
+
const res = await awardSessionTokenReward(client, 7, 'sess-aaaaaaaa', OPUS_1M, 1_000_000, 3.0);
|
|
258
|
+
assert.equal(res.awarded, false);
|
|
259
|
+
assert.equal(res.drachmae, 0);
|
|
260
|
+
assert.equal(res.settled, 'up_to_date');
|
|
261
|
+
assert.equal(client.queries.length, 1, 'SELECT only — no second INSERT');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await test('FORWARD PATH: a later ship in a backfilled session books only its own increment', async () => {
|
|
265
|
+
// The reason the backfill is written under the NORMAL reason rather than its
|
|
266
|
+
// own key: the watermark reads reason='session.token_reward', so a private key
|
|
267
|
+
// would be invisible to it and the next ship would pay the subagent leg AGAIN.
|
|
268
|
+
// Here the next ship uploads a digest whose model_usage now INCLUDES the
|
|
269
|
+
// subagent tokens (task 1003435) — $8 of usage, already backfilled to 10.
|
|
270
|
+
const eight = { 'claude-opus-4-8': { input: 1_600_000 } }; // 1.6M x $5/M = $8.00
|
|
271
|
+
const client = fakeClient([ledger(10, 0)]);
|
|
272
|
+
const res = await awardSessionTokenReward(client, 7, 'sess-aaaaaaaa', eight, 1_600_000);
|
|
273
|
+
assert.equal(res.trueCostUsd, 8.0);
|
|
274
|
+
assert.equal(res.awarded, false, 'must NOT re-pay the leg the backfill settled');
|
|
275
|
+
assert.equal(client.queries.length, 1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await test('a backfill can never clawback: a smaller basis books nothing, never a negative row', async () => {
|
|
279
|
+
const client = fakeClient([ledger(99, 0)]);
|
|
280
|
+
const res = await awardSessionTokenReward(client, 7, 'sess-aaaaaaaa', OPUS_1M, 1_000_000, 3.0);
|
|
281
|
+
assert.equal(res.awarded, false);
|
|
282
|
+
assert.equal(res.drachmae, 0);
|
|
283
|
+
assert.equal(client.queries.length, 1, 'no INSERT — the ledger is append-only');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await test('the legacy refusal still bites at the writer, not only in the planner', async () => {
|
|
287
|
+
const client = fakeClient([ledger(3, 1)]); // one reward_basis=0 row
|
|
288
|
+
const res = await awardSessionTokenReward(client, 7, 'sess-legacy', OPUS_1M, 1_000_000, 3.0);
|
|
289
|
+
assert.equal(res.awarded, false);
|
|
290
|
+
assert.equal(res.settled, 'legacy', 'ADR 0173 class is refused twice over');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await test('extraCostUsd defaults to 0, so every pre-existing call site is unchanged', async () => {
|
|
294
|
+
const client = fakeClient([ledger(0, 0), [{ id: 9 }]]);
|
|
295
|
+
const res = await awardSessionTokenReward(client, 7, 'sess-plain', OPUS_1M, 1_000_000);
|
|
296
|
+
assert.equal(res.trueCostUsd, 5.0);
|
|
297
|
+
assert.equal(res.drachmae, 6);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await test('a negative, infinite or garbage extraCostUsd cannot inflate a payout', async () => {
|
|
301
|
+
for (const bad of [-100, NaN, Infinity, -Infinity, 'lots', null, undefined]) {
|
|
302
|
+
const client = fakeClient([ledger(0, 0), [{ id: 1 }]]);
|
|
303
|
+
const res = await awardSessionTokenReward(client, 7, 'sess-x', OPUS_1M, 1_000_000, bad);
|
|
304
|
+
assert.equal(res.trueCostUsd, 5.0, `extraCostUsd ${String(bad)} must be treated as 0`);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
summary();
|