@bongos/core 1.19.316 → 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 +108 -33
- package/modules/economy/cost.js +84 -19
- package/modules/lifecycle/db-analytics.js +719 -0
- package/modules/lifecycle/db-claim-reads.js +165 -0
- package/modules/lifecycle/db-claims.js +934 -0
- package/modules/lifecycle/db-deps-criteria.js +441 -0
- package/modules/lifecycle/db-goals.js +763 -0
- package/modules/lifecycle/db-grade.js +233 -0
- package/modules/lifecycle/db-overrides.js +181 -0
- package/modules/lifecycle/db-rank-authz.js +446 -0
- package/modules/lifecycle/db-shared.js +115 -0
- package/modules/lifecycle/db-ship.js +994 -0
- package/modules/lifecycle/db-tasks.js +810 -0
- package/modules/lifecycle/db-versions.js +118 -0
- package/modules/lifecycle/db.js +167 -5564
- package/modules/lifecycle/publish-reconciler.js +7 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/coreB-carve-proof.sh +18 -9
- package/scripts/gds/fitness.js +34 -26
- package/scripts/gds/identity-strip.js +75 -0
- package/scripts/gds/provision.js +7 -3
- 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 +5 -1
- package/tests/auto_satisfy_criteria.mjs +3 -2
- package/tests/dependencies_migration.mjs +3 -2
- package/tests/fitness.mjs +43 -0
- package/tests/goal_abuse_matrix.mjs +3 -2
- package/tests/goal_advisory.mjs +3 -2
- package/tests/government_claim_eligibility.mjs +3 -1
- package/tests/government_rank_sync.mjs +9 -2
- package/tests/helpers.mjs +19 -0
- package/tests/migration_allocation.mjs +3 -1
- package/tests/publish_reconciler.mjs +41 -1
- package/tests/rank_gate.mjs +3 -1
- package/tests/reward_gate.mjs +3 -1
- package/tests/session_cost.mjs +64 -0
- package/tests/session_cost_row_raise.mjs +139 -0
- package/tests/session_records.mjs +72 -0
|
@@ -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
|
+
});
|