@bongos/core 1.19.444 → 1.19.446
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 +154 -49
- package/docs/adr/0054-session-token-reward-cost-plus.md +17 -0
- package/docs/copy-inventory.md +353 -352
- package/docs/copy-registry.json +397 -388
- 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/modules/hall-ui/public/atlas.html +1 -2
- package/modules/hall-ui/public/collab.html +1 -2
- package/modules/hall-ui/public/copy-desk.css +11 -11
- package/modules/hall-ui/public/copy-desk.html +1 -2
- package/modules/hall-ui/public/diagrams.html +1 -2
- package/modules/hall-ui/public/drachmae.html +1 -2
- package/modules/hall-ui/public/gate.html +1 -2
- package/modules/hall-ui/public/goals.html +1 -2
- package/modules/hall-ui/public/government.html +1 -2
- package/modules/hall-ui/public/harbor.html +1 -2
- package/modules/hall-ui/public/idea.html +1 -2
- package/modules/hall-ui/public/ideas.css +6 -8
- package/modules/hall-ui/public/ideas.html +1 -2
- package/modules/hall-ui/public/index.html +3 -4
- package/modules/hall-ui/public/modules.html +1 -2
- package/modules/hall-ui/public/my-projects.html +1 -2
- package/modules/hall-ui/public/not-ready.html +1 -2
- package/modules/hall-ui/public/pair.html +1 -2
- package/modules/hall-ui/public/people.html +1 -2
- package/modules/hall-ui/public/primer.html +1 -2
- package/modules/hall-ui/public/profile.html +1 -2
- package/modules/hall-ui/public/ranks.html +1 -2
- package/modules/hall-ui/public/roadmap.html +1 -2
- package/modules/hall-ui/public/scouting.html +1 -2
- package/modules/hall-ui/public/sessions.html +1 -2
- package/modules/hall-ui/public/settings.html +1 -2
- package/modules/hall-ui/public/shell.js +35 -2
- package/modules/hall-ui/public/style.css +818 -392
- package/modules/hall-ui/public/task.html +1 -2
- package/modules/hall-ui/public/watch.css +1 -1
- package/modules/hall-ui/public/watch.html +1 -2
- package/modules/hall-ui/public/work.html +1 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-subagent-rewards.js +485 -0
- package/src/module-api.js +1 -1
- package/tests/backfill_subagent_rewards.mjs +308 -0
- package/tests/hall_tokens.mjs +371 -0
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
9
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
10
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
12
11
|
<link rel="stylesheet" href="/builders/style.css?v=2026-07-03-roadmap1">
|
|
13
12
|
<style>
|
|
14
13
|
/* Page-local styles for the task-detail page (task 751) — the full-page
|
|
@@ -56,7 +55,7 @@
|
|
|
56
55
|
<p class="page-head__sub" id="task-sub">Loading task…</p>
|
|
57
56
|
</div>
|
|
58
57
|
|
|
59
|
-
<main>
|
|
58
|
+
<main id="hall-main">
|
|
60
59
|
<section class="scroll" id="task-scroll" aria-labelledby="task-h">
|
|
61
60
|
<div id="task-body">
|
|
62
61
|
<p class="deeplink-card__title" id="task-h">Loading…</p>
|
|
@@ -185,7 +185,7 @@ table.watch tr:hover td { background: var(--surface-2); }
|
|
|
185
185
|
|
|
186
186
|
/* Invite by GitHub username (task 1003044) — the Access requests section's own
|
|
187
187
|
form: the way in on an invite-only project, a shortcut on an apply one. */
|
|
188
|
-
.watch-invite { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--
|
|
188
|
+
.watch-invite { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--rule-soft); border-radius: 10px; }
|
|
189
189
|
.watch-invite__label { display: block; font-weight: 600; margin-bottom: 6px; }
|
|
190
190
|
.watch-invite__row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
191
191
|
.watch-invite__row input[type="text"] { flex: 1 1 200px; min-width: 0; }
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
9
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
10
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
12
11
|
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
13
12
|
<!-- Page-local styles (task 2173) — extracted from the old inline <style>
|
|
14
13
|
block per the hall placement rules; the attention tiles + tabs come from
|
|
@@ -25,7 +24,7 @@
|
|
|
25
24
|
<div class="app-content">
|
|
26
25
|
<div class="page-head"><p class="page-head__sub" id="watch-sub">Loading…</p></div>
|
|
27
26
|
|
|
28
|
-
<main>
|
|
27
|
+
<main id="hall-main">
|
|
29
28
|
<!-- Restricted notice — shown by watch.js when the viewer is not an Archon. -->
|
|
30
29
|
<section class="scroll" id="sealed-scroll" aria-labelledby="sealed-h" hidden>
|
|
31
30
|
<h2 id="sealed-h" class="scroll__h">Archons only</h2>
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
9
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
10
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
-
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
12
11
|
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
13
12
|
<!-- Page-local styles (task 2170) — extracted from the old inline <style>
|
|
14
13
|
block per the hall placement rules; shared pager/count-pill styles now
|
|
@@ -27,7 +26,7 @@
|
|
|
27
26
|
<p class="page-head__sub" id="work-sub">Loading tasks…</p>
|
|
28
27
|
</div>
|
|
29
28
|
|
|
30
|
-
<main>
|
|
29
|
+
<main id="hall-main">
|
|
31
30
|
<!-- Gate notice — shown by work.js when the viewer is signed in but hasn't
|
|
32
31
|
yet acknowledged the primer (the claimable feed is primer-gated
|
|
33
32
|
server-side). The server already bars an UNAUTHENTICATED visitor from
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.446",
|
|
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.446",
|
|
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.446",
|
|
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",
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/backfill-subagent-rewards.js — retroactive cost-plus top-up for the
|
|
5
|
+
// subagent leg the reward could not see before task 1003435 (ADR 0054).
|
|
6
|
+
//
|
|
7
|
+
// THE GAP. The ADR 0054 reward prices `session_records.model_usage`, and until
|
|
8
|
+
// task 1003435 that map was built from ONE file — the interactive transcript.
|
|
9
|
+
// Claude Code persists Agent/Task-tool spawns in a sibling subtree, so a parallel
|
|
10
|
+
// session was paid round(interactive x 1.20) when it was owed
|
|
11
|
+
// round((interactive + subagent) x 1.20). CLAUDE.md tells builders to delegate
|
|
12
|
+
// freely; following it moved real spend into a bucket the reward never counted.
|
|
13
|
+
//
|
|
14
|
+
// WHERE THE MISSING NUMBER COMES FROM. Not from transcripts — those live only on
|
|
15
|
+
// builders' machines and are frequently gone. The SPEND ledger recorded the leg
|
|
16
|
+
// all along: session-cost.js writes a `cost_log` row with source ending
|
|
17
|
+
// ':subagent' and source_ref '<session-id>:sub' (ADR 0028). That row is the
|
|
18
|
+
// recoverable evidence, priced through the same locked PRICES table the reward
|
|
19
|
+
// uses. This script reads it and tops the session up.
|
|
20
|
+
//
|
|
21
|
+
// READ THIS BEFORE PROPOSING TO RUN --apply — two prior decisions bound it:
|
|
22
|
+
//
|
|
23
|
+
// * ADR 0173 (2026-08-16, blocker 1000113) declined a STRUCTURALLY SIMILAR
|
|
24
|
+
// backfill — the pre-`core_196` legacy multi-ship shortfall — for reasons
|
|
25
|
+
// that partly apply here: reconstruction was incomplete, so paying only the
|
|
26
|
+
// traceable part was arbitrary, and overpayment was not corrected
|
|
27
|
+
// symmetrically. This script therefore REPORTS those same asymmetries
|
|
28
|
+
// instead of hiding them, and never touches the legacy class at all.
|
|
29
|
+
// * Task 1003217 (landed 2026-08-23T21:50Z) fixed a ~2.22x over-count in BOTH
|
|
30
|
+
// the cost ledger and the digest: one API call is written as several
|
|
31
|
+
// transcript lines that repeat the same usage block, and every one was being
|
|
32
|
+
// priced. Cost rows recorded BEFORE that fix are inflated, so paying from
|
|
33
|
+
// them would OVERPAY. They are reported as their own class and never paid.
|
|
34
|
+
// The cutoff is a LOWER BOUND, not a guarantee: a machine still running an
|
|
35
|
+
// older core kept writing inflated rows after that date. --since moves it.
|
|
36
|
+
//
|
|
37
|
+
// SAFETY MODEL (mirrors scripts/gds/backfill-task-rewards.js, ADR 0097):
|
|
38
|
+
// * DRY-RUN BY DEFAULT. With no flags it prints the full plan + grand total and
|
|
39
|
+
// writes NOTHING. A live mint requires ALL of: --apply, Archon rank verified
|
|
40
|
+
// server-side via GET /api/gds/me, and a typed y/N confirmation (--yes skips
|
|
41
|
+
// only the typing).
|
|
42
|
+
// * IDEMPOTENT BY CONSTRUCTION, not by a new bookkeeping key. Every mint goes
|
|
43
|
+
// through the REAL reward writer (modules/economy/credits.js
|
|
44
|
+
// awardSessionTokenReward) with the recovered subagent dollars passed as
|
|
45
|
+
// `extraCostUsd`. That means the watermark, the no-clawback rule, the legacy
|
|
46
|
+
// refusal and the (session_id, reward_basis) unique index all keep applying:
|
|
47
|
+
// a backfill row is simply a top-up at a higher basis. Re-running computes
|
|
48
|
+
// the same basis, finds it already paid, and books nothing.
|
|
49
|
+
// * THE FORWARD PATH STAYS CORRECT. Because the top-up is booked under the
|
|
50
|
+
// normal reason, a later ship in a backfilled session still sees it in the
|
|
51
|
+
// watermark and books only its own increment — it cannot re-pay the leg this
|
|
52
|
+
// script settled. That is the whole reason for not inventing a private
|
|
53
|
+
// reason key.
|
|
54
|
+
// * NEVER ACTS ON require/import. All work is inside main(), which only runs as
|
|
55
|
+
// a CLI. The test imports the pure planners and touches no DB.
|
|
56
|
+
//
|
|
57
|
+
// NOT A MIGRATION. A fresh instance has no underpaid history; migrations run
|
|
58
|
+
// everywhere. Same call as ADR 0097 made for the sibling script.
|
|
59
|
+
//
|
|
60
|
+
// WHERE IT RUNS: it needs Postgres, so it runs on the box, by the owner.
|
|
61
|
+
//
|
|
62
|
+
// USAGE:
|
|
63
|
+
// node scripts/gds/backfill-subagent-rewards.js --help
|
|
64
|
+
// node scripts/gds/backfill-subagent-rewards.js # dry-run
|
|
65
|
+
// node scripts/gds/backfill-subagent-rewards.js --json # dry-run, machine-readable
|
|
66
|
+
// node scripts/gds/backfill-subagent-rewards.js --since 2026-08-25
|
|
67
|
+
// node scripts/gds/backfill-subagent-rewards.js --apply # Archon-only live mint
|
|
68
|
+
|
|
69
|
+
const path = require('node:path');
|
|
70
|
+
const readline = require('node:readline');
|
|
71
|
+
|
|
72
|
+
const credits = require('../../modules/economy/credits');
|
|
73
|
+
const { arg, hasFlag, cliClient } = require('./cli-lib');
|
|
74
|
+
|
|
75
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
76
|
+
|
|
77
|
+
// When task 1003217's de-duplication fix landed (commit 760c92d4,
|
|
78
|
+
// 2026-08-23T17:50:15-04:00). Cost rows recorded before it over-count a single
|
|
79
|
+
// API call once per transcript line — measured 2.22x across 12 real transcripts —
|
|
80
|
+
// so their dollar amounts cannot be paid from. See the header.
|
|
81
|
+
const DEDUP_FIX_AT = '2026-08-23T21:50:15Z';
|
|
82
|
+
|
|
83
|
+
// The classes a candidate session can land in. Only `payable` ever mints.
|
|
84
|
+
const CLASSES = Object.freeze({
|
|
85
|
+
PAYABLE: 'payable',
|
|
86
|
+
ALREADY_FOLDED: 'already_folded',
|
|
87
|
+
UP_TO_DATE: 'up_to_date',
|
|
88
|
+
OVERPAID: 'overpaid',
|
|
89
|
+
PRE_DEDUP_FIX: 'pre_dedup_fix',
|
|
90
|
+
NO_SUBAGENT_RECORD: 'no_subagent_record',
|
|
91
|
+
PRE_SPLIT: 'pre_split',
|
|
92
|
+
LEGACY: 'legacy',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Why each non-payable class is not paid — printed in the report so the owner is
|
|
96
|
+
// reading reasons, not a bare bucket count.
|
|
97
|
+
const CLASS_NOTE = Object.freeze({
|
|
98
|
+
[CLASSES.ALREADY_FOLDED]: 'its digest was built AFTER task 1003435, so model_usage already prices the subagent leg — adding the cost row on top would DOUBLE-COUNT',
|
|
99
|
+
[CLASSES.UP_TO_DATE]: 'already paid at or above this basis — nothing owed',
|
|
100
|
+
[CLASSES.OVERPAID]: 'paid ABOVE its worth; ADR 0054 is append-only, so never clawed back',
|
|
101
|
+
[CLASSES.PRE_DEDUP_FIX]: 'subagent cost row predates the task-1003217 de-dup fix (~2.22x inflated) — paying it would OVERPAY',
|
|
102
|
+
[CLASSES.NO_SUBAGENT_RECORD]: 'no subagent cost row: the cost hook never ran (no token / hard kill / 401). Unrecoverable — never guessed at',
|
|
103
|
+
[CLASSES.PRE_SPLIT]: 'only a pre-split single `session` cost row, which lumps interactive + subagent and cannot be separated',
|
|
104
|
+
[CLASSES.LEGACY]: 'reward_basis=0 legacy row — a DIFFERENT gap that ADR 0173 already declined to backfill; not folded in here',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const round4 = (n) => Math.round((Number(n) || 0) * 10000) / 10000;
|
|
108
|
+
|
|
109
|
+
// classifySession — PURE. Decide what one candidate session is owed, and if it is
|
|
110
|
+
// not payable, exactly why. Split from the SQL so the whole decision table is
|
|
111
|
+
// unit-tested with no DB (tests/backfill_subagent_rewards.mjs).
|
|
112
|
+
//
|
|
113
|
+
// `row` is the shape fetchCandidates() returns. `cutoffMs` is the dedup-fix
|
|
114
|
+
// boundary; `marginPct`/`usdPeg` come from the instance reward policy so the
|
|
115
|
+
// arithmetic matches what actually pays.
|
|
116
|
+
function classifySession(row, { cutoffMs, marginPct, usdPeg } = {}) {
|
|
117
|
+
const sessionId = String(row.session_id);
|
|
118
|
+
const builderId = Number(row.builder_id);
|
|
119
|
+
const paid = Number(row.paid) || 0;
|
|
120
|
+
const base = { sessionId, builderId, paid, delta: 0 };
|
|
121
|
+
|
|
122
|
+
// ADR 0173 territory. awardSessionTokenReward refuses these anyway (it returns
|
|
123
|
+
// settled:'legacy'), so minting here would silently no-op — but reporting it as
|
|
124
|
+
// its own class is the honest form, and keeps the two gaps from being conflated.
|
|
125
|
+
if ((Number(row.legacy_rows) || 0) > 0) return { ...base, klass: CLASSES.LEGACY };
|
|
126
|
+
|
|
127
|
+
// THE DOUBLE-COUNT GUARD — the one that would actually mint bad money.
|
|
128
|
+
//
|
|
129
|
+
// Task 1003435 made the digest fold the subagents subtree INTO model_usage. So
|
|
130
|
+
// for any session whose digest was written after that landed, model_usage
|
|
131
|
+
// ALREADY prices interactive + subagent, and adding the cost_log subagent row
|
|
132
|
+
// on top would pay the same leg twice. This backfill exists only for the
|
|
133
|
+
// sessions priced BEFORE that fix.
|
|
134
|
+
//
|
|
135
|
+
// The test is the DATA, not a date: the fold stamps subagent_files /
|
|
136
|
+
// subagent_tokens / subagent_grader_skipped into metrics, so their presence
|
|
137
|
+
// means "this row's model_usage is already whole". That also handles the case a
|
|
138
|
+
// date could not — a session that shipped before the fix but whose SessionEnd
|
|
139
|
+
// digest was re-uploaded after it, overwriting model_usage with the folded map.
|
|
140
|
+
if (row.has_folded_usage) return { ...base, klass: CLASSES.ALREADY_FOLDED };
|
|
141
|
+
|
|
142
|
+
if (row.subagent_usd == null) {
|
|
143
|
+
return {
|
|
144
|
+
...base,
|
|
145
|
+
klass: row.has_pre_split_cost_row ? CLASSES.PRE_SPLIT : CLASSES.NO_SUBAGENT_RECORD,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const subagentUsd = round4(row.subagent_usd);
|
|
150
|
+
const recordedMs = Date.parse(row.subagent_recorded_at);
|
|
151
|
+
if (Number.isFinite(cutoffMs) && Number.isFinite(recordedMs) && recordedMs < cutoffMs) {
|
|
152
|
+
return { ...base, klass: CLASSES.PRE_DEDUP_FIX, subagentUsd };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Take the interactive side from the SAME function the reward books from, so
|
|
156
|
+
// the top-up only ever ADDS the missing leg and never silently re-prices what
|
|
157
|
+
// was already paid.
|
|
158
|
+
const { trueCostUsd: interactiveUsd } = credits.sessionTrueCostUsd(
|
|
159
|
+
row.model_usage, row.total_tokens
|
|
160
|
+
);
|
|
161
|
+
const trueCostUsd = round4(interactiveUsd + subagentUsd);
|
|
162
|
+
const { drachmae: owed } = credits.computeSessionTokenReward(trueCostUsd, marginPct, usdPeg);
|
|
163
|
+
const delta = owed - paid;
|
|
164
|
+
|
|
165
|
+
if (delta > 0) {
|
|
166
|
+
return {
|
|
167
|
+
...base, klass: CLASSES.PAYABLE, delta, owed, interactiveUsd, subagentUsd, trueCostUsd,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (delta === 0) return { ...base, klass: CLASSES.UP_TO_DATE, owed, subagentUsd };
|
|
171
|
+
return { ...base, klass: CLASSES.OVERPAID, owed, excess: -delta, subagentUsd };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// buildBackfillPlan — PURE. Classify every candidate, group the payable ones by
|
|
175
|
+
// builder, and total everything. The grand total is the number the owner signs
|
|
176
|
+
// off on, so it is computed here (tested) and never re-derived in the renderer.
|
|
177
|
+
function buildBackfillPlan(rows, opts = {}) {
|
|
178
|
+
const cutoffMs = Date.parse(opts.since || DEDUP_FIX_AT);
|
|
179
|
+
const marginPct = opts.marginPct;
|
|
180
|
+
const usdPeg = opts.usdPeg;
|
|
181
|
+
|
|
182
|
+
const sessions = (Array.isArray(rows) ? rows : [])
|
|
183
|
+
.map((r) => classifySession(r, { cutoffMs, marginPct, usdPeg }));
|
|
184
|
+
|
|
185
|
+
const counts = {};
|
|
186
|
+
for (const k of Object.values(CLASSES)) counts[k] = 0;
|
|
187
|
+
for (const s of sessions) counts[s.klass]++;
|
|
188
|
+
|
|
189
|
+
const byBuilder = new Map();
|
|
190
|
+
let grandTotal = 0;
|
|
191
|
+
for (const s of sessions) {
|
|
192
|
+
if (s.klass !== CLASSES.PAYABLE) continue;
|
|
193
|
+
if (!byBuilder.has(s.builderId)) {
|
|
194
|
+
byBuilder.set(s.builderId, { builderId: s.builderId, sessions: [], toMint: 0 });
|
|
195
|
+
}
|
|
196
|
+
const b = byBuilder.get(s.builderId);
|
|
197
|
+
b.sessions.push(s);
|
|
198
|
+
b.toMint += s.delta;
|
|
199
|
+
grandTotal += s.delta;
|
|
200
|
+
}
|
|
201
|
+
const plans = [...byBuilder.values()].sort((a, b) => a.builderId - b.builderId);
|
|
202
|
+
|
|
203
|
+
// Exposure the owner should see even though this run will not pay it: the
|
|
204
|
+
// unpaid classes are where the remaining, unrecoverable-or-untrustworthy money
|
|
205
|
+
// sits, and ADR 0173's objection was precisely about paying a partial set
|
|
206
|
+
// without stating the rest.
|
|
207
|
+
const unpaidSubagentUsd = round4(sessions
|
|
208
|
+
.filter((s) => s.klass === CLASSES.PRE_DEDUP_FIX)
|
|
209
|
+
.reduce((t, s) => t + (s.subagentUsd || 0), 0));
|
|
210
|
+
const overpaidExcess = sessions
|
|
211
|
+
.filter((s) => s.klass === CLASSES.OVERPAID)
|
|
212
|
+
.reduce((t, s) => t + (s.excess || 0), 0);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
plans, sessions, counts, grandTotal, unpaidSubagentUsd, overpaidExcess,
|
|
216
|
+
cutoff: new Date(cutoffMs).toISOString(),
|
|
217
|
+
totalCandidates: sessions.length,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// renderReport — PURE. The dry-run text. Leads with what WILL be paid, then every
|
|
222
|
+
// class that will not, each with its reason.
|
|
223
|
+
function renderReport(plan, { loginFor = () => null } = {}) {
|
|
224
|
+
const out = [];
|
|
225
|
+
const name = (id) => loginFor(id) || `builder ${id}`;
|
|
226
|
+
out.push('Subagent cost-plus backfill — plan (task 1003436, ADR 0054)');
|
|
227
|
+
out.push(` candidates: ${plan.totalCandidates} session(s); trust cutoff ${plan.cutoff}`);
|
|
228
|
+
out.push('');
|
|
229
|
+
|
|
230
|
+
if (plan.plans.length === 0) {
|
|
231
|
+
out.push(' NOTHING PAYABLE — no session qualifies for a top-up.');
|
|
232
|
+
} else {
|
|
233
|
+
out.push('TO MINT');
|
|
234
|
+
for (const p of plan.plans) {
|
|
235
|
+
out.push(` ${name(p.builderId)}: +${p.toMint} across ${p.sessions.length} session(s)`);
|
|
236
|
+
for (const s of p.sessions) {
|
|
237
|
+
out.push(
|
|
238
|
+
` ${s.sessionId.slice(0, 8)} +${s.delta}`
|
|
239
|
+
+ ` (owed ${s.owed} on $${s.trueCostUsd.toFixed(4)}`
|
|
240
|
+
+ ` = $${s.interactiveUsd.toFixed(4)} interactive + $${s.subagentUsd.toFixed(4)} subagent;`
|
|
241
|
+
+ ` already paid ${s.paid})`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
out.push('');
|
|
248
|
+
out.push('NOT PAID (and why)');
|
|
249
|
+
for (const k of [CLASSES.ALREADY_FOLDED, CLASSES.UP_TO_DATE, CLASSES.OVERPAID,
|
|
250
|
+
CLASSES.PRE_DEDUP_FIX, CLASSES.NO_SUBAGENT_RECORD, CLASSES.PRE_SPLIT, CLASSES.LEGACY]) {
|
|
251
|
+
out.push(` ${String(plan.counts[k]).padStart(5)} ${k} — ${CLASS_NOTE[k]}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
out.push('');
|
|
255
|
+
out.push('WHAT THIS RUN DOES NOT SETTLE (state it before deciding — ADR 0173 §Rationale 2/3)');
|
|
256
|
+
out.push(` $${plan.unpaidSubagentUsd.toFixed(2)} of subagent spend sits in pre-dedup-fix rows that are ~2.22x inflated and cannot be paid from.`);
|
|
257
|
+
out.push(` ${plan.overpaidExcess} credits of existing OVERpayment are not recovered; this corrects in one direction only.`);
|
|
258
|
+
out.push(' Sessions whose cost hook never ran left no trace at all and are unrecoverable.');
|
|
259
|
+
out.push('');
|
|
260
|
+
out.push(`GRAND TOTAL to mint: ${plan.grandTotal} credits`);
|
|
261
|
+
return { text: out.join('\n'), total: plan.grandTotal };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// DB side. Nothing below runs on require.
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
// Lazy + guarded: --help must not need a database.
|
|
269
|
+
function loadPool() {
|
|
270
|
+
// eslint-disable-next-line global-require
|
|
271
|
+
const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
|
|
272
|
+
return pool;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// One row per session_record, carrying: its stored usage (the interactive side),
|
|
276
|
+
// the recovered subagent cost row if any, whether only a pre-split `session` row
|
|
277
|
+
// exists, and what the cost-plus stream has already paid it.
|
|
278
|
+
async function fetchCandidates(pool) {
|
|
279
|
+
const { rows } = await pool.query(
|
|
280
|
+
`SELECT sr.session_id,
|
|
281
|
+
sr.builder_id,
|
|
282
|
+
sr.model_usage,
|
|
283
|
+
sr.total_tokens,
|
|
284
|
+
sub.amount_usd::float8 AS subagent_usd,
|
|
285
|
+
sub.recorded_at AS subagent_recorded_at,
|
|
286
|
+
(pre.id IS NOT NULL) AS has_pre_split_cost_row,
|
|
287
|
+
-- task 1003435 stamps these counters whenever the digest folded the
|
|
288
|
+
-- subagents subtree into model_usage. Their presence means this row's
|
|
289
|
+
-- model_usage is ALREADY whole, so the backfill must skip it or it
|
|
290
|
+
-- pays the same leg twice. Written with -> rather than the jsonb
|
|
291
|
+
-- key-exists operator, whose symbol collides with driver placeholders.
|
|
292
|
+
(sr.metrics -> 'subagent_files' IS NOT NULL
|
|
293
|
+
OR sr.metrics -> 'subagent_tokens' IS NOT NULL
|
|
294
|
+
OR sr.metrics -> 'subagent_grader_skipped' IS NOT NULL)
|
|
295
|
+
AS has_folded_usage,
|
|
296
|
+
COALESCE(cl.paid, 0)::int AS paid,
|
|
297
|
+
COALESCE(cl.legacy_rows, 0)::int AS legacy_rows
|
|
298
|
+
FROM session_records sr
|
|
299
|
+
-- cost_log.source_ref has no dedicated index; this is a one-time batch run
|
|
300
|
+
-- by hand on the box, not a hot path, so a seq scan here is acceptable and
|
|
301
|
+
-- an index migration would be schema churn for a script that runs once.
|
|
302
|
+
LEFT JOIN LATERAL (
|
|
303
|
+
SELECT id, amount_usd, recorded_at
|
|
304
|
+
FROM cost_log
|
|
305
|
+
WHERE source_ref = sr.session_id || ':sub'
|
|
306
|
+
AND source LIKE '%:subagent'
|
|
307
|
+
ORDER BY recorded_at ASC
|
|
308
|
+
LIMIT 1
|
|
309
|
+
) sub ON true
|
|
310
|
+
LEFT JOIN LATERAL (
|
|
311
|
+
SELECT id FROM cost_log
|
|
312
|
+
WHERE source_ref = sr.session_id AND source = 'session'
|
|
313
|
+
LIMIT 1
|
|
314
|
+
) pre ON true
|
|
315
|
+
LEFT JOIN LATERAL (
|
|
316
|
+
SELECT COALESCE(SUM(delta), 0)::int AS paid,
|
|
317
|
+
COUNT(*) FILTER (WHERE reward_basis = 0)::int AS legacy_rows
|
|
318
|
+
FROM credit_log
|
|
319
|
+
WHERE reason = 'session.token_reward' AND session_id = sr.session_id
|
|
320
|
+
) cl ON true
|
|
321
|
+
ORDER BY sr.builder_id, sr.session_id`
|
|
322
|
+
);
|
|
323
|
+
return rows;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Mint. Routes every top-up through the REAL reward writer so the watermark and
|
|
327
|
+
// the uniqueness index do the idempotency — see the header. Deliberately does NOT
|
|
328
|
+
// touch builders.total_credits: trg_credit_log_apply (migration 003) bumps it on
|
|
329
|
+
// INSERT, so an extra UPDATE here would double-credit.
|
|
330
|
+
async function applyPlan(pool, plan, rowsBySession) {
|
|
331
|
+
const client = await pool.connect();
|
|
332
|
+
let mintedRows = 0;
|
|
333
|
+
let mintedCredits = 0;
|
|
334
|
+
const skipped = [];
|
|
335
|
+
try {
|
|
336
|
+
await client.query('BEGIN');
|
|
337
|
+
for (const p of plan.plans) {
|
|
338
|
+
for (const s of p.sessions) {
|
|
339
|
+
const row = rowsBySession.get(s.sessionId);
|
|
340
|
+
if (!row) { skipped.push({ sessionId: s.sessionId, why: 'row vanished between plan and apply' }); continue; }
|
|
341
|
+
const res = await credits.awardSessionTokenReward(
|
|
342
|
+
client, s.builderId, s.sessionId, row.model_usage, row.total_tokens, s.subagentUsd
|
|
343
|
+
);
|
|
344
|
+
if (res && res.awarded && res.drachmae > 0) {
|
|
345
|
+
mintedRows++;
|
|
346
|
+
mintedCredits += res.drachmae;
|
|
347
|
+
} else {
|
|
348
|
+
skipped.push({ sessionId: s.sessionId, why: (res && res.settled) || 'nothing owed at apply time' });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
await client.query('COMMIT');
|
|
353
|
+
} catch (err) {
|
|
354
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
355
|
+
throw err;
|
|
356
|
+
} finally {
|
|
357
|
+
client.release();
|
|
358
|
+
}
|
|
359
|
+
return { mintedRows, mintedCredits, skipped };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function printHelp() {
|
|
363
|
+
console.log(`
|
|
364
|
+
backfill-subagent-rewards — top up the cost-plus reward for the subagent leg it
|
|
365
|
+
could not see before task 1003435.
|
|
366
|
+
|
|
367
|
+
(no flags) dry-run: print the plan + grand total, write nothing
|
|
368
|
+
--json dry-run as JSON
|
|
369
|
+
--since <ISO> trust cutoff for subagent cost rows (default ${DEDUP_FIX_AT},
|
|
370
|
+
when the task-1003217 ~2.22x over-count fix landed)
|
|
371
|
+
--apply mint (Archon only, typed confirmation)
|
|
372
|
+
--yes skip the typed confirmation (still needs --apply + Archon)
|
|
373
|
+
--help this
|
|
374
|
+
|
|
375
|
+
Runs where Postgres is reachable — i.e. on the box. Read the file header before
|
|
376
|
+
--apply: ADR 0173 declined a structurally similar backfill, and rows written
|
|
377
|
+
before the cutoff are inflated and are never paid from.
|
|
378
|
+
`.trim());
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function ask(question) {
|
|
382
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
383
|
+
return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a); }));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function main() {
|
|
387
|
+
if (hasFlag('--help')) { printHelp(); return 0; }
|
|
388
|
+
|
|
389
|
+
const apply = hasFlag('--apply');
|
|
390
|
+
const since = arg('--since') || DEDUP_FIX_AT;
|
|
391
|
+
if (!Number.isFinite(Date.parse(since))) {
|
|
392
|
+
console.error(`--since is not a parseable date: ${since}`);
|
|
393
|
+
return 2;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const { marginPct, usdPeg } = credits.rewardConfig();
|
|
397
|
+
const pool = loadPool();
|
|
398
|
+
let plan;
|
|
399
|
+
let rows;
|
|
400
|
+
try {
|
|
401
|
+
rows = await fetchCandidates(pool);
|
|
402
|
+
plan = buildBackfillPlan(rows, { since, marginPct, usdPeg });
|
|
403
|
+
} catch (err) {
|
|
404
|
+
console.error(`could not read the ledger: ${err && err.message ? err.message : err}`);
|
|
405
|
+
await pool.end().catch(() => {});
|
|
406
|
+
return 2;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (hasFlag('--json')) {
|
|
410
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
411
|
+
await pool.end().catch(() => {});
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.log(renderReport(plan).text);
|
|
416
|
+
|
|
417
|
+
if (!apply) {
|
|
418
|
+
console.log('');
|
|
419
|
+
console.log('DRY RUN — nothing was written. Re-run with --apply to mint (Archon only).');
|
|
420
|
+
await pool.end().catch(() => {});
|
|
421
|
+
return 0;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (plan.grandTotal <= 0) {
|
|
425
|
+
console.log('\nNothing to mint. Exiting without writing.');
|
|
426
|
+
await pool.end().catch(() => {});
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Rank is authority, and authority lives on the server (ADR 0016). Never infer
|
|
431
|
+
// it from anything local.
|
|
432
|
+
let me;
|
|
433
|
+
try {
|
|
434
|
+
const api = await cliClient();
|
|
435
|
+
const r = await api.me.getMe();
|
|
436
|
+
me = r && r.ok ? r.data : null;
|
|
437
|
+
} catch (_) { me = null; }
|
|
438
|
+
const rank = me && (me.builder ? me.builder.rank : me.rank);
|
|
439
|
+
if (rank !== 'archon') {
|
|
440
|
+
console.error(`\n--apply is Archon-only; this session is ${rank || 'unauthenticated'}. Nothing written.`);
|
|
441
|
+
await pool.end().catch(() => {});
|
|
442
|
+
return 3;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!hasFlag('--yes')) {
|
|
446
|
+
const a = await ask(`\nMint ${plan.grandTotal} credits across ${plan.plans.length} builder(s)? [y/N] `);
|
|
447
|
+
if (!/^y(es)?$/i.test(String(a).trim())) {
|
|
448
|
+
console.log('Aborted. Nothing written.');
|
|
449
|
+
await pool.end().catch(() => {});
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const rowsBySession = new Map(rows.map((r) => [String(r.session_id), r]));
|
|
455
|
+
try {
|
|
456
|
+
const res = await applyPlan(pool, plan, rowsBySession);
|
|
457
|
+
console.log(`\nMinted ${res.mintedCredits} credits across ${res.mintedRows} session(s).`);
|
|
458
|
+
if (res.skipped.length) {
|
|
459
|
+
console.log(`Skipped ${res.skipped.length} (already settled between plan and apply):`);
|
|
460
|
+
for (const s of res.skipped.slice(0, 20)) console.log(` ${s.sessionId.slice(0, 8)} — ${s.why}`);
|
|
461
|
+
}
|
|
462
|
+
} catch (err) {
|
|
463
|
+
console.error(`\nApply FAILED and rolled back: ${err && err.message ? err.message : err}`);
|
|
464
|
+
await pool.end().catch(() => {});
|
|
465
|
+
return 2;
|
|
466
|
+
}
|
|
467
|
+
await pool.end().catch(() => {});
|
|
468
|
+
return 0;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (require.main === module) {
|
|
472
|
+
main().then((code) => { process.exitCode = code; }).catch((err) => {
|
|
473
|
+
console.error(err);
|
|
474
|
+
process.exitCode = 2;
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
module.exports = {
|
|
479
|
+
CLASSES,
|
|
480
|
+
CLASS_NOTE,
|
|
481
|
+
DEDUP_FIX_AT,
|
|
482
|
+
classifySession,
|
|
483
|
+
buildBackfillPlan,
|
|
484
|
+
renderReport,
|
|
485
|
+
};
|
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.446'; // 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');
|