@delegance/claude-autopilot 7.10.1 → 7.11.0-pre.2

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.
Files changed (27) hide show
  1. package/dist/src/cli/index.js +28 -2
  2. package/dist/src/cli/runs.d.ts +29 -0
  3. package/dist/src/cli/runs.js +226 -0
  4. package/dist/src/core/concurrent-dispatch/budget-reservation.d.ts +163 -0
  5. package/dist/src/core/concurrent-dispatch/budget-reservation.js +642 -0
  6. package/dist/src/core/concurrent-dispatch/dep-graph.d.ts +68 -0
  7. package/dist/src/core/concurrent-dispatch/dep-graph.js +626 -0
  8. package/dist/src/core/concurrent-dispatch/git-op-queue.d.ts +47 -0
  9. package/dist/src/core/concurrent-dispatch/git-op-queue.js +114 -0
  10. package/dist/src/core/concurrent-dispatch/index.d.ts +13 -0
  11. package/dist/src/core/concurrent-dispatch/index.js +21 -0
  12. package/dist/src/core/concurrent-dispatch/merge-orchestrator.d.ts +99 -0
  13. package/dist/src/core/concurrent-dispatch/merge-orchestrator.js +831 -0
  14. package/dist/src/core/concurrent-dispatch/scheduler.d.ts +160 -0
  15. package/dist/src/core/concurrent-dispatch/scheduler.js +933 -0
  16. package/dist/src/core/concurrent-dispatch/types.d.ts +156 -0
  17. package/dist/src/core/concurrent-dispatch/types.js +38 -0
  18. package/dist/src/core/concurrent-dispatch/worktree-lifecycle.d.ts +138 -0
  19. package/dist/src/core/concurrent-dispatch/worktree-lifecycle.js +418 -0
  20. package/dist/src/core/phases/deploy.d.ts +19 -0
  21. package/dist/src/core/phases/deploy.js +87 -0
  22. package/dist/src/core/run-state/repo-lock.d.ts +110 -0
  23. package/dist/src/core/run-state/repo-lock.js +373 -0
  24. package/dist/src/core/run-state/serialized-writer.d.ts +90 -0
  25. package/dist/src/core/run-state/serialized-writer.js +341 -0
  26. package/package.json +1 -1
  27. package/skills/ui-ux-pro-max/SKILL.md +4 -4
