@bongos/core 1.19.154 → 1.19.155
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 +18 -13
- package/modules/sessions/db.js +133 -4
- package/modules/sessions/pollers/session-reward-reconcile.js +13 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/session_attach_from_claim.mjs +188 -0
- package/tests/session_reward_on_land.mjs +3 -1
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.155",
|
|
6
|
+
"core_contract": "1.19.155",
|
|
7
|
+
"source_commit": "64b481f3953b847b3950e606203102e1d130fbc9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-16T16:05:24.376Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 304,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1255,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1580,
|
|
20
|
+
"tree_sha256": "6da1e49fcc1c83ee83beaf54277f2ab817dba9dea35185de47e0528a4e9e53aa",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -4107,7 +4107,7 @@
|
|
|
4107
4107
|
{
|
|
4108
4108
|
"path": "modules/sessions/db.js",
|
|
4109
4109
|
"mode": "0000644",
|
|
4110
|
-
"sha256": "
|
|
4110
|
+
"sha256": "65cb78d4a3e0507df5d2b37ac99cdb3d762b98de5c34996f64c6de7ad38dc71a"
|
|
4111
4111
|
},
|
|
4112
4112
|
{
|
|
4113
4113
|
"path": "modules/sessions/module.json",
|
|
@@ -4117,7 +4117,7 @@
|
|
|
4117
4117
|
{
|
|
4118
4118
|
"path": "modules/sessions/pollers/session-reward-reconcile.js",
|
|
4119
4119
|
"mode": "0000644",
|
|
4120
|
-
"sha256": "
|
|
4120
|
+
"sha256": "32942d1169776ff1b2a32b85269c285f52167a6f3a7add7e38f74f108a9f3f42"
|
|
4121
4121
|
},
|
|
4122
4122
|
{
|
|
4123
4123
|
"path": "modules/sessions/pulse.js",
|
|
@@ -4187,12 +4187,12 @@
|
|
|
4187
4187
|
{
|
|
4188
4188
|
"path": "package-lock.json",
|
|
4189
4189
|
"mode": "0000644",
|
|
4190
|
-
"sha256": "
|
|
4190
|
+
"sha256": "356b00e692fa069d4f14d94317d0c126ef5c35879491be3fb41ea7c8536d3059"
|
|
4191
4191
|
},
|
|
4192
4192
|
{
|
|
4193
4193
|
"path": "package.json",
|
|
4194
4194
|
"mode": "0000644",
|
|
4195
|
-
"sha256": "
|
|
4195
|
+
"sha256": "62448d273d09db942d8c9fa123c8a6238070ca9bf754a6ecf0ea664d2c170df5"
|
|
4196
4196
|
},
|
|
4197
4197
|
{
|
|
4198
4198
|
"path": "public-docs/index.html",
|
|
@@ -5782,7 +5782,7 @@
|
|
|
5782
5782
|
{
|
|
5783
5783
|
"path": "src/module-api.js",
|
|
5784
5784
|
"mode": "0000644",
|
|
5785
|
-
"sha256": "
|
|
5785
|
+
"sha256": "bc766f15de530f14ccf76f0b280e58f4ec594efebea6570744012fc5483b98d4"
|
|
5786
5786
|
},
|
|
5787
5787
|
{
|
|
5788
5788
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7579,6 +7579,11 @@
|
|
|
7579
7579
|
"mode": "0000644",
|
|
7580
7580
|
"sha256": "921c24a356d0fc1f398563bab3e8bb6f0831919a6954d86b6d74fdc60ff3b5f9"
|
|
7581
7581
|
},
|
|
7582
|
+
{
|
|
7583
|
+
"path": "tests/session_attach_from_claim.mjs",
|
|
7584
|
+
"mode": "0000644",
|
|
7585
|
+
"sha256": "e76a3baf5fb2cdee08ffe8d80c55630b5ab4f708046f3c63c07c480926ccda31"
|
|
7586
|
+
},
|
|
7582
7587
|
{
|
|
7583
7588
|
"path": "tests/session_caps.mjs",
|
|
7584
7589
|
"mode": "0000644",
|
|
@@ -7612,7 +7617,7 @@
|
|
|
7612
7617
|
{
|
|
7613
7618
|
"path": "tests/session_reward_on_land.mjs",
|
|
7614
7619
|
"mode": "0000644",
|
|
7615
|
-
"sha256": "
|
|
7620
|
+
"sha256": "52be7bd7afd17f7f7d1c93efb9be32902d08062cd65f35e3e3709a0e3669050f"
|
|
7616
7621
|
},
|
|
7617
7622
|
{
|
|
7618
7623
|
"path": "tests/session_reward_reconcile.mjs",
|
package/modules/sessions/db.js
CHANGED
|
@@ -191,6 +191,67 @@ async function upsertSessionRecordWithReward(opts) {
|
|
|
191
191
|
});
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// attachClaimSessionsToTaskTx — SERVER-SIDE task↔session attribution (task
|
|
195
|
+
// 1002964). Every payout path keys on session_records.task_ids, and until this
|
|
196
|
+
// existed exactly ONE thing ever populated that column: ship.js's client-side
|
|
197
|
+
// upload (`task_ids: [taskId]`). When that upload skipped — no
|
|
198
|
+
// CLAUDE_CODE_SESSION_ID, an unsafe id, or a transcript the client couldn't find
|
|
199
|
+
// (classifyUploadSkip, scripts/gds/ship.js) — the session was left with an EMPTY
|
|
200
|
+
// task_ids, which matches NOTHING in awardDeferredSessionRewardForTask,
|
|
201
|
+
// reconcileUnbookedSessionRewards, or the upload-side gate. The reward then
|
|
202
|
+
// stranded PERMANENTLY, not late: even the state-based safety net could not see
|
|
203
|
+
// the row. Builder IAMMASTERQUA hit exactly this — 19 sessions, 19 empty, ~$730
|
|
204
|
+
// of recorded session cost, zero paid, for ten weeks, with the only diagnostic
|
|
205
|
+
// (recordUploadSkip's trace) written to their own laptop.
|
|
206
|
+
//
|
|
207
|
+
// The server does not need the client's help here: a claim already records WHO
|
|
208
|
+
// shipped WHAT and WHEN. So we recover the link from durable state — the
|
|
209
|
+
// builder's session_records whose activity window overlaps the claim window.
|
|
210
|
+
//
|
|
211
|
+
// Design notes:
|
|
212
|
+
// • OVERLAP, not containment: a session that was already running when the
|
|
213
|
+
// claim opened (or outlived it) still did that work. started_at/ended_at
|
|
214
|
+
// fall back to uploaded_at when the transcript carried no span.
|
|
215
|
+
// • OVER-ATTACHMENT IS FINANCIALLY SAFE. The reward is idempotent per
|
|
216
|
+
// session_id (the watermark SELECT + uniq_credit_session_token_reward_basis),
|
|
217
|
+
// so linking one session to several tasks pays it ONCE, never per task. The
|
|
218
|
+
// bounded downside is a longer task_ids list, not double-pay.
|
|
219
|
+
// • Bounded by `limit` so a pathological long-running session can't fan out
|
|
220
|
+
// an unbounded UPDATE.
|
|
221
|
+
// • Does NOT touch the landing gate. It only answers "which session did this
|
|
222
|
+
// task come from"; whether the task LANDED is still checked by every caller
|
|
223
|
+
// (ADR 0120 — never pay for code that didn't land).
|
|
224
|
+
// Returns { attached } (rows freshly linked).
|
|
225
|
+
async function attachClaimSessionsToTaskTx(client, taskId, { limit = 50 } = {}) {
|
|
226
|
+
const id = Number(taskId);
|
|
227
|
+
if (!Number.isFinite(id)) return { attached: 0 };
|
|
228
|
+
const n = Number(limit);
|
|
229
|
+
const cap = Number.isFinite(n) ? Math.max(1, Math.min(500, Math.trunc(n))) : 50;
|
|
230
|
+
const { rows } = await client.query(
|
|
231
|
+
`WITH cand AS (
|
|
232
|
+
SELECT DISTINCT sr.id
|
|
233
|
+
FROM claims c
|
|
234
|
+
JOIN session_records sr ON sr.builder_id = c.builder_id
|
|
235
|
+
WHERE c.task_id = $1
|
|
236
|
+
AND NOT (sr.task_ids @> ARRAY[$1]::bigint[])
|
|
237
|
+
AND COALESCE(sr.started_at, sr.uploaded_at) <= COALESCE(c.released_at, now())
|
|
238
|
+
AND COALESCE(sr.ended_at, sr.uploaded_at) >= c.claimed_at
|
|
239
|
+
ORDER BY sr.id
|
|
240
|
+
LIMIT $2
|
|
241
|
+
)
|
|
242
|
+
UPDATE session_records sr
|
|
243
|
+
SET task_ids = (
|
|
244
|
+
SELECT COALESCE(array_agg(DISTINCT e ORDER BY e), '{}')
|
|
245
|
+
FROM unnest(sr.task_ids || ARRAY[$1]::bigint[]) AS e
|
|
246
|
+
)
|
|
247
|
+
FROM cand
|
|
248
|
+
WHERE cand.id = sr.id
|
|
249
|
+
RETURNING sr.session_id`,
|
|
250
|
+
[id, cap]
|
|
251
|
+
);
|
|
252
|
+
return { attached: rows.length };
|
|
253
|
+
}
|
|
254
|
+
|
|
194
255
|
// awardDeferredSessionRewardForTask — the LAND-side half of ADR 0120 "pay on
|
|
195
256
|
// land". When a task reaches status='shipped', routes/sessions.js's task.shipped
|
|
196
257
|
// listener calls this with the shipped task id. It finds every session_record
|
|
@@ -198,16 +259,24 @@ async function upsertSessionRecordWithReward(opts) {
|
|
|
198
259
|
// ci/async path, where the digest was uploaded (and DEFERRED) before the code
|
|
199
260
|
// landed. Opens its own transaction.
|
|
200
261
|
//
|
|
262
|
+
// It first runs attachClaimSessionsToTaskTx (task 1002964) so a session whose
|
|
263
|
+
// client-side upload skipped is still found: before that, an unsent task_ids
|
|
264
|
+
// made the reward unreachable by every path, permanently.
|
|
265
|
+
//
|
|
201
266
|
// We do NOT pre-filter on token_reward_drachmae: awardSessionTokenReward is
|
|
202
267
|
// idempotent per session_id, so a session that already collected at upload time
|
|
203
268
|
// (sync mode) — or a re-fired event — is a clean no-op. The credit_log is the
|
|
204
269
|
// single source of truth for "already paid"; the mirror column is only
|
|
205
|
-
// observability, so it is never the idempotency key.
|
|
206
|
-
// (rows scanned, rewards freshly booked
|
|
270
|
+
// observability, so it is never the idempotency key.
|
|
271
|
+
// Returns { sessions, awarded, attached } (rows scanned, rewards freshly booked,
|
|
272
|
+
// links freshly recovered).
|
|
207
273
|
async function awardDeferredSessionRewardForTask(taskId) {
|
|
208
274
|
const id = Number(taskId);
|
|
209
|
-
if (!Number.isFinite(id)) return { sessions: 0, awarded: 0 };
|
|
275
|
+
if (!Number.isFinite(id)) return { sessions: 0, awarded: 0, attached: 0 };
|
|
210
276
|
return api.withTx(async (client) => {
|
|
277
|
+
// Recover any link the client-side upload failed to send BEFORE looking for
|
|
278
|
+
// payable sessions — otherwise a skipped upload is unpayable forever.
|
|
279
|
+
const { attached } = await attachClaimSessionsToTaskTx(client, id);
|
|
211
280
|
const { rows } = await client.query(
|
|
212
281
|
`SELECT session_id, builder_id, model_usage, total_tokens
|
|
213
282
|
FROM session_records
|
|
@@ -224,7 +293,7 @@ async function awardDeferredSessionRewardForTask(taskId) {
|
|
|
224
293
|
});
|
|
225
294
|
if (tokenReward && tokenReward.awarded && tokenReward.drachmae > 0) awarded += 1;
|
|
226
295
|
}
|
|
227
|
-
return { sessions: rows.length, awarded };
|
|
296
|
+
return { sessions: rows.length, awarded, attached };
|
|
228
297
|
});
|
|
229
298
|
}
|
|
230
299
|
|
|
@@ -287,6 +356,64 @@ async function reconcileUnbookedSessionRewards({ limit = 200 } = {}) {
|
|
|
287
356
|
});
|
|
288
357
|
}
|
|
289
358
|
|
|
359
|
+
// reconcileUnattachedClaimSessions — the ATTRIBUTION half of the safety net
|
|
360
|
+
// (task 1002964), and the reason reconcileUnbookedSessionRewards alone was not
|
|
361
|
+
// enough. That sweep starts from session_records.task_ids, so a session the
|
|
362
|
+
// client never linked is invisible to it — the very rows most in need of rescue
|
|
363
|
+
// are the ones it cannot see. This sweep runs FIRST and comes at it from the
|
|
364
|
+
// other side: start from LANDED tasks, walk their claims, and link the claiming
|
|
365
|
+
// builder's overlapping sessions. Once linked, the existing sweep pays them.
|
|
366
|
+
//
|
|
367
|
+
// Ordering matters: the poller calls this, then reconcileUnbookedSessionRewards.
|
|
368
|
+
// Same idempotency story as attachClaimSessionsToTaskTx — a session already
|
|
369
|
+
// carrying the task is skipped by the NOT (task_ids @> …) guard, and the payout
|
|
370
|
+
// stays exactly-once per session regardless of how many tasks it links to.
|
|
371
|
+
// Strictly pay-on-land: only tasks at status='shipped' are considered.
|
|
372
|
+
//
|
|
373
|
+
// ⚠️ LOOKBACK WINDOW — this is a SAFETY NET, NOT A BACKFILL. The reward sweep
|
|
374
|
+
// runs immediately after this one, so every task linked here becomes payable on
|
|
375
|
+
// the same tick. Unbounded, the first run would retro-link the ENTIRE history
|
|
376
|
+
// (measured on cloudbongos.com 2026-08-16: 1383 tasks / 1157 sessions for one
|
|
377
|
+
// builder alone) and mint a mass retroactive payout nobody authorised. Deliberate
|
|
378
|
+
// historical back-pay is an Archon decision made with eyes open via
|
|
379
|
+
// scripts/gds/backfill-task-rewards.js (dry-run by default, reviewed amounts) —
|
|
380
|
+
// never a side effect of a poller starting up. So we only consider tasks that
|
|
381
|
+
// landed inside the window: recent enough that a live client-side upload skip is
|
|
382
|
+
// what we are recovering from. Widen it deliberately, never casually.
|
|
383
|
+
// Returns { scanned, attached }.
|
|
384
|
+
const ATTACH_LOOKBACK_DAYS = Number(process.env.SESSION_ATTACH_LOOKBACK_DAYS) || 7;
|
|
385
|
+
|
|
386
|
+
async function reconcileUnattachedClaimSessions({ limit = 200, lookbackDays } = {}) {
|
|
387
|
+
const n = Number(limit);
|
|
388
|
+
const cap = Number.isFinite(n) ? Math.max(1, Math.min(1000, Math.trunc(n))) : 200;
|
|
389
|
+
const d = Number(lookbackDays ?? ATTACH_LOOKBACK_DAYS);
|
|
390
|
+
const days = Number.isFinite(d) ? Math.max(1, Math.min(365, Math.trunc(d))) : 7;
|
|
391
|
+
return api.withTx(async (client) => {
|
|
392
|
+
// Candidate LANDED tasks whose claiming builder has at least one overlapping
|
|
393
|
+
// session that is not yet linked. Bounded, newest first — a backlog drains
|
|
394
|
+
// over successive sweeps rather than in one unbounded UPDATE.
|
|
395
|
+
const { rows } = await client.query(
|
|
396
|
+
`SELECT DISTINCT c.task_id
|
|
397
|
+
FROM claims c
|
|
398
|
+
JOIN tasks t ON t.id = c.task_id AND t.status = 'shipped'
|
|
399
|
+
JOIN session_records sr ON sr.builder_id = c.builder_id
|
|
400
|
+
WHERE NOT (sr.task_ids @> ARRAY[c.task_id]::bigint[])
|
|
401
|
+
AND COALESCE(t.shipped_at, t.updated_at) >= now() - ($2 || ' days')::interval
|
|
402
|
+
AND COALESCE(sr.started_at, sr.uploaded_at) <= COALESCE(c.released_at, now())
|
|
403
|
+
AND COALESCE(sr.ended_at, sr.uploaded_at) >= c.claimed_at
|
|
404
|
+
ORDER BY c.task_id DESC
|
|
405
|
+
LIMIT $1`,
|
|
406
|
+
[cap, String(days)]
|
|
407
|
+
);
|
|
408
|
+
let attached = 0;
|
|
409
|
+
for (const r of rows) {
|
|
410
|
+
const res = await attachClaimSessionsToTaskTx(client, r.task_id);
|
|
411
|
+
attached += res.attached;
|
|
412
|
+
}
|
|
413
|
+
return { scanned: rows.length, attached };
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
290
417
|
// ---- 6D.2: session search + fetch (read side, ADR 0027) -------------------
|
|
291
418
|
|
|
292
419
|
// Safe ORDER BY whitelist — the ONLY value interpolated into the search SQL, so
|
|
@@ -459,6 +586,8 @@ module.exports = {
|
|
|
459
586
|
upsertSessionRecordWithReward, // route entry point — upsert + token reward (task 1005)
|
|
460
587
|
awardDeferredSessionRewardForTask, // ADR 0120 land-side trigger — task.shipped listener calls it
|
|
461
588
|
reconcileUnbookedSessionRewards, // ADR 0120 state-based safety net — books rewards for out-of-band ships
|
|
589
|
+
attachClaimSessionsToTaskTx, // task 1002964 — server-side task↔session link, recovered from the claim
|
|
590
|
+
reconcileUnattachedClaimSessions, // task 1002964 — attribution sweep; must run BEFORE the reward sweep
|
|
462
591
|
// search + fetch (6D.2)
|
|
463
592
|
searchSessions,
|
|
464
593
|
summarizeSessions,
|
|
@@ -29,13 +29,25 @@ let intervalTimer = null;
|
|
|
29
29
|
let firstTimer = null;
|
|
30
30
|
|
|
31
31
|
async function runSweep() {
|
|
32
|
+
// ATTRIBUTION FIRST (task 1002964). reconcileUnbookedSessionRewards starts from
|
|
33
|
+
// session_records.task_ids, so a session whose client-side upload skipped — and
|
|
34
|
+
// which therefore carries an EMPTY task_ids — is invisible to it. Those are
|
|
35
|
+
// precisely the stranded rows worth rescuing, so recover the task↔session link
|
|
36
|
+
// from the claim before looking for unbooked rewards. Both halves are
|
|
37
|
+
// idempotent, so a re-sweep neither re-links nor double-pays.
|
|
38
|
+
const attach = await db.reconcileUnattachedClaimSessions({ limit: 200 });
|
|
39
|
+
if (attach && attach.attached > 0) {
|
|
40
|
+
console.log(
|
|
41
|
+
`[session-reward-reconcile] linked ${attach.attached} session(s) to landed tasks from claim state (scanned ${attach.scanned})`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
32
44
|
const res = await db.reconcileUnbookedSessionRewards({ limit: 200 });
|
|
33
45
|
if (res && res.awarded > 0) {
|
|
34
46
|
console.log(
|
|
35
47
|
`[session-reward-reconcile] booked ${res.awarded} stranded session reward(s) (scanned ${res.scanned})`
|
|
36
48
|
);
|
|
37
49
|
}
|
|
38
|
-
return res;
|
|
50
|
+
return { ...res, attached: attach ? attach.attached : 0 };
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
// Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched by
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.155",
|
|
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.155",
|
|
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.155",
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -735,8 +735,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
735
735
|
// landed since 1.19.152 with no explicit bump. run 31956560292. (task 1002620)
|
|
736
736
|
// 1.19.154 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
737
737
|
// landed since 1.19.153 with no explicit bump. run 31957052737. (task 1002620)
|
|
738
|
+
// 1.19.155 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
739
|
+
// landed since 1.19.154 with no explicit bump. run 31957556550. (task 1002620)
|
|
738
740
|
// ---------------------------------------------------------------------------
|
|
739
|
-
const CORE_VERSION = '1.19.
|
|
741
|
+
const CORE_VERSION = '1.19.155'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
740
742
|
|
|
741
743
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
742
744
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// tests/session_attach_from_claim.mjs
|
|
2
|
+
//
|
|
3
|
+
// Regression guard for task 1002964 — SERVER-SIDE task↔session attribution.
|
|
4
|
+
//
|
|
5
|
+
// THE BUG. Every payout path keys on session_records.task_ids, and until this
|
|
6
|
+
// fix exactly ONE thing populated that column: ship.js's client-side upload.
|
|
7
|
+
// When that upload skipped (classifyUploadSkip: no_session_id /
|
|
8
|
+
// unsafe_session_id / transcript_not_found) the session was left with an EMPTY
|
|
9
|
+
// task_ids — which matches NOTHING in the upload-side gate,
|
|
10
|
+
// awardDeferredSessionRewardForTask, or reconcileUnbookedSessionRewards. The
|
|
11
|
+
// reward stranded PERMANENTLY, invisible even to the state-based safety net.
|
|
12
|
+
// Builder IAMMASTERQUA hit exactly this: 19 sessions, 19 empty, ~$730 of
|
|
13
|
+
// recorded session cost, zero paid, for ten weeks.
|
|
14
|
+
//
|
|
15
|
+
// THE FIX. The server recovers the link from durable claim state — the claiming
|
|
16
|
+
// builder's session_records whose activity window overlaps the claim window —
|
|
17
|
+
// so a client-side skip can no longer zero a builder's pay. What this must NOT
|
|
18
|
+
// do is loosen the landing gate: ADR 0120 still pays only for code that landed.
|
|
19
|
+
//
|
|
20
|
+
// Run: node tests/session_attach_from_claim.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
25
|
+
|
|
26
|
+
process.env.NODE_ENV = 'test';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const seams = require('../src/module-seams.js');
|
|
30
|
+
if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
|
|
31
|
+
const api = require('../src/module-api.js');
|
|
32
|
+
const db = require('../modules/sessions/db.js');
|
|
33
|
+
|
|
34
|
+
const { test, summary } = makeRunner();
|
|
35
|
+
|
|
36
|
+
// Same priceable fixture the sibling reward tests use: Opus 110k input →
|
|
37
|
+
// $0.55 true cost → ×1.20 → 1 drachma. A non-zero payout is what lets the
|
|
38
|
+
// idempotency watermark SELECT run at all.
|
|
39
|
+
const MODEL_USAGE = { 'claude-opus-4-8': { input: 110_000 } };
|
|
40
|
+
const WATERMARK_RE = /COALESCE\(SUM\(delta\)/;
|
|
41
|
+
|
|
42
|
+
const norm = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
|
|
43
|
+
const find = (queries, re) => queries.find((q) => re.test(norm(q)));
|
|
44
|
+
const all = (queries, re) => queries.filter((q) => re.test(norm(q)));
|
|
45
|
+
|
|
46
|
+
const ATTACH_RE = /WITH cand AS .* UPDATE session_records/;
|
|
47
|
+
const PAYABLE_RE = /SELECT session_id, builder_id, model_usage, total_tokens FROM session_records WHERE task_ids @>/;
|
|
48
|
+
|
|
49
|
+
async function withFakeTx(handler, run) {
|
|
50
|
+
const fake = makeSqlAwareClient(handler);
|
|
51
|
+
const prev = api.withTx;
|
|
52
|
+
api.withTx = (fn) => fn(fake._client);
|
|
53
|
+
try {
|
|
54
|
+
const result = await run();
|
|
55
|
+
return { result, queries: fake.queries };
|
|
56
|
+
} finally {
|
|
57
|
+
api.withTx = prev;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// =========================================================================
|
|
62
|
+
// (1) The attach runs, and runs BEFORE the payable-session lookup
|
|
63
|
+
// =========================================================================
|
|
64
|
+
|
|
65
|
+
await test('awardDeferredSessionRewardForTask: attaches from the claim BEFORE selecting payable sessions', async () => {
|
|
66
|
+
const seen = [];
|
|
67
|
+
const { result, queries } = await withFakeTx(
|
|
68
|
+
(s) => {
|
|
69
|
+
seen.push(s);
|
|
70
|
+
if (ATTACH_RE.test(s)) return { rows: [{ session_id: 'sess-hook-only' }] };
|
|
71
|
+
if (PAYABLE_RE.test(s)) {
|
|
72
|
+
return { rows: [{ session_id: 'sess-hook-only', builder_id: 7, model_usage: MODEL_USAGE, total_tokens: 5000 }] };
|
|
73
|
+
}
|
|
74
|
+
if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] };
|
|
75
|
+
if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 1 }] };
|
|
76
|
+
return null;
|
|
77
|
+
},
|
|
78
|
+
() => db.awardDeferredSessionRewardForTask(660)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const attachIdx = seen.findIndex((s) => ATTACH_RE.test(s));
|
|
82
|
+
const payableIdx = seen.findIndex((s) => PAYABLE_RE.test(s));
|
|
83
|
+
assert.ok(attachIdx >= 0, 'the claim-based attach UPDATE must run');
|
|
84
|
+
assert.ok(payableIdx >= 0, 'the payable-session SELECT must run');
|
|
85
|
+
assert.ok(attachIdx < payableIdx,
|
|
86
|
+
'the attach MUST precede the payable lookup — otherwise a skipped upload is unpayable forever');
|
|
87
|
+
assert.equal(result.attached, 1, 'the freshly-linked session is reported');
|
|
88
|
+
assert.equal(result.awarded, 1, 'the hook-only session is paid once linked');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// =========================================================================
|
|
92
|
+
// (2) The end-to-end shape the bug produced: hook-only record gets paid
|
|
93
|
+
// =========================================================================
|
|
94
|
+
|
|
95
|
+
await test('a hook-only record (empty task_ids, empty model_usage) is attributed and paid from total_tokens', async () => {
|
|
96
|
+
// The exact Masterqua row shape: no task, no per-model usage — only a token
|
|
97
|
+
// total. Pricing must fall back to totalTokens (task 1676) or this still pays 0.
|
|
98
|
+
const { result, queries } = await withFakeTx(
|
|
99
|
+
(s) => {
|
|
100
|
+
if (ATTACH_RE.test(s)) return { rows: [{ session_id: 'sess-masterqua' }] };
|
|
101
|
+
if (PAYABLE_RE.test(s)) {
|
|
102
|
+
return { rows: [{ session_id: 'sess-masterqua', builder_id: 1000025, model_usage: {}, total_tokens: 77_403_545 }] };
|
|
103
|
+
}
|
|
104
|
+
if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] };
|
|
105
|
+
if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 1 }] };
|
|
106
|
+
return null;
|
|
107
|
+
},
|
|
108
|
+
() => db.awardDeferredSessionRewardForTask(660)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const insert = find(queries, /^INSERT INTO credit_log/);
|
|
112
|
+
assert.ok(insert, 'an empty model_usage must still book a reward via the totalTokens fallback');
|
|
113
|
+
assert.equal(result.awarded, 1, 'the session that was stranded for ten weeks now pays');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// =========================================================================
|
|
117
|
+
// (3) Idempotency + the landing gate (the two things this must not break)
|
|
118
|
+
// =========================================================================
|
|
119
|
+
|
|
120
|
+
await test('attach SQL skips sessions already carrying the task (no re-link, no double-pay)', async () => {
|
|
121
|
+
const { queries } = await withFakeTx(
|
|
122
|
+
(s) => {
|
|
123
|
+
if (ATTACH_RE.test(s)) return { rows: [] }; // nothing new to link
|
|
124
|
+
if (PAYABLE_RE.test(s)) return { rows: [] };
|
|
125
|
+
return null;
|
|
126
|
+
},
|
|
127
|
+
() => db.awardDeferredSessionRewardForTask(660)
|
|
128
|
+
);
|
|
129
|
+
const attach = find(queries, ATTACH_RE);
|
|
130
|
+
assert.ok(/NOT \(sr\.task_ids @> ARRAY/.test(norm(attach)),
|
|
131
|
+
'the attach must exclude sessions already linked to this task');
|
|
132
|
+
assert.equal(all(queries, /^INSERT INTO credit_log/).length, 0, 'nothing linked → nothing paid');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('attach is bounded by a LIMIT so one long session cannot fan out an unbounded UPDATE', async () => {
|
|
136
|
+
const { queries } = await withFakeTx(
|
|
137
|
+
(s) => (ATTACH_RE.test(s) || PAYABLE_RE.test(s) ? { rows: [] } : null),
|
|
138
|
+
() => db.awardDeferredSessionRewardForTask(660)
|
|
139
|
+
);
|
|
140
|
+
const attach = find(queries, ATTACH_RE);
|
|
141
|
+
assert.ok(/LIMIT \$2/.test(norm(attach)), 'the candidate set must be bounded');
|
|
142
|
+
assert.equal(attach.params[1], 50, 'default cap');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await test('reconcileUnattachedClaimSessions considers ONLY landed tasks (ADR 0120 unchanged)', async () => {
|
|
146
|
+
const { queries } = await withFakeTx(
|
|
147
|
+
(s) => (/FROM claims c JOIN tasks t/.test(s) ? { rows: [] } : null),
|
|
148
|
+
() => db.reconcileUnattachedClaimSessions({ limit: 5 })
|
|
149
|
+
);
|
|
150
|
+
const scan = find(queries, /FROM claims c JOIN tasks t/);
|
|
151
|
+
assert.ok(scan, 'the attribution sweep must scan claims joined to tasks');
|
|
152
|
+
assert.ok(/t\.status = 'shipped'/.test(norm(scan)),
|
|
153
|
+
"the sweep must require status='shipped' — never attribute-and-pay unlanded work");
|
|
154
|
+
assert.equal(scan.params[0], 5, 'the caller-supplied limit is honoured');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// The guard that keeps a safety net from becoming an unauthorised mass payout.
|
|
158
|
+
// Without the window, the first sweep on cloudbongos.com would have retro-linked
|
|
159
|
+
// 1383 tasks / 1157 sessions for a single builder and paid them on the same tick.
|
|
160
|
+
await test('the sweep is WINDOWED — it can never become a retroactive mass backfill', async () => {
|
|
161
|
+
const { queries } = await withFakeTx(
|
|
162
|
+
(s) => (/FROM claims c JOIN tasks t/.test(s) ? { rows: [] } : null),
|
|
163
|
+
() => db.reconcileUnattachedClaimSessions({ limit: 5 })
|
|
164
|
+
);
|
|
165
|
+
const scan = find(queries, /FROM claims c JOIN tasks t/);
|
|
166
|
+
assert.ok(/shipped_at, t\.updated_at\) >= now\(\) - /.test(norm(scan)),
|
|
167
|
+
'the sweep MUST bound itself to recently-landed tasks — historical back-pay is a deliberate Archon decision, not a poller side effect');
|
|
168
|
+
assert.equal(scan.params[1], '7', 'conservative default lookback');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await test('lookbackDays is clamped to a sane range (no 0-day no-op, no 10-year backfill)', async () => {
|
|
172
|
+
for (const [given, expected] of [[0, '1'], [-5, '1'], [99999, '365'], ['junk', '7']]) {
|
|
173
|
+
const { queries } = await withFakeTx(
|
|
174
|
+
(s) => (/FROM claims c JOIN tasks t/.test(s) ? { rows: [] } : null),
|
|
175
|
+
() => db.reconcileUnattachedClaimSessions({ limit: 5, lookbackDays: given })
|
|
176
|
+
);
|
|
177
|
+
assert.equal(find(queries, /FROM claims c JOIN tasks t/).params[1], expected,
|
|
178
|
+
`lookbackDays ${given} must clamp to ${expected}`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await test('a non-finite task id is a clean no-op (no SQL, no reward)', async () => {
|
|
183
|
+
const { result, queries } = await withFakeTx(() => null, () => db.awardDeferredSessionRewardForTask('nope'));
|
|
184
|
+
assert.equal(queries.length, 0, 'a garbage id must not reach the DB');
|
|
185
|
+
assert.deepEqual(result, { sessions: 0, awarded: 0, attached: 0 });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
summary();
|
|
@@ -224,8 +224,10 @@ await test('awardDeferredSessionRewardForTask: no session shipped this task →
|
|
|
224
224
|
|
|
225
225
|
await test('awardDeferredSessionRewardForTask: non-numeric id → immediate no-op (no DB touch)', async () => {
|
|
226
226
|
// No withTx patch — a bad id must return before opening a transaction.
|
|
227
|
+
// `attached` joined the shape in task 1002964 (the claim-based attribution
|
|
228
|
+
// recovery that now runs ahead of the payable-session lookup).
|
|
227
229
|
const out = await db.awardDeferredSessionRewardForTask('not-a-number');
|
|
228
|
-
assert.deepEqual(out, { sessions: 0, awarded: 0 });
|
|
230
|
+
assert.deepEqual(out, { sessions: 0, awarded: 0, attached: 0 });
|
|
229
231
|
});
|
|
230
232
|
|
|
231
233
|
// =========================================================================
|