faberun 0.29.0 → 0.31.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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/campaign/campaign-brief.mjs +17 -6
  3. package/src/campaign/chain.mjs +124 -8
  4. package/src/campaign/ledger.mjs +19 -7
  5. package/src/campaign/metrics-command.mjs +68 -9
  6. package/src/campaign/metrics.mjs +75 -45
  7. package/src/campaign/record.mjs +8 -1
  8. package/src/campaign/reserve.mjs +340 -0
  9. package/src/campaign/spend.mjs +229 -0
  10. package/src/campaign/unpark.mjs +54 -1
  11. package/src/campaign/watch.mjs +314 -37
  12. package/src/contract/judge-envelope.mjs +17 -2
  13. package/src/contract/scope-findings.mjs +3 -1
  14. package/src/contract/task-packet.mjs +40 -5
  15. package/src/contract/verification.mjs +67 -1
  16. package/src/engine/aux-admission.mjs +124 -0
  17. package/src/engine/capacity.mjs +78 -0
  18. package/src/engine/dispatch.mjs +116 -23
  19. package/src/engine/failure-cause.mjs +59 -0
  20. package/src/engine/lifecycle.mjs +3 -2
  21. package/src/engine/phase-session.mjs +56 -10
  22. package/src/engine/prompts.mjs +57 -2
  23. package/src/engine/reserve-admission.mjs +99 -0
  24. package/src/engine/resume.mjs +3 -1
  25. package/src/engine/review.mjs +21 -0
  26. package/src/engine/run-command.mjs +166 -11
  27. package/src/engine/scheduler.mjs +71 -88
  28. package/src/engine/settle.mjs +15 -8
  29. package/src/engine/settlement-overlap.mjs +143 -0
  30. package/src/engine/verify.mjs +131 -7
  31. package/src/harnesses/session-metrics.mjs +112 -20
  32. package/src/notify/index.mjs +86 -10
  33. package/src/plan/freeze.mjs +55 -15
  34. package/src/plan/human-step.mjs +36 -0
  35. package/src/plan/pipeline.mjs +120 -38
  36. package/src/plan/preflight.mjs +81 -5
  37. package/src/plan/progress.mjs +101 -1
  38. package/src/plan/repo-facts.mjs +431 -55
  39. package/src/plan/resolve.mjs +13 -4
  40. package/src/plan/revise-context.mjs +108 -0
  41. package/src/plan/rounds.mjs +209 -50
  42. package/src/plan/sizing.mjs +53 -5
  43. package/src/plan/template.mjs +297 -49
  44. package/src/repo/integrate.mjs +47 -5
  45. package/src/repo/scope-closure.mjs +22 -0
  46. package/src/report/campaign-brief-estimate.mjs +47 -1
  47. package/src/report/campaign-brief.mjs +27 -0
  48. package/src/report/locale.mjs +20 -0
  49. package/src/report/metrics-report.mjs +38 -0
  50. package/src/report/progress.mjs +194 -10
  51. package/src/run/usage-windows.mjs +99 -4
  52. package/src/run/usage.mjs +69 -7
  53. package/src/web/api.mjs +179 -24
  54. package/src/web/app.mjs +27 -2
@@ -22,14 +22,30 @@
22
22
  * 1 only when a whole run had to be re-authored after a failure that
23
23
  * `resume` could not repair.
24
24
  *
25
+ * Costs are split the way the ledger separates them: the stages of a planning
26
+ * run, a judge's calls, and the worker calls that remain, with the retries the
27
+ * attempt counter records reported beside the split. Unmeasured spend stays
28
+ * unknown at every level — a class with no priced record is absent from the
29
+ * map and named in `unknownCountByClass` instead, never folded into a measured
30
+ * zero.
31
+ *
32
+ * The optional reserve a campaign configures (ADR 0011) is projected beside
33
+ * the spend: what is still held, what was released when the real charge
34
+ * arrived, and the part of a late charge that went past its reservation. A
35
+ * reservation blocks a new dispatch; it is not an absolute cap, and the
36
+ * projection says so rather than implying provider-side enforcement. All of
37
+ * that arithmetic is `spend.mjs`; this module projects the indicators.
38
+ *
25
39
  * Filesystem reads and the `faberun metrics` command's flags live in
26
40
  * `metrics-command.mjs`; keeping them separate leaves this module a pure
27
41
  * function over parsed records.
28
42
  */
29
43
 
30
44
  import { jsonObjectOf, round4, timestampMs } from "./metrics-evals.mjs";
