@edgehero/pi-dispatch 4.3.1 → 5.0.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 (256) hide show
  1. package/.env.example +84 -3
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The per-run budget ledger (issue #645, durable runs): a DURABLE record of every dollar hold a run has taken, so a
3
+ * recovered run never reserves, settles or releases the same hold twice, and never releases a hold whose container
4
+ * may still be alive.
5
+ *
6
+ * Why it exists: before #645 a dollar hold (`reserveDollars`'s `{ amountMicros, keys, ledgers }`) lived only in the
7
+ * worker's memory. A worker that crashed between the reservation and the settlement left the hold counted at the
8
+ * floor in every window it touched, which is the money-safe side, but the retry then reserved AGAIN, and nothing
9
+ * anywhere remembered that the first hold existed. A recovery that wanted to release it could not know whether its
10
+ * container was still running and spending under it.
11
+ *
12
+ * One hash per run, `run:budget:<sanitized run id>` (the run mirror's `sanitizeJobId`, so every per-run key of the
13
+ * deployment spells a run the same way), with fields:
14
+ * - `slot`: "held" once the run's job-count slots are reserved (`claimSlot`), so a recovery carries them instead of
15
+ * reserving them a second time;
16
+ * - `seq`: the last container number handed out (`nextSeq`): attempt 1 is 1, attempt 2 is 2, a recovery's first
17
+ * container the next one. A container number is never reused, so a hold is addressed by it unambiguously;
18
+ * - `h<seq>`: the hold container `seq` ran under, as JSON, validated before it is written (`validHold`);
19
+ * - `s<seq>`: that hold's state, `held` and then exactly one of the terminal states `settled`, `released` or
20
+ * `settled-crashed`. A terminal state never changes again: every transition is one Lua script that moves a hold
21
+ * only FROM `held` (a compare-and-set, the idiom of dollar-budget.mjs's SETTLE_SCRIPT), so two racing callers
22
+ * cannot both move it, and nothing can move it back;
23
+ * - `at`: the injected clock at the last write, for doctor (how stale a ledger is), never for a decision.
24
+ * The hash is PEXPIREd `ttlMs` (35 days by default, past the longest dollar window a hold can sit in: a month) on
25
+ * every write, so a run that never reaches `remove` cannot leave it behind for ever.
26
+ *
27
+ * `settled-crashed` is the one state that needs a word. A crashed attempt's real spend is UNKNOWN: its transcript's
28
+ * usage is agent-writable, so it cannot be trusted to settle down from. Its hold is therefore settled AT THE FLOOR:
29
+ * the window keys already hold the whole reservation, so the settlement is bookkeeping only. Nothing is INCRBY'd and
30
+ * nothing refunded; the state change only records that this hold is finished and must never be released, settled or
31
+ * carried as a remainder by anyone. That is why this module never touches a `budget:usd:*` key itself.
32
+ *
33
+ * Every method NEVER throws on a Valkey failure (or on a client that throws synchronously): it logs the error's code
34
+ * (`run_budget_ledger_error`, with the operation, never a run id, a key or a value) and returns `{ error }`, where
35
+ * `error` is a short code. The caller decides; for every write that guards money the decision is an InfraRetry, and
36
+ * the method's JSDoc says which. A bad argument (a seq that is not a positive integer, a hold of the wrong shape) is
37
+ * also returned as `{ error }`, not thrown: the processor turns either into the same never-started retry, and a
38
+ * TypeError escaping a budget path would be retried as an unknown failure instead.
39
+ *
40
+ * `redis` is any ioredis-compatible client with `eval`, `hgetall` and `del`, injected so the error paths are testable
41
+ * with a fake; the transitions themselves are tested against a real Valkey, because the atomicity of the scripts is
42
+ * the whole argument.
43
+ */
44
+
45
+ import { DOLLAR_KEY_PREFIX } from "./dollar-budget.mjs";
46
+ import { sanitizeJobId } from "./run-history.mjs";
47
+
48
+ /** The key prefix of every run's budget ledger. */
49
+ export const RUN_BUDGET_KEY_PREFIX = "run:budget:";
50
+ /** The default TTL: 35 days, past a month window (the longest a hold's keys live), refreshed on every write. */
51
+ export const RUN_BUDGET_TTL_MS = 35 * 24 * 3600 * 1000;
52
+ /** The most window keys a recorded hold may carry: every ledger reserveDollars can be handed today fits well inside. */
53
+ export const RUN_BUDGET_MAX_KEYS = 16;
54
+ /** A hold's states. `held` is the only one a transition starts from; the other three are terminal. */
55
+ export const RUN_BUDGET_STATES = Object.freeze(["held", "settled", "released", "settled-crashed"]);
56
+
57
+ /** The ledger key of `runId`. */
58
+ export function runBudgetKeyFor(runId) {
59
+ return `${RUN_BUDGET_KEY_PREFIX}${sanitizeJobId(runId)}`;
60
+ }
61
+
62
+ // claimSlot: set `slot` only when absent. Returns 1 when this call set it, 0 when it already existed. The TTL is
63
+ // refreshed either way: a recovery claiming a carried slot is activity on the run.
64
+ const CLAIM_SCRIPT = `local set = redis.call('HSETNX', KEYS[1], 'slot', 'held')
65
+ redis.call('HSET', KEYS[1], 'at', ARGV[2])
66
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
67
+ return set`;
68
+
69
+ // recordHold: write `h<seq>` and `s<seq>` = held only when `s<seq>` is absent. A seq that already has a state (held or
70
+ // terminal) is never overwritten: overwriting a terminal state with `held` would let it be settled a second time.
71
+ const RECORD_SCRIPT = `if redis.call('HEXISTS', KEYS[1], 's' .. ARGV[1]) == 1 then return 0 end
72
+ redis.call('HSET', KEYS[1], 'h' .. ARGV[1], ARGV[2], 's' .. ARGV[1], 'held', 'at', ARGV[4])
73
+ redis.call('PEXPIRE', KEYS[1], ARGV[3])
74
+ return 1`;
75
+
76
+ // settle / release: `held` -> ARGV[2], only from `held`. HGET of an absent field is false in Lua, never 'held'.
77
+ const MOVE_SCRIPT = `if redis.call('HGET', KEYS[1], 's' .. ARGV[1]) ~= 'held' then return 0 end
78
+ redis.call('HSET', KEYS[1], 's' .. ARGV[1], ARGV[2], 'at', ARGV[4])
79
+ redis.call('PEXPIRE', KEYS[1], ARGV[3])
80
+ return 1`;
81
+
82
+ // settleCrashed: every `s<k>` == held with k < ARGV[1] becomes settled-crashed, in ONE script, so two recoveries that
83
+ // race see the holds once between them: the second runs after the first has moved them all and finds none. Returns
84
+ // the seqs it moved. The pattern is anchored and digits-only, so `slot` and `seq` (which also start with "s") never
85
+ // match. Nothing is written, and the TTL is not touched, when no hold moved: a ledger that has expired is not revived.
86
+ const CRASHED_SCRIPT = `local flat = redis.call('HGETALL', KEYS[1])
87
+ local below = tonumber(ARGV[1])
88
+ local moved = {}
89
+ for i = 1, #flat, 2 do
90
+ local k = string.match(flat[i], '^s(%d+)$')
91
+ if k and flat[i + 1] == 'held' and tonumber(k) < below then
92
+ redis.call('HSET', KEYS[1], flat[i], 'settled-crashed')
93
+ moved[#moved + 1] = tonumber(k)
94
+ end
95
+ end
96
+ if #moved > 0 then
97
+ redis.call('HSET', KEYS[1], 'at', ARGV[3])
98
+ redis.call('PEXPIRE', KEYS[1], ARGV[2])
99
+ end
100
+ return moved`;
101
+
102
+ // nextSeq: HINCRBY and the TTL in one script, so a container number is never handed out on a key with no expiry.
103
+ const SEQ_SCRIPT = `local n = redis.call('HINCRBY', KEYS[1], 'seq', 1)
104
+ redis.call('HSET', KEYS[1], 'at', ARGV[2])
105
+ redis.call('PEXPIRE', KEYS[1], ARGV[1])
106
+ return n`;
107
+
108
+ const isSeq = (n) => Number.isSafeInteger(n) && n >= 1;
109
+ const isDollarKey = (k) => typeof k === "string" && k.length > DOLLAR_KEY_PREFIX.length + 1 && k.length <= 256 && k.startsWith(`${DOLLAR_KEY_PREFIX}:`);
110
+ const isDollarPrefix = (p) => typeof p === "string" && p.length <= 256 && (p === DOLLAR_KEY_PREFIX || p.startsWith(`${DOLLAR_KEY_PREFIX}:`));
111
+
112
+ /**
113
+ * The hold as it is stored, `{ amountMicros, keys, ledgers? }`, or `null` when `hold` is not the shape
114
+ * `reserveDollars` returns: `amountMicros` a non-negative safe integer, `keys` an array of at most
115
+ * `RUN_BUDGET_MAX_KEYS` distinct `budget:usd:*` keys, and `ledgers`, when present, `[{ keyPrefix, keys }]` whose keys
116
+ * flatten, in order, to exactly `keys` (holdLedgers' grouping). Only those fields are kept: a hold is settled later by
117
+ * exactly these keys, so anything else written beside them would be a field nobody validated.
118
+ *
119
+ * `ledgers` is optional because reserveDollars' zero-amount hold (`{ amountMicros: 0, keys: [] }`) carries none.
120
+ */
121
+ export function validHold(hold) {
122
+ if (!hold || typeof hold !== "object" || Array.isArray(hold)) return null;
123
+ const { amountMicros, keys, ledgers } = hold;
124
+ if (!Number.isSafeInteger(amountMicros) || amountMicros < 0) return null;
125
+ if (!Array.isArray(keys) || keys.length > RUN_BUDGET_MAX_KEYS || !keys.every(isDollarKey)) return null;
126
+ if (new Set(keys).size !== keys.length) return null;
127
+ if (ledgers === undefined) return { amountMicros, keys: [...keys] };
128
+ if (!Array.isArray(ledgers) || ledgers.length > RUN_BUDGET_MAX_KEYS) return null;
129
+ const out = [];
130
+ for (const l of ledgers) {
131
+ if (!l || typeof l !== "object" || !isDollarPrefix(l.keyPrefix) || !Array.isArray(l.keys) || l.keys.length === 0) return null;
132
+ out.push({ keyPrefix: l.keyPrefix, keys: [...l.keys] });
133
+ }
134
+ const flat = out.flatMap((l) => l.keys);
135
+ if (flat.length !== keys.length || flat.some((k, i) => k !== keys[i])) return null;
136
+ // Each ledger's keys sit under its own prefix: a hold whose model ledger names a deployment key is not reserveDollars'.
137
+ if (!out.every((l) => l.keys.every((k) => k.startsWith(`${l.keyPrefix}:`)))) return null;
138
+ return { amountMicros, keys: [...keys], ledgers: out };
139
+ }
140
+
141
+ /**
142
+ * Build the ledger. `now` (ms since the epoch) stamps `at`; `ttlMs` is the hash's expiry, refreshed on every write;
143
+ * `log(event, fields)` receives `run_budget_ledger_error` with `{ op, code }` and never a run id, a key or a value.
144
+ * A `ttlMs` that is not a positive safe integer is a wiring defect and throws here, at construction, never per call.
145
+ */
146
+ export function makeRunBudgetLedger({ redis, now = Date.now, ttlMs = RUN_BUDGET_TTL_MS, log = () => {} } = {}) {
147
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) throw new TypeError("ttlMs must be a positive safe integer of milliseconds");
148
+ const ttl = String(ttlMs);
149
+ const at = () => String(now());
150
+
151
+ async function attempt(op, fn) {
152
+ try {
153
+ return { value: await fn() };
154
+ } catch (error) {
155
+ const code = typeof error?.code === "string" ? error.code : "valkey";
156
+ log("run_budget_ledger_error", { op, code });
157
+ return { error: code };
158
+ }
159
+ }
160
+
161
+ return {
162
+ /**
163
+ * Claim the run's job-count slot: "new" when this call claimed it (the caller reserves the job-count ledgers, as
164
+ * every job did before #645), "carried" when an earlier attempt already had (a recovery skips reserveLedgers:
165
+ * the slot it would take is the one the crashed attempt is still counted in). `{ error }` on a Valkey fault: the
166
+ * caller cannot know which, so it reserves nothing and throws a never-started InfraRetry.
167
+ */
168
+ async claimSlot(runId) {
169
+ const r = await attempt("claimSlot", () => redis.eval(CLAIM_SCRIPT, 1, runBudgetKeyFor(runId), ttl, at()));
170
+ if (r.error) return { error: r.error };
171
+ return Number(r.value) === 1 ? "new" : "carried";
172
+ },
173
+
174
+ /**
175
+ * Record the hold container `seq` runs under, as `held`, only when that seq has no record yet.
176
+ *
177
+ * THE CONTRACT: a run.recover job never runs a container without a durable hold record. The caller reserves the
178
+ * dollars, calls this, and on ANY `{ error }` (`"bad-seq"`, `"bad-hold"`, `"exists"`, or a Valkey code) releases
179
+ * the hold it just reserved (`releaseDollars`) and throws a never-started InfraRetry; the container starts only
180
+ * after `{ ok: true }`. A hold that is not recorded is a hold a later recovery cannot see, so it could neither be
181
+ * settled at the floor nor kept from a double release. `"exists"` means the seq was already used, which only a
182
+ * caller that skipped `nextSeq` can cause; the recorded hold is left exactly as it was.
183
+ */
184
+ async recordHold(runId, seq, hold) {
185
+ if (!isSeq(seq)) return { error: "bad-seq" };
186
+ const stored = validHold(hold);
187
+ if (!stored) return { error: "bad-hold" };
188
+ const r = await attempt("recordHold", () => redis.eval(RECORD_SCRIPT, 1, runBudgetKeyFor(runId), String(seq), JSON.stringify(stored), ttl, at()));
189
+ if (r.error) return { error: r.error };
190
+ return Number(r.value) === 1 ? { ok: true } : { error: "exists" };
191
+ },
192
+
193
+ /** `held` -> `settled` for container `seq`: "moved", or "not-held" when it is absent or already terminal. */
194
+ settle(runId, seq) {
195
+ return move("settle", runId, seq, "settled");
196
+ },
197
+
198
+ /**
199
+ * `held` -> `released` for container `seq`: "moved", or "not-held". Only "moved" licenses the caller to give the
200
+ * dollars back (`releaseDollars`): a "not-held" hold was already settled or released by someone else, and giving
201
+ * it back again would refund money twice.
202
+ */
203
+ release(runId, seq) {
204
+ return move("release", runId, seq, "released");
205
+ },
206
+
207
+ /**
208
+ * Settle at the floor every hold still `held` below container `belowSeq` (the holds of the run's earlier, crashed
209
+ * containers), in one atomic script: `{ settled: [seq, ...] }`, ascending, or `{ error }`. Bookkeeping only: the
210
+ * window keys keep the whole reservation, which is what a crashed attempt of unknown spend is charged.
211
+ *
212
+ * The CALLER MUST HAVE CONFIRMED those containers are dead before calling (the same host's boot reaper has
213
+ * removed them, or the running-jobs check shows none of the run's containers alive). A hold settled here can
214
+ * never be released, which is the safe direction, but a container still alive under it would keep spending
215
+ * against a hold that is already closed, and its own settlement would then find it "not-held".
216
+ *
217
+ * Exactly once: two recoveries racing each run the script whole, so the second sees the holds already moved and
218
+ * returns `{ settled: [] }`.
219
+ */
220
+ async settleCrashed(runId, belowSeq) {
221
+ if (!isSeq(belowSeq)) return { error: "bad-seq" };
222
+ const r = await attempt("settleCrashed", () => redis.eval(CRASHED_SCRIPT, 1, runBudgetKeyFor(runId), String(belowSeq), ttl, at()));
223
+ if (r.error) return { error: r.error };
224
+ const settled = (Array.isArray(r.value) ? r.value : []).map(Number).sort((a, b) => a - b);
225
+ return { settled };
226
+ },
227
+
228
+ /**
229
+ * The next container number of the run (attempt 1 is 1, attempt 2 is 2, a recovery's first container the next),
230
+ * or `{ error }`: the caller cannot record a hold without one, so it throws a never-started InfraRetry before
231
+ * reserving anything.
232
+ */
233
+ async nextSeq(runId) {
234
+ const r = await attempt("nextSeq", () => redis.eval(SEQ_SCRIPT, 1, runBudgetKeyFor(runId), ttl, at()));
235
+ if (r.error) return { error: r.error };
236
+ const n = Number(r.value);
237
+ return isSeq(n) ? n : { error: "bad-reply" };
238
+ },
239
+
240
+ /**
241
+ * The ledger as `{ slot, seq, at, holds: [{ seq, state, amountMicros }] }` (holds ascending; `slot` null when not
242
+ * claimed; `amountMicros` null for a hold record that does not parse), `{ absent: true }` when there is none, or
243
+ * `{ error }`. For doctor and tests; no decision reads it, because a read followed by a write is exactly the race
244
+ * the scripts exist to avoid.
245
+ */
246
+ async read(runId) {
247
+ const r = await attempt("read", () => redis.hgetall(runBudgetKeyFor(runId)));
248
+ if (r.error) return { error: r.error };
249
+ const h = r.value && typeof r.value === "object" ? r.value : {};
250
+ if (Object.keys(h).length === 0) return { absent: true };
251
+ const holds = [];
252
+ for (const [field, state] of Object.entries(h)) {
253
+ const m = /^s(\d+)$/.exec(field);
254
+ if (!m) continue;
255
+ const seq = Number(m[1]);
256
+ let amountMicros = null;
257
+ try {
258
+ const parsed = JSON.parse(h[`h${seq}`]);
259
+ if (Number.isSafeInteger(parsed?.amountMicros)) amountMicros = parsed.amountMicros;
260
+ } catch {
261
+ // an unparseable record reads as an unknown amount; doctor says so rather than guessing
262
+ }
263
+ holds.push({ seq, state, amountMicros });
264
+ }
265
+ holds.sort((a, b) => a.seq - b.seq);
266
+ const stamp = Number(h.at);
267
+ return { slot: h.slot ?? null, seq: Number(h.seq) || 0, at: Number.isFinite(stamp) ? stamp : null, holds };
268
+ },
269
+
270
+ /** Delete the ledger at the run's final outcome: `{ ok: true }` or `{ error }` (the TTL removes it anyway). */
271
+ async remove(runId) {
272
+ const r = await attempt("remove", () => redis.del(runBudgetKeyFor(runId)));
273
+ return r.error ? { error: r.error } : { ok: true };
274
+ },
275
+ };
276
+
277
+ async function move(op, runId, seq, to) {
278
+ if (!isSeq(seq)) return { error: "bad-seq" };
279
+ const r = await attempt(op, () => redis.eval(MOVE_SCRIPT, 1, runBudgetKeyFor(runId), String(seq), to, ttl, at()));
280
+ if (r.error) return { error: r.error };
281
+ return Number(r.value) === 1 ? "moved" : "not-held";
282
+ }
283
+ }