@bongos/core 1.19.843 → 1.19.844
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 +37 -27
- package/docs/module-api-changelog.md +3 -1
- package/modules/agents/lib/spend-ceiling.js +126 -37
- package/modules/agents/migrations/agents_004_spend_cap.sql +53 -0
- package/modules/agents/pollers/event-router.js +29 -2
- package/modules/agents/routes/agents.js +38 -1
- package/modules/agents/spawn.js +73 -1
- package/modules/economy/cost-classify.js +11 -0
- package/modules/economy/cost.js +21 -3
- package/modules/economy/reward.js +7 -0
- package/modules/hall-ui/public/goals.states.json +2 -2
- package/modules/hall-ui/public/idea.states.json +1 -1
- package/modules/hall-ui/public/ideas.states.json +1 -1
- package/modules/hall-ui/public/index.states.json +1 -1
- package/modules/hall-ui/public/pair.states.json +1 -1
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/task.states.json +1 -1
- package/modules/hall-ui/public/work.states.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/agents_spend_guard.mjs +203 -2
- package/tests/hall_states_regex_escapes.mjs +98 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.844",
|
|
6
|
+
"core_contract": "1.19.844",
|
|
7
|
+
"source_commit": "980c92b65de8a99f2772b86fb7f69e365eed4dc6",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-17T18:
|
|
9
|
+
"built_at": "2026-09-17T18:59:26.454Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 495,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2272,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2793,
|
|
20
|
+
"tree_sha256": "af2219bd2cdea4b5ebb159bd2cc60e9d015182c15121196f16e1cbb9b4ac7bf5",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2577,7 +2577,7 @@
|
|
|
2577
2577
|
{
|
|
2578
2578
|
"path": "docs/module-api-changelog.md",
|
|
2579
2579
|
"mode": "0000644",
|
|
2580
|
-
"sha256": "
|
|
2580
|
+
"sha256": "185ae1211c5c546bf5d0a57acd5c97ad56b4e98133ba2aceded36bf2cc95c4ca"
|
|
2581
2581
|
},
|
|
2582
2582
|
{
|
|
2583
2583
|
"path": "docs/modules-contract.md",
|
|
@@ -3712,7 +3712,7 @@
|
|
|
3712
3712
|
{
|
|
3713
3713
|
"path": "modules/agents/lib/spend-ceiling.js",
|
|
3714
3714
|
"mode": "0000644",
|
|
3715
|
-
"sha256": "
|
|
3715
|
+
"sha256": "1144ee9be9cb3df57e876a868529ddbf78864fac1fdabc90ed2e010bd8d376a8"
|
|
3716
3716
|
},
|
|
3717
3717
|
{
|
|
3718
3718
|
"path": "modules/agents/lib/validate.js",
|
|
@@ -3734,6 +3734,11 @@
|
|
|
3734
3734
|
"mode": "0000644",
|
|
3735
3735
|
"sha256": "7a9763f90da032c81aa0b1a8461a2e13b58d9f529c1b857b048648feeadc6fdd"
|
|
3736
3736
|
},
|
|
3737
|
+
{
|
|
3738
|
+
"path": "modules/agents/migrations/agents_004_spend_cap.sql",
|
|
3739
|
+
"mode": "0000644",
|
|
3740
|
+
"sha256": "31a5ac35536cecd264bc989907f4cf094e050db0b8de85e7e3e7831cf7c25a9d"
|
|
3741
|
+
},
|
|
3737
3742
|
{
|
|
3738
3743
|
"path": "modules/agents/module.json",
|
|
3739
3744
|
"mode": "0000644",
|
|
@@ -3742,17 +3747,17 @@
|
|
|
3742
3747
|
{
|
|
3743
3748
|
"path": "modules/agents/pollers/event-router.js",
|
|
3744
3749
|
"mode": "0000644",
|
|
3745
|
-
"sha256": "
|
|
3750
|
+
"sha256": "ddd903f3702a3dd14e714dcf352e7c69f4bfbce5206e71892142aa8a81d44cde"
|
|
3746
3751
|
},
|
|
3747
3752
|
{
|
|
3748
3753
|
"path": "modules/agents/routes/agents.js",
|
|
3749
3754
|
"mode": "0000644",
|
|
3750
|
-
"sha256": "
|
|
3755
|
+
"sha256": "a5d80d87486e8c6e928fe4ac563643ceb5255df14fd89e7e20194e2d8196b301"
|
|
3751
3756
|
},
|
|
3752
3757
|
{
|
|
3753
3758
|
"path": "modules/agents/spawn.js",
|
|
3754
3759
|
"mode": "0000644",
|
|
3755
|
-
"sha256": "
|
|
3760
|
+
"sha256": "4e5e9ac97bd443d4091d8a6e4e246e080a2fe67793a77f4c4139b19019e37005"
|
|
3756
3761
|
},
|
|
3757
3762
|
{
|
|
3758
3763
|
"path": "modules/autonomy/CLAUDE.md",
|
|
@@ -4297,7 +4302,7 @@
|
|
|
4297
4302
|
{
|
|
4298
4303
|
"path": "modules/economy/cost-classify.js",
|
|
4299
4304
|
"mode": "0000644",
|
|
4300
|
-
"sha256": "
|
|
4305
|
+
"sha256": "fefd4908bd22aa3b747a7c8b4146e5a7e53731fe92612db105ea3512ae34300c"
|
|
4301
4306
|
},
|
|
4302
4307
|
{
|
|
4303
4308
|
"path": "modules/economy/cost-summary-shape.js",
|
|
@@ -4307,7 +4312,7 @@
|
|
|
4307
4312
|
{
|
|
4308
4313
|
"path": "modules/economy/cost.js",
|
|
4309
4314
|
"mode": "0000644",
|
|
4310
|
-
"sha256": "
|
|
4315
|
+
"sha256": "6cc28fe57bb50b6c3f4b7d4485838427fa5f12b5a79848f0358fdaa2874f6c1e"
|
|
4311
4316
|
},
|
|
4312
4317
|
{
|
|
4313
4318
|
"path": "modules/economy/credits.js",
|
|
@@ -4337,7 +4342,7 @@
|
|
|
4337
4342
|
{
|
|
4338
4343
|
"path": "modules/economy/reward.js",
|
|
4339
4344
|
"mode": "0000644",
|
|
4340
|
-
"sha256": "
|
|
4345
|
+
"sha256": "c4245b1b7c361eb44e0034f19227e208b956e9eb9ac03924e22b3017dd4a343a"
|
|
4341
4346
|
},
|
|
4342
4347
|
{
|
|
4343
4348
|
"path": "modules/economy/routes/cost.js",
|
|
@@ -4957,7 +4962,7 @@
|
|
|
4957
4962
|
{
|
|
4958
4963
|
"path": "modules/hall-ui/public/goals.states.json",
|
|
4959
4964
|
"mode": "0000644",
|
|
4960
|
-
"sha256": "
|
|
4965
|
+
"sha256": "14aefbb8b94ebc3df68744052f465b63ceaf3361f98a94972781ae01bc616735"
|
|
4961
4966
|
},
|
|
4962
4967
|
{
|
|
4963
4968
|
"path": "modules/hall-ui/public/government.css",
|
|
@@ -5037,7 +5042,7 @@
|
|
|
5037
5042
|
{
|
|
5038
5043
|
"path": "modules/hall-ui/public/idea.states.json",
|
|
5039
5044
|
"mode": "0000644",
|
|
5040
|
-
"sha256": "
|
|
5045
|
+
"sha256": "2299490186e19f2369069562dc8ba8dd837a2e89bfa94c7797f350cc653659c9"
|
|
5041
5046
|
},
|
|
5042
5047
|
{
|
|
5043
5048
|
"path": "modules/hall-ui/public/ideas.css",
|
|
@@ -5057,7 +5062,7 @@
|
|
|
5057
5062
|
{
|
|
5058
5063
|
"path": "modules/hall-ui/public/ideas.states.json",
|
|
5059
5064
|
"mode": "0000644",
|
|
5060
|
-
"sha256": "
|
|
5065
|
+
"sha256": "889041e8bcff6e7fa53f6bd09d0bed670e6b58cd68cda2ff7147a1a253e33e8d"
|
|
5061
5066
|
},
|
|
5062
5067
|
{
|
|
5063
5068
|
"path": "modules/hall-ui/public/index.html",
|
|
@@ -5067,7 +5072,7 @@
|
|
|
5067
5072
|
{
|
|
5068
5073
|
"path": "modules/hall-ui/public/index.states.json",
|
|
5069
5074
|
"mode": "0000644",
|
|
5070
|
-
"sha256": "
|
|
5075
|
+
"sha256": "74b6c43bb12a85e7d944069548c3e3a5de54f37267bbdc179157e1b164101399"
|
|
5071
5076
|
},
|
|
5072
5077
|
{
|
|
5073
5078
|
"path": "modules/hall-ui/public/md-reader.js",
|
|
@@ -5117,7 +5122,7 @@
|
|
|
5117
5122
|
{
|
|
5118
5123
|
"path": "modules/hall-ui/public/pair.states.json",
|
|
5119
5124
|
"mode": "0000644",
|
|
5120
|
-
"sha256": "
|
|
5125
|
+
"sha256": "3c48d1588f8512f0f9a0d5774acdf88c9a761e6b9f5f3da2d544a32bc06b4ab8"
|
|
5121
5126
|
},
|
|
5122
5127
|
{
|
|
5123
5128
|
"path": "modules/hall-ui/public/palette.js",
|
|
@@ -5347,7 +5352,7 @@
|
|
|
5347
5352
|
{
|
|
5348
5353
|
"path": "modules/hall-ui/public/settings.states.json",
|
|
5349
5354
|
"mode": "0000644",
|
|
5350
|
-
"sha256": "
|
|
5355
|
+
"sha256": "2d9f55bb007891b5d5b4511d7f29a20d01477a8395c84c259a42a7e6157aced9"
|
|
5351
5356
|
},
|
|
5352
5357
|
{
|
|
5353
5358
|
"path": "modules/hall-ui/public/shell.js",
|
|
@@ -5412,7 +5417,7 @@
|
|
|
5412
5417
|
{
|
|
5413
5418
|
"path": "modules/hall-ui/public/task.states.json",
|
|
5414
5419
|
"mode": "0000644",
|
|
5415
|
-
"sha256": "
|
|
5420
|
+
"sha256": "1be94e892012740aae899aa28425f4be9c94a5ae777c8c77e5e1b787791f5ec9"
|
|
5416
5421
|
},
|
|
5417
5422
|
{
|
|
5418
5423
|
"path": "modules/hall-ui/public/triumph.wav",
|
|
@@ -5457,7 +5462,7 @@
|
|
|
5457
5462
|
{
|
|
5458
5463
|
"path": "modules/hall-ui/public/work.states.json",
|
|
5459
5464
|
"mode": "0000644",
|
|
5460
|
-
"sha256": "
|
|
5465
|
+
"sha256": "786373b132587480ff58452286f950d246eae430f7531f0ccf4d01f290fdc435"
|
|
5461
5466
|
},
|
|
5462
5467
|
{
|
|
5463
5468
|
"path": "modules/ideas/CLAUDE.md",
|
|
@@ -7897,12 +7902,12 @@
|
|
|
7897
7902
|
{
|
|
7898
7903
|
"path": "package-lock.json",
|
|
7899
7904
|
"mode": "0000644",
|
|
7900
|
-
"sha256": "
|
|
7905
|
+
"sha256": "a6133e7d6f098080ea0b2d2d21d3bbf35c90a716dc20a4436a68c74c6416da84"
|
|
7901
7906
|
},
|
|
7902
7907
|
{
|
|
7903
7908
|
"path": "package.json",
|
|
7904
7909
|
"mode": "0000644",
|
|
7905
|
-
"sha256": "
|
|
7910
|
+
"sha256": "65a0092e9c2f20b87c7de5d89e552e005820a70cc5c00fb228d904363d842b22"
|
|
7906
7911
|
},
|
|
7907
7912
|
{
|
|
7908
7913
|
"path": "public-docs/index.html",
|
|
@@ -9837,7 +9842,7 @@
|
|
|
9837
9842
|
{
|
|
9838
9843
|
"path": "src/module-api.js",
|
|
9839
9844
|
"mode": "0000644",
|
|
9840
|
-
"sha256": "
|
|
9845
|
+
"sha256": "a97295bdd0b3f0fe6b6c903b910a2512e7fffcc4a98f6d2844096cbc6015d7a7"
|
|
9841
9846
|
},
|
|
9842
9847
|
{
|
|
9843
9848
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9997,7 +10002,7 @@
|
|
|
9997
10002
|
{
|
|
9998
10003
|
"path": "tests/agents_spend_guard.mjs",
|
|
9999
10004
|
"mode": "0000644",
|
|
10000
|
-
"sha256": "
|
|
10005
|
+
"sha256": "dde31247e6cb995487e21f78d5b44bfb24bc087bd7736c5a130ed4d8822d0cb7"
|
|
10001
10006
|
},
|
|
10002
10007
|
{
|
|
10003
10008
|
"path": "tests/agents_sync.mjs",
|
|
@@ -11689,6 +11694,11 @@
|
|
|
11689
11694
|
"mode": "0000644",
|
|
11690
11695
|
"sha256": "82bf1cc68150c247c108308d360d6408511f31a7af11d5e7a4721e7ea282f2bb"
|
|
11691
11696
|
},
|
|
11697
|
+
{
|
|
11698
|
+
"path": "tests/hall_states_regex_escapes.mjs",
|
|
11699
|
+
"mode": "0000644",
|
|
11700
|
+
"sha256": "906786d9728fd1d94449674422401ef1b53a460d5c84998565b1ebeab5e47c7a"
|
|
11701
|
+
},
|
|
11692
11702
|
{
|
|
11693
11703
|
"path": "tests/hall_studio_world.mjs",
|
|
11694
11704
|
"mode": "0000644",
|
|
@@ -2159,6 +2159,8 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2159
2159
|
1.19.842 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2160
2160
|
landed since 1.19.841 with no explicit bump. run 35258288930. (task 1002620)
|
|
2161
2161
|
1.19.843 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2162
|
-
landed since 1.19.842 with no explicit bump. run
|
|
2162
|
+
landed since 1.19.842 with no explicit bump. run 35259608793. (task 1002620)
|
|
2163
|
+
1.19.844 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2164
|
+
landed since 1.19.843 with no explicit bump. run 35262101669. (task 1002620)
|
|
2163
2165
|
---------------------------------------------------------------------------
|
|
2164
2166
|
```
|
|
@@ -59,6 +59,18 @@
|
|
|
59
59
|
// be saved by a generous default.
|
|
60
60
|
const DEFAULT_CAP_USD = 25;
|
|
61
61
|
|
|
62
|
+
// The PER-AGENT default (task 1002499, owner decision 2026-07-05: ~$5/month per
|
|
63
|
+
// agent under the ~$30/month global brake). Deliberately much smaller than the
|
|
64
|
+
// per-payer cap above, because it bounds a different thing: that one bounds the
|
|
65
|
+
// WALLET, this one bounds ONE DEFINITION. Under a single project payer the two
|
|
66
|
+
// were the same number, which meant a runaway agent could eat the whole instance
|
|
67
|
+
// ceiling and every other agent would then be refused for a bill it did not run
|
|
68
|
+
// up. At the historian exemplar's ~$0.90 a fire, $5 is about five fires a month —
|
|
69
|
+
// enough for a routine that answers a few times a week, and not enough for a loop.
|
|
70
|
+
// A definition sets its own with agents_definitions.spend_cap_usd; an instance
|
|
71
|
+
// moves every unset one with BONGOS_AGENT_SPEND_CAP_USD.
|
|
72
|
+
const DEFAULT_AGENT_CAP_USD = 5;
|
|
73
|
+
|
|
62
74
|
// Calendar month, UTC. It matches the window agents_001's spend index was built
|
|
63
75
|
// for ("What did agents cost this month?") and the $/month cap fire-budget's
|
|
64
76
|
// header defers to — and a calendar month is the unit a bill arrives in, which is
|
|
@@ -96,12 +108,116 @@ function payerFor(spendPayer, requestedByBuilderId) {
|
|
|
96
108
|
return { payer_kind: 'project', payer_builder_id: null };
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
// Read one SUM(cost_usd) and return a TRUSTWORTHY number, or null for "I don't
|
|
112
|
+
// know". Shared by the two ceilings below so the fail-closed reading of a ledger
|
|
113
|
+
// is written once — but note that sharing it is not itself the guarantee: the
|
|
114
|
+
// tests exercise BOTH ceilings against a throwing pool, a NULL sum and a garbage
|
|
115
|
+
// sum independently, because a helper's documented safety property is a claim
|
|
116
|
+
// until each caller is shown to honour it.
|
|
117
|
+
//
|
|
118
|
+
// Every "I don't know" collapses to null here, and every caller turns null into a
|
|
119
|
+
// REFUSAL. The one thing this must never do is return 0 for an absent value —
|
|
120
|
+
// Number(null) is 0, and a zero reads as "has spent nothing", which is the
|
|
121
|
+
// fail-OPEN direction wearing a plausible number. That is the whole of task
|
|
122
|
+
// 1001393, so the absence is rejected before it can be coerced.
|
|
123
|
+
async function sumOrNull(pool, sql, params) {
|
|
124
|
+
if (!pool || typeof pool.query !== 'function') return null;
|
|
125
|
+
let raw;
|
|
126
|
+
try {
|
|
127
|
+
const { rows } = await pool.query(sql, params);
|
|
128
|
+
raw = rows && rows[0] ? rows[0].spent : undefined;
|
|
129
|
+
} catch (_) {
|
|
130
|
+
// THE 1001393 CASE. An unreadable ledger is the one this file exists for.
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (raw === null || raw === undefined) return null;
|
|
134
|
+
// NUMERIC comes back as a string from pg, so this catches a genuinely
|
|
135
|
+
// unparseable value, not the ordinary one.
|
|
136
|
+
const n = Number(raw);
|
|
137
|
+
return (Number.isFinite(n) && n >= 0) ? n : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Summed BY PAYER, so a builder-paid instance gives each builder their own month
|
|
141
|
+
// and a project-paid one sums the instance. Rows written before agents_002 carry
|
|
142
|
+
// no payer_kind and match neither, which is the conservative direction: nobody is
|
|
143
|
+
// charged for spend that was not attributed to them.
|
|
144
|
+
const PAYER_SPEND_SQL = `SELECT COALESCE(SUM(cost_usd), 0) AS spent
|
|
145
|
+
FROM agents_runs
|
|
146
|
+
WHERE cost_usd IS NOT NULL
|
|
147
|
+
AND created_at >= $1
|
|
148
|
+
AND payer_kind = $2
|
|
149
|
+
AND payer_builder_id IS NOT DISTINCT FROM $3`;
|
|
150
|
+
|
|
151
|
+
// Summed BY AGENT NAME, across every payer. The name is the identity that
|
|
152
|
+
// survives a definition being re-synced, disabled or re-authored — agents_001
|
|
153
|
+
// built agents_runs_agent_idx (agent_name, created_at DESC) for exactly this
|
|
154
|
+
// read. Deliberately NOT keyed on definition_id: that column is ON DELETE SET
|
|
155
|
+
// NULL, so deleting a definition would zero its month and hand a fresh cap to a
|
|
156
|
+
// re-created agent of the same name.
|
|
157
|
+
const AGENT_SPEND_SQL = `SELECT COALESCE(SUM(cost_usd), 0) AS spent
|
|
158
|
+
FROM agents_runs
|
|
159
|
+
WHERE cost_usd IS NOT NULL
|
|
160
|
+
AND created_at >= $1
|
|
161
|
+
AND agent_name = $2`;
|
|
162
|
+
|
|
163
|
+
// The per-agent cap for one definition: its own spend_cap_usd (agents_004) when it
|
|
164
|
+
// declares one, else the instance default. A stored cap is schema-guaranteed
|
|
165
|
+
// positive, so anything else in that column is a row this code did not write and
|
|
166
|
+
// gets the default rather than a guess.
|
|
167
|
+
function agentCapFor(definition, fallback) {
|
|
168
|
+
const raw = definition && definition.spend_cap_usd;
|
|
169
|
+
if (raw === null || raw === undefined) return fallback;
|
|
170
|
+
const n = Number(raw);
|
|
171
|
+
return (Number.isFinite(n) && n > 0) ? n : fallback;
|
|
172
|
+
}
|
|
173
|
+
|
|
99
174
|
function createSpendCeiling({
|
|
100
175
|
capUsd = envNumber('BONGOS_AGENTS_SPEND_CAP_USD', DEFAULT_CAP_USD),
|
|
176
|
+
agentCapUsd = envNumber('BONGOS_AGENT_SPEND_CAP_USD', DEFAULT_AGENT_CAP_USD),
|
|
101
177
|
now = () => Date.now(),
|
|
102
178
|
} = {}) {
|
|
103
179
|
return {
|
|
104
180
|
capUsd,
|
|
181
|
+
agentCapUsd,
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* May THIS AGENT spend right now? (task 1002499)
|
|
185
|
+
*
|
|
186
|
+
* The sibling of check() below, and deliberately a separate call rather than
|
|
187
|
+
* a wider one: they refuse for different reasons and an operator must be able
|
|
188
|
+
* to tell "this instance's money is gone" from "this one agent has had its
|
|
189
|
+
* share". Both run on every fire, cheapest first.
|
|
190
|
+
*
|
|
191
|
+
* Records nothing, for the same reason check() does not: agents_runs is the
|
|
192
|
+
* accounting, and a ceiling that kept its own tally would be a second,
|
|
193
|
+
* disagreeing answer to what agents cost.
|
|
194
|
+
*
|
|
195
|
+
* Fails closed on every unhappy path, exactly like check(): no usable cap, no
|
|
196
|
+
* agent name, an unreadable ledger, or the cap already spent. The KNOWN
|
|
197
|
+
* OVERSHOOT in this file's header applies here too — a fire in flight carries
|
|
198
|
+
* cost_usd NULL and is invisible to this read.
|
|
199
|
+
*
|
|
200
|
+
* @returns {{ok: boolean, spentUsd: number|null, capUsd: number, remainingUsd: number, reason: string|null}}
|
|
201
|
+
*/
|
|
202
|
+
async checkAgent(pool, definition) {
|
|
203
|
+
const cap = agentCapFor(definition, Number(agentCapUsd));
|
|
204
|
+
const deny = (reason) => ({ ok: false, spentUsd: null, capUsd: cap, remainingUsd: 0, reason });
|
|
205
|
+
|
|
206
|
+
if (!Number.isFinite(cap) || cap <= 0) return deny('no_agent_spend_cap_configured');
|
|
207
|
+
const name = definition && definition.name;
|
|
208
|
+
// An unnamed definition cannot be summed, so its month is unknowable — the
|
|
209
|
+
// same "I don't know" a throwing ledger gives, and it gets the same answer.
|
|
210
|
+
if (typeof name !== 'string' || !name.trim()) return deny('no_agent_identity');
|
|
211
|
+
|
|
212
|
+
const spent = await sumOrNull(pool, AGENT_SPEND_SQL, [monthStartUtc(now()), name]);
|
|
213
|
+
if (spent === null) return deny('ledger_unreadable');
|
|
214
|
+
|
|
215
|
+
const remaining = cap - spent;
|
|
216
|
+
if (remaining <= 0) {
|
|
217
|
+
return { ok: false, spentUsd: spent, capUsd: cap, remainingUsd: 0, reason: 'agent_spend_cap_reached' };
|
|
218
|
+
}
|
|
219
|
+
return { ok: true, spentUsd: spent, capUsd: cap, remainingUsd: remaining, reason: null };
|
|
220
|
+
},
|
|
105
221
|
|
|
106
222
|
/**
|
|
107
223
|
* May this payer spend right now?
|
|
@@ -121,42 +237,12 @@ function createSpendCeiling({
|
|
|
121
237
|
if (kind !== 'project' && kind !== 'builder') return deny('no_payer');
|
|
122
238
|
const builderId = payer.payer_builder_id ?? null;
|
|
123
239
|
if (kind === 'builder' && builderId === null) return deny('no_payer');
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// month and a project-paid one sums the instance. Rows written before
|
|
131
|
-
// agents_002 carry no payer_kind and match neither, which is the
|
|
132
|
-
// conservative direction: nobody is charged for spend that was not
|
|
133
|
-
// attributed to them.
|
|
134
|
-
const { rows } = await pool.query(
|
|
135
|
-
`SELECT COALESCE(SUM(cost_usd), 0) AS spent
|
|
136
|
-
FROM agents_runs
|
|
137
|
-
WHERE cost_usd IS NOT NULL
|
|
138
|
-
AND created_at >= $1
|
|
139
|
-
AND payer_kind = $2
|
|
140
|
-
AND payer_builder_id IS NOT DISTINCT FROM $3`,
|
|
141
|
-
[since, kind, builderId],
|
|
142
|
-
);
|
|
143
|
-
const raw = rows && rows[0] ? rows[0].spent : undefined;
|
|
144
|
-
// NOT `Number(raw)` alone. Number(null) is 0, so a sum that came back as
|
|
145
|
-
// SQL NULL — a shape COALESCE should make impossible, and would therefore
|
|
146
|
-
// only appear if this query or that table changed under us — would read
|
|
147
|
-
// as "this payer has spent nothing" and admit the fire. That is the
|
|
148
|
-
// fail-OPEN direction wearing a plausible number, which is the whole
|
|
149
|
-
// failure mode task 1001393 was filed about, so an absent value is
|
|
150
|
-
// rejected BEFORE it can be coerced into a permissive one.
|
|
151
|
-
spent = (raw === null || raw === undefined) ? Number.NaN : Number(raw);
|
|
152
|
-
} catch (_) {
|
|
153
|
-
// THE 1001393 CASE. An unreadable ledger is the one this file exists for.
|
|
154
|
-
return deny('ledger_unreadable');
|
|
155
|
-
}
|
|
156
|
-
// A sum that is not a number is the same "I don't know" as a throw, and gets
|
|
157
|
-
// the same answer. NUMERIC comes back as a string from pg, so this catches a
|
|
158
|
-
// genuinely unparseable value, not the ordinary one.
|
|
159
|
-
if (!Number.isFinite(spent) || spent < 0) return deny('ledger_unreadable');
|
|
240
|
+
|
|
241
|
+
// sumOrNull carries the fail-closed reading (a missing pool, a throw, a NULL
|
|
242
|
+
// sum and an unparseable sum all become null) — see its header for why an
|
|
243
|
+
// absent value must never be coerced to 0.
|
|
244
|
+
const spent = await sumOrNull(pool, PAYER_SPEND_SQL, [monthStartUtc(now()), kind, builderId]);
|
|
245
|
+
if (spent === null) return deny('ledger_unreadable');
|
|
160
246
|
|
|
161
247
|
const remaining = cap - spent;
|
|
162
248
|
if (remaining <= 0) {
|
|
@@ -167,4 +253,7 @@ function createSpendCeiling({
|
|
|
167
253
|
};
|
|
168
254
|
}
|
|
169
255
|
|
|
170
|
-
module.exports = {
|
|
256
|
+
module.exports = {
|
|
257
|
+
createSpendCeiling, payerFor, monthStartUtc, agentCapFor,
|
|
258
|
+
DEFAULT_CAP_USD, DEFAULT_AGENT_CAP_USD,
|
|
259
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- agents_004_spend_cap.sql — the PER-AGENT monthly spend cap (task 1002499).
|
|
2
|
+
--
|
|
3
|
+
-- agents_002 recorded who PAYS for a fire, and modules/agents/lib/spend-ceiling.js
|
|
4
|
+
-- bounds what a payer may spend in a month. Both ask "how much has this WALLET
|
|
5
|
+
-- spent", and neither can answer "how much has this AGENT spent" — so under a
|
|
6
|
+
-- single project payer one runaway definition consumes the whole instance ceiling
|
|
7
|
+
-- and every other agent is refused for a bill it did not run up. The owner's
|
|
8
|
+
-- decision (2026-07-05, recorded on task 1002499) is ~$5/month per agent, layered
|
|
9
|
+
-- under the ~$30/month global autonomy brake: a per-agent cap bounds the blast
|
|
10
|
+
-- radius of ONE bad definition, and the global brake bounds the fleet.
|
|
11
|
+
--
|
|
12
|
+
-- NULL MEANS "USE THE INSTANCE DEFAULT", NOT "UNCAPPED". The default lives in
|
|
13
|
+
-- lib/spend-ceiling.js (BONGOS_AGENT_SPEND_CAP_USD, default $5) so a definition
|
|
14
|
+
-- that never thought about money still has a ceiling, and an instance can move
|
|
15
|
+
-- every unset agent with one env var. Storing the default on each row instead
|
|
16
|
+
-- would freeze today's number into every definition ever synced — the same trap
|
|
17
|
+
-- agents_002's header names for deriving payer_kind, in the other direction.
|
|
18
|
+
-- Nothing may read this column as "no cap": the CHECK below forbids a zero or a
|
|
19
|
+
-- negative, so the only two states are a positive number and "ask the default".
|
|
20
|
+
--
|
|
21
|
+
-- WHY NO NEW INDEX. The read this backs — SUM(cost_usd) for one agent this month
|
|
22
|
+
-- — is already served by agents_runs_agent_idx (agent_name, created_at DESC),
|
|
23
|
+
-- which agents_001 created with the comment "the read behind a per-agent spend
|
|
24
|
+
-- rollup". This migration is the rollup that index was built for.
|
|
25
|
+
--
|
|
26
|
+
-- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
|
|
27
|
+
-- object is agents_-prefixed, there is no down-migration, and the filename is
|
|
28
|
+
-- agents_-prefixed (ADR 0108 §5). Idempotent: safe to re-run.
|
|
29
|
+
|
|
30
|
+
BEGIN;
|
|
31
|
+
|
|
32
|
+
-- The per-agent monthly ceiling in USD. NULL = fall back to the instance default.
|
|
33
|
+
-- numeric(10,4) mirrors agents_runs.cost_usd, so a cap and the spend it bounds are
|
|
34
|
+
-- the same kind of number and a comparison cannot lose a fraction of a cent.
|
|
35
|
+
ALTER TABLE agents_definitions ADD COLUMN IF NOT EXISTS spend_cap_usd numeric(10, 4);
|
|
36
|
+
|
|
37
|
+
DO $$
|
|
38
|
+
BEGIN
|
|
39
|
+
-- A stored cap must be a real ceiling. Zero would read as "this agent may never
|
|
40
|
+
-- fire", which is what `enabled = false` already says and says better; a
|
|
41
|
+
-- negative is meaningless. Both are refused at the schema rather than left for
|
|
42
|
+
-- the guard to interpret, because a guard that has to decide what 0 means is a
|
|
43
|
+
-- guard with two possible answers.
|
|
44
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agents_definitions_spend_cap_positive_chk') THEN
|
|
45
|
+
ALTER TABLE agents_definitions ADD CONSTRAINT agents_definitions_spend_cap_positive_chk
|
|
46
|
+
CHECK (spend_cap_usd IS NULL OR spend_cap_usd > 0);
|
|
47
|
+
END IF;
|
|
48
|
+
END $$;
|
|
49
|
+
|
|
50
|
+
COMMENT ON COLUMN agents_definitions.spend_cap_usd IS
|
|
51
|
+
'Per-agent monthly spend ceiling in USD (task 1002499). NULL = use the instance default from lib/spend-ceiling.js (BONGOS_AGENT_SPEND_CAP_USD, default $5). Never "uncapped" — a positive value is the only storable cap.';
|
|
52
|
+
|
|
53
|
+
COMMIT;
|
|
@@ -113,8 +113,13 @@ const FANOUT_MAX = envInt('BONGOS_AGENTS_EVENT_FANOUT_MAX', DEFAULT_FANOUT_MAX);
|
|
|
113
113
|
// `enabled = false` by agents_definitions_flagged_not_armed_chk, so the WHERE
|
|
114
114
|
// clause below already excludes it and a second check here would only be a
|
|
115
115
|
// second place to get it wrong.
|
|
116
|
+
// `spend_cap_usd` joins the list for step (g2) — the per-agent ceiling reads the
|
|
117
|
+
// definition's own cap (task 1002499). It has to be selected here or every agent
|
|
118
|
+
// silently takes the instance default, which is the quiet-wrong-answer shape this
|
|
119
|
+
// spelled-out list otherwise protects against: a cap set on a definition would be
|
|
120
|
+
// stored, shown by the API, and ignored by the only code that enforces it.
|
|
116
121
|
const DEF_COLUMNS = `
|
|
117
|
-
id, name, persona, trigger_type, trigger_spec, model_tier
|
|
122
|
+
id, name, persona, trigger_type, trigger_spec, model_tier, spend_cap_usd
|
|
118
123
|
`;
|
|
119
124
|
|
|
120
125
|
// ---------------------------------------------------------------------------
|
|
@@ -590,10 +595,32 @@ async function fanOut(payload, deps = {}) {
|
|
|
590
595
|
return report;
|
|
591
596
|
}
|
|
592
597
|
|
|
598
|
+
// (g2) THE PER-AGENT CEILING (task 1002499), once PER AGENT rather than once
|
|
599
|
+
// for the fan-out. That is the whole difference from (g) above: the payer
|
|
600
|
+
// ceiling asks a question about the wallet, so one answer covers every agent
|
|
601
|
+
// sharing it, while this one asks about each definition and so has as many
|
|
602
|
+
// answers as there are agents. An agent over its share is skipped with its own
|
|
603
|
+
// ledger row; its siblings still fire, which is the behaviour a per-agent cap
|
|
604
|
+
// exists to produce — one runaway definition must not silence the fleet.
|
|
605
|
+
const stillAdmitted = [];
|
|
606
|
+
for (const a of admitted) {
|
|
607
|
+
const agentRoom = await ceiling.checkAgent(pool, a.definition);
|
|
608
|
+
if (agentRoom.ok) { stillAdmitted.push(a); continue; }
|
|
609
|
+
const reason = agentRoom.reason === 'agent_spend_cap_reached'
|
|
610
|
+
? `agent '${a.definition && a.definition.name}' has reached its $${agentRoom.capUsd} monthly cap — the instance still has room, but this agent has spent its share`
|
|
611
|
+
: `this agent's spend ceiling could not be established (${agentRoom.reason}) — a ceiling that cannot be read must not be assumed`;
|
|
612
|
+
await ledgerNoGo(deps, { definition: a.definition, taskId, reason, spendPayer });
|
|
613
|
+
report.skipped += 1;
|
|
614
|
+
}
|
|
615
|
+
if (!stillAdmitted.length) {
|
|
616
|
+
report.reason = 'agent_spend_ceiling';
|
|
617
|
+
return report;
|
|
618
|
+
}
|
|
619
|
+
|
|
593
620
|
// (h) FIRE. Sequentially, so N agents on one ship are N model calls in a row
|
|
594
621
|
// rather than N at once — the spend ceiling's own blind spot is fires already
|
|
595
622
|
// in flight, and serialising is what keeps that blind spot one fire wide.
|
|
596
|
-
for (const a of
|
|
623
|
+
for (const a of stillAdmitted) {
|
|
597
624
|
const fired = await fireOne(a, { task, taskId, spendPayer }, deps);
|
|
598
625
|
if (fired && fired.ran) report.fired += 1; else report.skipped += 1;
|
|
599
626
|
}
|
|
@@ -122,7 +122,7 @@ const COLUMNS = `
|
|
|
122
122
|
id, name, title, trigger_type, trigger_spec, model_tier,
|
|
123
123
|
scope_modules, scope_paths, scope_violation,
|
|
124
124
|
source, provenance, author_rank,
|
|
125
|
-
source_path, last_synced_at, enabled, created_at, updated_at
|
|
125
|
+
source_path, last_synced_at, enabled, spend_cap_usd, created_at, updated_at
|
|
126
126
|
`;
|
|
127
127
|
|
|
128
128
|
// PURE. Row → wire shape, so the projection is unit-testable without a database.
|
|
@@ -168,6 +168,14 @@ function serializeDefinition(row, { includePersona = false } = {}) {
|
|
|
168
168
|
// is the one disarm the registry can explain; anything else is simply off.
|
|
169
169
|
disarmed_reason: row.enabled === true ? null : (row.scope_violation ? 'scope_violation' : null),
|
|
170
170
|
last_synced_at: row.last_synced_at || null,
|
|
171
|
+
// The agent's own monthly ceiling (task 1002499), or null when it takes the
|
|
172
|
+
// instance default. Served as a NUMBER, not the pg NUMERIC string, so a caller
|
|
173
|
+
// comparing it against a spend does not silently compare strings. Null here
|
|
174
|
+
// never means "uncapped" — agents_004's CHECK makes a positive value the only
|
|
175
|
+
// storable cap, and an unset one resolves to BONGOS_AGENT_SPEND_CAP_USD.
|
|
176
|
+
spend_cap_usd: row.spend_cap_usd === null || row.spend_cap_usd === undefined
|
|
177
|
+
? null
|
|
178
|
+
: Number(row.spend_cap_usd),
|
|
171
179
|
created_at: row.created_at,
|
|
172
180
|
updated_at: row.updated_at,
|
|
173
181
|
};
|
|
@@ -605,6 +613,35 @@ module.exports = function agentsRoutes() {
|
|
|
605
613
|
);
|
|
606
614
|
if (!rows.length) return res.fail('agent_not_found', 404);
|
|
607
615
|
const definition = rows[0];
|
|
616
|
+
|
|
617
|
+
// THE PER-AGENT CEILING (task 1002499), after the payer's and before the
|
|
618
|
+
// gate. It cannot run any earlier: it needs the definition, both for its own
|
|
619
|
+
// spend_cap_usd and for the name it sums by.
|
|
620
|
+
//
|
|
621
|
+
// A THIRD refusal with a THIRD code, for the same reason the first two are
|
|
622
|
+
// separate: 429 says "too fast", `spend_cap_reached` says "this wallet's
|
|
623
|
+
// month is gone", and this one says "this wallet has room, but THIS AGENT
|
|
624
|
+
// has had its share". Collapsing them would tell an operator staring at a
|
|
625
|
+
// refused fire to go raise the wrong number — and under a single project
|
|
626
|
+
// payer the wrong number is the one that refuses every OTHER agent too.
|
|
627
|
+
const agentRoom = await spendCeiling.checkAgent(api.pool, definition);
|
|
628
|
+
if (!agentRoom.ok) {
|
|
629
|
+
return res.fail('agent_spend_cap_reached', {
|
|
630
|
+
status: 402,
|
|
631
|
+
message: agentRoom.reason === 'agent_spend_cap_reached'
|
|
632
|
+
? `agent '${definition.name}' has reached its $${agentRoom.capUsd} monthly cap — the instance still has room, but this agent has spent its share`
|
|
633
|
+
: `this fire is refused because the agent's spend ceiling could not be established (${agentRoom.reason}) — a ceiling that cannot be read must not be assumed`,
|
|
634
|
+
details: {
|
|
635
|
+
scope: 'agent-spend-per-agent',
|
|
636
|
+
reason: agentRoom.reason,
|
|
637
|
+
agent: definition.name,
|
|
638
|
+
cap_usd: agentRoom.capUsd,
|
|
639
|
+
// null whenever the refusal was a failure to READ, never a claimed sum.
|
|
640
|
+
spent_usd: agentRoom.spentUsd,
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
608
645
|
const gate = gateFor(definition);
|
|
609
646
|
const trigger = { type: 'on-demand', ref: `on-demand:${req.builder.github_login || req.builder.id}` };
|
|
610
647
|
|
package/modules/agents/spawn.js
CHANGED
|
@@ -36,6 +36,11 @@
|
|
|
36
36
|
const api = require('../../src/module-api');
|
|
37
37
|
const { payerFor } = require('./lib/spend-ceiling');
|
|
38
38
|
|
|
39
|
+
// Namespaced + structured, like the module's other two log sites (routes/agents.js,
|
|
40
|
+
// pollers/event-router.js). A bare console.warn here would be the module's only
|
|
41
|
+
// unstructured line, and the fitness console ratchet counts it.
|
|
42
|
+
const log = api.logger('agents');
|
|
43
|
+
|
|
39
44
|
// tier → concrete model id. The mapping is BRANDING, not a constant here: an
|
|
40
45
|
// instance retunes its own tiers, and the DB constraint already limits the
|
|
41
46
|
// vocabulary to these two.
|
|
@@ -139,6 +144,68 @@ async function insertRun(pool, row) {
|
|
|
139
144
|
return r.rows[0].id;
|
|
140
145
|
}
|
|
141
146
|
|
|
147
|
+
// The cost_log SOURCE TAG for one agent's spend (task 1002499). The `agent:`
|
|
148
|
+
// family is what modules/economy/cost.js's autonomous MTD brake matches on, beside
|
|
149
|
+
// `cron:`, so the prefix is a contract and not a label — see that function's header.
|
|
150
|
+
// The name is already schema-constrained to ^[a-z][a-z0-9-]*$ (agents_001), so
|
|
151
|
+
// nothing here has to sanitise it, but the tag is built in ONE place so a future
|
|
152
|
+
// rename cannot leave the brake matching a prefix nobody writes any more.
|
|
153
|
+
function costSourceTag(agentName) {
|
|
154
|
+
return `agent:${agentName}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Mirror one completed fire's cost into cost_log, so the project-wide brake can
|
|
158
|
+
// see it (task 1002499).
|
|
159
|
+
//
|
|
160
|
+
// WHY A MIRROR AND NOT A MOVE. agents_runs.cost_usd stays the ACCOUNTING — it is
|
|
161
|
+
// what the per-payer and per-agent ceilings sum, it carries the payer, and it is
|
|
162
|
+
// the row the schema's invariants are written about. cost_log is the project-wide
|
|
163
|
+
// ROLL-UP: the autonomy spend-brake and the cost dashboard's by_source breakdown
|
|
164
|
+
// both read it, and neither knows agents_runs exists. Before this, agent spend
|
|
165
|
+
// appeared in exactly one of those two places, which is why the brake was blind
|
|
166
|
+
// to it. The mirror is a projection of the ledger, never a second ledger: it is
|
|
167
|
+
// written once, after the cost is final, keyed so a retry cannot double-post.
|
|
168
|
+
//
|
|
169
|
+
// IDEMPOTENT BY CONSTRUCTION. logCost is insert-once on (source, source_ref), and
|
|
170
|
+
// source_ref is the run id — the one value that is unique per fire and already
|
|
171
|
+
// exists. A re-run of this function for the same fire is a no-op at the database,
|
|
172
|
+
// not a second row, which matters because the alternative to a stable key here is
|
|
173
|
+
// double-counting money.
|
|
174
|
+
//
|
|
175
|
+
// BEST-EFFORT, DELIBERATELY. A fire that ran and was ledgered in agents_runs has
|
|
176
|
+
// already happened and already cost money; failing it now because the roll-up
|
|
177
|
+
// could not be written would discard a completed fire's result to fix a reporting
|
|
178
|
+
// row. The direction of that trade is the opposite of the ceilings' — they fail
|
|
179
|
+
// CLOSED because they decide whether to spend, this fails OPEN because the spend
|
|
180
|
+
// is already past. What it must never do is stay silent: an unwritten mirror
|
|
181
|
+
// means the brake is under-counting, so the failure is logged with the run id.
|
|
182
|
+
async function mirrorCostToLedger({ runId, definition, costUsd, builderId = null }, deps = {}) {
|
|
183
|
+
if (!(typeof costUsd === 'number' && costUsd > 0)) return false;
|
|
184
|
+
const name = definition && definition.name;
|
|
185
|
+
if (!runId || typeof name !== 'string' || !name) return false;
|
|
186
|
+
const economy = deps.economy
|
|
187
|
+
|| (typeof api.resolveOptional === 'function' ? api.resolveOptional('reward', null) : null);
|
|
188
|
+
if (!economy || typeof economy.logCost !== 'function') return false;
|
|
189
|
+
try {
|
|
190
|
+
await economy.logCost({
|
|
191
|
+
amountUsd: costUsd,
|
|
192
|
+
category: 'api',
|
|
193
|
+
source: costSourceTag(name),
|
|
194
|
+
sourceRef: `agent-run-${runId}`,
|
|
195
|
+
// Who ASKED, when anyone did. Not who PAYS — payer_kind lives on the
|
|
196
|
+
// agents_runs row and is that question's answer; cost_log's builder_id is
|
|
197
|
+
// per-builder attribution, and an event fire legitimately has neither.
|
|
198
|
+
builderId,
|
|
199
|
+
description: `agent ${name} — fire ${runId}`.slice(0, 1000),
|
|
200
|
+
});
|
|
201
|
+
return true;
|
|
202
|
+
} catch (e) {
|
|
203
|
+
(deps.log || log).warn({ run_id: runId, agent: name, err: (e && e.message) || String(e) },
|
|
204
|
+
'cost mirror failed — agents_runs still has the cost, but the autonomous MTD brake is under-counting by it');
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
142
209
|
async function finishRun(pool, id, patch) {
|
|
143
210
|
const cols = Object.keys(patch);
|
|
144
211
|
const set = cols.map((c, i) => `${c} = $${i + 2}`).join(', ');
|
|
@@ -218,6 +285,11 @@ async function spawnAgent({
|
|
|
218
285
|
outputRef: ok ? (deps.outputRef || (res && res.output_ref) || null) : null,
|
|
219
286
|
});
|
|
220
287
|
await finishRun(pool, runId, patch);
|
|
288
|
+
// AFTER the ledger write, never before: the mirror projects a cost that is
|
|
289
|
+
// already recorded, so a row in cost_log with no agents_runs row behind it
|
|
290
|
+
// must not be possible. A failed or zero-cost fire mirrors nothing — the
|
|
291
|
+
// guard is inside mirrorCostToLedger so both call shapes share it.
|
|
292
|
+
await mirrorCostToLedger({ runId, definition, costUsd, builderId: requestedByBuilderId }, deps);
|
|
221
293
|
return { ok, ran: true, runId, model, cost_usd: costUsd, duration_ms: durationMs };
|
|
222
294
|
} catch (e) {
|
|
223
295
|
const durationMs = Math.max(0, now() - started);
|
|
@@ -228,4 +300,4 @@ async function spawnAgent({
|
|
|
228
300
|
}
|
|
229
301
|
}
|
|
230
302
|
|
|
231
|
-
module.exports = { spawnAgent, resolveModel, plannedRow, completedRow, composePrompt };
|
|
303
|
+
module.exports = { spawnAgent, resolveModel, plannedRow, completedRow, composePrompt, mirrorCostToLedger, costSourceTag };
|
|
@@ -46,6 +46,17 @@ const COST_WRITE_SURFACES = [
|
|
|
46
46
|
file: 'scripts/gds/session-cost.js',
|
|
47
47
|
builder_id: 'the authed builder; category=api, source_ref=<session-id>, task_id=null. Idempotent per session.',
|
|
48
48
|
},
|
|
49
|
+
{
|
|
50
|
+
// task 1002499: an agent fire mirrors its cost here so the autonomous MTD
|
|
51
|
+
// spend-brake can see it. agents_runs.cost_usd stays the accounting; this is
|
|
52
|
+
// the project-wide roll-up, written once per fire after the cost is final and
|
|
53
|
+
// keyed source_ref=agent-run-<id> so a retry cannot double-post. It reaches
|
|
54
|
+
// logCost through the economy PORT (ADR 0083) — still one writer, which is the
|
|
55
|
+
// property this list exists to state.
|
|
56
|
+
surface: 'modules/agents/spawn.js (fire mirror) → reward port → db.logCost() (source=agent:<name>)',
|
|
57
|
+
file: 'modules/agents/spawn.js',
|
|
58
|
+
builder_id: 'whoever REQUESTED the fire, or null for an event fire, which nobody asked for. Not the PAYER — that is payer_kind on the agents_runs row, a different question.',
|
|
59
|
+
},
|
|
49
60
|
{
|
|
50
61
|
surface: 'migrations / direct DB',
|
|
51
62
|
file: '—',
|
package/modules/economy/cost.js
CHANGED
|
@@ -55,16 +55,34 @@ async function getBuilderCostStatus(builderId) {
|
|
|
55
55
|
return classifyCostStatus(row.spend_mtd_usd, row.budget_usd);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// System 3 (task 1351): project-wide AUTONOMOUS api spend month-to-date
|
|
59
|
-
// `cron:%`-sourced rows (System 1 / task 1344 tags cron-launched sessions). The
|
|
58
|
+
// System 3 (task 1351): project-wide AUTONOMOUS api spend month-to-date. The
|
|
60
59
|
// autonomy precheck's hard spend-brake compares this against a ceiling so a
|
|
61
60
|
// runaway autonomous fleet self-throttles. Returns a plain number (USD).
|
|
61
|
+
//
|
|
62
|
+
// TWO SOURCE FAMILIES, because there are two ways this project spends money with
|
|
63
|
+
// nobody watching (task 1002499):
|
|
64
|
+
// cron:% — cron-launched SESSIONS (System 1 / task 1344).
|
|
65
|
+
// agent:% — agent fires (modules/agents). Each one is a `claude -p` the server
|
|
66
|
+
// spawns on its own, and POST /agents/:name/invoke answers 202
|
|
67
|
+
// without waiting, so the caller is not present for the spend either.
|
|
68
|
+
//
|
|
69
|
+
// The agent family was the hole this closes. Agent fires recorded their cost in
|
|
70
|
+
// `agents_runs.cost_usd` and nowhere else, so the brake — which reads cost_log —
|
|
71
|
+
// could not see a dollar of it: the per-payer and per-agent ceilings in
|
|
72
|
+
// modules/agents/lib/spend-ceiling.js bounded each wallet and each definition,
|
|
73
|
+
// while the fleet-wide brake they sit under was measuring only half the fleet.
|
|
74
|
+
//
|
|
75
|
+
// THE PREFIX MATCH IS THE CONTRACT, not a convenience. `agent:<name>` is written
|
|
76
|
+
// by exactly one place (the mirror in modules/agents/spawn.js) and the shape is
|
|
77
|
+
// asserted there, so widening the brake to a family rather than a list means a
|
|
78
|
+
// new agent is inside the ceiling the day it is authored, with nothing to
|
|
79
|
+
// remember to add here.
|
|
62
80
|
async function autonomousApiSpendMtd() {
|
|
63
81
|
const { rows } = await pool.query(
|
|
64
82
|
`SELECT COALESCE(SUM(amount_usd), 0) AS usd
|
|
65
83
|
FROM cost_log
|
|
66
84
|
WHERE category = 'api'
|
|
67
|
-
AND source LIKE 'cron:%'
|
|
85
|
+
AND (source LIKE 'cron:%' OR source LIKE 'agent:%')
|
|
68
86
|
AND recorded_at >= date_trunc('month', now())`
|
|
69
87
|
);
|
|
70
88
|
return Number(rows[0]?.usd) || 0;
|
|
@@ -67,6 +67,13 @@ module.exports = {
|
|
|
67
67
|
setBuilderMonthlyBudget: (...a) => cost.setBuilderMonthlyBudget(...a),
|
|
68
68
|
reconcileSessionCosts: (...a) => cost.reconcileSessionCosts(...a),
|
|
69
69
|
autonomousApiSpendMtd: (...a) => cost.autonomousApiSpendMtd(...a),
|
|
70
|
+
// The one WRITE the agents module needs (task 1002499). cost_log is economy's
|
|
71
|
+
// table and modules may only cross the wall through a port (ADR 0083 / 0093 §2),
|
|
72
|
+
// so an agent fire mirrors its cost through here rather than by importing cost.js
|
|
73
|
+
// or writing the INSERT itself. It is the same logCost every other writer uses —
|
|
74
|
+
// not an agent-shaped variant — so there is still exactly one writer, which is
|
|
75
|
+
// the property modules/economy/cost-classify.js's COST_WRITE_SURFACES enumerates.
|
|
76
|
+
logCost: (...a) => cost.logCost(...a),
|
|
70
77
|
leaderboard: (...a) => credits.leaderboard(...a),
|
|
71
78
|
getLeaderboardWithAchievements: (...a) => achievements.getLeaderboardWithAchievements(...a),
|
|
72
79
|
listAchievements: (...a) => achievements.listAchievements(...a),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"stub": { "prefix": "/builders/" },
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
9
|
"states": {
|
|
10
10
|
"record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#idea-body", "#idea-h"] } },
|
|
11
11
|
"missing": { "auth": true, "url": "/idea/999999", "actions": [["wait", 600]], "expect": { "visible": ["#idea-h"] } }
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
8
|
"_console": "The 501 is the harness, not the page: the meter re-scores through POST /inbox/score on every keystroke and scripts/hall-preview/server.js answers every write with preview_readonly by design. Declared rather than hidden.",
|
|
9
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "\\b501\\b", "Unauthorized", "Not Found", "Not Implemented", "load failed"],
|
|
10
10
|
"states": {
|
|
11
11
|
"compose": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#fi-form", "#fi-fields", "#fi-queue"] } },
|
|
12
12
|
"develop": { "auth": true, "url": "/ideas?develop=1000884", "actions": [["wait", 700]], "expect": { "visible": ["#fi-develop"] } }
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"_feeds": "As work.states.json: the kit's stub is not the hall's feed set, so Home renders its skeletons and empty states here. The populated dashboard is the hall-preview harness.",
|
|
8
8
|
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox", "/api/(gds|bongos)/live", "/api/(gds|bongos)/analytics/", "/api/(gds|bongos)/achievements", "/api/(gds|bongos)/versions", "/api/(gds|bongos)/leaderboard"],
|
|
9
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
10
10
|
"_1003441": "The `leaderboard` state left with the view: the Roll is the /roster page now and #leaderboard REDIRECTS there, so there is no #view-leaderboard on this page to make visible. The redirect is proven in tests/hall_nav.mjs (it asserts location.replace('/roster') fires on the index and nowhere else) rather than by a render state, because a state whose page navigates away measures the destination, not this page. `standing-karma` replaces the coverage: task 2178 made each Standing rail section addressable and none had a measured state.",
|
|
11
11
|
"states": {
|
|
12
12
|
"home": { "auth": true, "expect": { "visible": ["#view-home"], "hidden": ["#view-standing"] } },
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"stub": { "prefix": "/builders/" },
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
9
|
"states": {
|
|
10
10
|
"request": { "auth": true, "url": "/pair?code=K7QD-4M2X", "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-approve-btn"] } },
|
|
11
11
|
"nocode": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#pair-h1", "#pair-scroll .pair-status--err"] } }
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"stub": { "prefix": "/builders/" },
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/", "/downloads/devbox/"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "/downloads/devbox/", "
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "/downloads/devbox/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed", "Password field"],
|
|
9
9
|
"states": {
|
|
10
10
|
"account": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#settings-h1", "#display-name-input", "#craft-chips", "#privacy-rows"] } },
|
|
11
11
|
"access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"stub": { "prefix": "/builders/" },
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"ignoreRequests": ["/api/(gds|bongos)/"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
9
9
|
"states": {
|
|
10
10
|
"record": { "auth": true, "actions": [["wait", 600]], "expect": { "visible": ["#task-body", "#task-grade", "#task-sessions", "#copy-prompt-btn"] } },
|
|
11
11
|
"from-goal": { "auth": true, "url": "/task/2166?from=%2Fgoals%23%2Fgoal%2F1000081", "actions": [["wait", 600]], "expect": { "visible": ["#task-back", "#copy-prompt-btn"], "attr": [["#task-back", "href", "/goals#/goal/1000081"]] } },
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"modeQuery": false,
|
|
7
7
|
"_feeds": "The kit's stub carries a small fixture set, not the hall's task feeds, so the board renders its EMPTY and SKELETON states here — which is the point: an empty queue is a state the floors must hold in. The feed 404s are declared, never hidden.",
|
|
8
8
|
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/", "/api/(gds|bongos)/tasks", "/api/(gds|bongos)/claims", "/api/(gds|bongos)/goals", "/api/(gds|bongos)/inbox/", "/api/(gds|bongos)/versions"],
|
|
9
|
-
"ignoreConsole": ["/api/(gds|bongos)/", "
|
|
9
|
+
"ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
|
|
10
10
|
"states": {
|
|
11
11
|
"ready": { "auth": true, "expect": { "visible": ["#claim-scroll"], "hidden": ["#wip-scroll", "#backlog-scroll", "#history-scroll"] } },
|
|
12
12
|
"in-progress": { "auth": true, "url": "/builders/work#in-progress", "actions": [["wait", 400]], "expect": { "visible": ["#wip-scroll"] } },
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.844",
|
|
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.844",
|
|
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.844",
|
|
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
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.844'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -25,8 +25,11 @@ import { validateManifest } from '../src/module-loader/manifest-schema.js';
|
|
|
25
25
|
import ceilingMod from '../modules/agents/lib/spend-ceiling.js';
|
|
26
26
|
import spawnMod from '../modules/agents/spawn.js';
|
|
27
27
|
|
|
28
|
-
const {
|
|
29
|
-
|
|
28
|
+
const {
|
|
29
|
+
createSpendCeiling, payerFor, monthStartUtc, agentCapFor,
|
|
30
|
+
DEFAULT_CAP_USD, DEFAULT_AGENT_CAP_USD,
|
|
31
|
+
} = ceilingMod;
|
|
32
|
+
const { plannedRow, mirrorCostToLedger, costSourceTag } = spawnMod;
|
|
30
33
|
|
|
31
34
|
// A registry with one metered module and one ordinary one, so every assertion can
|
|
32
35
|
// show that the guard bites the declaring module and NOTHING else.
|
|
@@ -270,3 +273,201 @@ test('the ceiling RECORDS NOTHING — the ledger is the only tally', async () =>
|
|
|
270
273
|
assert.doesNotMatch(sql, /INSERT|UPDATE|DELETE/i);
|
|
271
274
|
}
|
|
272
275
|
});
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// 4. the per-AGENT monthly ceiling (task 1002499)
|
|
279
|
+
//
|
|
280
|
+
// Its own section, and its own tests against the same hostile pools section 3
|
|
281
|
+
// uses, DELIBERATELY. checkAgent and check now share sumOrNull, and a shared
|
|
282
|
+
// helper's documented safety property is a claim until each caller is shown to
|
|
283
|
+
// honour it — a refactor that made checkAgent swallow a null would pass every
|
|
284
|
+
// test written only against check.
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
const HISTORIAN = { name: 'historian', spend_cap_usd: null };
|
|
288
|
+
|
|
289
|
+
test('a per-agent cap is SMALLER than the payer cap — it bounds a definition, not a wallet', () => {
|
|
290
|
+
assert.ok(DEFAULT_AGENT_CAP_USD < DEFAULT_CAP_USD,
|
|
291
|
+
'if the per-agent default ever reaches the payer default it stops bounding anything: '
|
|
292
|
+
+ 'one agent could spend the whole instance ceiling, which is the case this exists for');
|
|
293
|
+
assert.equal(DEFAULT_AGENT_CAP_USD, 5, 'the owner decision of 2026-07-05, recorded on task 1002499');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('under its cap: allowed, with the arithmetic shown', async () => {
|
|
297
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
298
|
+
const r = await c.checkAgent(poolOf('1.8000'), HISTORIAN);
|
|
299
|
+
assert.equal(r.ok, true);
|
|
300
|
+
assert.equal(r.spentUsd, 1.8);
|
|
301
|
+
assert.equal(r.capUsd, 5);
|
|
302
|
+
assert.equal(r.remainingUsd, 3.2);
|
|
303
|
+
assert.equal(r.reason, null);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('at or past its cap: refused with its OWN code, never the payer one', async () => {
|
|
307
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
308
|
+
for (const spent of ['5', '5.0001', '90']) {
|
|
309
|
+
const r = await c.checkAgent(poolOf(spent), HISTORIAN);
|
|
310
|
+
assert.equal(r.ok, false, spent);
|
|
311
|
+
assert.equal(r.reason, 'agent_spend_cap_reached', spent);
|
|
312
|
+
assert.notEqual(r.reason, 'spend_cap_reached',
|
|
313
|
+
'the two refusals must stay distinguishable: one says raise the instance cap, '
|
|
314
|
+
+ 'the other says raise THIS agent — and under a project payer the wrong advice '
|
|
315
|
+
+ 'raises a number that was never the constraint');
|
|
316
|
+
assert.equal(r.remainingUsd, 0, spent);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('a definition own cap beats the instance default, in BOTH directions', async () => {
|
|
321
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
322
|
+
const generous = await c.checkAgent(poolOf('7'), { name: 'a', spend_cap_usd: '20.0000' });
|
|
323
|
+
assert.equal(generous.ok, true, '$7 spent against its own $20 cap is fine, though it is past the default');
|
|
324
|
+
assert.equal(generous.capUsd, 20);
|
|
325
|
+
const stingy = await c.checkAgent(poolOf('2'), { name: 'b', spend_cap_usd: '1.5000' });
|
|
326
|
+
assert.equal(stingy.ok, false, '$2 spent against its own $1.50 cap is over, though it is under the default');
|
|
327
|
+
assert.equal(stingy.reason, 'agent_spend_cap_reached');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('agentCapFor: only a positive number overrides; anything else takes the default', () => {
|
|
331
|
+
assert.equal(agentCapFor({ spend_cap_usd: '12.5' }, 5), 12.5, 'pg NUMERIC arrives as a string');
|
|
332
|
+
assert.equal(agentCapFor({ spend_cap_usd: 12.5 }, 5), 12.5);
|
|
333
|
+
assert.equal(agentCapFor({ spend_cap_usd: null }, 5), 5, 'NULL means use the default, never uncapped');
|
|
334
|
+
assert.equal(agentCapFor({}, 5), 5, 'a projection that dropped the column must not read as uncapped');
|
|
335
|
+
for (const bad of [0, -1, 'nonsense', NaN, Infinity]) {
|
|
336
|
+
assert.equal(agentCapFor({ spend_cap_usd: bad }, 5), 5,
|
|
337
|
+
String(bad) + ' is not a cap this code wrote (agents_004 CHECK forbids it), so it takes the default');
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('AN UNREADABLE LEDGER REFUSES HERE TOO — checkAgent does not inherit the fix, it must honour it', async () => {
|
|
342
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
343
|
+
const throwing = { query: async () => { throw new Error('connection terminated'); } };
|
|
344
|
+
assert.equal((await c.checkAgent(throwing, HISTORIAN)).reason, 'ledger_unreadable');
|
|
345
|
+
// The shapes that would coerce to a permissive 0. Each is asserted against
|
|
346
|
+
// checkAgent specifically, because the whole hazard is a sum that LOOKS fine.
|
|
347
|
+
for (const rows of [[{ spent: null }], [{ spent: undefined }], [{}], [], [{ spent: 'nonsense' }], [{ spent: '-1' }]]) {
|
|
348
|
+
const r = await c.checkAgent({ query: async () => ({ rows }) }, HISTORIAN);
|
|
349
|
+
assert.equal(r.ok, false, JSON.stringify(rows));
|
|
350
|
+
assert.equal(r.reason, 'ledger_unreadable', JSON.stringify(rows));
|
|
351
|
+
assert.equal(r.spentUsd, null, 'a refusal that could not read must not claim a sum');
|
|
352
|
+
}
|
|
353
|
+
assert.equal((await c.checkAgent(null, HISTORIAN)).reason, 'ledger_unreadable', 'no pool at all');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('no usable cap and no agent identity both REFUSE — an unbounded agent is the defect wearing a cap name', async () => {
|
|
357
|
+
for (const cap of [0, -5, NaN, 'nonsense']) {
|
|
358
|
+
const c = createSpendCeiling({ agentCapUsd: cap, now: () => AT });
|
|
359
|
+
const r = await c.checkAgent(poolOf('0'), { name: 'x', spend_cap_usd: null });
|
|
360
|
+
assert.equal(r.ok, false, String(cap));
|
|
361
|
+
assert.equal(r.reason, 'no_agent_spend_cap_configured', String(cap));
|
|
362
|
+
}
|
|
363
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
364
|
+
for (const definition of [null, {}, { name: '' }, { name: ' ' }, { name: 42 }]) {
|
|
365
|
+
const r = await c.checkAgent(poolOf('0'), definition);
|
|
366
|
+
assert.equal(r.ok, false, JSON.stringify(definition));
|
|
367
|
+
assert.equal(r.reason, 'no_agent_identity', JSON.stringify(definition));
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('the agent sum is keyed on agent_name and the calendar month — not definition_id', async () => {
|
|
372
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
373
|
+
let sql = null; let params = null;
|
|
374
|
+
await c.checkAgent({ query: async (s, p) => { sql = s; params = p; return { rows: [{ spent: '0' }] }; } }, HISTORIAN);
|
|
375
|
+
assert.match(sql, /agent_name\s*=\s*\$2/, 'keyed on the NAME');
|
|
376
|
+
assert.doesNotMatch(sql, /definition_id/,
|
|
377
|
+
'definition_id is ON DELETE SET NULL, so keying on it would zero an agent month '
|
|
378
|
+
+ 'when its definition is deleted and hand a re-created agent a fresh cap');
|
|
379
|
+
assert.doesNotMatch(sql, /payer_kind/, 'a per-agent cap spans every payer — that is the point');
|
|
380
|
+
assert.equal(params[0].toISOString(), '2026-09-01T00:00:00.000Z');
|
|
381
|
+
assert.equal(params[1], 'historian');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('checkAgent RECORDS NOTHING either — agents_runs stays the only tally', async () => {
|
|
385
|
+
const c = createSpendCeiling({ agentCapUsd: 5, now: () => AT });
|
|
386
|
+
const statements = [];
|
|
387
|
+
const pool = { query: async (s) => { statements.push(s); return { rows: [{ spent: '1' }] }; } };
|
|
388
|
+
await c.checkAgent(pool, HISTORIAN);
|
|
389
|
+
await c.checkAgent(pool, HISTORIAN);
|
|
390
|
+
assert.equal(statements.length, 2);
|
|
391
|
+
for (const s of statements) {
|
|
392
|
+
assert.match(s, /^\s*SELECT/i);
|
|
393
|
+
assert.doesNotMatch(s, /INSERT|UPDATE|DELETE/i);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// 5. the cost mirror (task 1002499) — what makes the fleet-wide brake able to
|
|
399
|
+
// see agent spend at all
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
const mirrorPort = () => {
|
|
403
|
+
const rows = [];
|
|
404
|
+
return { rows, logCost: async (r) => { rows.push(r); return { id: 1 }; } };
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
test('the source tag is the agent: family the MTD brake matches on', () => {
|
|
408
|
+
assert.equal(costSourceTag('historian'), 'agent:historian');
|
|
409
|
+
assert.ok(costSourceTag('x').startsWith('agent:'),
|
|
410
|
+
'the prefix is a CONTRACT: modules/economy/cost.js widens the autonomous MTD sum to '
|
|
411
|
+
+ 'source LIKE agent:%, so a tag outside the family is spend the brake cannot see');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('a completed fire mirrors its cost, keyed so a retry cannot double-post', async () => {
|
|
415
|
+
const economy = mirrorPort();
|
|
416
|
+
const wrote = await mirrorCostToLedger(
|
|
417
|
+
{ runId: 77, definition: { name: 'historian' }, costUsd: 0.9123, builderId: 25 }, { economy },
|
|
418
|
+
);
|
|
419
|
+
assert.equal(wrote, true);
|
|
420
|
+
assert.equal(economy.rows.length, 1);
|
|
421
|
+
const row = economy.rows[0];
|
|
422
|
+
assert.equal(row.amountUsd, 0.9123);
|
|
423
|
+
assert.equal(row.category, 'api', 'the category the brake filters on, beside the source');
|
|
424
|
+
assert.equal(row.source, 'agent:historian');
|
|
425
|
+
assert.equal(row.sourceRef, 'agent-run-77',
|
|
426
|
+
'logCost is insert-once on (source, source_ref), and the run id is the one value '
|
|
427
|
+
+ 'unique per fire — the alternative to a stable key here is double-counting money');
|
|
428
|
+
assert.equal(row.builderId, 25, 'who ASKED; the PAYER lives on the agents_runs row');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('nothing to mirror is not an error, and nothing is written', async () => {
|
|
432
|
+
for (const args of [
|
|
433
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: 0 },
|
|
434
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: null },
|
|
435
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: -1 },
|
|
436
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: '0.9' },
|
|
437
|
+
{ runId: null, definition: { name: 'a' }, costUsd: 0.9 },
|
|
438
|
+
{ runId: 1, definition: {}, costUsd: 0.9 },
|
|
439
|
+
{ runId: 1, definition: null, costUsd: 0.9 },
|
|
440
|
+
]) {
|
|
441
|
+
const economy = mirrorPort();
|
|
442
|
+
assert.equal(await mirrorCostToLedger(args, { economy }), false, JSON.stringify(args));
|
|
443
|
+
assert.equal(economy.rows.length, 0, 'a cost_log row with no fire behind it must not be possible');
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('the mirror FAILS OPEN and says so — the opposite direction from the ceilings, on purpose', async () => {
|
|
448
|
+
// The ceilings fail closed because they decide whether to SPEND. This runs after
|
|
449
|
+
// the money is gone and the agents_runs row is written, so refusing here would
|
|
450
|
+
// discard a completed fire result to fix a reporting row.
|
|
451
|
+
const economy = { logCost: async () => { throw new Error('cost_log unreachable'); } };
|
|
452
|
+
const warnings = [];
|
|
453
|
+
const log = { warn: (fields, msg) => warnings.push({ fields, msg }) };
|
|
454
|
+
assert.equal(await mirrorCostToLedger(
|
|
455
|
+
{ runId: 9, definition: { name: 'historian' }, costUsd: 0.9 }, { economy, log },
|
|
456
|
+
), false);
|
|
457
|
+
assert.equal(warnings.length, 1, 'silence would leave the brake under-counting with nothing to notice it');
|
|
458
|
+
// STRUCTURED, not a formatted string: the run id and the agent are fields an
|
|
459
|
+
// operator can filter the journal by, which is the point of naming them at all.
|
|
460
|
+
assert.equal(warnings[0].fields.run_id, 9, 'names the run');
|
|
461
|
+
assert.equal(warnings[0].fields.agent, 'historian', 'names the agent');
|
|
462
|
+
assert.match(warnings[0].fields.err, /cost_log unreachable/, 'and what actually went wrong');
|
|
463
|
+
assert.match(warnings[0].msg, /under-counting/, 'and says what the consequence is');
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('no economy port: no throw, no write — an instance may run agents without economy', async () => {
|
|
467
|
+
assert.equal(await mirrorCostToLedger(
|
|
468
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: 0.9 }, { economy: null },
|
|
469
|
+
), false);
|
|
470
|
+
assert.equal(await mirrorCostToLedger(
|
|
471
|
+
{ runId: 1, definition: { name: 'a' }, costUsd: 0.9 }, { economy: {} },
|
|
472
|
+
), false, 'a port without logCost is the same as no port — ADR 0083 degrades, it does not crash');
|
|
473
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// tests/hall_states_regex_escapes.mjs — a states file's ignore lists are REGEX
|
|
2
|
+
// SOURCES, so a JSON escape that produces a control character is a silently dead
|
|
3
|
+
// pattern (task 1003638).
|
|
4
|
+
//
|
|
5
|
+
// THE DEFECT. `modules/ui-design/kit/lib.js` does:
|
|
6
|
+
//
|
|
7
|
+
// const con = ignoreConsole.map((s) => new RegExp(s));
|
|
8
|
+
//
|
|
9
|
+
// so every entry is a regex source. In JSON, `"\b"` is the escape for BACKSPACE
|
|
10
|
+
// (U+0008) — not the two characters a regex needs for a word boundary. Eight states
|
|
11
|
+
// files wrote `"\b401\b"` where they meant `"\\b401\\b"`, compiling a pattern that
|
|
12
|
+
// matches a literal backspace beside "401". Console text never contains one, so the
|
|
13
|
+
// declared noise suppression did nothing and nothing ever said so.
|
|
14
|
+
//
|
|
15
|
+
// MEASURED across modules/hall-ui/public/*.states.json on 2026-09-17: 8 files, 17
|
|
16
|
+
// entries — goals, idea, ideas, index, pair, settings, task, work. (The row that
|
|
17
|
+
// filed this named only `work`, and listed `task`, `idea` and `ideas` among the
|
|
18
|
+
// HEALTHY files; all three were broken. The fix followed the computed list.)
|
|
19
|
+
//
|
|
20
|
+
// Run: node --test tests/hall_states_regex_escapes.mjs
|
|
21
|
+
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const DIR = path.join(REPO_ROOT, 'modules/hall-ui/public');
|
|
30
|
+
const REGEX_LISTS = ['ignoreConsole', 'ignoreRequests', 'ignoreControls'];
|
|
31
|
+
const CONTROL = /[\x00-\x1f]/;
|
|
32
|
+
|
|
33
|
+
const files = readdirSync(DIR).filter((n) => n.endsWith('.states.json'));
|
|
34
|
+
|
|
35
|
+
test('there are states files to check — an empty sweep must not read as a pass', () => {
|
|
36
|
+
assert.ok(files.length > 10, `expected the hall states files, found ${files.length}`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('no ignore-list entry contains a control character', () => {
|
|
40
|
+
const offenders = [];
|
|
41
|
+
for (const name of files) {
|
|
42
|
+
const json = JSON.parse(readFileSync(path.join(DIR, name), 'utf8'));
|
|
43
|
+
for (const key of REGEX_LISTS) {
|
|
44
|
+
if (!Array.isArray(json[key])) continue;
|
|
45
|
+
for (const entry of json[key]) {
|
|
46
|
+
if (CONTROL.test(entry)) {
|
|
47
|
+
const codes = [...entry].map((c) => c.charCodeAt(0)).filter((n) => n < 32)
|
|
48
|
+
.map((n) => `U+${n.toString(16).padStart(4, '0')}`).join(',');
|
|
49
|
+
offenders.push(`${name} ${key}: ${JSON.stringify(entry)} (${codes})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
assert.deepEqual(offenders, [],
|
|
55
|
+
`a JSON escape produced a control character in a regex source — almost certainly \\b meant as a word boundary:\n ${offenders.join('\n ')}`);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('every ignore-list entry compiles as a regex', () => {
|
|
59
|
+
// lib.js calls new RegExp(s) with no try/catch, so an uncompilable entry is a
|
|
60
|
+
// crash in the walk rather than a dead pattern.
|
|
61
|
+
const bad = [];
|
|
62
|
+
for (const name of files) {
|
|
63
|
+
const json = JSON.parse(readFileSync(path.join(DIR, name), 'utf8'));
|
|
64
|
+
for (const key of REGEX_LISTS) {
|
|
65
|
+
if (!Array.isArray(json[key])) continue;
|
|
66
|
+
for (const entry of json[key]) {
|
|
67
|
+
try { new RegExp(entry); } catch (e) { bad.push(`${name} ${key}: ${JSON.stringify(entry)} — ${e.message}`); }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
assert.deepEqual(bad, [], `uncompilable regex source:\n ${bad.join('\n ')}`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('the word-boundary patterns actually match a real console line', () => {
|
|
75
|
+
// The behavioural half: shape alone would still pass if someone "fixed" the control
|
|
76
|
+
// character by deleting it (leaving "401", which over-matches, e.g. "1401ms").
|
|
77
|
+
const line = 'Failed to load resource: the server responded with a status of 404 (Not Found)';
|
|
78
|
+
const work = JSON.parse(readFileSync(path.join(DIR, 'work.states.json'), 'utf8'));
|
|
79
|
+
const matched = work.ignoreConsole.some((s) => new RegExp(s).test(line));
|
|
80
|
+
assert.equal(matched, true, 'the declared 404 suppression must match a real 404 console line');
|
|
81
|
+
|
|
82
|
+
// And the boundary must still be a boundary — not a bare number.
|
|
83
|
+
const boundary = work.ignoreConsole.find((s) => s.includes('404'));
|
|
84
|
+
assert.ok(boundary, 'a 404 entry must exist');
|
|
85
|
+
assert.equal(new RegExp(boundary).test('elapsed 1404ms'), false,
|
|
86
|
+
'the pattern must not match a 404 embedded in a longer number — that is what the word boundary is for');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('the broken form is genuinely inert — this is what the bug cost', () => {
|
|
90
|
+
// Pins WHY it was invisible: the old value silently matched nothing rather than
|
|
91
|
+
// erroring, so every gate stayed green while the suppression did nothing.
|
|
92
|
+
const broken = '\x08404\x08';
|
|
93
|
+
const line = 'Failed to load resource: the server responded with a status of 404 (Not Found)';
|
|
94
|
+
assert.equal(new RegExp(broken).test(line), false,
|
|
95
|
+
'the backspace form matches nothing — silent, which is why it survived');
|
|
96
|
+
assert.doesNotThrow(() => new RegExp(broken),
|
|
97
|
+
'and it compiles, so nothing ever complained');
|
|
98
|
+
});
|