31
- import { UNKNOWN_COST_REASONS } from "../run/usage.mjs";
32
45
  import { MAX_ATTEMPTS as NOTIFY_MAX_ATTEMPTS } from "../notify/index.mjs";
46
+ import { costByClassOf, costTotalsOf, pricedCostOf, reserveOf, retryRecordsOf } from "./spend.mjs";
47
+
48
+ /** @typedef {import("./spend.mjs").ReserveProjection} ReserveProjection */
33
49
 
34
50
  /** Node statuses that are not terminal: everything else settles a logical node. */
35
51
  const OPEN_STATUSES = new Set(["pending", "running"]);
@@ -44,8 +60,6 @@ const SETTLED_NOTIFY_STATUSES = new Set(["delivered", "no_transport", "filtered"
44
60
  /** Target latency for a terminal/attention event to carry a settled receipt (TECH-SPEC section 6). */
45
61
  const NOTIFY_TARGET_SEC = 60;
46
62
  const SECONDS_PER_HOUR = 3600;
47
- /** A usage record with no provider-reported cost is `unknown` provenance (`appendUsageRecord`). */
48
- const UNKNOWN_COST_PROVENANCE = "unknown";
49
63
 
50
64
  /** @typedef {Record<string, unknown>} JsonObject */
51
65
  /** @typedef {"down"|"up"|"informative"} Direction */
@@ -53,6 +67,7 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
53
67
  /** @typedef {Indicator & {unknownCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}} CostIndicator */
54
68
  /** @typedef {Indicator & {unknownCount: number}} JudgeCostShareIndicator */
55
69
  /** @typedef {{value: Record<string, number>|null, direction: Direction, count: number, numerator?: Record<string, number>, denominator?: Record<string, number>, excludedRunIds?: string[], missingSources?: string[]}} GroupedIndicator */
70
+ /** @typedef {GroupedIndicator & {unknownCount: number, unknownCountByClass: Record<string, number>}} ClassCostIndicator */
56
71
  /** @typedef {{atMs: number, index: number, event: JsonObject}} RunEvent */
57
72
  /** @typedef {{runId: string, id: string, status: string, attempt?: number|null, revisions?: number|null, review?: string|null, judgeIndependence?: string|null}} RunNode */
58
73
 
@@ -66,6 +81,7 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
66
81
  * campaign?: JsonObject,
67
82
  * requirements?: unknown[],
68
83
  * excludedRunIds?: string[],
84
+ * planningRunIds?: string[],
69
85
  * missingSources?: string[],
70
86
  * now?: number,
71
87
  * }} MetricsInput
@@ -80,6 +96,8 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
80
96
  * usageTokensByKind: GroupedIndicator,
81
97
  * usageTokensByKindByRuntime: GroupedIndicator,
82
98
  * usageCostUsd: CostIndicator,
99
+ * usageCostUsdByClass: ClassCostIndicator,
100
+ * retryCostUsd: CostIndicator,
83
101
  * judgeFindingRate: Indicator,
84
102
  * judgeCostShare: JudgeCostShareIndicator,
85
103
  * blockingJudgeFirstPassRate: GroupedIndicator,
@@ -98,16 +116,14 @@ const UNKNOWN_COST_PROVENANCE = "unknown";
98
116
  * @param {MetricsInput} [input]
99
117
  * @returns {CampaignMetrics}
100
118
  */