@@ -0,0 +1,642 @@
1
+ // src/core/concurrent-dispatch/budget-reservation.ts
2
+ //
3
+ // Budget reservation ledger for concurrent subagent dispatch. PR 3 of 6 of
4
+ // the v7.11.0 concurrent subagent execution spec — the budget half of the
5
+ // "event + budget atomicity" layer.
6
+ //
7
+ // What problem does this solve?
8
+ //
9
+ // A naive concurrent dispatcher would read the current spend, decide
10
+ // "remaining budget is $5, this subagent costs ~$2, dispatch it", and
11
+ // write `task.started` — but TWO dispatches running this logic in parallel
12
+ // could both observe $5 remaining, both pass the check, and over-spend.
13
+ //
14
+ // The fix is to make (replay → check → append) atomic. Every reserve /
15
+ // release / increase routes through the per-run `SerializedWriter`'s
16
+ // `withExclusive` API, which holds an exclusive file lock for the full
17
+ // critical section. Under that lock we re-replay events.ndjson from disk
18
+ // (authoritative source) BEFORE the budget check, so even a second
19
+ // `BudgetReservation` instance (or a second scheduler process) sees the
20
+ // first instance's appended reservation before its own check runs.
21
+ //
22
+ // Budget accounting model (corrected after Codex pass 1):
23
+ //
24
+ // We track three running totals (internally as INTEGER MICROS to avoid
25
+ // floating-point drift; see "Why integer micros" below):
26
+ //
27
+ // spentTotal = sum of `task.budget_released.actual_cost_usd`
28
+ // — money the subagent actually spent and we now
29
+ // commit against the run cap.
30
+ //
31
+ // activeReservedTotal = sum of (reserved_usd) for tasks whose latest
32
+ // event is `task.budget_reserved` or
33
+ // `task.budget_increased_reservation` (NOT yet
34
+ // released). The latest increase REPLACES the
35
+ // prior reserved value (absolute, not delta).
36
+ //
37
+ // committedTotal = spentTotal + activeReservedTotal
38
+ //
39
+ // The headroom check is `caps.perRunUSD - committedTotal >= newReservation`.
40
+ // Releasing a task: spentTotal += actualCost; activeReservedTotal -=
41
+ // reserved. Net effect: a $6 reservation released with $6 actual spend
42
+ // moves $6 from "active reserved" to "spent" — total commitment is
43
+ // unchanged. Earlier the naive `in_flight = reserved - released_actual`
44
+ // model would have left $0 committed, allowing the run to over-spend.
45
+ //
46
+ // Why replay events.ndjson on every reserve?
47
+ //
48
+ // The single-scheduler-per-run contract is enforced upstream (by the
49
+ // per-run lock in `lock.ts` + cross-process repo lock from PR 2), so in
50
+ // well-behaved deployments the in-memory cache and disk state agree.
51
+ //
52
+ // But the file lock alone doesn't prevent stale-cache reasoning if a
53
+ // second `BudgetReservation` instance is created (e.g. test bug, a
54
+ // resumed run that didn't `hydrateFromEvents`, or a future BullMQ
55
+ // worker design). Authoritative replay inside the lock is the
56
+ // correctness floor; the cache is just an optimisation.
57
+ //
58
+ // Cost: O(n) read of events.ndjson per reserve, where n = number of
59
+ // events so far. Acceptable up to ~100k events per run; if we ever push
60
+ // beyond that we can add a `budget-state-cache.json` checkpoint.
61
+ //
62
+ // Why integer micros (Codex pass 1, WARN #6)?
63
+ //
64
+ // JS `number` is IEEE-754 double, so naive USD arithmetic accumulates
65
+ // error: `0.1 + 0.2 === 0.30000000000000004`. Over thousands of
66
+ // reservations this drift can flip a cap check the wrong way. We store
67
+ // all internal totals as integer micros (1 USD = 1_000_000 micros).
68
+ // Range: `Number.MAX_SAFE_INTEGER / 1_000_000 ≈ $9.007e9` — way more
69
+ // than any plausible per-run budget. Public API still accepts and emits
70
+ // decimal USD; we convert at the boundary via `Math.round(usd *
71
+ // 1_000_000)` (input) and `micros / 1_000_000` (output).
72
+ //
73
+ // What this file is NOT:
74
+ //
75
+ // * The dispatch loop — that's PR 4 (#191). This file exports the API
76
+ // PR 4 calls to gate dispatch.
77
+ // * A spend tracker for the v6 run-state engine's `phase.cost` events —
78
+ // those remain unchanged. Reservations live alongside `phase.cost`.
79
+ //
80
+ // Spec:
81
+ // docs/superpowers/specs/2026-05-19-v7.11.0-concurrent-subagent-execution-design.md
82
+ // sections "Budget reservation semantics" + "Integration with run-state
83
+ // engine (v6)".
84
+ import * as fs from 'node:fs';
85
+ import { GuardrailError } from "../errors.js";
86
+ // ---- Micros conversion helpers -------------------------------------------
87
+ // 1 USD = 1_000_000 micros. `Math.round` uses round-half-away-from-zero (NOT
88
+ // banker's rounding); for budget enforcement at the boundary where every
89
+ // reservation is a fresh value, the rounding mode is symmetric and inputs
90
+ // rarely land exactly on a half-micro, so the simpler primitive is fine.
91
+ // (Inputs are also `Number.isFinite` and `>= 0` validated upstream — see
92
+ // `assertNonNegativeFiniteUsd`.) Range: `Number.MAX_SAFE_INTEGER /
93
+ // 1_000_000 ≈ $9.007e9` — more than any plausible per-run cap.
94
+ const MICROS_PER_USD = 1_000_000;
95
+ function usdToMicros(usd) {
96
+ return Math.round(usd * MICROS_PER_USD);
97
+ }
98
+ function microsToUsd(micros) {
99
+ return micros / MICROS_PER_USD;
100
+ }
101
+ /** Max USD value we will accept at the public API. Above this, the
102
+ * integer-micros representation could exceed `Number.MAX_SAFE_INTEGER`
103
+ * and arithmetic would lose precision — making cap comparisons
104
+ * unreliable. `MAX_SAFE_INTEGER / MICROS_PER_USD ≈ $9.007e9` which is
105
+ * more than any plausible per-run budget for a CLI tool. */
106
+ const MAX_USD = Number.MAX_SAFE_INTEGER / MICROS_PER_USD;
107
+ /** Reject NaN, Infinity, negative, and out-of-safe-range USD inputs at the
108
+ * public API boundary before they reach the micros conversion.
109
+ *
110
+ * Codex pass 2 CRITICAL #2 — `NaN > anything` is false so a NaN
111
+ * preFlightEstimate would slip past the hard cap, and `usdToMicros(NaN)`
112
+ * returns NaN which then corrupts every subsequent comparison.
113
+ *
114
+ * Codex pass 3 WARN — values above MAX_USD would lose precision once
115
+ * converted to micros and aggregated, breaking exact cap comparisons. */
116
+ function assertNonNegativeFiniteUsd(label, value) {
117
+ if (!Number.isFinite(value) || value < 0) {
118
+ throw new GuardrailError(`${label}: must be a finite non-negative number (got ${String(value)})`, {
119
+ code: 'user_input',
120
+ provider: 'concurrent-dispatch',
121
+ details: { label, value },
122
+ });
123
+ }
124
+ if (value > MAX_USD) {
125
+ throw new GuardrailError(`${label}: must be <= $${MAX_USD} (got ${String(value)}) — exceeds safe-integer micros range`, {
126
+ code: 'user_input',
127
+ provider: 'concurrent-dispatch',
128
+ details: { label, value, maxUsd: MAX_USD },
129
+ });
130
+ }
131
+ }
132
+ /** Thrown by `reserve()` / `increaseReservation()` when the requested
133
+ * amount would push the run over `perRunUSD`, or when a pre-flight
134
+ * exceeds `perSubagentUSD`. Carries the GuardrailError code
135
+ * `budget_exceeded` for upstream resume classification. */
136
+ export class BudgetExceededError extends GuardrailError {
137
+ constructor(message, details) {
138
+ super(message, {
139
+ code: 'budget_exceeded',
140
+ provider: 'concurrent-dispatch',
141
+ details,
142
+ });
143
+ }
144
+ }
145
+ /**
146
+ * Budget reservation ledger. One instance per run, owned by the scheduler.
147
+ * All mutations route through the supplied `SerializedWriter` and re-replay
148
+ * events.ndjson under the exclusive lock so they are atomic against
149
+ * concurrent callers AND any other `BudgetReservation` instances pointed at
150
+ * the same run.
151
+ */
152
+ export class BudgetReservation {
153
+ writer;
154
+ /** In-memory mirror of disk state, kept for fast `snapshot()` reads.
155
+ * NOT authoritative — every mutating operation re-replays the on-disk
156
+ * log inside the writer's exclusive lock before checking caps. Per-task
157
+ * reservations stored in USD (public API); the running totals below are
158
+ * in MICROS (integer-safe arithmetic). */
159
+ reservations = new Map();
160
+ spentMicros = 0;
161
+ activeReservedMicros = 0;
162
+ constructor(writer) {
163
+ this.writer = writer;
164
+ }
165
+ /** Re-seed in-memory state from events.ndjson. Call on construction OR
166
+ * on resume. Safe to call multiple times — overwrites the cache. */
167
+ async hydrateFromEvents(eventsNdjsonPath) {
168
+ const replay = BudgetReservation.replayFromEventsMicros(eventsNdjsonPath);
169
+ this.applyReplayMicros(replay);
170
+ }
171
+ /** Read the current in-memory state. Best-effort: a concurrent writer
172
+ * in another instance could make this stale until the next mutating
173
+ * call re-hydrates from disk. */
174
+ snapshot() {
175
+ return {
176
+ spentTotal: microsToUsd(this.spentMicros),
177
+ activeReservedTotal: microsToUsd(this.activeReservedMicros),
178
+ committedTotal: microsToUsd(this.spentMicros + this.activeReservedMicros),
179
+ perTask: new Map(this.reservations),
180
+ };
181
+ }
182
+ /**
183
+ * Reserve budget for a task. Atomic under the writer lock:
184
+ *
185
+ * 1. HARD cap check (pre-lock): `preFlightEstimate <= caps.perSubagentUSD`
186
+ * 2. Acquire SerializedWriter lock
187
+ * 3. Re-replay events.ndjson from disk (authoritative)
188
+ * 4. Verify task doesn't already have an in-flight reservation
189
+ * 5. Verify `caps.perRunUSD - committedTotal >= preFlightEstimate`
190
+ * (committedTotal = spent + active reservations)
191
+ * 6. If pass: append `task.budget_reserved` event + fsync
192
+ * If fail: append `task.budget_halt` event + fsync, then throw
193
+ * 7. Release lock
194
+ *
195
+ * Throws `BudgetExceededError` on cap violations. The `task.budget_halt`
196
+ * variant is durable: the event lands inside the same critical section
197
+ * before the throw propagates, so a crash post-throw still surfaces the
198
+ * halt on resume.
199
+ */
200
+ async reserve(taskId, opts) {
201
+ // Input validation (Codex pass 2 CRITICAL #2). Reject NaN/Infinity/
202
+ // negative BEFORE the hard-cap check — `NaN > x` is false in JS so a
203
+ // NaN estimate would otherwise sail past the cap and corrupt every
204
+ // downstream comparison.
205
+ assertNonNegativeFiniteUsd(`task.${taskId}: preFlightEstimateUsd`, opts.preFlightEstimateUsd);
206
+ assertNonNegativeFiniteUsd('caps.perRunUSD', opts.caps.perRunUSD);
207
+ assertNonNegativeFiniteUsd('caps.perSubagentUSD', opts.caps.perSubagentUSD);
208
+ if (opts.preFlightEstimateUsd > opts.caps.perSubagentUSD) {
209
+ throw new BudgetExceededError(`task.${taskId}: pre-flight estimate $${opts.preFlightEstimateUsd.toFixed(2)} exceeds perSubagentUSD cap of $${opts.caps.perSubagentUSD.toFixed(2)}`, {
210
+ task_id: taskId,
211
+ preFlightEstimateUsd: opts.preFlightEstimateUsd,
212
+ perSubagentUSD: opts.caps.perSubagentUSD,
213
+ violation: 'per_subagent_hard_cap',
214
+ });
215
+ }
216
+ const preflightMicros = usdToMicros(opts.preFlightEstimateUsd);
217
+ const perRunMicros = usdToMicros(opts.caps.perRunUSD);
218
+ await this.writer.withExclusive(async ({ writeEvent, eventsNdjsonPath }) => {
219
+ // Authoritative replay INSIDE the lock — defends against stale
220
+ // cache when multiple BudgetReservation instances share an event
221
+ // file. Also rebuilds our own cache so the next snapshot() call
222
+ // reflects disk state.
223
+ const disk = BudgetReservation.replayFromEventsMicros(eventsNdjsonPath);
224
+ this.applyReplayMicros(disk);
225
+ // Codex pass 2 CRITICAL #1: `task.budget_halt` is documented as the
226
+ // terminal record. Once it lands for a task, no later mutating
227
+ // event for that task is allowed. (Run-wide halt enforcement lives
228
+ // at the scheduler layer — this guard prevents the same-task
229
+ // resurrection class of bug.)
230
+ if (disk.haltedTaskIds.has(taskId)) {
231
+ throw new GuardrailError(`task.${taskId}: cannot reserve; task is terminally halted`, {
232
+ code: 'adapter_bug',
233
+ provider: 'concurrent-dispatch',
234
+ details: { task_id: taskId, terminal_state: 'budget_halt' },
235
+ });
236
+ }
237
+ // Codex pass 2 WARN #5: task_ids are immutable across their
238
+ // lifecycle. A released task cannot be re-reserved under the same
239
+ // id — caller must use a new id (or future attempt_id model).
240
+ const existing = this.reservations.get(taskId);
241
+ if (existing) {
242
+ throw new GuardrailError(existing.released
243
+ ? `task.${taskId}: cannot re-reserve a released task_id; use a new id`
244
+ : `task.${taskId}: already has an in-flight reservation`, {
245
+ code: 'adapter_bug',
246
+ provider: 'concurrent-dispatch',
247
+ details: { task_id: taskId, released: existing.released },
248
+ });
249
+ }
250
+ const committedMicros = this.spentMicros + this.activeReservedMicros;
251
+ const remainingMicros = perRunMicros - committedMicros;
252
+ if (remainingMicros < preflightMicros) {
253
+ // Halt: emit the terminal record THEN throw. The event lands
254
+ // first so resume classifies this correctly even if the throw
255
+ // propagates past a crash boundary.
256
+ const remainingUsd = microsToUsd(remainingMicros);
257
+ await writeEvent({
258
+ event: 'task.budget_halt',
259
+ task_id: taskId,
260
+ budget_remaining_usd: remainingUsd,
261
+ preflight_estimate_usd: opts.preFlightEstimateUsd,
262
+ });
263
+ throw new BudgetExceededError(`task.${taskId}: remaining budget $${remainingUsd.toFixed(2)} below pre-flight estimate $${opts.preFlightEstimateUsd.toFixed(2)} (perRunUSD=$${opts.caps.perRunUSD.toFixed(2)}, spent=$${microsToUsd(this.spentMicros).toFixed(2)}, active=$${microsToUsd(this.activeReservedMicros).toFixed(2)})`, {
264
+ task_id: taskId,
265
+ preFlightEstimateUsd: opts.preFlightEstimateUsd,
266
+ perRunUSD: opts.caps.perRunUSD,
267
+ spentTotalUsd: microsToUsd(this.spentMicros),
268
+ activeReservedTotalUsd: microsToUsd(this.activeReservedMicros),
269
+ remaining: remainingUsd,
270
+ violation: 'per_run_cap_at_reserve',
271
+ });
272
+ }
273
+ // Reservation approved. Append event + update cache.
274
+ const remainingAfterMicros = remainingMicros - preflightMicros;
275
+ await writeEvent({
276
+ event: 'task.budget_reserved',
277
+ task_id: taskId,
278
+ reserved_usd: opts.preFlightEstimateUsd,
279
+ run_budget_remaining_after_reservation_usd: microsToUsd(remainingAfterMicros),
280
+ });
281
+ this.reservations.set(taskId, {
282
+ task_id: taskId,
283
+ reserved_usd: opts.preFlightEstimateUsd,
284
+ released: false,
285
+ });
286
+ this.activeReservedMicros += preflightMicros;
287
+ });
288
+ }
289
+ /**
290
+ * Bump an in-flight reservation to a new ABSOLUTE value. Atomic under
291
+ * the writer lock; re-replays disk state before checking caps.
292
+ * Re-checks `perRunUSD` against the NEW reservation total; halts the
293
+ * run with `task.budget_halt` if the bump would exceed the cap.
294
+ *
295
+ * Throws `BudgetExceededError` (with durable `task.budget_halt`) if no
296
+ * prior reservation exists, the bump is downward (use `release`
297
+ * instead), or the bump exceeds caps.
298
+ */
299
+ async increaseReservation(taskId, opts) {
300
+ // Input validation (Codex pass 2 CRITICAL #2).
301
+ assertNonNegativeFiniteUsd(`task.${taskId}: newReservedUsd`, opts.newReservedUsd);
302
+ assertNonNegativeFiniteUsd('caps.perRunUSD', opts.caps.perRunUSD);
303
+ assertNonNegativeFiniteUsd('caps.perSubagentUSD', opts.caps.perSubagentUSD);
304
+ // Pre-lock validation that doesn't depend on on-disk state.
305
+ if (opts.newReservedUsd > opts.caps.perSubagentUSD) {
306
+ throw new BudgetExceededError(`task.${taskId}: increased reservation $${opts.newReservedUsd.toFixed(2)} exceeds perSubagentUSD cap of $${opts.caps.perSubagentUSD.toFixed(2)}`, {
307
+ task_id: taskId,
308
+ newReservedUsd: opts.newReservedUsd,
309
+ perSubagentUSD: opts.caps.perSubagentUSD,
310
+ violation: 'per_subagent_hard_cap_on_increase',
311
+ });
312
+ }
313
+ const newReservedMicros = usdToMicros(opts.newReservedUsd);
314
+ const perRunMicros = usdToMicros(opts.caps.perRunUSD);
315
+ await this.writer.withExclusive(async ({ writeEvent, eventsNdjsonPath }) => {
316
+ const disk = BudgetReservation.replayFromEventsMicros(eventsNdjsonPath);
317
+ this.applyReplayMicros(disk);
318
+ if (disk.haltedTaskIds.has(taskId)) {
319
+ throw new GuardrailError(`task.${taskId}: cannot increase reservation; task is terminally halted`, {
320
+ code: 'adapter_bug',
321
+ provider: 'concurrent-dispatch',
322
+ details: { task_id: taskId, terminal_state: 'budget_halt' },
323
+ });
324
+ }
325
+ const prior = this.reservations.get(taskId);
326
+ if (!prior || prior.released) {
327
+ throw new GuardrailError(`task.${taskId}: cannot increase reservation; no in-flight reservation found`, {
328
+ code: 'adapter_bug',
329
+ provider: 'concurrent-dispatch',
330
+ details: { task_id: taskId },
331
+ });
332
+ }
333
+ const priorReservedMicros = usdToMicros(prior.reserved_usd);
334
+ if (newReservedMicros <= priorReservedMicros) {
335
+ throw new GuardrailError(`task.${taskId}: increaseReservation must raise the cap (prior=$${prior.reserved_usd.toFixed(2)}, new=$${opts.newReservedUsd.toFixed(2)})`, {
336
+ code: 'user_input',
337
+ provider: 'concurrent-dispatch',
338
+ details: {
339
+ task_id: taskId,
340
+ priorReservedUsd: prior.reserved_usd,
341
+ newReservedUsd: opts.newReservedUsd,
342
+ },
343
+ });
344
+ }
345
+ const deltaMicros = newReservedMicros - priorReservedMicros;
346
+ const projectedCommittedMicros = this.spentMicros + this.activeReservedMicros + deltaMicros;
347
+ if (projectedCommittedMicros > perRunMicros) {
348
+ const remainingMicros = perRunMicros - (this.spentMicros + this.activeReservedMicros);
349
+ await writeEvent({
350
+ event: 'task.budget_halt',
351
+ task_id: taskId,
352
+ budget_remaining_usd: microsToUsd(remainingMicros),
353
+ preflight_estimate_usd: microsToUsd(deltaMicros),
354
+ });
355
+ throw new BudgetExceededError(`task.${taskId}: increase reservation by $${microsToUsd(deltaMicros).toFixed(2)} would commit $${microsToUsd(projectedCommittedMicros).toFixed(2)} vs perRunUSD cap of $${opts.caps.perRunUSD.toFixed(2)} (spent=$${microsToUsd(this.spentMicros).toFixed(2)}, active=$${microsToUsd(this.activeReservedMicros).toFixed(2)})`, {
356
+ task_id: taskId,
357
+ priorReservedUsd: prior.reserved_usd,
358
+ newReservedUsd: opts.newReservedUsd,
359
+ delta: microsToUsd(deltaMicros),
360
+ perRunUSD: opts.caps.perRunUSD,
361
+ spentTotalUsd: microsToUsd(this.spentMicros),
362
+ activeReservedTotalUsd: microsToUsd(this.activeReservedMicros),
363
+ projectedCommittedUsd: microsToUsd(projectedCommittedMicros),
364
+ violation: 'per_run_cap_at_increase',
365
+ });
366
+ }
367
+ // Bump approved. Append event + update cache.
368
+ await writeEvent({
369
+ event: 'task.budget_increased_reservation',
370
+ task_id: taskId,
371
+ prior_reserved_usd: prior.reserved_usd,
372
+ new_reserved_usd: opts.newReservedUsd,
373
+ reason: opts.reason,
374
+ });
375
+ prior.reserved_usd = opts.newReservedUsd;
376
+ this.activeReservedMicros += deltaMicros;
377
+ });
378
+ }
379
+ /**
380
+ * Release a reservation with the actual cost. Atomic under the writer
381
+ * lock; re-replays disk state. Moves the task's commitment from
382
+ * `activeReserved` to `spent`. Emits `task.budget_released` with
383
+ * `delta_vs_reservation_usd` (positive = under, negative = over).
384
+ *
385
+ * Note: a release with `actualCostUsd > reserved_usd` is NOT a halt —
386
+ * the budget was already committed at reservation time. The scheduler
387
+ * is expected to issue `increaseReservation` mid-flight when telemetry
388
+ * suggests overrun, and to emit `task.failed{error_type:'budget_exceeded'}`
389
+ * if the increase itself fails. This `release` just records the truth.
390
+ */
391
+ async release(taskId, opts) {
392
+ // Input validation (Codex pass 2 CRITICAL #2). Also catches NaN
393
+ // which would otherwise propagate through micros into spentTotal.
394
+ assertNonNegativeFiniteUsd(`task.${taskId}: actualCostUsd`, opts.actualCostUsd);
395
+ const actualMicros = usdToMicros(opts.actualCostUsd);
396
+ await this.writer.withExclusive(async ({ writeEvent, eventsNdjsonPath }) => {
397
+ const disk = BudgetReservation.replayFromEventsMicros(eventsNdjsonPath);
398
+ this.applyReplayMicros(disk);
399
+ // NOTE: release() is INTENTIONALLY allowed after task.budget_halt.
400
+ // Codex pass 3 CRITICAL #2: if increaseReservation triggered the
401
+ // halt, the task still has an active reservation that must be
402
+ // finalized (or it permanently strands activeReservedMicros for
403
+ // the run). The terminal-halt guard applies only to operations
404
+ // that would create NEW state for the task (reserve,
405
+ // increaseReservation). release records actual spend and clears
406
+ // the active reservation — it is the legitimate finalizer.
407
+ const prior = this.reservations.get(taskId);
408
+ if (!prior) {
409
+ throw new GuardrailError(`task.${taskId}: cannot release; no reservation found`, {
410
+ code: 'adapter_bug',
411
+ provider: 'concurrent-dispatch',
412
+ details: { task_id: taskId },
413
+ });
414
+ }
415
+ if (prior.released) {
416
+ throw new GuardrailError(`task.${taskId}: reservation already released`, {
417
+ code: 'adapter_bug',
418
+ provider: 'concurrent-dispatch',
419
+ details: { task_id: taskId },
420
+ });
421
+ }
422
+ const priorReservedMicros = usdToMicros(prior.reserved_usd);
423
+ const deltaMicros = priorReservedMicros - actualMicros;
424
+ await writeEvent({
425
+ event: 'task.budget_released',
426
+ task_id: taskId,
427
+ actual_cost_usd: opts.actualCostUsd,
428
+ delta_vs_reservation_usd: microsToUsd(deltaMicros),
429
+ });
430
+ prior.released = true;
431
+ prior.actual_cost_usd = opts.actualCostUsd;
432
+ // Move commitment from "active reserved" to "spent". Net effect on
433
+ // committedTotal: prior.reserved_usd was reserved, now actualCost
434
+ // is spent — committedTotal changes by (actualCost - reserved_usd).
435
+ // If actualCost < reserved: committed goes DOWN (we free unused
436
+ // reservation). If actualCost == reserved: no change. If actualCost
437
+ // > reserved: committed goes UP (the overspend is recorded).
438
+ this.activeReservedMicros -= priorReservedMicros;
439
+ this.spentMicros += actualMicros;
440
+ });
441
+ }
442
+ /** Internal: replace in-memory ledger from a replay summary (in micros).
443
+ * Used by every mutating method to re-sync cache with disk under the
444
+ * lock. */
445
+ applyReplayMicros(replay) {
446
+ this.reservations = replay.perTask;
447
+ this.spentMicros = replay.spentMicros;
448
+ this.activeReservedMicros = replay.activeReservedMicros;
449
+ }
450
+ // -------------------------------------------------------------------------
451
+ // Replay — pure, no IO beyond reading the events file. Static so resume
452
+ // and tests can call it without instantiating a writer.
453
+ // -------------------------------------------------------------------------
454
+ /**
455
+ * Replay events.ndjson into a fresh `BudgetReplaySummary` (decimal USD).
456
+ * Used by:
457
+ * 1. `hydrateFromEvents` — runtime cache seed on resume / startup
458
+ * 2. tests — to assert reconstruction is exact
459
+ *
460
+ * Internally calls `replayFromEventsMicros` and converts to USD at the
461
+ * boundary so external consumers see decimal numbers, but the fold itself
462
+ * is integer-micro arithmetic.
463
+ *
464
+ * Line parsing is lenient: blank lines are skipped, parse failures stop
465
+ * the walk (treated as truncated tail; the next `appendEvent` would
466
+ * emit `run.recovery`, and budget state stays consistent up to the
467
+ * last-known-good line).
468
+ */
469
+ static replayFromEvents(eventsNdjsonPath) {
470
+ const micros = BudgetReservation.replayFromEventsMicros(eventsNdjsonPath);
471
+ return {
472
+ spentTotal: microsToUsd(micros.spentMicros),
473
+ activeReservedTotal: microsToUsd(micros.activeReservedMicros),
474
+ committedTotal: microsToUsd(micros.spentMicros + micros.activeReservedMicros),
475
+ perTask: micros.perTask,
476
+ };
477
+ }
478
+ /**
479
+ * Internal: micro-precision replay. Used by every mutating method INSIDE
480
+ * `withExclusive` so cap checks operate on integer arithmetic.
481
+ */
482
+ static replayFromEventsMicros(eventsNdjsonPath) {
483
+ const perTask = new Map();
484
+ const haltedTaskIds = new Set();
485
+ let spentMicros = 0;
486
+ let activeReservedMicros = 0;
487
+ if (!fs.existsSync(eventsNdjsonPath)) {
488
+ return { spentMicros, activeReservedMicros, perTask, haltedTaskIds };
489
+ }
490
+ const raw = fs.readFileSync(eventsNdjsonPath, 'utf8');
491
+ if (!raw) {
492
+ return { spentMicros, activeReservedMicros, perTask, haltedTaskIds };
493
+ }
494
+ const lines = raw.split('\n');
495
+ // Always skip the final element of `split('\n')`:
496
+ // - File ending in '\n' → trailing '' is dropped (no JSON to parse).
497
+ // - Truncated tail (no '\n') → the last partial line is untrusted
498
+ // and dropped because we can't guarantee it's a complete JSON
499
+ // object. The next appendEvent will emit `run.recovery`.
500
+ // Both branches resolve to `lines.length - 2`, hence the bare
501
+ // expression (no conditional). Bugbot flagged the prior ternary as
502
+ // dead code — correct call.
503
+ const lastIdx = lines.length - 2;
504
+ for (let i = 0; i <= lastIdx; i++) {
505
+ const line = lines[i];
506
+ if (!line)
507
+ continue;
508
+ let ev;
509
+ try {
510
+ ev = JSON.parse(line);
511
+ }
512
+ catch {
513
+ // Mid-file corruption — stop the walk. The events file is the
514
+ // source of truth; this fold is best-effort and conservative
515
+ // (we keep state up to the last valid line).
516
+ break;
517
+ }
518
+ if (!ev || typeof ev !== 'object' || typeof ev.event !== 'string')
519
+ continue;
520
+ switch (ev.event) {
521
+ case 'task.budget_reserved': {
522
+ // Codex pass 3 CRITICAL #1: halt is terminal — reserving a
523
+ // halted task_id post-halt is a corruption signal.
524
+ if (haltedTaskIds.has(ev.task_id)) {
525
+ throw new GuardrailError(`replay: task.${ev.task_id} has task.budget_reserved AFTER task.budget_halt (corrupt ledger)`, {
526
+ code: 'adapter_bug',
527
+ provider: 'concurrent-dispatch',
528
+ details: {
529
+ task_id: ev.task_id,
530
+ event: 'task.budget_reserved',
531
+ terminal_state: 'budget_halt',
532
+ },
533
+ });
534
+ }
535
+ // Bugbot pass 3: duplicate task.budget_reserved for the same
536
+ // task_id would silently inflate activeReservedMicros (the
537
+ // runtime guard in reserve() prevents this from happening on
538
+ // happy paths — only corrupt logs can produce it). Fail closed.
539
+ if (perTask.has(ev.task_id)) {
540
+ throw new GuardrailError(`replay: task.${ev.task_id} has duplicate task.budget_reserved (corrupt ledger)`, {
541
+ code: 'adapter_bug',
542
+ provider: 'concurrent-dispatch',
543
+ details: { task_id: ev.task_id, event: 'task.budget_reserved' },
544
+ });
545
+ }
546
+ const entry = {
547
+ task_id: ev.task_id,
548
+ reserved_usd: ev.reserved_usd,
549
+ released: false,
550
+ };
551
+ perTask.set(ev.task_id, entry);
552
+ activeReservedMicros += usdToMicros(ev.reserved_usd);
553
+ break;
554
+ }
555
+ case 'task.budget_increased_reservation': {
556
+ if (haltedTaskIds.has(ev.task_id)) {
557
+ throw new GuardrailError(`replay: task.${ev.task_id} has task.budget_increased_reservation AFTER task.budget_halt (corrupt ledger)`, {
558
+ code: 'adapter_bug',
559
+ provider: 'concurrent-dispatch',
560
+ details: {
561
+ task_id: ev.task_id,
562
+ event: 'task.budget_increased_reservation',
563
+ terminal_state: 'budget_halt',
564
+ },
565
+ });
566
+ }
567
+ const entry = perTask.get(ev.task_id);
568
+ if (!entry) {
569
+ // Log gap — the reserved event is missing. Treat the
570
+ // increase's new value as the baseline so cache reflects
571
+ // disk; resume will surface this elsewhere.
572
+ perTask.set(ev.task_id, {
573
+ task_id: ev.task_id,
574
+ reserved_usd: ev.new_reserved_usd,
575
+ released: false,
576
+ });
577
+ activeReservedMicros += usdToMicros(ev.new_reserved_usd);
578
+ break;
579
+ }
580
+ // `new_reserved_usd` is the ABSOLUTE new reservation, so we
581
+ // adjust the running total by the delta from prior, not by
582
+ // adding the new value. (Codex pass 1 flagged the prior
583
+ // comment as ambiguous; the implementation here is the
584
+ // intended semantics.)
585
+ const priorMicros = usdToMicros(entry.reserved_usd);
586
+ const newMicros = usdToMicros(ev.new_reserved_usd);
587
+ entry.reserved_usd = ev.new_reserved_usd;
588
+ activeReservedMicros += newMicros - priorMicros;
589
+ break;
590
+ }
591
+ case 'task.budget_released': {
592
+ const entry = perTask.get(ev.task_id);
593
+ if (!entry) {
594
+ // Released without reservation — record the phantom entry
595
+ // so resume can detect it. We DO count the actual cost
596
+ // against `spentMicros` because the money was spent; the
597
+ // missing reservation is a separate corruption signal.
598
+ perTask.set(ev.task_id, {
599
+ task_id: ev.task_id,
600
+ reserved_usd: 0,
601
+ released: true,
602
+ actual_cost_usd: ev.actual_cost_usd,
603
+ });
604
+ spentMicros += usdToMicros(ev.actual_cost_usd);
605
+ break;
606
+ }
607
+ // Bugbot pass 3: duplicate task.budget_released would
608
+ // double-subtract from activeReservedMicros and double-add to
609
+ // spentMicros. Fail closed — runtime release() rejects on
610
+ // `prior.released` so corrupt logs are the only path.
611
+ if (entry.released) {
612
+ throw new GuardrailError(`replay: task.${ev.task_id} has duplicate task.budget_released (corrupt ledger)`, {
613
+ code: 'adapter_bug',
614
+ provider: 'concurrent-dispatch',
615
+ details: { task_id: ev.task_id, event: 'task.budget_released' },
616
+ });
617
+ }
618
+ entry.released = true;
619
+ entry.actual_cost_usd = ev.actual_cost_usd;
620
+ // Move commitment from "active reserved" to "spent".
621
+ activeReservedMicros -= usdToMicros(entry.reserved_usd);
622
+ spentMicros += usdToMicros(ev.actual_cost_usd);
623
+ break;
624
+ }
625
+ case 'task.budget_halt': {
626
+ // Halt is a terminal record for the task. Track so later
627
+ // mutating calls can refuse (Codex pass 2 CRITICAL #1). Note
628
+ // that halt does NOT free or change accounting totals — the
629
+ // task that triggered the halt may not have ever reserved
630
+ // (over-cap reserve writes halt without reserving).
631
+ haltedTaskIds.add(ev.task_id);
632
+ break;
633
+ }
634
+ default:
635
+ // Other event types don't affect budget ledger state.
636
+ break;
637
+ }
638
+ }
639
+ return { spentMicros, activeReservedMicros, perTask, haltedTaskIds };
640
+ }
641
+ }
642
+ //# sourceMappingURL=budget-reservation.js.map