@cohortapp/agent-sdk 2.5.1 → 2.6.0
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/bin/maestro.mjs +185 -88
- package/bin/maestro.test.mjs +175 -48
- package/docs/runbooks/backup-restore.md +65 -33
- package/framework-features.json +4 -4
- package/lib/backup/policy.mjs +710 -0
- package/lib/backup/policy.test.mjs +305 -0
- package/lib/budget-escalate.mjs +133 -0
- package/lib/budget-escalate.test.mjs +232 -0
- package/lib/budget-guard.envelope.test.mjs +476 -0
- package/lib/budget-guard.mjs +853 -75
- package/lib/budget-guard.test.mjs +91 -42
- package/lib/cadences.mjs +33 -0
- package/lib/channels/orgmail/adapter.mjs +88 -3
- package/lib/channels/orgmail/adapter.test.mjs +137 -0
- package/lib/channels/repeat-suppressor.mjs +198 -0
- package/lib/channels/repeat-suppressor.test.mjs +134 -0
- package/lib/comms/receipts.mjs +297 -0
- package/lib/cost/ledger-row.mjs +333 -0
- package/lib/cost/ledger-row.test.mjs +183 -0
- package/lib/execution/drive.mjs +28 -1
- package/lib/execution/effects.mjs +191 -12
- package/lib/execution/effects.test.mjs +50 -11
- package/lib/goals/admission.mjs +13 -1
- package/lib/goals/admission.test.mjs +26 -1
- package/lib/goals/loop.mjs +13 -0
- package/lib/kpi-sensors.test.mjs +3 -0
- package/lib/mandate/cache.mjs +13 -5
- package/lib/mandate/derive.mjs +146 -21
- package/lib/mandate/derive.test.mjs +50 -6
- package/lib/mandate/model.mjs +32 -4
- package/lib/mandate/refresh.test.mjs +16 -2
- package/lib/mcp/server.test.mjs +12 -3
- package/lib/model-router/economics.mjs +107 -76
- package/lib/model-router/economics.test.mjs +64 -46
- package/lib/model-router/integration-coverage.test.mjs +39 -37
- package/lib/model-router/ledger.mjs +75 -22
- package/lib/model-router/ledger.test.mjs +35 -2
- package/lib/org/client.mjs +14 -0
- package/lib/org/cost-sync.mjs +16 -2
- package/lib/org/doctor.mjs +62 -1
- package/lib/org/doctor.test.mjs +36 -3
- package/lib/org/email-remedy.mjs +49 -0
- package/lib/org/engagement-ledger.mjs +376 -0
- package/lib/org/engagement-ledger.test.mjs +112 -0
- package/lib/org/engagement.mjs +1056 -0
- package/lib/org/engagement.test.mjs +739 -0
- package/lib/org/messaging.mjs +230 -3
- package/lib/org/messaging.test.mjs +110 -1
- package/lib/org/param-contract.mjs +56 -2
- package/lib/org/param-contract.test.mjs +26 -0
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +5 -0
- package/lib/org/protocol.test.mjs +7 -1
- package/lib/org/tool-surface.mjs +506 -10
- package/lib/org/tool-surface.test.mjs +191 -7
- package/lib/org/ui-parity.mjs +333 -6
- package/lib/org/ui-parity.test.mjs +96 -3
- package/lib/org/work-ledger.mjs +241 -0
- package/lib/org/work-ledger.test.mjs +237 -0
- package/lib/plan/adoption-e2e.test.mjs +366 -0
- package/lib/plan/budget-enforcement.test.mjs +400 -0
- package/lib/plan/budget-runtime.mjs +215 -0
- package/lib/plan/compile.mjs +201 -5
- package/lib/plan/compile.test.mjs +19 -5
- package/lib/plan/emit.mjs +8 -0
- package/lib/plan/emit.test.mjs +18 -0
- package/lib/resource-governor.mjs +58 -12
- package/lib/resource-governor.test.mjs +41 -1
- package/lib/security/audit-engine.mjs +45 -8
- package/lib/security/audit-engine.test.mjs +35 -0
- package/lib/setup/enroll-from-cohort.mjs +14 -1
- package/lib/setup/sections/mandate.mjs +48 -7
- package/lib/setup/sections/mandate.test.mjs +17 -2
- package/lib/setup/sections/orgmail.mjs +10 -2
- package/lib/setup/state.mjs +83 -2
- package/lib/telemetry/collect.mjs +360 -20
- package/lib/telemetry/collect.test.mjs +266 -0
- package/package.json +1 -1
- package/scripts/cost/track-claude-usage.mjs +207 -48
- package/scripts/cost/track-claude-usage.test.mjs +148 -0
- package/scripts/daemon/agent-daemon.mjs +315 -17
- package/scripts/daemon/assurance-e2e.test.mjs +421 -0
- package/scripts/daemon/assurance.mjs +944 -0
- package/scripts/daemon/assurance.test.mjs +668 -0
- package/scripts/daemon/cadence-consumer-governance.test.mjs +56 -0
- package/scripts/daemon/cadence-consumer.mjs +147 -9
- package/scripts/daemon/cadence-consumer.test.mjs +6 -0
- package/scripts/daemon/cadence-handlers.mjs +158 -0
- package/scripts/daemon/cadence-handlers.test.mjs +64 -0
- package/scripts/daemon/deliver.mjs +314 -0
- package/scripts/daemon/dispatcher-governance.test.mjs +10 -0
- package/scripts/daemon/dispatcher.mjs +64 -6
- package/scripts/daemon/responder-cost.test.mjs +68 -0
- package/scripts/daemon/responder.mjs +351 -298
- package/scripts/local-triggers/generate-plists.test.mjs +7 -4
- package/scripts/maintenance/backup-run.mjs +415 -0
- package/scripts/maintenance/backup-to-cloud.sh +16 -116
- package/scripts/org/send-orgmail.mjs +16 -0
- package/scripts/record-receipt.sh +63 -0
- package/scripts/restore-from-backup.sh +14 -3
- package/scripts/restore-from-backup.test.mjs +8 -5
- package/scripts/send-email-threaded.py +47 -0
- package/scripts/send-sms.sh +4 -0
- package/scripts/send-whatsapp.sh +4 -0
- package/scripts/setup/init-backup.mjs +93 -38
- package/scripts/slack-send.sh +12 -0
package/lib/budget-guard.mjs
CHANGED
|
@@ -1,33 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/budget-guard.mjs —
|
|
3
|
-
*
|
|
4
|
-
* Sums today's per-session cost rows from the
|
|
5
|
-
* (`state/cost-tracking/<date>.jsonl
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
2
|
+
* lib/budget-guard.mjs — the seat's ceiling, and what happens when it is hit.
|
|
3
|
+
*
|
|
4
|
+
* Sums today's per-session cost rows from the ledger
|
|
5
|
+
* (`state/cost-tracking/<date>.jsonl`) and reports where the period sits against
|
|
6
|
+
* the seat's FUNDED ENVELOPE, plus the enforcement posture that band implies.
|
|
7
|
+
*
|
|
8
|
+
* ── WHERE THE CEILING COMES FROM (this is the part that was missing) ────────
|
|
9
|
+
* It used to be `DAILY_SPEND_CAP_USD` / `config/recovery.yaml` — files that live
|
|
10
|
+
* inside the agent's own repo, which is to say: the seat set its own budget. The
|
|
11
|
+
* real number is `Employee.payBasis.meteredBudget` in hq, published on the
|
|
12
|
+
* mandate body as `seatBudgetCents` and cached at `state/mandate/cache.json`.
|
|
13
|
+
*
|
|
14
|
+
* hq Employee.payBasis.meteredBudget → mandate body seatBudgetCents
|
|
15
|
+
* → state/mandate/cache.json → readSeatEnvelope → dailyAllowance
|
|
16
|
+
* → every decision below.
|
|
17
|
+
*
|
|
18
|
+
* The envelope is monthly; today's allowance is the REMAINING envelope over the
|
|
19
|
+
* REMAINING days (a quiet week funds a busy one). The local cap survives as a
|
|
20
|
+
* SAFETY NET: when the two disagree the lower binds, and the disagreement is
|
|
21
|
+
* logged. When hq published nothing, the local cap binds and every read carries
|
|
22
|
+
* an "UNFUNDED SEAT" degradation — an agent that cannot know its funding must
|
|
23
|
+
* say so loudly rather than assume a number. (The last time something assumed
|
|
24
|
+
* one, the whole fleet ran on an invented 500c-per-obligation allowance.)
|
|
25
|
+
*
|
|
26
|
+
* ── THE LADDER (a ladder, not a cliff) — see `postureForBand` ───────────────
|
|
27
|
+
* 80 % escalate to the supervisor (never to the owner)
|
|
28
|
+
* 100 % degrade the rung: cheapest model class, fan-out 1, D3 stops
|
|
29
|
+
* 125 % suspend the OUTCOME obligations (not the seat)
|
|
30
|
+
* >150 % refuse at spawn level: DEFER anything that is not a human reply
|
|
31
|
+
*
|
|
32
|
+
* No band self-clears by agent action. Bands reset at the period boundary (the
|
|
33
|
+
* ledger + notice files are UTC-date-stamped) or when a supervisor/human RAISES
|
|
34
|
+
* the envelope — fingerprinted on the binding cap, which the seat cannot raise
|
|
35
|
+
* on its own (the binding cap is the LOWER of envelope and local config).
|
|
13
36
|
*
|
|
14
37
|
* Config knobs (env-overridable; tests inject via deps):
|
|
15
|
-
* DAILY_SPEND_CAP_USD
|
|
38
|
+
* DAILY_SPEND_CAP_USD local safety-net cap in USD (default 25)
|
|
16
39
|
* DAILY_ITERATION_CAP optional cap on session count (default 0 = off)
|
|
17
40
|
*
|
|
18
|
-
* The window resets at UTC midnight automatically — the ledger + budget files
|
|
19
|
-
* are date-stamped, so "today" is simply the current UTC date.
|
|
20
|
-
*
|
|
21
41
|
* Constraints (CLAUDE.md): ESM, Node built-ins only, injectable clock +
|
|
22
42
|
* ledger path so tests are hermetic. Never throws on I/O failure: a guard
|
|
23
|
-
* that can't read the ledger reports band 0
|
|
24
|
-
*
|
|
43
|
+
* that can't read the ledger reports band 0 — degrade toward keeping work
|
|
44
|
+
* flowing, not toward bricking. FAIL-OPEN IS FINE; SILENT IS NOT.
|
|
25
45
|
*/
|
|
26
46
|
|
|
27
47
|
import {
|
|
28
48
|
existsSync,
|
|
29
49
|
mkdirSync,
|
|
30
50
|
readFileSync,
|
|
51
|
+
readdirSync,
|
|
31
52
|
writeFileSync,
|
|
32
53
|
renameSync,
|
|
33
54
|
unlinkSync,
|
|
@@ -35,6 +56,8 @@ import {
|
|
|
35
56
|
import { join, resolve, dirname } from "node:path";
|
|
36
57
|
import { randomBytes } from "node:crypto";
|
|
37
58
|
|
|
59
|
+
import { summariseRows, imputeUnmeasured } from "./cost/ledger-row.mjs";
|
|
60
|
+
|
|
38
61
|
// ---------------------------------------------------------------------------
|
|
39
62
|
// Config
|
|
40
63
|
// ---------------------------------------------------------------------------
|
|
@@ -42,6 +65,29 @@ import { randomBytes } from "node:crypto";
|
|
|
42
65
|
export const DEFAULT_DAILY_SPEND_CAP_USD = 25;
|
|
43
66
|
export const DEFAULT_DAILY_ITERATION_CAP = 0; // 0 = no session-count cap
|
|
44
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Envelope freshness, mirroring `lib/mandate/cache.mjs` TIERS (duplicated as two
|
|
70
|
+
* numbers rather than imported: this module's contract is Node built-ins only
|
|
71
|
+
* because it sits on the spawn hot path and must never throw on an import).
|
|
72
|
+
* AGING report it, keep enforcing it.
|
|
73
|
+
* EXPIRY stop treating the cached number as hq's decision.
|
|
74
|
+
*/
|
|
75
|
+
export const ENVELOPE_AGING_SEC = 24 * 3600;
|
|
76
|
+
export const ENVELOPE_EXPIRY_SEC = 7 * 24 * 3600;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The band a seat sits at when sessions ran and NONE of them could be measured
|
|
80
|
+
* or priced from evidence at any horizon.
|
|
81
|
+
*
|
|
82
|
+
* DEGRADE, not normal, and not refuse. "We cannot measure, therefore we degrade"
|
|
83
|
+
* is a decision that needs no price table: it costs the seat its frontier models
|
|
84
|
+
* and its self-directed work while leaving every inbox reply intact, and it
|
|
85
|
+
* cannot be reached by a seat that is simply cheap today (an empty ledger is
|
|
86
|
+
* quiet, not blind — `summariseRows().blind` requires sessions to have run).
|
|
87
|
+
* Without it, `blind` was computed, surfaced, and enforced by nobody.
|
|
88
|
+
*/
|
|
89
|
+
export const BLIND_MIN_BAND = 100;
|
|
90
|
+
|
|
45
91
|
/**
|
|
46
92
|
* Read the daily spend cap from config/recovery.yaml, if present. We extract the
|
|
47
93
|
* single scalar we need with a targeted regex rather than pulling in a YAML
|
|
@@ -109,14 +155,18 @@ function todayUtc(deps) {
|
|
|
109
155
|
return new Date(clock(deps)()).toISOString().slice(0, 10);
|
|
110
156
|
}
|
|
111
157
|
|
|
158
|
+
function agentRootOf(deps) {
|
|
159
|
+
return resolve(
|
|
160
|
+
(deps && deps.agentRoot) ||
|
|
161
|
+
process.env.AGENT_ROOT ||
|
|
162
|
+
process.env.AGENT_DIR ||
|
|
163
|
+
process.cwd()
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
112
167
|
function ledgerDir(deps) {
|
|
113
168
|
if (deps && deps.ledgerDir) return resolve(deps.ledgerDir);
|
|
114
|
-
|
|
115
|
-
(deps && deps.agentRoot) ||
|
|
116
|
-
process.env.AGENT_ROOT ||
|
|
117
|
-
process.env.AGENT_DIR ||
|
|
118
|
-
process.cwd();
|
|
119
|
-
return join(resolve(root), "state", "cost-tracking");
|
|
169
|
+
return join(agentRootOf(deps), "state", "cost-tracking");
|
|
120
170
|
}
|
|
121
171
|
|
|
122
172
|
function ledgerFile(deps) {
|
|
@@ -128,20 +178,92 @@ function budgetStateFile(deps) {
|
|
|
128
178
|
}
|
|
129
179
|
|
|
130
180
|
// ---------------------------------------------------------------------------
|
|
131
|
-
// Banding
|
|
181
|
+
// Banding + enforcement posture
|
|
132
182
|
// ---------------------------------------------------------------------------
|
|
133
183
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
184
|
+
/**
|
|
185
|
+
* The governance bands, as INCLUSIVE LOWER BOUNDS on `pct`. 0 = under 80 %.
|
|
186
|
+
*
|
|
187
|
+
* These replaced [50, 75, 90, 100]. The old set banded a LOCAL cap using an
|
|
188
|
+
* under-read numerator: three of its four rungs fired before anything was at
|
|
189
|
+
* risk, and only the top rung had teeth (and it was binary — essential-only or
|
|
190
|
+
* nothing). The new set bands the SEAT ENVELOPE hq published, using the
|
|
191
|
+
* authoritative cost, so 100 % means "this seat has spent what the org funded",
|
|
192
|
+
* not "a config file in the agent's own repo said 25".
|
|
193
|
+
*
|
|
194
|
+
* 150 is inclusive on purpose (the decision writes ">150 %"): a seat sitting at
|
|
195
|
+
* exactly 1.5x its funded envelope is already over, and a strict `>` would make
|
|
196
|
+
* the refuse rung unreachable at the round number most likely to be hit.
|
|
197
|
+
*/
|
|
198
|
+
export const BANDS = [80, 100, 125, 150];
|
|
199
|
+
|
|
200
|
+
/** Enforcement modes, ascending. The resource governor consumes these by name. */
|
|
201
|
+
export const MODES = Object.freeze({
|
|
202
|
+
NORMAL: "normal",
|
|
203
|
+
DEGRADED: "degraded",
|
|
204
|
+
SUSPENDED: "suspended",
|
|
205
|
+
REFUSED: "refused",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/** `lib/execution/route.mjs` rung 5 = `team` (sub-agent fan-out). */
|
|
209
|
+
const TEAM_RUNG = 5;
|
|
136
210
|
|
|
137
211
|
function bandForPct(pct) {
|
|
212
|
+
if (!Number.isFinite(pct)) return 0;
|
|
213
|
+
if (pct >= 150) return 150;
|
|
214
|
+
if (pct >= 125) return 125;
|
|
138
215
|
if (pct >= 100) return 100;
|
|
139
|
-
if (pct >=
|
|
140
|
-
if (pct >= 75) return 75;
|
|
141
|
-
if (pct >= 50) return 50;
|
|
216
|
+
if (pct >= 80) return 80;
|
|
142
217
|
return 0;
|
|
143
218
|
}
|
|
144
219
|
|
|
220
|
+
/**
|
|
221
|
+
* The structured enforcement instruction for a band. PURE, total, never throws.
|
|
222
|
+
*
|
|
223
|
+
* Every consumer reads THIS instead of re-deriving thresholds from `band`, so
|
|
224
|
+
* the ladder is written down exactly once:
|
|
225
|
+
* lib/model-router/economics.budgetLadder → routing (cheapest class)
|
|
226
|
+
* lib/resource-governor.admit → spawn admission
|
|
227
|
+
* lib/plan/compile.applyBudgetPosture → obligation suspension
|
|
228
|
+
* scripts/daemon/cadence-handlers → D3 self-directed work
|
|
229
|
+
*
|
|
230
|
+
* The rungs, and why each is where it is:
|
|
231
|
+
* 80 ESCALATE — one notice to the SUPERVISOR, once per band per period.
|
|
232
|
+
* Never to the owner: an agent told it is running out of money has every
|
|
233
|
+
* incentive to optimise the meter instead of the work.
|
|
234
|
+
* 100 DEGRADE THE RUNG — cheapest model class, fan-out capped at 1 (the
|
|
235
|
+
* `team` rung becomes unavailable), self-directed D3 work stops. Inbox
|
|
236
|
+
* DMs are still answered, at the degraded rung. This is the rung that was
|
|
237
|
+
* missing: enforcement used to be binary.
|
|
238
|
+
* 125 SUSPEND THE OBLIGATIONS, NOT THE SEAT — OUTCOME obligations flip to
|
|
239
|
+
* `suspended` with a `budget_breach` drift row. Inline/offline_safe
|
|
240
|
+
* cadences continue.
|
|
241
|
+
* 150 REFUSE, AT SPAWN LEVEL ONLY — DEFER (never hard-refuse) anything that is
|
|
242
|
+
* not a direct human reply. A seat-level refuse would brick the seat, and
|
|
243
|
+
* Invariant #1 forbids that.
|
|
244
|
+
*
|
|
245
|
+
* @param {number} band 0|80|100|125|150
|
|
246
|
+
*/
|
|
247
|
+
export function postureForBand(band) {
|
|
248
|
+
const b = Number(band) || 0;
|
|
249
|
+
const mode =
|
|
250
|
+
b >= 150 ? MODES.REFUSED
|
|
251
|
+
: b >= 125 ? MODES.SUSPENDED
|
|
252
|
+
: b >= 100 ? MODES.DEGRADED
|
|
253
|
+
: MODES.NORMAL;
|
|
254
|
+
return Object.freeze({
|
|
255
|
+
band: b,
|
|
256
|
+
mode,
|
|
257
|
+
escalateToSupervisor: b >= 80,
|
|
258
|
+
cheapModelOnly: b >= 100,
|
|
259
|
+
maxFanout: b >= 100 ? 1 : null,
|
|
260
|
+
maxRung: b >= 100 ? TEAM_RUNG - 1 : null,
|
|
261
|
+
selfDirected: b < 100,
|
|
262
|
+
suspendOutcomeObligations: b >= 125,
|
|
263
|
+
refuseNonHumanSpawn: b >= 150,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
145
267
|
// ---------------------------------------------------------------------------
|
|
146
268
|
// Ledger sum
|
|
147
269
|
// ---------------------------------------------------------------------------
|
|
@@ -149,23 +271,378 @@ function bandForPct(pct) {
|
|
|
149
271
|
/**
|
|
150
272
|
* Sum today's spend + session count from the JSONL ledger. Never throws;
|
|
151
273
|
* malformed lines are skipped, a missing file yields zeroes.
|
|
274
|
+
*
|
|
275
|
+
* WHAT CHANGED AND WHY (2026-08-12)
|
|
276
|
+
* ---------------------------------
|
|
277
|
+
* This used to sum `row.estimated_usd` and count every line as one session.
|
|
278
|
+
* Both halves were wrong, and both made the cap unable to bind:
|
|
279
|
+
*
|
|
280
|
+
* - `estimated_usd` on the v1 tracker rows was priced from a table with NO
|
|
281
|
+
* CACHE TIER, while ~99% of this agent's prompt tokens are cache reads. The
|
|
282
|
+
* governor saw $90.79 on a day that actually cost $235.62 against a $25 cap.
|
|
283
|
+
* We now bill against the CLI's authoritative `total_cost_usd` and fall back
|
|
284
|
+
* to the (now cache-aware) estimate only when it is absent — logged, never
|
|
285
|
+
* silent. See lib/cost/ledger-row.mjs.
|
|
286
|
+
* - Rows with no measured tokens summed as $0, so a systematic usage-parse
|
|
287
|
+
* regression would quietly drive the day's spend toward zero exactly when
|
|
288
|
+
* telemetry broke. Unmeasured sessions are now imputed at the day's mean
|
|
289
|
+
* measured cost and reported in `degradations`.
|
|
290
|
+
* - Zero-LLM attribution rows (message sends) were counted as sessions,
|
|
291
|
+
* inflating the iteration-cap denominator. They are excluded from `sessions`
|
|
292
|
+
* and reported as `nonLlmRows`.
|
|
152
293
|
*/
|
|
153
294
|
function sumToday(deps) {
|
|
154
295
|
const file = ledgerFile(deps);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
296
|
+
const emptyResult = {
|
|
297
|
+
spentUSD: 0, sessions: 0, measuredUSD: 0, imputedUSD: 0,
|
|
298
|
+
unmeasuredSessions: 0, nonLlmRows: 0, blind: false, degradations: [],
|
|
299
|
+
};
|
|
300
|
+
if (!existsSync(file)) return emptyResult;
|
|
158
301
|
let body;
|
|
159
|
-
try { body = readFileSync(file, "utf-8"); } catch { return
|
|
302
|
+
try { body = readFileSync(file, "utf-8"); } catch { return emptyResult; }
|
|
303
|
+
|
|
304
|
+
const rows = [];
|
|
305
|
+
let malformed = 0;
|
|
160
306
|
for (const line of body.split("\n")) {
|
|
161
307
|
if (!line.trim()) continue;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
308
|
+
try { rows.push(JSON.parse(line)); } catch { malformed += 1; }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const summary = summariseRows(rows);
|
|
312
|
+
// When TODAY measured nothing, fall back to the month's mean measured session
|
|
313
|
+
// cost rather than to an invented per-session floor. A day that is blind end
|
|
314
|
+
// to end is exactly when a made-up number does the most damage: the previous
|
|
315
|
+
// $0.25/session constant was ~8x below this fleet's observed $1.95 mean, so a
|
|
316
|
+
// total outage on a $68 day imputed $8.75 and reported band 0.
|
|
317
|
+
const imputed = imputeUnmeasured(summary, {
|
|
318
|
+
fallbackPerSessionUsd: deps && deps.__monthMeanUsd !== undefined
|
|
319
|
+
? deps.__monthMeanUsd
|
|
320
|
+
: monthMeanSessionUsd(deps),
|
|
321
|
+
});
|
|
322
|
+
const degradations = [...summary.degradations];
|
|
323
|
+
if (malformed > 0) degradations.push(`${malformed} unparseable ledger line(s) skipped`);
|
|
324
|
+
if (imputed.imputedUsd > 0) {
|
|
325
|
+
degradations.push(
|
|
326
|
+
`imputed $${imputed.imputedUsd} for ${summary.unmeasured} unmeasured session(s) at $${imputed.perSessionUsd}/session (${imputed.basis}) — counted toward the cap so a telemetry outage cannot silence the governor`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
if (imputed.unpriceable) {
|
|
330
|
+
degradations.push(
|
|
331
|
+
`${summary.unmeasured} unmeasured session(s) and NO measured session at any horizon (today or month-to-date) — ` +
|
|
332
|
+
"their cost cannot be imputed from evidence and is NOT being invented. The band is floored at DEGRADE instead " +
|
|
333
|
+
"(see BLIND_MIN_BAND): we cannot measure, therefore we degrade."
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
spentUSD: +(summary.measuredUsd + imputed.imputedUsd).toFixed(6),
|
|
339
|
+
measuredUSD: summary.measuredUsd,
|
|
340
|
+
imputedUSD: imputed.imputedUsd,
|
|
341
|
+
sessions: summary.sessions,
|
|
342
|
+
unmeasuredSessions: summary.unmeasured,
|
|
343
|
+
nonLlmRows: summary.nonLlmRows,
|
|
344
|
+
blind: summary.blind,
|
|
345
|
+
unpriceable: imputed.unpriceable === true,
|
|
346
|
+
degradations,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The month's mean measured session cost — the second rung of the imputation
|
|
352
|
+
* ladder, used when TODAY has measured nothing at all. Evidence, not a constant.
|
|
353
|
+
* Returns null when the whole month is unmeasured (then nothing is invented).
|
|
354
|
+
*/
|
|
355
|
+
function monthMeanSessionUsd(deps) {
|
|
356
|
+
const dir = ledgerDir(deps);
|
|
357
|
+
const month = todayUtc(deps).slice(0, 7);
|
|
358
|
+
let files = [];
|
|
359
|
+
try {
|
|
360
|
+
files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.startsWith(month));
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
let usd = 0;
|
|
365
|
+
let n = 0;
|
|
366
|
+
for (const f of files) {
|
|
367
|
+
let body;
|
|
368
|
+
try { body = readFileSync(join(dir, f), "utf-8"); } catch { continue; }
|
|
369
|
+
const rows = [];
|
|
370
|
+
for (const line of body.split("\n")) {
|
|
371
|
+
if (!line.trim()) continue;
|
|
372
|
+
try { rows.push(JSON.parse(line)); } catch { /* skip */ }
|
|
373
|
+
}
|
|
374
|
+
const s = summariseRows(rows);
|
|
375
|
+
usd += s.measuredUsd;
|
|
376
|
+
n += s.measured;
|
|
377
|
+
}
|
|
378
|
+
return n > 0 && usd > 0 ? usd / n : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// The denominator — what the ORG funded, not what the seat configured
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Sum every date-stamped ledger file in the current UTC month.
|
|
387
|
+
*
|
|
388
|
+
* This is what lets a quiet week fund a busy one: the daily allowance is the
|
|
389
|
+
* REMAINING envelope over the REMAINING days, not a flat 1/N slice that expires
|
|
390
|
+
* unused each midnight.
|
|
391
|
+
*/
|
|
392
|
+
export function monthToDate(deps) {
|
|
393
|
+
const dir = ledgerDir(deps);
|
|
394
|
+
const month = todayUtc(deps).slice(0, 7); // YYYY-MM
|
|
395
|
+
let files = [];
|
|
396
|
+
try {
|
|
397
|
+
files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.startsWith(month));
|
|
398
|
+
} catch {
|
|
399
|
+
return { spentUSD: 0, days: 0 };
|
|
400
|
+
}
|
|
401
|
+
// One month-wide mean, computed once, so a day that measured NOTHING still
|
|
402
|
+
// imputes from evidence instead of contributing $0 to the month's total.
|
|
403
|
+
const fallbackPerSessionUsd = monthMeanSessionUsd(deps);
|
|
404
|
+
let spentUSD = 0;
|
|
405
|
+
for (const f of files.sort()) {
|
|
406
|
+
let body;
|
|
407
|
+
try { body = readFileSync(join(dir, f), "utf-8"); } catch { continue; }
|
|
408
|
+
const rows = [];
|
|
409
|
+
for (const line of body.split("\n")) {
|
|
410
|
+
if (!line.trim()) continue;
|
|
411
|
+
try { rows.push(JSON.parse(line)); } catch { /* skip */ }
|
|
412
|
+
}
|
|
413
|
+
const summary = summariseRows(rows);
|
|
414
|
+
spentUSD += summary.measuredUsd + imputeUnmeasured(summary, { fallbackPerSessionUsd }).imputedUsd;
|
|
415
|
+
}
|
|
416
|
+
return { spentUSD: +spentUSD.toFixed(6), days: files.length };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Read the seat's FUNDED envelope off the cached mandate body.
|
|
421
|
+
*
|
|
422
|
+
* The chain, end to end, with no invented link:
|
|
423
|
+
* hq `Employee.payBasis.meteredBudget` (dollars/month, `ai_seat` basis)
|
|
424
|
+
* → `loadMandateEnvelope` (× 100)
|
|
425
|
+
* → mandate body `seatBudgetCents` + `budgetPeriod:"monthly"`
|
|
426
|
+
* → `mandate.get` → `state/mandate/cache.json`
|
|
427
|
+
* → HERE → `dailyAllowance` → every enforcement decision below.
|
|
428
|
+
*
|
|
429
|
+
* A null at any link travels as a null and NAMES ITSELF; nothing downstream
|
|
430
|
+
* substitutes a number for it. (The last time something did, every obligation in
|
|
431
|
+
* the fleet ran on an invented 500c allowance for months.)
|
|
432
|
+
*
|
|
433
|
+
* @returns {{ seatBudgetCents:number|null, budgetPeriod:string|null,
|
|
434
|
+
* supervisorMemberId:string|null, source:string, reason:string|null }}
|
|
435
|
+
*/
|
|
436
|
+
export function readSeatEnvelope(deps) {
|
|
437
|
+
if (deps && deps.seatEnvelope !== undefined) {
|
|
438
|
+
const e = deps.seatEnvelope || {};
|
|
439
|
+
return {
|
|
440
|
+
seatBudgetCents: Number.isFinite(e.seatBudgetCents) ? e.seatBudgetCents : null,
|
|
441
|
+
budgetPeriod: e.budgetPeriod || (Number.isFinite(e.seatBudgetCents) ? "monthly" : null),
|
|
442
|
+
supervisorMemberId: e.supervisorMemberId || null,
|
|
443
|
+
source: e.source || "injected",
|
|
444
|
+
reason: e.reason || (Number.isFinite(e.seatBudgetCents) ? null : "the injected envelope carries no seatBudgetCents"),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const path =
|
|
448
|
+
(deps && deps.mandateCachePath) ||
|
|
449
|
+
join(agentRootOf(deps), "state", "mandate", "cache.json");
|
|
450
|
+
const miss = (reason, source = "mandate-cache") => ({
|
|
451
|
+
seatBudgetCents: null, budgetPeriod: null, supervisorMemberId: null, source, reason,
|
|
452
|
+
});
|
|
453
|
+
if (!existsSync(path)) return miss("no state/mandate/cache.json — this seat has never fetched a mandate");
|
|
454
|
+
let record;
|
|
455
|
+
try {
|
|
456
|
+
record = JSON.parse(readFileSync(path, "utf-8"));
|
|
457
|
+
} catch (err) {
|
|
458
|
+
return miss(`state/mandate/cache.json is unreadable (${err && err.message ? err.message : err})`);
|
|
459
|
+
}
|
|
460
|
+
const body = (record && record.body) || {};
|
|
461
|
+
const supervisor =
|
|
462
|
+
(Array.isArray(body.collaborators) ? body.collaborators : []).find(
|
|
463
|
+
(c) => c && String(c.role || "").toUpperCase() === "REVIEWER" && c.memberId
|
|
464
|
+
) || null;
|
|
465
|
+
|
|
466
|
+
if (!Number.isFinite(body.seatBudgetCents)) {
|
|
467
|
+
// Carry hq's OWN words when it published them — hq knows which link is null,
|
|
468
|
+
// and paraphrasing it here would lose the one actionable sentence.
|
|
469
|
+
const named = (Array.isArray(body.degradations) ? body.degradations : []).find(
|
|
470
|
+
(d) => d && d.field === "seatBudgetCents"
|
|
471
|
+
);
|
|
472
|
+
return {
|
|
473
|
+
seatBudgetCents: null,
|
|
474
|
+
budgetPeriod: null,
|
|
475
|
+
supervisorMemberId: supervisor ? supervisor.memberId : null,
|
|
476
|
+
source: record && record.source === "server" ? "hq" : `mandate-cache:${(record && record.source) || "unknown"}`,
|
|
477
|
+
reason: named
|
|
478
|
+
? `hq published no envelope: ${named.reason}`
|
|
479
|
+
: record && record.source !== "server"
|
|
480
|
+
? "the mandate cache is LOCAL (never adopted from hq), so it carries no funded envelope"
|
|
481
|
+
: "hq published seatBudgetCents:null — the seat has no funded envelope",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
// ── PROVENANCE + STALENESS, ON THE SUCCESS PATH ───────────────────────────
|
|
485
|
+
// `record.source` was checked only in the MISS branch, and `fetchedAt` was not
|
|
486
|
+
// read at all. Both matter here, and neither is cosmetic:
|
|
487
|
+
//
|
|
488
|
+
// provenance — a `source:"local"` cache is one the seat wrote for itself
|
|
489
|
+
// (`lib/setup/sections/mandate.mjs` derives a local body offline). A
|
|
490
|
+
// seatBudgetCents on such a record is the seat setting its own ceiling,
|
|
491
|
+
// which is the ONE thing this module exists to prevent. Refuse it; the
|
|
492
|
+
// local safety-net cap binds instead, loudly.
|
|
493
|
+
// staleness — hq can revoke funding (`Employee.payBasis.meteredBudget` →
|
|
494
|
+
// null) while the agent is partitioned. Without an expiry the cached
|
|
495
|
+
// number keeps producing `funded:true` and a live daily allowance forever,
|
|
496
|
+
// and the "employee.payBasis.meteredBudget" label is then a provenance
|
|
497
|
+
// claim asserted rather than verified. Past the mandate cache's own
|
|
498
|
+
// EXPIRED tier (7d, lib/mandate/cache.mjs TIERS.stale) the envelope stops
|
|
499
|
+
// being authoritative. AGING is kept but reported.
|
|
500
|
+
const recordSource = (record && record.source) || "unknown";
|
|
501
|
+
if (recordSource !== "server") {
|
|
502
|
+
return {
|
|
503
|
+
seatBudgetCents: null,
|
|
504
|
+
budgetPeriod: null,
|
|
505
|
+
supervisorMemberId: supervisor ? supervisor.memberId : null,
|
|
506
|
+
source: `mandate-cache:${recordSource}`,
|
|
507
|
+
reason:
|
|
508
|
+
`the mandate cache carries a seatBudgetCents but its source is "${recordSource}", not "server" — ` +
|
|
509
|
+
"a locally-derived body is the SEAT's own number and may not be used as the org-funded ceiling",
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const ageSec = envelopeAgeSeconds(record, clock(deps)());
|
|
513
|
+
if (ageSec >= ENVELOPE_EXPIRY_SEC) {
|
|
514
|
+
return {
|
|
515
|
+
seatBudgetCents: null,
|
|
516
|
+
budgetPeriod: null,
|
|
517
|
+
supervisorMemberId: supervisor ? supervisor.memberId : null,
|
|
518
|
+
source: "mandate-cache:expired",
|
|
519
|
+
reason:
|
|
520
|
+
`the cached envelope was fetched ${Math.floor(ageSec / 3600)}h ago (> ${ENVELOPE_EXPIRY_SEC / 3600}h) — ` +
|
|
521
|
+
"hq may have changed or revoked this seat's funding since, so the number is no longer authoritative. " +
|
|
522
|
+
"Run `maestro mandate sync` (or let the mandate cadence refresh it) to re-establish the ceiling.",
|
|
523
|
+
};
|
|
167
524
|
}
|
|
168
|
-
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
seatBudgetCents: body.seatBudgetCents,
|
|
528
|
+
budgetPeriod: body.budgetPeriod || "monthly",
|
|
529
|
+
supervisorMemberId: supervisor ? supervisor.memberId : null,
|
|
530
|
+
source: body.budgetSource || "employee.payBasis.meteredBudget",
|
|
531
|
+
reason: null,
|
|
532
|
+
ageSeconds: ageSec,
|
|
533
|
+
// Kept as a DEGRADATION rather than a refusal: an envelope nobody has
|
|
534
|
+
// refreshed in a day or three is still hq's number, and refusing it would
|
|
535
|
+
// brick a seat whose only sin is a quiet mandate cadence.
|
|
536
|
+
staleReason: ageSec >= ENVELOPE_AGING_SEC
|
|
537
|
+
? `the funded envelope was last fetched from hq ${Math.floor(ageSec / 3600)}h ago — it is still being enforced, but it expires at ${ENVELOPE_EXPIRY_SEC / 3600}h`
|
|
538
|
+
: null,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Seconds since the mandate cache was fetched; Infinity when never/unparseable. */
|
|
543
|
+
function envelopeAgeSeconds(record, nowMs) {
|
|
544
|
+
const at = record && record.fetchedAt;
|
|
545
|
+
if (!at) return Infinity;
|
|
546
|
+
const then = Date.parse(String(at));
|
|
547
|
+
if (!Number.isFinite(then) || !Number.isFinite(nowMs)) return Infinity;
|
|
548
|
+
return Math.max(0, (nowMs - then) / 1000);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function daysInUtcMonth(ms) {
|
|
552
|
+
const d = new Date(ms);
|
|
553
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The binding daily allowance, and how it was arrived at.
|
|
558
|
+
*
|
|
559
|
+
* The envelope is monthly dollars. Today's allowance is the REMAINING envelope
|
|
560
|
+
* spread over the REMAINING days of the month, reconciled against month-to-date
|
|
561
|
+
* actuals — an under-spent week raises today's ceiling instead of expiring.
|
|
562
|
+
*
|
|
563
|
+
* When the envelope and the local `DAILY_SPEND_CAP_USD` disagree, THE LOWER ONE
|
|
564
|
+
* BINDS and the disagreement is logged: a seat may not spend past what the org
|
|
565
|
+
* funded, and a local config file may not be used to authorise MORE than the org
|
|
566
|
+
* funded either. With no envelope at all the local cap binds and that fact is a
|
|
567
|
+
* degradation on every single read — an agent that cannot know its funding says
|
|
568
|
+
* so, loudly, rather than assuming a number.
|
|
569
|
+
*/
|
|
570
|
+
export function dailyAllowance(deps) {
|
|
571
|
+
const degradations = [];
|
|
572
|
+
const configuredCapUSD = cfgSpendCap(deps);
|
|
573
|
+
const env = readSeatEnvelope(deps);
|
|
574
|
+
const now = clock(deps)();
|
|
575
|
+
const daysInMonth = daysInUtcMonth(now);
|
|
576
|
+
const dayOfMonth = new Date(now).getUTCDate();
|
|
577
|
+
const mtd = monthToDate(deps);
|
|
578
|
+
const today = sumToday(deps);
|
|
579
|
+
|
|
580
|
+
if (env.seatBudgetCents == null) {
|
|
581
|
+
degradations.push(
|
|
582
|
+
`UNFUNDED SEAT: ${env.reason || "no funded envelope"} — falling back to the LOCAL cap ` +
|
|
583
|
+
`($${configuredCapUSD}/day, which this seat can edit). The org-funded ceiling is NOT being enforced.`
|
|
584
|
+
);
|
|
585
|
+
return {
|
|
586
|
+
allowanceUSD: configuredCapUSD,
|
|
587
|
+
capSource: "local-config",
|
|
588
|
+
configuredCapUSD,
|
|
589
|
+
funded: false,
|
|
590
|
+
envelope: { seatBudgetCents: null, monthUSD: null, source: env.source, supervisorMemberId: env.supervisorMemberId },
|
|
591
|
+
month: { spentUSD: mtd.spentUSD, pct: null, daysInMonth, dayOfMonth },
|
|
592
|
+
degradations,
|
|
593
|
+
disagreementUSD: null,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const monthUSD = env.seatBudgetCents / 100;
|
|
598
|
+
const remainingDays = Math.max(1, daysInMonth - dayOfMonth + 1);
|
|
599
|
+
// Month-to-date INCLUDING today would charge today's spend against today's own
|
|
600
|
+
// allowance twice, so the allowance is set from the days already closed.
|
|
601
|
+
const spentBeforeToday = Math.max(0, mtd.spentUSD - today.spentUSD);
|
|
602
|
+
const remainingUSD = monthUSD - spentBeforeToday;
|
|
603
|
+
const envelopeAllowanceUSD = remainingUSD > 0 ? remainingUSD / remainingDays : 0;
|
|
604
|
+
|
|
605
|
+
let allowanceUSD = envelopeAllowanceUSD;
|
|
606
|
+
let capSource = "seat-envelope";
|
|
607
|
+
let disagreementUSD = null;
|
|
608
|
+
if (Number.isFinite(configuredCapUSD) && configuredCapUSD > 0) {
|
|
609
|
+
disagreementUSD = +(envelopeAllowanceUSD - configuredCapUSD).toFixed(6);
|
|
610
|
+
if (configuredCapUSD < envelopeAllowanceUSD) {
|
|
611
|
+
allowanceUSD = configuredCapUSD;
|
|
612
|
+
capSource = "local-config";
|
|
613
|
+
}
|
|
614
|
+
if (Math.abs(disagreementUSD) > 0.01) {
|
|
615
|
+
degradations.push(
|
|
616
|
+
`the seat envelope allows $${envelopeAllowanceUSD.toFixed(2)} today ` +
|
|
617
|
+
`(remaining $${remainingUSD.toFixed(2)} over ${remainingDays} day(s)) but the local cap says ` +
|
|
618
|
+
`$${configuredCapUSD.toFixed(2)} — the LOWER binds ($${allowanceUSD.toFixed(2)}, ${capSource})`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
allowanceUSD: +allowanceUSD.toFixed(6),
|
|
625
|
+
capSource,
|
|
626
|
+
configuredCapUSD,
|
|
627
|
+
funded: true,
|
|
628
|
+
envelope: {
|
|
629
|
+
seatBudgetCents: env.seatBudgetCents,
|
|
630
|
+
monthUSD,
|
|
631
|
+
source: env.source,
|
|
632
|
+
supervisorMemberId: env.supervisorMemberId,
|
|
633
|
+
remainingUSD: +remainingUSD.toFixed(6),
|
|
634
|
+
remainingDays,
|
|
635
|
+
allowanceUSD: +envelopeAllowanceUSD.toFixed(6),
|
|
636
|
+
},
|
|
637
|
+
month: {
|
|
638
|
+
spentUSD: mtd.spentUSD,
|
|
639
|
+
pct: monthUSD > 0 ? +((mtd.spentUSD / monthUSD) * 100).toFixed(2) : null,
|
|
640
|
+
daysInMonth,
|
|
641
|
+
dayOfMonth,
|
|
642
|
+
},
|
|
643
|
+
degradations,
|
|
644
|
+
disagreementUSD,
|
|
645
|
+
};
|
|
169
646
|
}
|
|
170
647
|
|
|
171
648
|
// ---------------------------------------------------------------------------
|
|
@@ -173,53 +650,233 @@ function sumToday(deps) {
|
|
|
173
650
|
// ---------------------------------------------------------------------------
|
|
174
651
|
|
|
175
652
|
/**
|
|
176
|
-
* Compute the
|
|
653
|
+
* Compute the period's budget status and the enforcement posture it implies.
|
|
654
|
+
*
|
|
655
|
+
* The band is the WORSE of two readings, because either one alone is gameable by
|
|
656
|
+
* the calendar:
|
|
657
|
+
* dayPct today's spend against today's binding allowance
|
|
658
|
+
* monthPct month-to-date spend against the whole funded envelope
|
|
659
|
+
* A seat that blew the month on the 3rd is in the suspend band on the 4th even
|
|
660
|
+
* though "today" looks quiet; a seat with a healthy month that spikes 3x in one
|
|
661
|
+
* day still degrades today.
|
|
177
662
|
*
|
|
178
|
-
* @param {object} [deps] { now?, ledgerDir?, agentRoot?, capUSD?, iterationCap
|
|
179
|
-
*
|
|
180
|
-
* spentUSD:number, capUSD:number, pct:number, band:0|50|75|90|100,
|
|
181
|
-
* essentialOnly:boolean, sessions:number, iterationCap:number, date:string
|
|
182
|
-
* }}
|
|
663
|
+
* @param {object} [deps] { now?, ledgerDir?, agentRoot?, capUSD?, iterationCap?,
|
|
664
|
+
* seatEnvelope?, mandateCachePath?, log? }
|
|
183
665
|
*/
|
|
184
666
|
export function dailyStatus(deps) {
|
|
185
|
-
const capUSD = cfgSpendCap(deps);
|
|
186
667
|
const iterationCap = cfgIterationCap(deps);
|
|
187
|
-
const
|
|
668
|
+
const today = sumToday(deps);
|
|
669
|
+
const { spentUSD, sessions } = today;
|
|
670
|
+
const allowance = dailyAllowance(deps);
|
|
671
|
+
const extraDegradations = [];
|
|
188
672
|
|
|
189
|
-
|
|
673
|
+
// ── THE LATCH: a seat may not demote its own band by editing its own repo ──
|
|
674
|
+
// The module invariant is "no band self-clears by agent action", and the
|
|
675
|
+
// fingerprint half of it was true only when the ENVELOPE was already the
|
|
676
|
+
// binding cap. In the ordinary posture — a $25 local `config/recovery.yaml`
|
|
677
|
+
// cap under a larger envelope share — the local file IS the binding cap, so
|
|
678
|
+
// raising it raised the ceiling, dropped the percentage, and walked the seat
|
|
679
|
+
// out of `degraded` (measured: $28 spent, band 100 → one edit → band 80).
|
|
680
|
+
// So for BANDING we use the lowest binding cap observed this period. It resets
|
|
681
|
+
// when hq moves the envelope (an act the seat cannot perform) and at the
|
|
682
|
+
// period boundary. Lowering the local cap still tightens immediately.
|
|
683
|
+
const latched = readCapLatch(deps, allowance);
|
|
684
|
+
const capUSD = allowance.allowanceUSD;
|
|
685
|
+
const bandingCapUSD = latched.bandingCapUSD;
|
|
686
|
+
if (bandingCapUSD < capUSD - 0.005) {
|
|
687
|
+
extraDegradations.push(
|
|
688
|
+
`the binding daily cap read $${capUSD.toFixed(2)} but this period already ran under $${bandingCapUSD.toFixed(2)} ` +
|
|
689
|
+
`(${latched.source}); banding uses the LOWER — a seat cannot raise its own ceiling out of a band it is already in. ` +
|
|
690
|
+
"Only hq (Employee.payBasis.meteredBudget) can raise it."
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// `null`, not 0, when there is no allowance left to divide by. Forcing this to
|
|
695
|
+
// 0 reported "0 % of today's allowance" for a seat whose envelope was fully
|
|
696
|
+
// spent — a reading that is not merely useless but backwards.
|
|
697
|
+
const spendPct = bandingCapUSD > 0
|
|
698
|
+
? (spentUSD / bandingCapUSD) * 100
|
|
699
|
+
: spentUSD > 0 ? null : 0;
|
|
700
|
+
if (spendPct === null) {
|
|
701
|
+
extraDegradations.push(
|
|
702
|
+
`today's allowance is $0.00 (the monthly envelope is spent out) and $${spentUSD.toFixed(2)} was spent anyway — ` +
|
|
703
|
+
"the day percentage is undefined, not 0; the MONTH percentage is the binding reading"
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const monthPct = Number.isFinite(allowance.month.pct) ? allowance.month.pct : 0;
|
|
190
707
|
const iterPct = iterationCap > 0 ? (sessions / iterationCap) * 100 : 0;
|
|
191
|
-
// The binding constraint is whichever is
|
|
192
|
-
|
|
193
|
-
|
|
708
|
+
// The binding constraint is whichever is closest to (or furthest past) the cap.
|
|
709
|
+
// A null day-reading contributes nothing; the exhausted-envelope case is
|
|
710
|
+
// already ≥100 % on the month, so nothing is lost by skipping it.
|
|
711
|
+
const pct = Math.max(spendPct ?? 0, monthPct, iterPct);
|
|
712
|
+
let band = bandForPct(pct);
|
|
713
|
+
|
|
714
|
+
// ── BLIND ⇒ DEGRADE ────────────────────────────────────────────────────────
|
|
715
|
+
// `blind` (sessions ran, none measured) was computed and reported and enforced
|
|
716
|
+
// by nothing. It is now load-bearing: we do not invent a price, we lower the
|
|
717
|
+
// rung. Only when the day is TRULY unpriceable — if a month mean was available
|
|
718
|
+
// the spend was imputed from it and the ordinary band applies.
|
|
719
|
+
if (today.blind && today.unpriceable && band < BLIND_MIN_BAND) {
|
|
720
|
+
extraDegradations.push(
|
|
721
|
+
`band raised ${band}% → ${BLIND_MIN_BAND}%: ${today.unmeasuredSessions} session(s) ran today and NONE could be ` +
|
|
722
|
+
"measured or priced. An unmeasurable seat is degraded, not trusted."
|
|
723
|
+
);
|
|
724
|
+
band = BLIND_MIN_BAND;
|
|
725
|
+
}
|
|
726
|
+
const posture = postureForBand(band);
|
|
727
|
+
|
|
728
|
+
const degradations = [...today.degradations, ...allowance.degradations, ...extraDegradations];
|
|
729
|
+
// Degradations ride on the returned status AND go to the log: the daemon is
|
|
730
|
+
// usually the only thing awake when telemetry breaks, and a governor that
|
|
731
|
+
// quietly under-counts is the failure mode this module now exists to prevent.
|
|
732
|
+
if (degradations.length > 0) {
|
|
733
|
+
for (const d of degradations) {
|
|
734
|
+
try { console.warn(`[budget-guard] degraded: ${d}`); } catch { /* never throw */ }
|
|
735
|
+
}
|
|
736
|
+
}
|
|
194
737
|
|
|
195
738
|
return {
|
|
196
739
|
spentUSD,
|
|
197
740
|
capUSD,
|
|
741
|
+
// The cap the BAND was computed against (the latch above). Equal to capUSD
|
|
742
|
+
// except when the seat raised its own local cap mid-period.
|
|
743
|
+
bandingCapUSD,
|
|
198
744
|
pct: +pct.toFixed(2),
|
|
745
|
+
dayPct: spendPct === null ? null : +spendPct.toFixed(2),
|
|
746
|
+
monthPct: +monthPct.toFixed(2),
|
|
199
747
|
band,
|
|
200
|
-
|
|
748
|
+
mode: posture.mode,
|
|
749
|
+
posture,
|
|
750
|
+
// Legacy field, kept so no caller silently loses its gate during the
|
|
751
|
+
// rollout. It now means the SUSPEND band (125 %) — the rung whose behaviour
|
|
752
|
+
// matches what `essentialOnly` always did: defer non-inbox work.
|
|
753
|
+
essentialOnly: band >= 125,
|
|
201
754
|
sessions,
|
|
202
755
|
iterationCap,
|
|
203
756
|
date: todayUtc(deps),
|
|
757
|
+
// Where the ceiling came from. `funded:false` means hq published no envelope
|
|
758
|
+
// and the number below is the seat's own config, not the org's decision.
|
|
759
|
+
funded: allowance.funded,
|
|
760
|
+
capSource: allowance.capSource,
|
|
761
|
+
configuredCapUSD: allowance.configuredCapUSD,
|
|
762
|
+
envelope: allowance.envelope,
|
|
763
|
+
month: allowance.month,
|
|
764
|
+
supervisorMemberId: allowance.envelope.supervisorMemberId || null,
|
|
765
|
+
// Measurement provenance — a caller can tell a cheap day from a blind one.
|
|
766
|
+
measuredUSD: today.measuredUSD,
|
|
767
|
+
imputedUSD: today.imputedUSD,
|
|
768
|
+
unmeasuredSessions: today.unmeasuredSessions,
|
|
769
|
+
nonLlmRows: today.nonLlmRows,
|
|
770
|
+
blind: today.blind,
|
|
771
|
+
degradations,
|
|
204
772
|
};
|
|
205
773
|
}
|
|
206
774
|
|
|
207
775
|
/**
|
|
208
|
-
*
|
|
209
|
-
*
|
|
776
|
+
* The lowest binding cap this period has run under, and the envelope it belongs
|
|
777
|
+
* to. See the LATCH comment in {@link dailyStatus} for why banding may not use a
|
|
778
|
+
* cap the seat just raised for itself.
|
|
779
|
+
*
|
|
780
|
+
* Stored beside the notice ledger (same UTC-date-stamped file, so it resets at
|
|
781
|
+
* the period boundary). The latch is dropped when the ENVELOPE fingerprint
|
|
782
|
+
* changes — hq raising or lowering the funded envelope is a real governance act
|
|
783
|
+
* and must take effect immediately, in both directions.
|
|
784
|
+
*
|
|
785
|
+
* Never throws; on any read/write failure the current cap is used unlatched
|
|
786
|
+
* (fail-open) and the caller sees `source:"unlatched"`.
|
|
210
787
|
*/
|
|
211
|
-
|
|
788
|
+
function readCapLatch(deps, allowance) {
|
|
789
|
+
const current = Number(allowance && allowance.allowanceUSD);
|
|
790
|
+
const envelopeFp = `${(allowance && allowance.envelope && allowance.envelope.seatBudgetCents) ?? "null"}:${(allowance && allowance.envelope && allowance.envelope.source) || "none"}`;
|
|
791
|
+
if (!Number.isFinite(current) || current <= 0) {
|
|
792
|
+
return { bandingCapUSD: Number.isFinite(current) ? current : 0, source: "unlatched" };
|
|
793
|
+
}
|
|
212
794
|
const p = budgetStateFile(deps);
|
|
213
|
-
|
|
795
|
+
let prior = null;
|
|
796
|
+
try {
|
|
797
|
+
if (existsSync(p)) prior = JSON.parse(readFileSync(p, "utf-8"));
|
|
798
|
+
} catch {
|
|
799
|
+
return { bandingCapUSD: current, source: "unlatched" };
|
|
800
|
+
}
|
|
801
|
+
const priorMin = prior && Number(prior.min_binding_cap_usd);
|
|
802
|
+
const priorFp = prior && prior.envelope_id;
|
|
803
|
+
const keep =
|
|
804
|
+
Number.isFinite(priorMin) && priorMin > 0 && priorFp === envelopeFp
|
|
805
|
+
? Math.min(priorMin, current)
|
|
806
|
+
: current;
|
|
807
|
+
|
|
808
|
+
if (!prior || prior.min_binding_cap_usd !== keep || prior.envelope_id !== envelopeFp) {
|
|
809
|
+
writeNotified(
|
|
810
|
+
{
|
|
811
|
+
...(prior && typeof prior === "object" ? prior : {}),
|
|
812
|
+
min_binding_cap_usd: +keep.toFixed(6),
|
|
813
|
+
envelope_id: envelopeFp,
|
|
814
|
+
},
|
|
815
|
+
deps
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
bandingCapUSD: +keep.toFixed(6),
|
|
820
|
+
source: keep < current ? `the lowest cap seen this period under the same envelope` : "current",
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Read the per-period notification ledger. Never throws.
|
|
826
|
+
*
|
|
827
|
+
* `envelope_fingerprint` is what makes "NO BAND SELF-CLEARS BY AGENT ACTION"
|
|
828
|
+
* enforceable. Notices clear only when the BINDING cap moves UP — and the seat
|
|
829
|
+
* cannot do that to itself, because the binding cap is the LOWER of the org
|
|
830
|
+
* envelope and the local config: editing the local file upward changes nothing,
|
|
831
|
+
* and editing it downward only tightens. Raising the ceiling is a supervisor /
|
|
832
|
+
* human act in hq, exactly like adoption.
|
|
833
|
+
*
|
|
834
|
+
* @param {object} [deps]
|
|
835
|
+
* @param {string} [currentFingerprint] `${capUSD}:${capSource}` for this read
|
|
836
|
+
*/
|
|
837
|
+
export function readNotified(deps, currentFingerprint) {
|
|
838
|
+
const p = budgetStateFile(deps);
|
|
839
|
+
if (!existsSync(p)) return { notified_bands: [], envelope_fingerprint: currentFingerprint ?? null };
|
|
214
840
|
try {
|
|
215
841
|
const raw = JSON.parse(readFileSync(p, "utf-8"));
|
|
216
842
|
const arr = Array.isArray(raw.notified_bands) ? raw.notified_bands.filter((n) => BANDS.includes(n)) : [];
|
|
217
|
-
|
|
843
|
+
const stored = raw.envelope_fingerprint ?? null;
|
|
844
|
+
const delivery = raw.delivery && typeof raw.delivery === "object" ? raw.delivery : {};
|
|
845
|
+
// The period file ALSO carries the cap latch (`min_binding_cap_usd` /
|
|
846
|
+
// `envelope_id`). Carry those through untouched: `maybeNotify` merge-writes
|
|
847
|
+
// over whatever this returns, so dropping them here would silently reset the
|
|
848
|
+
// latch on the first notice — and the latch is what stops a seat demoting
|
|
849
|
+
// its own band.
|
|
850
|
+
const latch = {
|
|
851
|
+
...(raw.min_binding_cap_usd !== undefined ? { min_binding_cap_usd: raw.min_binding_cap_usd } : {}),
|
|
852
|
+
...(raw.envelope_id !== undefined ? { envelope_id: raw.envelope_id } : {}),
|
|
853
|
+
};
|
|
854
|
+
if (currentFingerprint != null && stored != null && stored !== currentFingerprint) {
|
|
855
|
+
const now = Number(String(currentFingerprint).split(":")[0]);
|
|
856
|
+
const before = Number(String(stored).split(":")[0]);
|
|
857
|
+
if (Number.isFinite(now) && Number.isFinite(before) && now > before) {
|
|
858
|
+
return { ...latch, notified_bands: [], envelope_fingerprint: currentFingerprint, cleared: "envelope-raised", delivery: {} };
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return { ...latch, notified_bands: arr, envelope_fingerprint: stored, delivery };
|
|
218
862
|
} catch {
|
|
219
|
-
return { notified_bands: [] };
|
|
863
|
+
return { notified_bands: [], envelope_fingerprint: currentFingerprint ?? null, delivery: {} };
|
|
220
864
|
}
|
|
221
865
|
}
|
|
222
866
|
|
|
867
|
+
/**
|
|
868
|
+
* Delivery retry policy for a band notice. See {@link maybeNotify}.
|
|
869
|
+
*
|
|
870
|
+
* A band is marked notified only once the caller CONFIRMS delivery, or once we
|
|
871
|
+
* have tried `MAX_DELIVERY_ATTEMPTS` times and given up (which is logged as an
|
|
872
|
+
* error, because a rung whose whole purpose is "tell the supervisor" silently
|
|
873
|
+
* not telling anybody is the failure this subsystem exists to end).
|
|
874
|
+
*/
|
|
875
|
+
export const MAX_DELIVERY_ATTEMPTS = 6;
|
|
876
|
+
/** Matches the cadence consumer's 5-minute escalation timer. */
|
|
877
|
+
export const DELIVERY_RETRY_MS = 5 * 60_000;
|
|
878
|
+
|
|
879
|
+
/** Merge-write the period state file (it carries the notice ledger AND the cap latch). */
|
|
223
880
|
function writeNotified(state, deps) {
|
|
224
881
|
const p = budgetStateFile(deps);
|
|
225
882
|
try {
|
|
@@ -234,22 +891,52 @@ function writeNotified(state, deps) {
|
|
|
234
891
|
}
|
|
235
892
|
|
|
236
893
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
|
|
894
|
+
* The fingerprint notices are keyed to. THE ENVELOPE ONLY — never the binding
|
|
895
|
+
* cap.
|
|
896
|
+
*
|
|
897
|
+
* It used to be `${capUSD}:${capSource}`, which meant a seat that raised its own
|
|
898
|
+
* `config/recovery.yaml` cap moved the fingerprint upward and `readNotified`
|
|
899
|
+
* read that as "a supervisor raised the ceiling", wiping the notice ledger. The
|
|
900
|
+
* seat could then re-clear its own alarms indefinitely. `seatBudgetCents` is set
|
|
901
|
+
* in hq on the Employee record and is the one number the seat cannot write.
|
|
902
|
+
*/
|
|
903
|
+
function fingerprintOf(status) {
|
|
904
|
+
const cents = (status.envelope && status.envelope.seatBudgetCents) ?? null;
|
|
905
|
+
return `${cents == null ? "unfunded" : cents}:${(status.envelope && status.envelope.source) || "none"}`;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Emit AT MOST ONE notice per band per period, ADDRESSED TO THE SUPERVISOR.
|
|
910
|
+
*
|
|
911
|
+
* If the current band is above any not-yet-notified threshold(s), the highest
|
|
912
|
+
* such band is surfaced and every crossed band is recorded (so a jump from
|
|
913
|
+
* 40 %→130 % notifies once at 125, not three times).
|
|
914
|
+
*
|
|
915
|
+
* THE RECIPIENT IS NOT THE OWNER. It is the seat's REVIEWER collaborator —
|
|
916
|
+
* `WorkforceMember.supervisorId` as hq published it on the mandate body. An
|
|
917
|
+
* agent told "you are running out of money" has every incentive to optimise the
|
|
918
|
+
* meter rather than the work, and the meter is the one number it must not be
|
|
919
|
+
* able to move. When no supervisor edge exists the notice is LOGGED LOUDLY and
|
|
920
|
+
* sent NOWHERE: an unsupervised seat is a provisioning gap, not a licence to
|
|
921
|
+
* hand the agent its own budget alarm.
|
|
922
|
+
*
|
|
923
|
+
* @param {object} [deps] { now?, ledgerDir?, capUSD?, notify?, log? }
|
|
924
|
+
* @returns {{ notified:boolean, band:0|80|100|125|150, status:object,
|
|
925
|
+
* message?:string, recipient?:{memberId:string, role:"REVIEWER"}|null }}
|
|
246
926
|
*/
|
|
247
927
|
export function maybeNotify(deps) {
|
|
928
|
+
const log = (deps && typeof deps.log === "function")
|
|
929
|
+
? deps.log
|
|
930
|
+
: (level, msg) => { (level === "error" ? console.error : console.warn)(`[budget-guard] ${msg}`); };
|
|
248
931
|
const status = dailyStatus(deps);
|
|
249
932
|
if (status.band === 0) return { notified: false, band: 0, status };
|
|
250
933
|
|
|
251
|
-
const
|
|
252
|
-
const
|
|
934
|
+
const fp = fingerprintOf(status);
|
|
935
|
+
const prior = readNotified(deps, fp);
|
|
936
|
+
const already = new Set(prior.notified_bands);
|
|
937
|
+
if (prior.cleared === "envelope-raised") {
|
|
938
|
+
log("warn", `budget notices cleared: the binding ceiling moved up to $${status.capUSD.toFixed(2)} (${status.capSource})`);
|
|
939
|
+
}
|
|
253
940
|
|
|
254
941
|
// Every band threshold at or below the current band that hasn't fired yet.
|
|
255
942
|
const crossed = BANDS.filter((b) => status.band >= b && !already.has(b));
|
|
@@ -257,23 +944,114 @@ export function maybeNotify(deps) {
|
|
|
257
944
|
return { notified: false, band: status.band, status };
|
|
258
945
|
}
|
|
259
946
|
|
|
260
|
-
//
|
|
947
|
+
// Surface ONE notice at the highest crossed band.
|
|
261
948
|
const topBand = crossed[crossed.length - 1];
|
|
262
|
-
|
|
949
|
+
|
|
950
|
+
// ── ATTEMPT, NOT COMPLETION ────────────────────────────────────────────────
|
|
951
|
+
// The band used to be written into `notified_bands` HERE, before anything was
|
|
952
|
+
// put on the wire — so one ECONNREFUSED (or a daemon that started before the
|
|
953
|
+
// org credential resolved) burned the rung for the whole period and the
|
|
954
|
+
// 5-minute timer then polled forever without ever retrying. The band is now
|
|
955
|
+
// recorded as an ATTEMPT with a backoff, and only `commit()` — called by the
|
|
956
|
+
// delivery leg after a successful send — marks it notified. After
|
|
957
|
+
// MAX_DELIVERY_ATTEMPTS we stop retrying and record it as UNDELIVERED, loudly:
|
|
958
|
+
// giving up quietly is the same bug wearing a hat.
|
|
959
|
+
const now = clock(deps)();
|
|
960
|
+
const track = prior.delivery && prior.delivery[String(topBand)];
|
|
961
|
+
const attempts = Number(track && track.attempts) || 0;
|
|
962
|
+
const lastAt = Number(track && track.lastAttemptMs) || 0;
|
|
963
|
+
const retryMs = Number.isFinite(deps && deps.retryMs) ? deps.retryMs : DELIVERY_RETRY_MS;
|
|
964
|
+
|
|
965
|
+
if (attempts > 0 && attempts < MAX_DELIVERY_ATTEMPTS && now - lastAt < retryMs) {
|
|
966
|
+
return {
|
|
967
|
+
notified: false,
|
|
968
|
+
band: status.band,
|
|
969
|
+
status,
|
|
970
|
+
deferred: true,
|
|
971
|
+
reason: `retry backoff: attempt ${attempts}/${MAX_DELIVERY_ATTEMPTS} was ${Math.round((now - lastAt) / 1000)}s ago`,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const persist = (patch) =>
|
|
976
|
+
writeNotified({ ...prior, envelope_fingerprint: fp, ...patch }, deps);
|
|
977
|
+
|
|
978
|
+
const markNotified = () =>
|
|
979
|
+
persist({
|
|
980
|
+
notified_bands: [...prior.notified_bands, ...crossed].sort((a, b) => a - b),
|
|
981
|
+
delivery: { ...prior.delivery, [String(topBand)]: { attempts: attempts + 1, lastAttemptMs: now, delivered: true } },
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// Record the attempt immediately so a crashed process cannot spin, but do NOT
|
|
985
|
+
// consume the band.
|
|
986
|
+
const gaveUp = attempts + 1 >= MAX_DELIVERY_ATTEMPTS;
|
|
987
|
+
persist({
|
|
988
|
+
notified_bands: gaveUp
|
|
989
|
+
? [...prior.notified_bands, ...crossed].sort((a, b) => a - b)
|
|
990
|
+
: prior.notified_bands,
|
|
991
|
+
delivery: {
|
|
992
|
+
...prior.delivery,
|
|
993
|
+
[String(topBand)]: { attempts: attempts + 1, lastAttemptMs: now, delivered: false, gaveUp },
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
if (gaveUp) {
|
|
997
|
+
log(
|
|
998
|
+
"error",
|
|
999
|
+
`band ${topBand}% notice ABANDONED after ${MAX_DELIVERY_ATTEMPTS} delivery attempts — the supervisor was never told. ` +
|
|
1000
|
+
"This is a delivery outage, not a quiet period: check the org credential and channel.resolveOrCreateDm."
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
263
1003
|
|
|
264
1004
|
const message = formatNotice(topBand, status);
|
|
1005
|
+
const recipient = status.supervisorMemberId
|
|
1006
|
+
? { memberId: status.supervisorMemberId, role: "REVIEWER" }
|
|
1007
|
+
: null;
|
|
1008
|
+
if (!recipient) {
|
|
1009
|
+
log(
|
|
1010
|
+
"error",
|
|
1011
|
+
`band ${topBand}% reached and this seat has NO supervisor edge (the mandate body carries no REVIEWER ` +
|
|
1012
|
+
`collaborator) — the notice has nowhere to go and is deliberately NOT being sent to the owner. ${message}`
|
|
1013
|
+
);
|
|
1014
|
+
// Nothing can be delivered, so retrying every 5 minutes forever would only
|
|
1015
|
+
// spam the log. Consume the band; the missing edge is a provisioning fact
|
|
1016
|
+
// doctor reports separately.
|
|
1017
|
+
markNotified();
|
|
1018
|
+
return { notified: true, band: topBand, status, message, recipient: null, commit: () => {}, delivered: false };
|
|
1019
|
+
}
|
|
1020
|
+
log("warn", `band ${topBand}% → notifying supervisor ${recipient.memberId}: ${message}`);
|
|
1021
|
+
|
|
265
1022
|
const notifyFn = deps && typeof deps.notify === "function" ? deps.notify : null;
|
|
266
1023
|
if (notifyFn) {
|
|
267
|
-
|
|
1024
|
+
// An injected `notify` IS the delivery leg (tests, and any caller that wires
|
|
1025
|
+
// one). A throw means it did not arrive, so the band stays uncommitted.
|
|
1026
|
+
try {
|
|
1027
|
+
notifyFn({ band: topBand, status, message, recipient });
|
|
1028
|
+
markNotified();
|
|
1029
|
+
return { notified: true, band: topBand, status, message, recipient, commit: () => {}, delivered: true };
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
log("error", `band ${topBand}% notice threw on delivery (${err && err.message ? err.message : err}) — NOT marking the band notified; it will be retried`);
|
|
1032
|
+
return { notified: true, band: topBand, status, message, recipient, commit: markNotified, delivered: false };
|
|
1033
|
+
}
|
|
268
1034
|
}
|
|
269
|
-
|
|
1035
|
+
|
|
1036
|
+
// No injected delivery: the caller (lib/budget-escalate.mjs) owns the wire and
|
|
1037
|
+
// MUST call `commit()` once the notice is actually delivered.
|
|
1038
|
+
return { notified: true, band: topBand, status, message, recipient, commit: markNotified, delivered: false, attempts: attempts + 1 };
|
|
270
1039
|
}
|
|
271
1040
|
|
|
272
1041
|
function formatNotice(band, status) {
|
|
273
1042
|
const usd = status.spentUSD.toFixed(2);
|
|
274
1043
|
const cap = status.capUSD.toFixed(2);
|
|
1044
|
+
const envelope = status.envelope && status.envelope.monthUSD != null
|
|
1045
|
+
? ` Seat envelope $${status.envelope.monthUSD.toFixed(2)}/month (${status.capSource}); month-to-date $${status.month.spentUSD.toFixed(2)} (${status.monthPct}%).`
|
|
1046
|
+
: " NO org-funded envelope is published for this seat — only the seat's own local cap binds.";
|
|
1047
|
+
if (band >= 150) {
|
|
1048
|
+
return `Seat spend at ${status.pct}% of its ceiling ($${usd} / $${cap} today). REFUSING new sessions that are not a direct human reply; inbox replies continue.${envelope}`;
|
|
1049
|
+
}
|
|
1050
|
+
if (band >= 125) {
|
|
1051
|
+
return `Seat spend at ${status.pct}% ($${usd} / $${cap} today). OUTCOME obligations SUSPENDED (budget_breach drift recorded); inline + offline-safe cadences continue.${envelope}`;
|
|
1052
|
+
}
|
|
275
1053
|
if (band >= 100) {
|
|
276
|
-
return `
|
|
1054
|
+
return `Seat spend at ${status.pct}% ($${usd} / $${cap} today). Degrading: cheapest model class, sub-agent fan-out capped at 1, self-directed work stopped. Inbox replies continue.${envelope}`;
|
|
277
1055
|
}
|
|
278
|
-
return `
|
|
1056
|
+
return `Seat spend at ${status.pct}% ($${usd} / $${cap} today) — heads-up only, nothing is degraded yet.${envelope}`;
|
|
279
1057
|
}
|