101
- export function projectMetrics({ events = [], usageRecords = [], notifications = [], nodes = [], journal = [], campaign = undefined, requirements = undefined, excludedRunIds = [], missingSources = [], now = Date.now() } = {}) {
119
+ export function projectMetrics({ events = [], usageRecords = [], notifications = [], nodes = [], journal = [], campaign = undefined, requirements = undefined, excludedRunIds = [], planningRunIds = [], missingSources = [], now = Date.now() } = {}) {
102
120
  void now;
103
121
  const eventList = events.map(jsonObjectOf).filter((event) => event !== null);
104
122
  const excluded = excludedRunIdsFor(campaign, excludedRunIds);
105
123
  const eligibleNodes = nodes.filter((node) => !excluded.includes(node.runId));
106
124
  const eligibleEvents = eventList.filter((event) => typeof event.runId !== "string" || !excluded.includes(event.runId));
107
- const eligibleUsageRecords = usageRecords.filter((record) => {
108
- const object = jsonObjectOf(record);
109
- return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
110
- });
125
+ const usageRecordList = usageRecords.map(jsonObjectOf).filter((record) => record !== null);
126
+ const eligibleUsageRecords = usageRecordList.filter((record) => typeof record.runId !== "string" || !excluded.includes(record.runId));
111
127
  const eligibleNotifications = notifications.filter((record) => {
112
128
  const object = jsonObjectOf(record);
113
129
  return object === null || typeof object.runId !== "string" || !excluded.includes(object.runId);
@@ -116,7 +132,11 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
116
132
  const runs = runsPerCampaignOf(nodes, eventList);
117
133
  const closedCheckpoints = closedCheckpointsOf(nodes);
118
134
  const span = eventSpanOf(eventList);
119
- const usage = usageTotalsOf(usageRecords);
135
+ // Spend is campaign-wide, exactly like `usageCostUsd`: a run a replacement
136
+ // took out of the rates still spent what it spent.
137
+ const usage = usageTotalsOf(usageRecordList);
138
+ const usageByClass = costByClassOf(usageRecordList, planningRunIds);
139
+ const retries = costTotalsOf(retryRecordsOf(usageRecordList));
120
140
  const judgeYield = judgeYieldOf(eligibleEvents, eligibleUsageRecords);
121
141
  const gates = blockingJudgeFirstPassRateOf(eligibleNodes, eligibleEvents);
122
142
  const notify = notifyReceiptRateOf(eligibleNotifications);
@@ -137,6 +157,17 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
137
157
  unknownCountByReason: usage.unknownCountByReason,
138
158
  unknownFractionByReason: usage.unknownFractionByReason,
139
159
  },
160
+ usageCostUsdByClass: {
161
+ ...grouped("down", usageByClass.count, usageByClass.value),
162
+ unknownCount: usageByClass.unknownCount,
163
+ unknownCountByClass: usageByClass.unknownCountByClass,
164
+ },
165
+ retryCostUsd: {
166
+ ...measured("down", retries.costCount, retries.costUsd),
167
+ unknownCount: retries.unknownCount,
168
+ unknownCountByReason: retries.unknownCountByReason,
169
+ unknownFractionByReason: retries.unknownFractionByReason,
170
+ },
140
171
  judgeFindingRate: rate("up", judgeYield.findings, judgeYield.judged, excluded),
141
172
  judgeCostShare: {
142
173
  ...measured("down", usage.costCount, usage.costUsd === null ? null : usage.judgeCostUsd / usage.costUsd),
@@ -148,7 +179,7 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
148
179
  intentToVerifiedSeconds: northStar,
149
180
  humanTouches: touches,
150
181
  };
151
- const projected = /** @type {CampaignMetrics & {effectiveJudgeIndependence: string|null}} */ (applyMissingSources(metrics, missingSources));
182
+ const projected = /** @type {CampaignMetrics & {effectiveJudgeIndependence: string|null, reserve: ReserveProjection}} */ (applyMissingSources(metrics, missingSources));
152
183
  // R36: the effective judge-independence mode the launch recorded on the run
153
184
  // snapshots, read back from the reduced node input rather than re-derived
154
185
  // from the contract or the machine config. It is metadata about the sources,
@@ -158,9 +189,21 @@ export function projectMetrics({ events = [], usageRecords = [], notifications =
158
189
  value: recordedJudgeIndependenceOf(eligibleNodes),
159
190
  enumerable: false,
160
191
  });
192
+ // The optional reserve is policy state derived from the same records, not a
193
+ // section-6 indicator: non-enumerable for the same reason, and nulled out
194
+ // when the usage source it reads was not preserved.
195
+ Object.defineProperty(projected, "reserve", {
196
+ value: reserveOf(usageRecordList, campaign, missingUsageSources(projected)),
197
+ enumerable: false,
198
+ });
161
199
  return projected;
162
200
  }
163
201
 
202
+ /** @param {CampaignMetrics} metrics @returns {string[]} */
203
+ function missingUsageSources(metrics) {
204
+ return Array.isArray(metrics.usageCostUsd.missingSources) ? metrics.usageCostUsd.missingSources : [];
205
+ }
206
+
164
207
  /**
165
208
  * The effective judge-independence mode the launch recorded on the run
166
209
  * snapshots, reduced to the `judgeIndependence` field of the node input. The
@@ -202,6 +245,8 @@ function applyMissingSources(metrics, missingSources) {
202
245
  usageTokensByKind: withMissing(metrics.usageTokensByKind, ["usage.jsonl"]),
203
246
  usageTokensByKindByRuntime: withMissing(metrics.usageTokensByKindByRuntime, ["usage.jsonl"]),
204
247
  usageCostUsd: withMissing(metrics.usageCostUsd, ["usage.jsonl"]),
248
+ usageCostUsdByClass: withMissing(metrics.usageCostUsdByClass, ["usage.jsonl"]),
249
+ retryCostUsd: withMissing(metrics.retryCostUsd, ["usage.jsonl"]),
205
250
  judgeFindingRate: withMissing(metrics.judgeFindingRate, ["events.jsonl", "usage.jsonl"]),
206
251
  judgeCostShare: withMissing(metrics.judgeCostShare, ["usage.jsonl"]),
207
252
  blockingJudgeFirstPassRate: withMissing(metrics.blockingJudgeFirstPassRate, ["nodes.json", "events.jsonl"]),
@@ -433,30 +478,24 @@ function eventSpanOf(events) {
433
478
  * Tokens by kind and total cost across every linked run's usage records, both
434
479
  * as a campaign total and broken out per runtime (TECH-SPEC section 6). A
435
480
  * record contributes exactly what it recorded: uncached input, cache-read
436
- * input and output tokens are independent totals. Cost sums only records
437
- * whose provenance is not `unknown` (`appendUsageRecord` sets `unknown`
438
- * exactly when the provider reported no cost); every other record's
439
- * invocation is counted separately rather than folded into a measured zero.
481
+ * input and output tokens are independent totals. The cost half is
482
+ * `costTotalsOf`: it sums only records whose provenance is not `unknown`
483
+ * (`appendUsageRecord` sets `unknown` exactly when the provider reported no
484
+ * cost) and counts every other record separately rather than folding it into a
485
+ * measured zero.
440
486
  *
441
- * @param {unknown[]} usageRecords
487
+ * @param {JsonObject[]} records
442
488
  * @returns {{tokensByKind: Record<string, number>, tokensByKindByRuntime: Record<string, number>, tokenCount: number, costUsd: number|null, judgeCostUsd: number, costCount: number, unknownCount: number, unknownJudgeCount: number, unknownCountByReason: Record<string, number>, unknownFractionByReason: Record<string, number>}}
443
489
  */
444
- function usageTotalsOf(usageRecords) {
490
+ function usageTotalsOf(records) {
445
491
  const tokensByKind = { inputTokens: 0, cacheReadInputTokens: 0, outputTokens: 0 };
446
492
  /** @type {Record<string, number>} */
447
493
  const tokensByKindByRuntime = {};
448
494
  let tokenCount = 0;
449
- let costUsd = 0;
450
495
  let judgeCostUsd = 0;
451
- let costCount = 0;
452
- let unknownCount = 0;
453
496
  let unknownJudgeCount = 0;
454
- /** @type {Record<string, number>} */
455
- const unknownCountByReason = {};
456
497
  const kinds = /** @type {("inputTokens"|"cacheReadInputTokens"|"outputTokens")[]} */ (["inputTokens", "cacheReadInputTokens", "outputTokens"]);
457
- for (const raw of usageRecords) {
458
- const record = jsonObjectOf(raw);
459
- if (record === null) continue;
498
+ for (const record of records) {
460
499
  let measuredAny = false;
461
500
  const runtime = typeof record.runtimeId === "string" && record.runtimeId !== "" ? record.runtimeId : null;
462
501
  for (const kind of kinds) {
@@ -467,34 +506,25 @@ function usageTotalsOf(usageRecords) {
467
506
  measuredAny = true;
468
507
  }
469
508
  if (measuredAny) tokenCount += 1;
470
- if (typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costProvenance !== UNKNOWN_COST_PROVENANCE) {
471
- costUsd += record.costUsd;
472
- if (record.role === "judge") judgeCostUsd += record.costUsd;
473
- costCount += 1;
474
- } else {
475
- unknownCount += 1;
476
- if (record.role === "judge") unknownJudgeCount += 1;
477
- const reason = typeof record.unknownReason === "string" && UNKNOWN_COST_REASONS.includes(record.unknownReason)
478
- ? record.unknownReason
479
- : "legacy";
480
- unknownCountByReason[reason] = (unknownCountByReason[reason] ?? 0) + 1;
481
- }
509
+ if (record.role !== "judge") continue;
510
+ // The judge share divides the same priced dollars this class split reports,
511
+ // so a judge retry stays judge cost here and counts as a retry too.
512
+ const judgeCost = pricedCostOf(record);
513
+ if (judgeCost === null) unknownJudgeCount += 1;
514
+ else judgeCostUsd += judgeCost;
482
515
  }
483
- const totalCount = costCount + unknownCount;
484
- const unknownFractionByReason = Object.fromEntries(
485
- Object.entries(unknownCountByReason).map(([reason, count]) => [reason, round4(count / totalCount)]),
486
- );
516
+ const cost = costTotalsOf(records);
487
517
  return {
488
518
  tokensByKind,
489
519
  tokensByKindByRuntime,
490
520
  tokenCount,
491
- costUsd: costCount === 0 ? null : costUsd,
521
+ costUsd: cost.costUsd,
492
522
  judgeCostUsd,
493
- costCount,
494
- unknownCount,
523
+ costCount: cost.costCount,
524
+ unknownCount: cost.unknownCount,
495
525
  unknownJudgeCount,
496
- unknownCountByReason,
497
- unknownFractionByReason,
526
+ unknownCountByReason: cost.unknownCountByReason,
527
+ unknownFractionByReason: cost.unknownFractionByReason,
498
528
  };
499
529
  }
500
530
 
@@ -11,7 +11,7 @@ import { errorCode } from "../util.mjs";
11
11
  import { join } from "node:path";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { readFileSync } from "node:fs";
14
- import { assertObject, requireId, requirePacketHash, requireString, requireText, requireTimestamp } from "../contract/assert.mjs";
14
+ import { assertObject, nonNegativeNumber, requireId, requirePacketHash, requireString, requireText, requireTimestamp } from "../contract/assert.mjs";
15
15
 
16
16
  /** @typedef {import("./index.mjs").Campaign} Campaign */
17
17
  /** @typedef {import("../notify/index.mjs").JsonObject} JsonObject */
@@ -122,6 +122,13 @@ export function validateCampaign(campaign) {
122
122
  }
123
123
  }
124
124
  if (record.landBranch !== undefined) requireText(record.landBranch, "campaign.landBranch");
125
+ // The optional balance the reserve gate gates new known-cost dispatches
126
+ // against (ADR 0011). Absent means no reserve policy at all, which must stay
127
+ // distinct from a balance of zero — zero gates every known-cost dispatch —
128
+ // so the field is never defaulted on read, and present it must be a finite
129
+ // non-negative number, because anything else either gates everything or
130
+ // reserves money the campaign does not have.
131
+ if (record.reserveUsd !== undefined) nonNegativeNumber(record.reserveUsd, "campaign.reserveUsd");
125
132
  // The ledger preservation marker is optional on read (a record written
126
133
  // before it existed reads as not preserved), but once written it must be a
127
134
  // boolean so removal can rely on the state it carries.
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The enforcement half of the optional campaign balance (ADR 0011): the one
3
+ * mutator that arms `campaign.reserveUsd` on the record, and the reservation
4
+ * store a dispatch gate holds, releases and reconciles against.
5
+ *
6
+ * The balance is one field on `campaign.json`; the reservations against it
7
+ * are runtime state, so they live in their own file under the campaign
8
+ * directory instead of in a record every mutator rewrites whole — a
9
+ * check-and-deduct cannot be atomic under a last-writer-wins whole-file
10
+ * write, and two runs' controllers dispatching under one campaign do race.
11
+ * Every state mutation therefore runs under a lock that reuses the run
12
+ * controller lock's mechanics (`../run/lock.mjs`), which brings pid-liveness
13
+ * takeover: a holder that crashed mid-reservation cannot wedge the gate shut.
14
+ *
15
+ * Known costs hold their amount before dispatch and settle to released or
16
+ * charged. A cost the provider never priced is recorded with `costUsd: null`
17
+ * and holds nothing — the exposure stays unknown instead of reading as free —
18
+ * and its reconciliation charges the real amount with no late difference,
19
+ * because there was no reservation to be late against. A charge above its
20
+ * reservation settles anyway and records the overshoot: the reserve gates new
21
+ * dispatches, it is never an absolute ceiling.
22
+ */
23
+ import { randomUUID } from "node:crypto";
24
+ import { existsSync, readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { acquire, LockBusyError } from "../run/lock.mjs";
27
+ import { writeJsonAtomic } from "../run/store.mjs";
28
+ import { nonNegativeNumber } from "../contract/assert.mjs";
29
+ import { sleepSync } from "../util.mjs";
30
+ import { CAMPAIGN_FILE } from "./layout.mjs";
31
+ import { round4 } from "./metrics-evals.mjs";
32
+ import { readCampaign } from "./record.mjs";
33
+
34
+ /** @typedef {import("./index.mjs").Campaign} Campaign */
35
+
36
+ /**
37
+ * The record as the reserve reads it: the shared `Campaign` typedef lives in
38
+ * `index.mjs`, and this module's writes and reads of the balance are typed
39
+ * against the field added here, so a record without the field reads as
40
+ * unconfigured — never as a zero.
41
+ *
42
+ * @typedef {Campaign & {reserveUsd?: number}} CampaignWithReserve
43
+ */
44
+
45
+ /** The campaign-directory subdirectory holding the reserve's lock and state. */
46
+ export const RESERVE_DIR = "reserve";
47
+ /** The reservation state file inside `RESERVE_DIR`. */
48
+ export const RESERVE_STATE_FILE = "state.json";
49
+ const RESERVE_STATE_SCHEMA_VERSION = 1;
50
+ // No measurement behind these bounds: a critical section is two atomic file
51
+ // writes, so a 5 s wait exceeds it by orders of magnitude on any machine, and
52
+ // the budget is what turns a wedged live holder into a loud throw instead of
53
+ // an unbounded queue.
54
+ const LOCK_WAIT_BUDGET_MS = 5_000;
55
+ const LOCK_RETRY_MS = 5;
56
+
57
+ /**
58
+ * One reservation against the balance. `status` is `held` from admission,
59
+ * then `released` (no charge to reconcile) or `charged` (the real cost
60
+ * arrived). A `costUsd: null` reservation holds nothing and is the unknown
61
+ * exposure ADR 0011 keeps visible until a real charge reconciles it.
62
+ *
63
+ * @typedef {{
64
+ * id: string,
65
+ * at: string,
66
+ * runId?: string,
67
+ * node?: string,
68
+ * costUsd: number|null,
69
+ * status: "held"|"released"|"charged",
70
+ * chargedUsd?: number,
71
+ * lateChargeUsd?: number|null,
72
+ * settledAt?: string,
73
+ * }} Reservation
74
+ */
75
+ /** @typedef {{schemaVersion: number, reservations: Reservation[]}} ReserveState */
76
+ /**
77
+ * @typedef {{
78
+ * armed: boolean,
79
+ * configuredUsd: number|null,
80
+ * heldUsd: number,
81
+ * chargedUsd: number,
82
+ * availableUsd: number|null,
83
+ * unknownExposureCount: number,
84
+ * reservations: Reservation[],
85
+ * }} ReserveStatus
86
+ */
87
+ /**
88
+ * @typedef {{
89
+ * admitted: boolean,
90
+ * armed: boolean,
91
+ * reservation: Reservation|null,
92
+ * availableUsd: number|null,
93
+ * }} ReserveDecision
94
+ */
95
+
96
+ /**
97
+ * The one writer of `campaign.reserveUsd` (docs/FIELD-OWNERSHIP.md): arm the
98
+ * optional balance a campaign gates new known-cost dispatches with. The
99
+ * amount is validated by the same schema that reads the record back; a closed
100
+ * campaign refuses, like every other record mutator.
101
+ *
102
+ * @param {string} campaignPath
103
+ * @param {number} reserveUsd
104
+ * @param {{at?: string}} [options]
105
+ * @returns {Campaign}
106
+ */
107
+ export function configureCampaignReserve(campaignPath, reserveUsd, { at = new Date().toISOString() } = {}) {
108
+ nonNegativeNumber(reserveUsd, "reserveUsd");
109
+ const campaign = readCampaign(campaignPath);
110
+ if (campaign.status === "closed") throw new Error(`campaign is closed: ${campaign.id}`);
111
+ /** @type {CampaignWithReserve} */
112
+ const updated = { ...campaign, reserveUsd, updatedAt: at };
113
+ writeJsonAtomic(join(campaignPath, CAMPAIGN_FILE), updated);
114
+ return updated;
115
+ }
116
+
117
+ /**
118
+ * Reserve a dispatch's cost against the campaign balance before the dispatch
119
+ * happens. A campaign with no configured balance admits without writing any
120
+ * state — the reserve is only applied where it was armed. A cost with no
121
+ * provider price (`costUsd` null) is admitted without holding anything and
122
+ * recorded as an unknown exposure, never as a zero. Atomic: the
123
+ * check-and-deduct runs under the campaign's reserve lock, so two competing
124
+ * reservations cannot both be admitted when their combined cost passes the
125
+ * available balance.
126
+ *
127
+ * @param {string} campaignPath
128
+ * @param {{runId?: string, node?: string, costUsd?: number|null, at?: string}} request
129
+ * an absent `costUsd` is an unknown cost, the same as an explicit null
130
+ * @returns {ReserveDecision}
131
+ */
132
+ export function reserveCampaignCost(campaignPath, { runId, node, costUsd, at = new Date().toISOString() } = {}) {
133
+ if (costUsd !== null && costUsd !== undefined) nonNegativeNumber(costUsd, "costUsd");
134
+ return withReserveLock(campaignPath, () => {
135
+ const configured = reserveUsdOf(readCampaign(campaignPath));
136
+ if (configured === undefined) {
137
+ return { admitted: true, armed: false, reservation: null, availableUsd: null };
138
+ }
139
+ const state = readStateFile(campaignPath);
140
+ const available = availableUsdOf(configured, state);
141
+ // Rounded on both sides so float dust cannot refuse a reservation that
142
+ // exactly empties the balance (measured shape of the failure: 100 - 30 -
143
+ // 70 lands 4.5e-15 above zero).
144
+ if (costUsd !== null && costUsd !== undefined && round4(costUsd) > round4(available)) {
145
+ return { admitted: false, armed: true, reservation: null, availableUsd: available };
146
+ }
147
+ /** @type {Reservation} */
148
+ const reservation = { id: randomUUID(), at, costUsd: costUsd ?? null, status: "held" };
149
+ if (runId !== undefined) reservation.runId = runId;
150
+ if (node !== undefined) reservation.node = node;
151
+ state.reservations.push(reservation);
152
+ writeStateFile(campaignPath, state);
153
+ const spent = reservation.costUsd === null ? 0 : reservation.costUsd;
154
+ return { admitted: true, armed: true, reservation, availableUsd: round4(available - spent) };
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Give a reservation's hold back without a charge: the gated call never
160
+ * launched, or ended with no provider cost to reconcile. Idempotent: an
161
+ * already settled reservation is returned unchanged.
162
+ *
163
+ * @param {string} campaignPath
164
+ * @param {string} reservationId
165
+ * @param {{at?: string}} [options]
166
+ * @returns {Reservation}
167
+ */
168
+ export function releaseCampaignReservation(campaignPath, reservationId, { at = new Date().toISOString() } = {}) {
169
+ return withReserveLock(campaignPath, () => {
170
+ const state = readStateFile(campaignPath);
171
+ const reservation = findReservation(state, reservationId);
172
+ if (reservation.status === "held") {
173
+ reservation.status = "released";
174
+ reservation.settledAt = at;
175
+ writeStateFile(campaignPath, state);
176
+ }
177
+ return reservation;
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Reconcile a reservation against the charge that actually arrived. A charge
183
+ * above the reservation settles anyway and records the difference as the
184
+ * late charge; a reservation of an unmeasured cost charges the real amount
185
+ * with `lateChargeUsd: null`, because there was no reservation to be late
186
+ * against. Idempotent by amount: reconciling the same charge twice returns
187
+ * the settled reservation instead of charging twice, and a different amount
188
+ * for an already charged reservation refuses.
189
+ *
190
+ * @param {string} campaignPath
191
+ * @param {string} reservationId
192
+ * @param {number|null} costUsd a null charge is refused by the same validation
193
+ * that reads the record: reconciling must carry a real number, never another
194
+ * unknown
195
+ * @param {{at?: string}} [options]
196
+ * @returns {Reservation}
197
+ */
198
+ export function reconcileCampaignReservation(campaignPath, reservationId, costUsd, { at = new Date().toISOString() } = {}) {
199
+ const charge = nonNegativeNumber(costUsd, "costUsd");
200
+ return withReserveLock(campaignPath, () => {
201
+ const state = readStateFile(campaignPath);
202
+ const reservation = findReservation(state, reservationId);
203
+ if (reservation.status === "charged") {
204
+ if (reservation.chargedUsd === charge) return reservation;
205
+ throw new Error(`reservation ${reservationId} already charged ${reservation.chargedUsd} USD, refusing to recharge at ${charge} USD`);
206
+ }
207
+ if (reservation.status === "released") {
208
+ throw new Error(`reservation ${reservationId} was released without a charge; it cannot take one afterwards`);
209
+ }
210
+ reservation.status = "charged";
211
+ reservation.chargedUsd = charge;
212
+ reservation.lateChargeUsd = reservation.costUsd === null ? null : round4(Math.max(0, charge - reservation.costUsd));
213
+ reservation.settledAt = at;
214
+ writeStateFile(campaignPath, state);
215
+ return reservation;
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Read the reserve's current state beside the record that armed it. The
221
+ * numbers are derived from the reservation list on every read, so a state
222
+ * file written by an older admission carries no migration.
223
+ *
224
+ * @param {string} campaignPath
225
+ * @returns {ReserveStatus}
226
+ */
227
+ export function readCampaignReserve(campaignPath) {
228
+ const configured = reserveUsdOf(readCampaign(campaignPath));
229
+ const state = readStateFile(campaignPath);
230
+ let heldUsd = 0;
231
+ let chargedUsd = 0;
232
+ let unknownExposureCount = 0;
233
+ for (const reservation of state.reservations) {
234
+ if (reservation.status === "held") {
235
+ if (reservation.costUsd === null) unknownExposureCount += 1;
236
+ else heldUsd += reservation.costUsd;
237
+ } else if (reservation.status === "charged") {
238
+ chargedUsd += reservation.chargedUsd ?? 0;
239
+ }
240
+ }
241
+ return {
242
+ armed: configured !== undefined,
243
+ configuredUsd: configured ?? null,
244
+ heldUsd: round4(heldUsd),
245
+ chargedUsd: round4(chargedUsd),
246
+ availableUsd: configured === undefined ? null : availableUsdOf(configured, state),
247
+ unknownExposureCount,
248
+ reservations: state.reservations,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * The balance off the record. Read through one cast instead of spreading
254
+ * `any` over the module: the schema validates the field, and an unconfigured
255
+ * record must reach readers as `undefined`, not as a zero.
256
+ *
257
+ * @param {Campaign} campaign
258
+ * @returns {number|undefined}
259
+ */
260
+ function reserveUsdOf(campaign) {
261
+ return /** @type {number|undefined} */ (/** @type {CampaignWithReserve} */ (campaign).reserveUsd);
262
+ }
263
+
264
+ /**
265
+ * @param {number} configured
266
+ * @param {ReserveState} state
267
+ * @returns {number}
268
+ */
269
+ function availableUsdOf(configured, state) {
270
+ let held = 0;
271
+ let charged = 0;
272
+ for (const reservation of state.reservations) {
273
+ if (reservation.status === "held") {
274
+ if (reservation.costUsd !== null) held += reservation.costUsd;
275
+ } else if (reservation.status === "charged") {
276
+ charged += reservation.chargedUsd ?? 0;
277
+ }
278
+ }
279
+ return round4(configured - held - charged);
280
+ }
281
+
282
+ /**
283
+ * @template T
284
+ * @param {string} campaignPath
285
+ * @param {() => T} body
286
+ * @returns {T}
287
+ */
288
+ function withReserveLock(campaignPath, body) {
289
+ const deadline = Date.now() + LOCK_WAIT_BUDGET_MS;
290
+ for (;;) {
291
+ let lock;
292
+ try {
293
+ lock = acquire(join(campaignPath, RESERVE_DIR));
294
+ } catch (error) {
295
+ if (!(error instanceof LockBusyError) || Date.now() >= deadline) {
296
+ throw error instanceof LockBusyError
297
+ ? new Error(`campaign reserve lock still held after ${LOCK_WAIT_BUDGET_MS} ms: ${error.message}`)
298
+ : error;
299
+ }
300
+ sleepSync(LOCK_RETRY_MS);
301
+ continue;
302
+ }
303
+ try {
304
+ return body();
305
+ } finally {
306
+ lock.release();
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * @param {string} campaignPath
313
+ * @returns {ReserveState}
314
+ */
315
+ function readStateFile(campaignPath) {
316
+ const path = join(campaignPath, RESERVE_DIR, RESERVE_STATE_FILE);
317
+ if (!existsSync(path)) return { schemaVersion: RESERVE_STATE_SCHEMA_VERSION, reservations: [] };
318
+ const state = /** @type {ReserveState} */ (JSON.parse(readFileSync(path, "utf8")));
319
+ if (!Array.isArray(state.reservations)) throw new TypeError(`reserve state ${path} has no reservations array`);
320
+ return state;
321
+ }
322
+
323
+ /**
324
+ * @param {string} campaignPath
325
+ * @param {ReserveState} state
326
+ */
327
+ function writeStateFile(campaignPath, state) {
328
+ writeJsonAtomic(join(campaignPath, RESERVE_DIR, RESERVE_STATE_FILE), state);
329
+ }
330
+
331
+ /**
332
+ * @param {ReserveState} state
333
+ * @param {string} reservationId
334
+ * @returns {Reservation}
335
+ */
336
+ function findReservation(state, reservationId) {
337
+ const reservation = state.reservations.find((entry) => entry.id === reservationId);
338
+ if (reservation === undefined) throw new Error(`no reservation ${reservationId} in the campaign's reserve`);
339
+ return reservation;
340
+ }