@edgehero/pi-dispatch 4.3.1 → 5.1.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 (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -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 +118 -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 +136 -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 +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -1,18 +1,37 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
1
+ import { CANARY_LOADS, recordedPackageCanary } from "./package-canary-record.mjs";
2
+ import { LOADED_LINE_CUT, recordedLoaded } from "./loaded-record.mjs";
3
+ import { cleanTouch, touchScopeOf } from "./touch-state.mjs";
4
+ import { ANSWER_ID_RE, ANSWER_RUN_RE } from "./answerer.mjs";
5
+ import { MODEL_WHY, providerFailureOf } from "./model-failover-protocol.mjs";
6
+ import { forkArchiveFact, recordedFork } from "./fork-job.mjs";
7
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
8
  import * as nodeFs from "node:fs";
3
9
  import { scrubCredentials } from "./redact.mjs";
4
10
  import { basename, join } from "node:path";
5
11
  import { resolveBackendName } from "./backend-registry.mjs";
12
+ import { reservedInOf } from "./dollar-budget.mjs";
6
13
  import { isForgeKind, targetSeparator } from "./forges.mjs";
7
14
  import { MODEL_REF_PATTERN as USAGE_ID_PATTERN } from "./model-ref.mjs";
15
+ import { modelListProblem } from "./model-ref.mjs";
16
+ import { MODEL_ROLE_RE } from "./model-roles.mjs";
8
17
  import { isProjectId } from "./project-id.mjs";
9
18
  import { HOST_CPUS_MAX, HOST_MEMORY_MAX_MIB, HOST_SLOTS_MAX, recordedJobSize } from "./job-size.mjs";
10
19
  import { waitArmed } from "./wait-for.mjs";
11
- import { scheduledForMillis } from "./repeat-slot.mjs";
20
+ import { secretProfileNeeded } from "./capabilities.mjs";
21
+ import { cronRunPrefixes, scheduledForMillis } from "./bullmq-internals.mjs";
12
22
  import { EARLIER_MAX, recordedEarlier } from "./run-earlier.mjs";
23
+ import { recordReasonOf } from "./record-reason.mjs";
24
+ import { WATCH_ID_RE } from "./watch-contract.mjs";
25
+ import { recordedWatchDecision } from "./watch-record.mjs";
13
26
 
14
27
  export { EARLIER_MAX, recordedEarlier };
15
28
 
29
+ /** Host-authored replay lineage only, never a path or the original task text. */
30
+ export function recordedCanary(value) {
31
+ if (!value || typeof value !== "object" || typeof value.sourceJobId !== "string" || !/^[A-Za-z0-9._:-]{1,200}$/.test(value.sourceJobId) || typeof value.role !== "string" || !MODEL_ROLE_RE.test(value.role) || value.role === "inherit" || modelListProblem([value.candidate]) !== null) return null;
32
+ return { sourceJobId: value.sourceJobId, role: value.role, candidate: value.candidate };
33
+ }
34
+
16
35
  /**
17
36
  * Durable per-run history.
18
37
  *
@@ -26,7 +45,7 @@ export { EARLIER_MAX, recordedEarlier };
26
45
  * with a fake and no disk. `makeLogSink` streams a job's raw output to a per-job `.log` and recovers the
27
46
  * turn count from a bounded tail; `makeRecordWriter` serialises a finished run to a JSON sidecar;
28
47
  * `makeFindPreviousRun` reads a scheduler's most recent prior sidecar back; `makeLogReaper` sweeps aged
29
- * `.log`/`.json` files at boot and on the retention timer (issue #292).
48
+ * `.log`/`.json` files at boot and on the retention timer (issue #292), and the writer's leftover temporary files.
30
49
  */
31
50
 
32
51
  /**
@@ -126,6 +145,17 @@ function decisiveExitLine(text) {
126
145
  return last;
127
146
  }
128
147
 
148
+ /** File-result receipt. Only the log sink's authenticated branch may hand this to the collector. */
149
+ export function parseExitAnswerDraftDigest(text) {
150
+ const line = decisiveExitLine(text);
151
+ return line?.code === 0 && /^[a-f0-9]{64}$/.test(line?.answerDraftDigest ?? "") ? line.answerDraftDigest : null;
152
+ }
153
+
154
+ export function parseExitQuestionRefDigest(text) {
155
+ const line = decisiveExitLine(text);
156
+ return line?.code === 0 && line.reason === "awaiting-input" && /^[a-f0-9]{64}$/.test(line?.questionRefDigest ?? "") ? line.questionRefDigest : null;
157
+ }
158
+
129
159
  /**
130
160
  * Recover the agent's turn count from buffered container stdout, or `null` if it is not reported.
131
161
  *
@@ -172,6 +202,7 @@ export function parseExitTurns(text) {
172
202
  * Live from the first image that declares `mcp`: an image built before the worker named them prints the same line.
173
203
  */
174
204
  export const MCP_RUNNER_REASONS = Object.freeze(["mcp-unavailable", "mcp-config-invalid", "mcp-config-mismatch", "mcp-secret-missing", "mcp-command-missing", "mcp-shadowed"]);
205
+ export const ANSWER_DRAFT_RUNNER_REASONS = Object.freeze(["answer-draft-refused"]);
175
206
  /**
176
207
  * The runner's exit-2 refusals of a job armed with `run.awaitInput` (issue #625), each before the prompt: another
177
208
  * extension registers the tool a parked run ends on (`await-input-shadowed`), or the job has no session file to park
@@ -179,7 +210,24 @@ export const MCP_RUNNER_REASONS = Object.freeze(["mcp-unavailable", "mcp-config-
179
210
  * `run.resume` and refuses that with no store). Named for MCP_RUNNER_REASONS' reason: the operator's fix is not a budget.
180
211
  */
181
212
  export const AWAIT_INPUT_RUNNER_REASONS = Object.freeze(["await-input-shadowed", "await-input-without-session"]);
182
- export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "cost-cap-unenforceable", "model-policy-unenforceable", ...MCP_RUNNER_REASONS, ...AWAIT_INPUT_RUNNER_REASONS]);
213
+ /**
214
+ * The runner's exit-2 refusal of a job armed with `run.progress` (issue #644), before the prompt: another extension
215
+ * registers `report_progress`, the job tool whose calls the worker reads as phases. Named for MCP_RUNNER_REASONS' reason.
216
+ */
217
+ export const PROGRESS_RUNNER_REASONS = Object.freeze(["report-progress-shadowed"]);
218
+ /**
219
+ * The runner's exit-2 refusal of a job whose `run.excludeExtensionTools` names a tool no staged package or overlay extension registered (issue #800), before the prompt, on a job
220
+ * the worker marked strict. Named for MCP_RUNNER_REASONS' reason: the operator's fix is a name, a stage or the extension, and not a budget.
221
+ */
222
+ export const EXCLUDE_RUNNER_REASONS = Object.freeze(["exclude-tool-not-loaded"]);
223
+ /**
224
+ * The runner's exit-2 refusals of a job armed with `run.steer` (issue #646), before the prompt: PI_STEER is set but
225
+ * `/inbox` is not a readable directory (`steer-inbox-missing`), or no exit key arrived to sign the delivery acks with
226
+ * (`steer-no-exit-key`). Both are a worker regression or a hand-run container, since the worker arms steering only
227
+ * with the mount and a key; named for MCP_RUNNER_REASONS' reason, the operator's fix is not a budget.
228
+ */
229
+ export const STEER_RUNNER_REASONS = Object.freeze(["steer-inbox-missing", "steer-no-exit-key"]);
230
+ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "model-roles-invalid", "model-roles-shadowed", "cost-cap-unenforceable", "model-policy-unenforceable", ...MCP_RUNNER_REASONS, ...EXCLUDE_RUNNER_REASONS, ...ANSWER_DRAFT_RUNNER_REASONS, ...AWAIT_INPUT_RUNNER_REASONS, ...PROGRESS_RUNNER_REASONS, ...STEER_RUNNER_REASONS, "question-group-refused", "question-group-unanswered", "approval-config-invalid", "approval-without-session", "approval-shadowed", "approval-decision-invalid"]);
183
231
 
184
232
  /**
185
233
  * The runner's reasons that ride a COMPLETED exit (`code: 0`) and that the worker acts on, closed (issue #625). One so
@@ -190,7 +238,7 @@ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap
190
238
  * only inside its exit-0 branch, so the worst a forged line can do on a real exit 0 is mark a completed run parked: the
191
239
  * worker then posts one fixed sentence and writes a marker beside a transcript the job already promoted.
192
240
  */
193
- export const RUNNER_COMPLETED_REASONS = new Set(["awaiting-input"]);
241
+ export const RUNNER_COMPLETED_REASONS = new Set(["awaiting-input", "awaiting-approval"]);
194
242
 
195
243
  /**
196
244
  * The reason off the decisive exit line (`decisiveExitLine`), or `null`: a member of `RUNNER_POLICY_REASONS`
@@ -212,12 +260,27 @@ export function parseExitReason(text) {
212
260
  return parsed?.code === 2 && RUNNER_POLICY_REASONS.has(parsed?.reason) ? parsed.reason : null;
213
261
  }
214
262
 
263
+ /** Only authenticated runner metadata, never the model's summary. The worker still checks the declared action. */
264
+ export function parseExitApproval(text) {
265
+ const line = decisiveExitLine(text);
266
+ const value = line?.approval;
267
+ if (line?.code !== 0 || line.reason !== "awaiting-approval" || !value || typeof value.action !== "string" || !/^[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?$/.test(value.action) || value.action.length > 80 || typeof value.toolCallId !== "string" || !/^[A-Za-z0-9_.:|/-]{1,256}$/.test(value.toolCallId)) return null;
268
+ return { action: value.action, toolCallId: value.toolCallId };
269
+ }
270
+
271
+ /** The audit contains host-validated identifiers only; rebuild it so tool arguments cannot add text. */
272
+ export function approvalRecordOf(value) {
273
+ if (!value || !/^[0-9a-f]{64}$/.test(value.requestId ?? "") || typeof value.action !== "string" || !/^[a-z][a-z0-9-]*(?::[a-z][a-z0-9-]*)?$/.test(value.action) || value.action.length > 80) return null;
274
+ if (value.decision !== undefined && (!["approved", "declined", "question"].includes(value.decision) || !["forge", "operator", "panel", "system"].includes(value.actorKind) || !/^(?:[0-9a-f]{64}|expiry|revocation)$/.test(value.actorId ?? "") || !Number.isSafeInteger(value.decidedAt) || value.decidedAt < 0)) return null;
275
+ return { requestId: value.requestId, action: value.action, ...(value.decision !== undefined ? { decision: value.decision, actorKind: value.actorKind, actorId: value.actorId, decidedAt: value.decidedAt } : {}) };
276
+ }
277
+
215
278
  /**
216
279
  * The cost guard's refusal rules a `cost-cap` exit line may name as its `why` (issue #507): the runner's
217
280
  * `COST_REFUSALS` (`image/runner/src/outcome.mjs`), restated because the shipped worker cannot import the runner, and
218
281
  * pinned to it by a test that reads that source. CLOSED, because the value reaches the PII-free record.
219
282
  */
220
- export const COST_CAP_WHYS = Object.freeze(["unboundable", "external", "over-cap"]);
283
+ export const COST_CAP_WHYS = Object.freeze(["unboundable", "external", "over-cap", "final-payload"]);
221
284
 
222
285
  /**
223
286
  * The `why` off the decisive exit line (`decisiveExitLine`), or `null`: a member of `COST_CAP_WHYS`, and only when that
@@ -228,6 +291,36 @@ export const COST_CAP_WHYS = Object.freeze(["unboundable", "external", "over-cap
228
291
  * only beside a `cost-cap` reason, where it becomes the record's `why`: the worst a forged line can do is name the
229
292
  * wrong rule of the three.
230
293
  */
294
+ /** Bounded refusal diagnostics: hashes and closed reasons only, never agent-authored names. */
295
+ export const MODEL_ROLE_REFUSAL_REASONS = Object.freeze(["unknown-role", "model-not-allowed", "unknown-agent", "invalid-arguments", "aborted"]);
296
+ export function modelRolesTelemetryOf(value) {
297
+ if (value === null || typeof value !== "object" || Array.isArray(value) || !Array.isArray(value.refusals) || value.refusals.length > 8) return null;
298
+ const refusals = [];
299
+ for (const row of value.refusals) {
300
+ if (row === null || typeof row !== "object" || typeof row.agentHash !== "string" || !/^[a-f0-9]{64}$/.test(row.agentHash) || !MODEL_ROLE_REFUSAL_REASONS.includes(row.reason)) return null;
301
+ refusals.push({ agentHash: row.agentHash, reason: row.reason });
302
+ }
303
+ return { refusals };
304
+ }
305
+ export function parseExitModelRoles(text) {
306
+ return modelRolesTelemetryOf(decisiveExitLine(text)?.modelRoles);
307
+ }
308
+
309
+ export function parseExitProviderFailure(text) {
310
+ const parsed = decisiveExitLine(text);
311
+ return parsed?.code === 1 ? providerFailureOf(parsed.providerFailure) : null;
312
+ }
313
+
314
+ export function parseExitReplaySafe(text) {
315
+ const parsed = decisiveExitLine(text);
316
+ return typeof parsed?.replaySafe === "boolean" ? parsed.replaySafe : null;
317
+ }
318
+
319
+ export function parseExitToolsStarted(text) {
320
+ const parsed = decisiveExitLine(text);
321
+ return typeof parsed?.toolsStarted === "boolean" ? parsed.toolsStarted : null;
322
+ }
323
+
231
324
  export function parseExitWhy(text) {
232
325
  const parsed = decisiveExitLine(text);
233
326
  if (parsed === null) return null;
@@ -409,6 +502,306 @@ export function parseExitOomKilled(text) {
409
502
  return parsed?.by === EXIT_BY_SUPERVISOR && parsed?.code === 137 && parsed?.reason === EXIT_OOM_KILLED && used !== null && used.oomKills !== null && used.oomKills > 0;
410
503
  }
411
504
 
505
+ /**
506
+ * The tool names the run record's `progress.tools` counts by name (issue #644): pi's built-in tools and the two job tools
507
+ * pi-dispatch offers. A tool name is model-chosen (pi records an unknown name as the model sent it), so a name enters the
508
+ * record only as a count under one of these literals, compared and never copied; every other name counts as `other`.
509
+ */
510
+ export const PROGRESS_RECORD_TOOLS = Object.freeze(["read", "bash", "edit", "write", "grep", "find", "ls", "await_human_input", "report_progress"]);
511
+ // `replayed` (issue #645): the orphaned tool calls a recovered run's runner answered by running them again.
512
+ // Issue #662: the reducer's `recoveryInterrupted` and `recoveryNotStarted` summary keys are deliberately NOT here. They feed
513
+ // the processor's `recovered` fallback; listed here they would also land in `progress`, a second copy of those counts.
514
+ const PROGRESS_COUNT_KEYS = Object.freeze(["turns", "steps", "failed", "interrupted", "replayed", "toolMs", "longestStepMs", "longestInterruptedMs", "phasesDeclared"]);
515
+
516
+ /**
517
+ * The record's `progress` (issue #644, INT-RUN-HISTORY-FILE-CONTRACT), REBUILT from what run-progress.mjs `summary`
518
+ * returned: the closed count keys, `lastPhaseIndex` beside a declared phase, and `tools` under the closed names plus
519
+ * `other`, each a non-negative safe integer, zeros left out. Undefined (the key absent) when the reducer did not run for
520
+ * the job: the switch off, no transcript file, or an older worker. A non-object is absent too; a bad value is dropped,
521
+ * never copied.
522
+ */
523
+ export function recordedProgress(p) {
524
+ if (p === null || typeof p !== "object" || Array.isArray(p)) return undefined;
525
+ const count = (v) => Number.isSafeInteger(v) && v > 0;
526
+ const out = {};
527
+ for (const key of PROGRESS_COUNT_KEYS) if (Object.hasOwn(p, key) && count(p[key])) out[key] = p[key];
528
+ if (out.phasesDeclared > 0 && Object.hasOwn(p, "lastPhaseIndex") && Number.isSafeInteger(p.lastPhaseIndex) && p.lastPhaseIndex >= 0) out.lastPhaseIndex = p.lastPhaseIndex;
529
+ const t = Object.hasOwn(p, "tools") ? p.tools : null;
530
+ if (t !== null && typeof t === "object" && !Array.isArray(t)) {
531
+ const tools = {};
532
+ for (const name of [...PROGRESS_RECORD_TOOLS, "other"]) if (Object.hasOwn(t, name) && count(t[name])) tools[name] = t[name];
533
+ if (Object.keys(tools).length > 0) out.tools = tools;
534
+ }
535
+ return out;
536
+ }
537
+
538
+ /** Where a recovered run's checkpoint came from (issue #645): workspace-checkpoint.mjs's manifest `kind`. */
539
+ export const RECOVERED_FROM = Object.freeze(["live", "exit", "salvage"]);
540
+
541
+ /**
542
+ * Why a job that armed `run.recover` ran without it (issue #645), the record's `recoverSkipped`. A fixed enum: `local`
543
+ * (recovery is for forge jobs in v1, so a local job runs as it always did), `no-run-id` (the job's queue id could not be
544
+ * derived from its data, so nothing could be keyed by it), `base-unprimed` (the checkpoint store could not take the
545
+ * fresh clone's base, or its tree could not be read, so no checkpoint of this run can be restored), `workspace-extensions` (the
546
+ * base commit holds `.pi/extensions/`, whose imports could load agent-written code in a recovered run).
547
+ */
548
+ export const RECOVER_SKIPPED_REASONS = Object.freeze(["local", "no-run-id", "base-unprimed", "workspace-extensions"]);
549
+
550
+ /**
551
+ * Why a job that armed `run.steer` ran without it (issue #646), the record's `steerSkipped`. A fixed enum:
552
+ * `venue-copies` (the job's venue copies files instead of binding them, so a message written after the start would never
553
+ * reach the container), `no-exit-key` (the image does not declare `exitAuth`, so no delivery ack could be verified and
554
+ * every message would be reported late while the agent may have read it), `no-transcript` (no transcript file could be
555
+ * staged, or this worker has no tail or relay wired, so nothing could read the acks), `no-inbox` (the inbox directory
556
+ * could not be made). The job still runs, as it would without `run.steer`.
557
+ */
558
+ export const STEER_SKIPPED_REASONS = Object.freeze(["venue-copies", "no-exit-key", "no-transcript", "no-inbox"]);
559
+
560
+ /**
561
+ * Why a run had no live transcript (and so no live progress) although this worker would read one (issue #712), the
562
+ * record's `liveSkipped`. A fixed enum, `run-container.mjs`'s own named skip (`live_transcript_skipped`): `venue-copies`
563
+ * (the venue copies the job's files and does not mirror the transcript over the exec's stderr), `no-capability` (it
564
+ * would, and the job image does not declare `transcriptStderr`), `no-exit-key` (the image declares no `exitAuth`, so the
565
+ * mirrored records could not be verified). Recorded so the console and the panel can say WHY a run's transcript is
566
+ * absent instead of listing the switch, the expiry and the start as the only causes. The job still runs.
567
+ */
568
+ export const LIVE_SKIPPED_REASONS = Object.freeze(["venue-copies", "no-capability", "no-exit-key"]);
569
+
570
+ /**
571
+ * Why a job whose project has a secret bundle ran without it (issue #704), the record's `bundleSkipped`. A fixed enum:
572
+ * `manual` (a run nobody's trigger made: `pi-dispatch run`, `/dispatch run`, `dispatch_run`), `chained` (an `/outbox`
573
+ * child), `opted-out` (its trigger sets `run.secretBundle: false`), `fork` (a pull request whose head is in another
574
+ * repository, on a bundle without `forkReviews`), `head-unverified` (the forge answered 401, 403 or 404 when asked for
575
+ * the pull request's head, so whether it is a fork could not be known and the job is treated as one). The job still
576
+ * runs, without the bundle.
577
+ */
578
+ export const BUNDLE_SKIPPED_REASONS = Object.freeze(["manual", "chained", "opted-out", "fork", "head-unverified"]);
579
+
580
+ /** The most variable names the record's `bundle` lists: the bundle's own cap (triggers.mjs `SECRET_BUNDLE_VARS_MAX`). */
581
+ const BUNDLE_NAMES_MAX = 64;
582
+ const BUNDLE_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
583
+ const BUNDLE_ID = /^[A-Za-z0-9._-]{1,128}$/;
584
+
585
+ /**
586
+ * The record's `bundle` (issue #704): `{ id, variables, shadowed, placeholders?, plain?, placeholderNames? }`, the bundle's id and the
587
+ * variable NAMES the job received from it and the ones the trigger's own run.secrets shadowed, sorted, and on an
588
+ * OpenShell venue two counts (below), or null on any violation. REBUILT,
589
+ * never the source's object, and NAMES ONLY: each is held to the environment-name charset, so no value, no
590
+ * reference and no free text can reach a record by this key. The names are the operator's own, from their own
591
+ * manager, the class the record already holds in `project` and the run.secrets names doctor prints; a record says
592
+ * exactly what a run held, which is the answer to "a grant now changes outside review".
593
+ */
594
+ export function recordedBundle(b) {
595
+ if (b === null || typeof b !== "object" || Array.isArray(b)) return null;
596
+ if (typeof b.id !== "string" || !BUNDLE_ID.test(b.id)) return null;
597
+ const names = (list) => (Array.isArray(list) && list.length <= BUNDLE_NAMES_MAX && list.every((n) => typeof n === "string" && BUNDLE_VAR_NAME.test(n)) ? [...new Set(list)].sort() : null);
598
+ const variables = names(b.variables);
599
+ const shadowed = names(b.shadowed ?? []);
600
+ if (variables === null || shadowed === null) return null;
601
+ // Issue #704 part 3: on a venue whose own layer holds the credentials (both OpenShell venues), how many of the
602
+ // received variables the agent saw as placeholders and how many as values. A PAIR of integers, each 0 to the
603
+ // variable cap, or neither key: a record never says one without the other, and every other venue's record is
604
+ // byte-identical to before.
605
+ const count = (n) => Number.isSafeInteger(n) && n >= 0 && n <= BUNDLE_NAMES_MAX;
606
+ const placeholderNames = names(b.placeholderNames);
607
+ const named = placeholderNames !== null && placeholderNames.every((n) => variables.includes(n) && !shadowed.includes(n)) && placeholderNames.length <= b.placeholders;
608
+ const counted = count(b.placeholders) && count(b.plain) && b.placeholders + b.plain <= variables.length;
609
+ return { id: b.id, variables, shadowed, ...(counted ? { placeholders: b.placeholders, plain: b.plain, ...(named ? { placeholderNames } : {}) } : {}) };
610
+ }
611
+
612
+ /** The two OpenShell venues and the mode each runs in (backends.mjs `OPENSHELL_BACKENDS`, pinned by a test). */
613
+ const OPENSHELL_VENUE_MODES = Object.freeze({ openshell: "copy", "openshell-bind": "bind" });
614
+ /** The most rule hosts the record's `openshell` lists; `ruleHostCount` says how many there were in all. */
615
+ export const OPENSHELL_RULE_HOSTS_MAX = 32;
616
+ /** A rule host as the generated policy names it (openshell-policy.mjs `HOST_RE`, plus a leading `*.` for a dot entry). */
617
+ const RULE_HOST = /^(?:\*\.)?(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
618
+ /** The most providers and bound credentials an attempt can hold (backend-openshell.mjs `OPENSHELL_LIMITS`: 32 x 32). */
619
+ const OPENSHELL_COUNT_MAX = 32 * 32;
620
+ /** The most rule hosts `ruleHostCount` may claim: far above any allowlist, a bound on a number read back from a file. */
621
+ const OPENSHELL_RULE_COUNT_MAX = 65_536;
622
+
623
+ /**
624
+ * The record's `openshell` (issue #712): `{ mode, ruleHosts, ruleHostCount, providers, placeholders }`, what the
625
+ * attempt's sandbox policy admitted and bound, or null on any violation and on every other venue. REBUILT, never the
626
+ * source's object:
627
+ * - `mode`: `copy` or `bind`, and it must be the mode of the venue the job resolved to, so a venue's record cannot
628
+ * claim the other's;
629
+ * - `ruleHosts`: the policy's endpoint hosts, deduplicated, sorted, held to the host-name charset (no port, no path,
630
+ * no userinfo: a name that fails is dropped, never repaired), at most `OPENSHELL_RULE_HOSTS_MAX`;
631
+ * `ruleHostCount` the number of valid hosts before that cap, so a view can say how many it does not list;
632
+ * - `providers`: how many credential providers the sandbox was given (the job's own and one per bundle host);
633
+ * - `placeholders`: how many credential keys those providers bound (the agent saw each as a placeholder).
634
+ * ADMISSIBLE because every host is OPERATOR CONFIG: the policy is generated from egress-allowlist.conf,
635
+ * model-endpoints.json and the fixed forge host (openshell-policy.mjs `networkPolicyFor`), and a bundle host must
636
+ * already be one of those endpoints. No payload, issue, branch or repository reaches a host (the repository is a path
637
+ * in the push rule, which is not read), so the record still holds no attacker-chosen string. Counts, never a key name
638
+ * or a value.
639
+ */
640
+ export function recordedOpenshell(o, backend) {
641
+ if (o === null || typeof o !== "object" || Array.isArray(o)) return null;
642
+ if (!Object.hasOwn(OPENSHELL_VENUE_MODES, backend ?? "") || o.mode !== OPENSHELL_VENUE_MODES[backend]) return null;
643
+ if (!Array.isArray(o.ruleHosts)) return null;
644
+ const count = (n) => Number.isSafeInteger(n) && n >= 0 && n <= OPENSHELL_COUNT_MAX;
645
+ if (!count(o.providers) || !count(o.placeholders)) return null;
646
+ const hosts = [...new Set(o.ruleHosts.filter((h) => typeof h === "string" && RULE_HOST.test(h)))].sort();
647
+ // The adapter hands the whole list and no count; a record read back (the admin's rebuild) carries its own count, which
648
+ // is kept when it is an integer at least the hosts it lists, so a capped list does not shrink its total on a re-read.
649
+ const given = Number.isSafeInteger(o.ruleHostCount) && o.ruleHostCount >= 0 && o.ruleHostCount <= OPENSHELL_RULE_COUNT_MAX ? o.ruleHostCount : 0;
650
+ const listed = hosts.slice(0, OPENSHELL_RULE_HOSTS_MAX);
651
+ return { mode: o.mode, ruleHosts: listed, ruleHostCount: Math.max(hosts.length, given >= listed.length ? given : 0), providers: o.providers, placeholders: o.placeholders };
652
+ }
653
+
654
+ /** A secrets profile name as the triggers file admits it (triggers.mjs `validateSecretsProfile`), bounded here. */
655
+ const SECRETS_PROFILE_NAME = /^[A-Za-z0-9._-]{1,128}$/;
656
+
657
+ /**
658
+ * The record's `secretsProfile` (issue #712): the NAME of the resolver profile a job's `run.secrets` resolved through,
659
+ * its own `run.secretsProfile` or the implicit `default`, only for a job that armed secrets, else null. The rule is
660
+ * capabilities.mjs `secretProfileNeeded`, the one the router routes by (`secretsArmed` and `resolveSecrets` spelled
661
+ * receiver-safe; a test holds the three together), imported rather than secrets.mjs, which spawns and would pull the
662
+ * worker's runtime into every reader of this module (the admin bundle's import walks refuse it). Operator config,
663
+ * charset-checked at load and again here; never a path, a reference or a value. Whether the resolve worked is the
664
+ * record's `outcome`/`reason`.
665
+ */
666
+ export function recordedSecretsProfile(data) {
667
+ const name = secretProfileNeeded(data);
668
+ return typeof name === "string" && SECRETS_PROFILE_NAME.test(name) ? name : null;
669
+ }
670
+
671
+ /**
672
+ * The record's `steered` (issue #646): `{ delivered, late, withdrawn }`, three non-negative safe integers counted off the
673
+ * run's inbox at its final outcome (inbox-settle.mjs), or null on any violation. Rebuilt, never the source's object.
674
+ */
675
+ export function recordedSteered(s) {
676
+ if (s === null || typeof s !== "object" || Array.isArray(s)) return null;
677
+ const ok = (v) => Number.isSafeInteger(v) && v >= 0;
678
+ if (!ok(s.delivered) || !ok(s.late) || !ok(s.withdrawn)) return null;
679
+ return { delivered: s.delivered, late: s.late, withdrawn: s.withdrawn };
680
+ }
681
+
682
+ /** The most message ids an exit line's `steer.deliveredIds` may name: the inbox's own cap (steer-inbox.mjs). */
683
+ export const STEER_DELIVERED_IDS_MAX = 16;
684
+
685
+ /**
686
+ * The runner's inbox counts off an exit line (issue #646, the exit line's `steer`): `{ delivered, pending }`, each a
687
+ * non-negative safe integer or null, plus `deliveredIds` when the line names any: the ids the runner saw pi persist (32
688
+ * lowercase hex each, at most STEER_DELIVERED_IDS_MAX, deduplicated; a list that is not an array of such ids, or longer
689
+ * than the cap, is dropped WHOLE, never cut). Null when the value is not an object or carries nothing. The counts are
690
+ * advisory telemetry like `recovery`; the ids are acted on (run-container.mjs marks them delivered, review finding F3)
691
+ * only off a line the exit key SIGNED, which is the only kind runContainer hands on when a key was issued.
692
+ */
693
+ export function steerCounts(s) {
694
+ if (s === null || typeof s !== "object" || Array.isArray(s)) return null;
695
+ const out = { delivered: Object.hasOwn(s, "delivered") && isCount(s.delivered) ? s.delivered : null, pending: Object.hasOwn(s, "pending") && isCount(s.pending) ? s.pending : null };
696
+ const ids = Object.hasOwn(s, "deliveredIds") ? s.deliveredIds : undefined;
697
+ if (Array.isArray(ids) && ids.length > 0 && ids.length <= STEER_DELIVERED_IDS_MAX && ids.every((id) => typeof id === "string" && /^[0-9a-f]{32}$/.test(id))) out.deliveredIds = [...new Set(ids)];
698
+ return out.delivered === null && out.pending === null && out.deliveredIds === undefined ? null : out;
699
+ }
700
+
701
+ /** The decisive exit line's `canaryLoad` (issue #796), `loaded` or `failed`, or null for anything else. Never throws. */
702
+ export function parseExitCanaryLoad(text) {
703
+ const parsed = decisiveExitLine(text);
704
+ return CANARY_LOADS.includes(parsed?.canaryLoad) ? parsed.canaryLoad : null;
705
+ }
706
+
707
+ /**
708
+ * The decisive exit line's `loaded` as the runner wrote it (issue #800), `LOADED_LINE_CUT` when the line says the runner had
709
+ * one and no room for it (`loadedCut: true`), or null when it says neither, which is a line from an older image or from the
710
+ * supervisor. Handed on unread: every check is `rebuildLoaded`'s (loaded-record.mjs), which knows what the line is checked
711
+ * against, and the sink knows none of that. A value that is no object is still handed on, so it reads as a line that failed
712
+ * its checks and not as a line that said nothing. Never throws.
713
+ */
714
+ export function parseExitLoaded(text) {
715
+ const line = decisiveExitLine(text);
716
+ return line?.loaded ?? (line?.loadedCut === true ? LOADED_LINE_CUT : null);
717
+ }
718
+
719
+ /** The decisive exit line's `steer` block (`steerCounts`), or null. Never throws. */
720
+ export function parseExitSteer(text) {
721
+ const parsed = decisiveExitLine(text);
722
+ return parsed === null ? null : steerCounts(parsed?.steer);
723
+ }
724
+
725
+ /** The runner's recovery counts (issue #645, the exit line's `recovery`), in the record's order. */
726
+ export const RECOVERY_COUNT_KEYS = Object.freeze(["orphans", "replayed", "interrupted", "notStarted"]);
727
+
728
+ /** A recovery id is a queue job id: the charset `sanitizeJobId` keeps, plus the `:` a BullMQ id may hold. Never prose. */
729
+ const RECOVERY_OF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
730
+ const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
731
+
732
+ /**
733
+ * The runner's `recovery` block off an exit line (issue #645): `{ orphans, replayed, interrupted, notStarted }`, each a
734
+ * non-negative safe integer or null, REBUILT over RECOVERY_COUNT_KEYS, or null when the value is not an object or carries
735
+ * no count at all. A present value that is not a count is null on its own (advisory telemetry from inside the container,
736
+ * like `resources`, which no decision reads). Never throws.
737
+ */
738
+ export function recoveryCounts(r) {
739
+ if (r === null || typeof r !== "object" || Array.isArray(r)) return null;
740
+ const out = {};
741
+ for (const key of RECOVERY_COUNT_KEYS) out[key] = Object.hasOwn(r, key) && isCount(r[key]) ? r[key] : null;
742
+ return RECOVERY_COUNT_KEYS.some((key) => out[key] !== null) ? out : null;
743
+ }
744
+
745
+ /** The decisive exit line's `recovery` block (`recoveryCounts`), or null. Never throws. */
746
+ export function parseExitRecovery(text) {
747
+ const parsed = decisiveExitLine(text);
748
+ return parsed === null ? null : recoveryCounts(parsed?.recovery);
749
+ }
750
+
751
+ /**
752
+ * Where a recovered run's counts came from (issue #662), the record's `recovered.countsFrom`: `exit-line` (the runner's
753
+ * exit line, signed where the image signs it) or `transcript` (the progress reducer's reading of the recovery marks in the
754
+ * session file, the fallback when no exit line came, as from a container that was killed). The transcript is
755
+ * agent-writable, so the exit line's counts win whenever it carries a `recovery` block, and transcript counts stay
756
+ * labelled as such.
757
+ */
758
+ export const RECOVERY_COUNTS_FROM = Object.freeze(["exit-line", "transcript"]);
759
+
760
+ /**
761
+ * The record's `recovered` (issue #645), REBUILT: `{ from, n, fromAttempt, checkpointSeq, orphans, replayed, interrupted,
762
+ * notStarted }`, `from` a member of RECOVERED_FROM and every other field a non-negative safe integer or null, plus
763
+ * `countsFrom` (RECOVERY_COUNTS_FROM, issue #662) when the source names a valid one; any other value is dropped. Null when
764
+ * `from` or `n` is not valid: a recovery whose source or number is unknown is not one this record can describe.
765
+ */
766
+ export function recordedRecovered(r) {
767
+ if (r === null || typeof r !== "object" || Array.isArray(r)) return null;
768
+ if (!RECOVERED_FROM.includes(r.from) || !(Number.isSafeInteger(r.n) && r.n >= 1)) return null;
769
+ const int = (v) => (isCount(v) ? v : null);
770
+ const counts = recoveryCounts(r) ?? {};
771
+ const countsFrom = Object.hasOwn(r, "countsFrom") && RECOVERY_COUNTS_FROM.includes(r.countsFrom) ? { countsFrom: r.countsFrom } : {};
772
+ return { from: r.from, n: r.n, fromAttempt: int(r.fromAttempt), checkpointSeq: int(r.checkpointSeq), ...Object.fromEntries(RECOVERY_COUNT_KEYS.map((k) => [k, counts[k] ?? null])), ...countsFrom };
773
+ }
774
+
775
+ /**
776
+ * A recovery's counts off the progress reducer's summary (issue #662), the fallback for a missing exit line:
777
+ * `{ orphans, replayed, interrupted, notStarted }` with `orphans` the sum (each orphan takes exactly one branch,
778
+ * image/runner/src/recover.mjs), or null when the summary is absent or counts no mark at all. A zero is not taken from
779
+ * the transcript: it cannot tell "nothing was orphaned" from "killed before the runner resolved anything", and the
780
+ * record's null already says "not known".
781
+ */
782
+ export function recoveryCountsFromProgress(p) {
783
+ if (p === null || typeof p !== "object" || Array.isArray(p)) return null;
784
+ const n = (v) => (isCount(v) ? v : 0);
785
+ const replayed = n(p.replayed);
786
+ const interrupted = n(p.recoveryInterrupted);
787
+ const notStarted = n(p.recoveryNotStarted);
788
+ const orphans = replayed + interrupted + notStarted;
789
+ if (orphans === 0 || !Number.isSafeInteger(orphans)) return null;
790
+ return { orphans, replayed, interrupted, notStarted };
791
+ }
792
+
793
+ /** The record's `checkpoints` (issue #645): `{ written, failed, skipped }`, three counts, or null on any violation. */
794
+ export function recordedCheckpoints(c) {
795
+ if (c === null || typeof c !== "object" || Array.isArray(c)) return null;
796
+ if (!isCount(c.written) || !isCount(c.failed) || !isCount(c.skipped)) return null;
797
+ return { written: c.written, failed: c.failed, skipped: c.skipped };
798
+ }
799
+
800
+ /** The record's `recoveryOf` (issue #645): the base run's queue id when it matches RECOVERY_OF_RE, else null. */
801
+ export function recordedRecoveryOf(id) {
802
+ return typeof id === "string" && RECOVERY_OF_RE.test(id) ? id : null;
803
+ }
804
+
412
805
  /** The validating rebuild behind `parseExitResources` and the record's `resources`: null on any violation. */
413
806
  function rebuildResources(r) {
414
807
  if (r === null || typeof r !== "object" || Array.isArray(r)) return null;
@@ -493,9 +886,9 @@ export function parseExitTokens(text) {
493
886
  return null;
494
887
  }
495
888
 
496
- /** The ten per-row counters, in the row's serialisation order. Absent is an honest zero; anything
497
- * present must be a finite non-negative number or the whole block is refused. */
498
- const USAGE_ROW_NUMERIC_KEYS = ["calls", "input", "output", "cacheRead", "cacheWrite", "cacheWrite1h", "reasoning", "total", "cost", "unpriced"];
889
+ /** The eleven per-row counters, in the row's serialisation order. Absent is an honest zero, which is how a record from
890
+ * before `providerBilled` reads; anything present must be a finite non-negative number or the whole block is refused. */
891
+ const USAGE_ROW_NUMERIC_KEYS = ["calls", "input", "output", "cacheRead", "cacheWrite", "cacheWrite1h", "reasoning", "total", "cost", "unpriced", "providerBilled"];
499
892
 
500
893
  /**
501
894
  * The runner's per-(provider,model) usage ledger off the exit line -- `{ v, piAi, truncated, models }`
@@ -567,7 +960,7 @@ function rebuildUsage(u) {
567
960
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return null;
568
961
  nums[key] = value;
569
962
  }
570
- // The explicit 12-key literal: an unknown key on the emitted row is dropped HERE, by never
963
+ // The explicit 13-key literal: an unknown key on the emitted row is dropped HERE, by never
571
964
  // being read -- the buildRecord no-spread posture, applied one level down.
572
965
  models.push({
573
966
  provider,
@@ -582,11 +975,54 @@ function rebuildUsage(u) {
582
975
  total: nums.total,
583
976
  cost: nums.cost,
584
977
  unpriced: nums.unpriced,
978
+ providerBilled: nums.providerBilled,
585
979
  });
586
980
  }
587
981
  return { v: u.v, piAi, truncated, models };
588
982
  }
589
983
 
984
+ /**
985
+ * The run record's version (issue #761, INT-RUN-HISTORY-FILE-CONTRACT): `v`, the LAST key `buildRecord` writes. It
986
+ * rises only when an EXISTING field changes its meaning, unit or type. A new field never raises it: every addition is
987
+ * additive and nullable or a conditional key, which every reader already skips when it does not know it, so a version
988
+ * for one would only make older readers drop records they can read. A record written before `v` existed reads as 1.
989
+ */
990
+ export const RUN_RECORD_VERSION = 1;
991
+
992
+ /**
993
+ * The stable core: the fields that keep their meaning in EVERY version, so a reader that meets a record from a newer
994
+ * pi-dispatch can still say which job and attempt it is about, how it ended, when, where and for which project it ran.
995
+ * Everything else in such a record may mean something this build does not know, so a fold that adds up anything beyond
996
+ * these (money, slot time, sizes) skips the record and says how many it skipped. A change to one of these cannot be
997
+ * expressed by `v`, since every older reader reads the core of any version: it would need a record of another name.
998
+ *
999
+ * One historical exception the core cannot repair: `attempt` counted from 0 in every release before v2.0.0 (BullMQ's
1000
+ * `attemptsMade` as it was), and those records carry no `v`. A reader comparing attempts against such a record sees
1001
+ * another attempt, which fails toward the queue's own verdict.
1002
+ */
1003
+ export const RECORD_CORE_FIELDS = Object.freeze(["jobId", "attempt", "outcome", "startedAt", "endedAt", "host", "kind", "project"]);
1004
+
1005
+ /**
1006
+ * A record's version: 1 when it carries no `v` (every record before issue #761), the value itself when it is a positive
1007
+ * safe integer, and null for anything else (not a record, or a `v` no release writes). Pure; never throws.
1008
+ */
1009
+ export function recordVersionOf(record) {
1010
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return null;
1011
+ if (!Object.hasOwn(record, "v") || record.v === undefined) return 1;
1012
+ return Number.isSafeInteger(record.v) && record.v >= 1 ? record.v : null;
1013
+ }
1014
+
1015
+ /**
1016
+ * Whether this build can read every field of `record`: its version is 1 up to `supported` (this build's own,
1017
+ * `RUN_RECORD_VERSION`, unless a test passes another). A record that is not readable still has its core
1018
+ * (`RECORD_CORE_FIELDS`); a malformed `v` is not readable either, since no release wrote it and nothing says what its
1019
+ * other fields mean.
1020
+ */
1021
+ export function recordReadable(record, { supported = RUN_RECORD_VERSION } = {}) {
1022
+ const version = recordVersionOf(record);
1023
+ return version !== null && version <= supported;
1024
+ }
1025
+
590
1026
  /**
591
1027
  * Build the durable run record from the full BullMQ job wrapper and the run's outcome.
592
1028
  *
@@ -596,24 +1032,50 @@ function rebuildUsage(u) {
596
1032
  * path stay out of the record -- for local jobs only the folder's `basename` is kept, because the full
597
1033
  * path embeds the operator's OS account name.
598
1034
  *
599
- * `reason` is a fixed enum passthrough (worker-abort | over-budget | dollar-cap | allocation-cap | envelope-mismatch | portfolio-no-envelope | portfolio-snapshot-oversize | unprotected-branch |
600
- * runner-policy | provider-auth-refused | job-image-missing | egress-proxy-missing | ...), never free-form or payload text. `exitCode`, `turns`, and `budgetReserved`
1035
+ * `reason` is a token of the fixed enum (worker-abort | over-budget | dollar-cap | allocation-cap | envelope-mismatch | portfolio-no-envelope | portfolio-snapshot-oversize | unprotected-branch |
1036
+ * runner-policy | provider-auth-refused | job-image-missing | egress-proxy-missing | container-infra-exit | ...), never free-form or payload text,
1037
+ * and it comes through ONE door, `recordReasonOf` (record-reason.mjs), rather than as `source.reason` copied: an InfraRetry
1038
+ * built without a token carries its whole message as its reason, and a forge host's message names an API path, a
1039
+ * repository and a library's own words. So an error whose reason is no token records `worker-error` (a failure is never
1040
+ * reasonless), and a result's records null. `exitCode`, `turns`, and `budgetReserved`
601
1041
  * default to `null` when the outcome does not carry them, so the record shape is stable whether or not
602
1042
  * the source reports those fields.
603
1043
  */
604
- export function buildRecord({ job, result, error, startedAt, endedAt, host = null, defaultBackend = null, project = null, size = null, capacity = null, earlier = null }) {
1044
+ export function buildRecord({ job, result, error, startedAt, endedAt, host = null, defaultBackend = null, project = null, size = null, capacity = null, earlier = null, maxTurns = null, touchTracking = false }) {
605
1045
  const data = job.data ?? {};
1046
+ const fork = recordedFork(data.fork, sourceModel(result, error));
606
1047
  const kind = data.kind ?? job.name;
607
1048
  const source = result ?? error ?? {};
1049
+ const progress = recordedProgress(source.progress);
1050
+ const recoveryOf = recordedRecoveryOf(data.recoveryOf);
1051
+ const recovered = recordedRecovered(source.recovered);
1052
+ const checkpoints = recordedCheckpoints(source.checkpoints);
1053
+ // Issue #646: only for a job that armed `run.steer`, so no other record can carry either key.
1054
+ const steered = data.steer === true ? recordedSteered(source.steered) : null;
1055
+ const steerSkipped = data.steer === true && STEER_SKIPPED_REASONS.includes(source.steerSkipped) ? source.steerSkipped : null;
1056
+ // Issue #712: on every job, since any job's transcript can be skipped; absent on every venue that streams it.
1057
+ const liveSkipped = LIVE_SKIPPED_REASONS.includes(source.liveSkipped) ? source.liveSkipped : null;
1058
+ // Issue #712: the OpenShell venues' policy facts, held to the venue the job resolved to, and the secrets profile.
1059
+ const backend = resolveBackendName(data, defaultBackend);
1060
+ const openshell = recordedOpenshell(source.openshell, backend);
1061
+ const secretsProfile = recordedSecretsProfile(data);
1062
+ // Issue #704: only for a job whose project has a secret bundle, so no other record can carry either key.
1063
+ const bundle = recordedBundle(source.bundle);
1064
+ if (bundle !== null && !Object.hasOwn(OPENSHELL_VENUE_MODES, backend)) delete bundle.placeholderNames;
1065
+ const bundleSkipped = bundle === null && BUNDLE_SKIPPED_REASONS.includes(source.bundleSkipped) ? source.bundleSkipped : null;
1066
+ const loaded = recordedLoaded(source.loaded);
1067
+ const jobCreatedAt = jobCreatedAtOf(job);
608
1068
  return {
609
1069
  jobId: job.id,
610
- kind: kind ?? null,
1070
+ // Internal references can refuse before they resolve to a local execution envelope. Keep the public
1071
+ // forge/local vocabulary intact; purpose below identifies this analysis without inventing a forge kind.
1072
+ kind: kind === "question-grouping" || kind === "answer-draft" ? null : kind ?? null,
611
1073
  target: targetFor(kind, data),
612
1074
  flow: data.flow ?? null,
613
1075
  startedAt: startedAt ?? null,
614
1076
  endedAt: endedAt ?? null,
615
1077
  outcome: result ? (result.outcome ?? null) : "failed",
616
- reason: source.reason ?? null,
1078
+ reason: recordReasonOf({ result, error }),
617
1079
  exitCode: source.exitCode ?? null,
618
1080
  turns: source.turns ?? null,
619
1081
  // Token accounting (INT-RUN-HISTORY-FILE-CONTRACT): additive and nullable, an explicit literal, no
@@ -630,6 +1092,11 @@ export function buildRecord({ job, result, error, startedAt, endedAt, host = nul
630
1092
  usage: source.usage ?? null,
631
1093
  provider: source.provider ?? null,
632
1094
  model: source.model ?? null,
1095
+ ...(typeof source.modelRole === "string" && /^[a-z][a-z0-9_-]{0,31}$/.test(source.modelRole) ? { modelRole: source.modelRole } : {}),
1096
+ ...(recordedCanary(data.canary) ? { canary: recordedCanary(data.canary) } : {}),
1097
+ ...(MODEL_WHY.includes(source.modelWhy) ? { modelWhy: source.modelWhy } : {}),
1098
+ ...(providerFailureOf(source.providerFailure) ? { providerFailure: providerFailureOf(source.providerFailure) } : {}),
1099
+ ...(modelRolesTelemetryOf(source.modelRoles) ? { modelRoles: modelRolesTelemetryOf(source.modelRoles) } : {}),
633
1100
  budgetReserved: source.budgetReserved ?? null,
634
1101
  // The ATTEMPT NUMBER, 1-based: the first run of a job is 1, its retry 2 (ledger item, issue #464 round). Every
635
1102
  // record is written while the job is still processing, where BullMQ's `attemptsMade` counts the attempts FINISHED
@@ -710,14 +1177,18 @@ export function buildRecord({ job, result, error, startedAt, endedAt, host = nul
710
1177
  // operator-authored config checked against a charset at load, never payload. `null` only where no
711
1178
  // default was passed (a dependency-injection seam; `recordRun` in start.mjs always passes one) or
712
1179
  // where the job data carries an explicit null, which no producer writes and the blessed gate refuses.
713
- backend: resolveBackendName(data, defaultBackend),
1180
+ backend,
714
1181
  // The dollar reservation's outcome (issue #501, INT-RUN-HISTORY-FILE-CONTRACT). Additive, nullable, an explicit
715
1182
  // literal REBUILT here (never the source's object), TAIL position after `backend` on the same contract: field order
716
1183
  // is the serialisation order. `{ reservedMicros, settledMicros, basis, modelBasis }`: two integers of micro-dollars,
717
1184
  // one fixed token (`metered` | `floor` | `refunded` | `unreserved`), and `modelBasis`, how the per-model windows
718
1185
  // settled (`metered` | `floor` | `refunded`, null when the job held none). Null when no dollar window applied to the job (no dollar setting, or a
719
- // refusal before the reservation step), which is every record of a deployment that sets none.
1186
+ // refusal before the reservation step), which is every record of a deployment that sets none. Issue #761: then
1187
+ // `reservedIn`, `{ day?, week?, month?, valkey }`, only where the job's settled amount is in a deployment window
1188
+ // (`deploymentWindowsOf`), for doctor's reset check; absent otherwise.
720
1189
  dollars: dollarsOf(source.dollars),
1190
+ ...(recordedWatchDecision(data.watch?.decision ?? source.decision) ? { decision: recordedWatchDecision(data.watch?.decision ?? source.decision) } : {}),
1191
+ ...(approvalRecordOf(source.approval) ? { approval: approvalRecordOf(source.approval) } : {}),
721
1192
  // The refusal's detail (PR #558, the end-of-round check of #501 and #502): which rule of the reason refused the job, the same fixed token
722
1193
  // the worker's log line carries, e.g. `overlay-link` under `model-unknown`, so the drill-in can tell the
723
1194
  // deployment's file from the job's model. Additive, nullable, an explicit literal, TAIL position after `dollars`
@@ -745,6 +1216,8 @@ export function buildRecord({ job, result, error, startedAt, endedAt, host = nul
745
1216
  // carried it: an older image, one whose supervisor died too, or a venue that hides the cgroup. Produced inside the
746
1217
  // job's container, so a job can inflate every number: advisory (docs/insights.md).
747
1218
  resources: rebuildResources(source.resources),
1219
+ // Issue #796: a canary's candidate and what the runner said of it. An exact name and version, validated, and a closed word: no attacker-chosen string. Absent on every other run.
1220
+ ...(recordedPackageCanary(data.packageCanary, source.canaryLoad) ? { packageCanary: recordedPackageCanary(data.packageCanary, source.canaryLoad) } : {}),
748
1221
  // The size the job was given (issue #596, INT-RUN-HISTORY-FILE-CONTRACT): `{ memMiB, cpuCenti, source }`, resolved
749
1222
  // at the pickup gate from the limits snapshot and passed in beside `project` (start.mjs `recordRun`), so the record
750
1223
  // says what the job was promised next to what it used (`resources`). Additive, nullable, an explicit literal REBUILT
@@ -791,6 +1264,84 @@ export function buildRecord({ job, result, error, startedAt, endedAt, host = nul
791
1264
  // retry like any other (its earlier attempt's record is carried in `earlier`), so it is false there. A boolean,
792
1265
  // TAIL position after `earlier`, so the capacity report can say how many runs that is.
793
1266
  stalledRepick: Number.isInteger(job.stalledCounter) && job.stalledCounter > 0 && !(Number.isInteger(job.attemptsMade) && job.attemptsMade > 0),
1267
+ // Where the run got to (issue #644, INT-RUN-HISTORY-FILE-CONTRACT): the turns, steps, failed and interrupted steps,
1268
+ // tool time, the phase count and last phase index, and the steps per tool under a closed set of names, from the
1269
+ // worker's own reading of the transcript (run-progress.mjs). REBUILT here (`recordedProgress`), TAIL position after
1270
+ // `stalledRepick`. Integers and closed keys only: no phase name, note or tool name the model chose can reach it,
1271
+ // so the record stays PII-free by construction. Read from a file the agent can write, so advisory, like
1272
+ // `resources`. A CONDITIONAL key, unlike its neighbours: present only when the reducer ran for this job (the
1273
+ // switch on and a transcript file present), so every record of a run without it is byte-identical to before.
1274
+ ...(progress !== undefined ? { progress } : {}),
1275
+ // Durable runs (issue #645, INT-RUN-HISTORY-FILE-CONTRACT), TAIL position after `progress`, each a CONDITIONAL key
1276
+ // like it, so a record of a job that never armed `run.recover` is byte-identical to before. Integers and fixed
1277
+ // enums only, every one REBUILT here, never the source's object:
1278
+ // - `recoveryOf`: the base run's queue id, off this job's own data (a recovery job, `<runId>.r<n>`, writes its
1279
+ // own record beside the base's). The id class the record's own `jobId` already is, charset-checked;
1280
+ // - `recovered`: where the checkpoint came from (`live` | `exit` | `salvage`), the recovery number, the crashed
1281
+ // attempt's container number and checkpoint seq, and the runner's orphan counts;
1282
+ // - `recoverSkipped`: why an armed job ran without recovery (RECOVER_SKIPPED_REASONS);
1283
+ // - `checkpoints`: how many checkpoints this attempt wrote, failed and skipped.
1284
+ ...(recoveryOf !== null ? { recoveryOf } : {}),
1285
+ ...(recovered !== null ? { recovered } : {}),
1286
+ ...(RECOVER_SKIPPED_REASONS.includes(source.recoverSkipped) ? { recoverSkipped: source.recoverSkipped } : {}),
1287
+ ...(checkpoints !== null ? { checkpoints } : {}),
1288
+ ...(fork !== null ? { fork } : {}),
1289
+ ...(source.forkSource && typeof source.forkSource.saved === "boolean" ? { forkSource: forkArchiveFact(source.forkSource) } : {}),
1290
+ // Steering (issue #646, INT-RUN-HISTORY-FILE-CONTRACT), TAIL position after the durable-run keys, CONDITIONAL like
1291
+ // them and only for a job whose data says `steer: true`, so every other record is byte-identical to before:
1292
+ // - `steered`: how many of the run's messages were delivered (a verified ack), late (the run ended first) and
1293
+ // withdrawn, counted off the inbox at the final outcome. Three integers, REBUILT (`recordedSteered`);
1294
+ // - `steerSkipped`: why an armed job ran without steering (STEER_SKIPPED_REASONS), a fixed enum.
1295
+ ...(steered !== null ? { steered } : {}),
1296
+ ...(steerSkipped !== null ? { steerSkipped } : {}),
1297
+ // Project secret bundles (issue #704, INT-RUN-HISTORY-FILE-CONTRACT), TAIL position after the steering keys,
1298
+ // CONDITIONAL like them, so a record of a job whose project has no bundle is byte-identical to before:
1299
+ // - `bundle`: `{ id, variables, shadowed }`, NAMES only, REBUILT (`recordedBundle`), and on an OpenShell venue
1300
+ // `placeholders` and `plain`, two counts (issue #704 part 3);
1301
+ // - `bundleSkipped`: why the job ran without its project's bundle (BUNDLE_SKIPPED_REASONS), a fixed enum.
1302
+ ...(bundle !== null ? { bundle } : {}),
1303
+ ...(bundleSkipped !== null ? { bundleSkipped } : {}),
1304
+ // The live transcript (issue #712, INT-RUN-HISTORY-FILE-CONTRACT), TAIL position after the bundle keys, CONDITIONAL
1305
+ // like them, so a record of a run whose transcript streamed (or that no reader asked for) is byte-identical:
1306
+ // - `liveSkipped`: why the run had no live transcript (LIVE_SKIPPED_REASONS), a fixed enum.
1307
+ ...(liveSkipped !== null ? { liveSkipped } : {}),
1308
+ // OpenShell run facts and the secrets profile (issue #712, INT-RUN-HISTORY-FILE-CONTRACT), TAIL position after
1309
+ // `liveSkipped`, CONDITIONAL like it, so a record of any other venue, or of a job that armed no secrets, is
1310
+ // byte-identical to before:
1311
+ // - `openshell`: `{ mode, ruleHosts, ruleHostCount, providers, placeholders }`, REBUILT (`recordedOpenshell`),
1312
+ // host names that are operator config and counts, on the two OpenShell venues only;
1313
+ // - `secretsProfile`: the profile NAME `run.secrets` resolved through (`recordedSecretsProfile`).
1314
+ ...(openshell !== null ? { openshell } : {}),
1315
+ ...(secretsProfile !== null ? { secretsProfile } : {}),
1316
+ // Effective turn budget captured at pickup, not the settings as they stand when the run ends (#747).
1317
+ ...(Number.isSafeInteger(maxTurns) && maxTurns > 0 ? { maxTurns } : {}),
1318
+ // Both an unresolved internal reference's refusal and its resolved classifier run stay out of workload
1319
+ // rates. Keep their ordinary history and spend: analysis must not improve the rate by adding idle runs.
1320
+ ...(kind === "question-grouping" || data.purpose === "zero-touch-grouping" && data.questionGrouping ? { purpose: "zero-touch-grouping" } : {}),
1321
+ ...(data.purpose === "answer-draft" && ANSWER_RUN_RE.test(data.answerDraft?.runId ?? "") && ANSWER_ID_RE.test(data.answerDraft?.ruleId ?? "") ? { purpose: "answer-draft", answerDraft: { sourceRunId: data.answerDraft.runId, ruleId: data.answerDraft.ruleId } } : {}),
1322
+ ...(touchTracking ? { touchTrackingVersion: 1, touchScopeId: touchScopeOf(data, host) } : {}),
1323
+ ...(Array.isArray(source.touches) ? { touches: source.touches.slice(0, 16).map(cleanTouch).filter(Boolean) } : {}),
1324
+ ...(source.touchLineageSkipped === "source-unavailable" ? { touchLineageSkipped: "source-unavailable" } : {}),
1325
+ // What the run loaded (issue #800, INT-RUN-HISTORY-FILE-CONTRACT): REBUILT (`recordedLoaded`), a display no decision
1326
+ // reads. CONDITIONAL, TAIL position after the touch keys, so a refusal before the image was known, and every older
1327
+ // record, is byte-identical to what it was. Names only from what the deployment staged and configured, and counts
1328
+ // for what a repository holds. A repository extension can still forge what the line alone says, each held to a
1329
+ // closed shape (loaded-record.mjs says which and what is left).
1330
+ ...(loaded !== null ? { loaded } : {}),
1331
+ // When the job under this id was created (issue #811, INT-RUN-HISTORY-FILE-CONTRACT): BullMQ's `job.timestamp`, the
1332
+ // add's moment, kept across every retry, stall and deferral of the job, as an ISO string. An id comes back after a
1333
+ // lost queue and a hand redelivery, while an older job's touch events stay in the touch index, so the Zero touch
1334
+ // view counts an event against this run only from this time on (less the clock skew), and never from an older job
1335
+ // under the same id. Not `queuedAt` (null on a retry, a stall and a `run.waitFor` job, and the moment the job became
1336
+ // ELIGIBLE, which a delay moves later), and not the record's `startedAt` (a touch during an earlier, stalled
1337
+ // attempt is this run's and predates it). CONDITIONAL, the last key before `v`: absent when the job carries no
1338
+ // representable timestamp, and a reader then applies no bound. Additive, so `v` stays 1. A number's rendering,
1339
+ // so PII-free.
1340
+ ...(jobCreatedAt !== null ? { jobCreatedAt } : {}),
1341
+ // The record's version (issue #761, `RUN_RECORD_VERSION`), the LAST key on every record and after every conditional
1342
+ // key, so it stays last whatever a record carries and every earlier position is untouched. Unconditional: a reader
1343
+ // asks one key, and a missing one reads as 1.
1344
+ v: RUN_RECORD_VERSION,
794
1345
  };
795
1346
  }
796
1347
 
@@ -816,9 +1367,16 @@ export function newerRecord(a, b) {
816
1367
  /**
817
1368
  * What a retry's record carries forward from the record it is about to replace: that record's own `earlier`, then its
818
1369
  * own slot interval when it held a slot (`capacity` an object), each rebuilt; null when there is nothing.
1370
+ *
1371
+ * A record from a NEWER pi-dispatch (`recordReadable` false: a stalled job a newer host ran, picked up here) is read
1372
+ * by its core alone (`RECORD_CORE_FIELDS`): its own interval from `host`, `startedAt` and `endedAt`, sizes unknown, and
1373
+ * no `earlier` chain or `capacity`, whose meaning may have changed. Without `capacity` there is no telling whether it
1374
+ * held a slot, and the interval is kept anyway: a refusal before a slot is recorded as starting and ending at the moment
1375
+ * it refused, so it adds next to no slot time, while dropping the interval would lose the whole run of one that did.
819
1376
  */
820
1377
  export function earlierFrom(previous) {
821
1378
  if (previous === null || typeof previous !== "object" || Array.isArray(previous)) return null;
1379
+ if (!recordReadable(previous)) return recordedEarlier([{ host: previous.host, startedAt: previous.startedAt, endedAt: previous.endedAt }]);
822
1380
  const carried = Array.isArray(previous.earlier) ? previous.earlier : [];
823
1381
  const own = previous.capacity !== null && typeof previous.capacity === "object" && !Array.isArray(previous.capacity) ? [{ host: previous.host, startedAt: previous.startedAt, endedAt: previous.endedAt, memMiB: previous.size?.memMiB, cpuCenti: previous.size?.cpuCenti }] : [];
824
1382
  return recordedEarlier([...carried, ...own]);
@@ -849,6 +1407,15 @@ export function queuedAtOf(job) {
849
1407
  return at <= 8.64e15 ? new Date(at).toISOString() : null;
850
1408
  }
851
1409
 
1410
+ /**
1411
+ * When a job was created, as an ISO string, or null: its `timestamp` when that is a non-negative safe integer a Date can
1412
+ * hold (8.64e15 is the last), else null. The record's `jobCreatedAt` (issue #811).
1413
+ */
1414
+ export function jobCreatedAtOf(job) {
1415
+ const timestamp = job?.timestamp;
1416
+ return Number.isSafeInteger(timestamp) && timestamp >= 0 && timestamp <= 8.64e15 ? new Date(timestamp).toISOString() : null;
1417
+ }
1418
+
852
1419
  /** A record's non-negative safe integer, else null. */
853
1420
  const recordInt = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : null);
854
1421
  /** A record's budget dimension: an integer, `"off"` (the budget's `Infinity`, or the word itself), else null. */
@@ -924,11 +1491,17 @@ const DOLLAR_BASES = new Set(["metered", "floor", "refunded", "unreserved"]);
924
1491
  // How the job's MODEL windows settled (issue #502 part 6); anything else, null included, records null.
925
1492
  const MODEL_BASES = new Set(["metered", "floor", "refunded"]);
926
1493
 
927
- /** The record's `dollars`, rebuilt from named fields only, or null when the source carries none or a malformed one. */
1494
+ /**
1495
+ * The record's `dollars`, rebuilt from named fields only, or null when the source carries none or a malformed one.
1496
+ * `reservedIn` (issue #761: which deployment windows the settled amount is in, and the Valkey's endpoint tag) is kept
1497
+ * only as `reservedInOf` rebuilds it, dates and a hash, and is absent otherwise, so the record stays PII-free and a
1498
+ * record of a job that reserved in no deployment window keeps the shape it had.
1499
+ */
928
1500
  function dollarsOf(d) {
929
1501
  if (d === null || typeof d !== "object") return null;
930
1502
  if (!Number.isSafeInteger(d.reservedMicros) || d.reservedMicros < 0 || !Number.isSafeInteger(d.settledMicros) || d.settledMicros < 0 || !DOLLAR_BASES.has(d.basis)) return null;
931
- return { reservedMicros: d.reservedMicros, settledMicros: d.settledMicros, basis: d.basis, modelBasis: MODEL_BASES.has(d.modelBasis) ? d.modelBasis : null };
1503
+ const reservedIn = reservedInOf(d.reservedIn);
1504
+ return { reservedMicros: d.reservedMicros, settledMicros: d.settledMicros, basis: d.basis, modelBasis: MODEL_BASES.has(d.modelBasis) ? d.modelBasis : null, ...(reservedIn ? { reservedIn } : {}) };
932
1505
  }
933
1506
 
934
1507
  /**
@@ -952,6 +1525,7 @@ function dollarsOf(d) {
952
1525
  * starts carrying a title.
953
1526
  */
954
1527
  export function targetFor(kind, data) {
1528
+ if (kind === "watch") return typeof data.watch?.ruleId === "string" && WATCH_ID_RE.test(data.watch.ruleId) ? `watch:${data.watch.ruleId}` : null;
955
1529
  if (kind === "local") return `local:${basename(data.folder ?? "")}`;
956
1530
  if (isForgeKind(kind)) return `${data.repo}${targetSeparator(kind, data.target?.type)}${data.target?.number}`;
957
1531
  return null;
@@ -1034,7 +1608,7 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
1034
1608
  // `exitKey` (issue #545): the key this job's runner signs its exit line with, or null when none was issued (an image
1035
1609
  // that does not declare `exitAuth`, or a caller that predates the field). With a key, the exit-line fields are read
1036
1610
  // from the AUTHENTICATED lines only, and none at all reads exactly like a container that wrote no exit line.
1037
- return function openJobLog(jobId, { exitKey = null } = {}) {
1611
+ return function openJobLog(jobId, { exitKey = null, suppressCapture = false } = {}) {
1038
1612
  let tail = "";
1039
1613
  let stream = null;
1040
1614
 
@@ -1042,7 +1616,7 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
1042
1616
  try {
1043
1617
  tail += typeof chunk === "string" ? chunk : chunk.toString("utf8");
1044
1618
  if (tail.length > TAIL_CAP_BYTES) tail = tail.slice(tail.length - TAIL_CAP_BYTES);
1045
- if (!enabled) return;
1619
+ if (!enabled || suppressCapture) return;
1046
1620
  if (stream === null) {
1047
1621
  stream = fs.createWriteStream(join(logsDir, `${sanitizeJobId(jobId)}.log`), { flags: "a" });
1048
1622
  // Attach before the first write so an EPIPE/ENOSPC/EACCES surfaces as a log line rather
@@ -1067,8 +1641,15 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
1067
1641
  const exitText = keyed ? authenticExitLines(tail, exitKey) : tail;
1068
1642
  const exitAuth = exitText === "" ? "unverified" : "verified";
1069
1643
  const exitReason = parseExitReason(exitText);
1644
+ const approval = parseExitApproval(exitText);
1645
+ const answerDraftDigest = keyed && exitAuth === "verified" ? parseExitAnswerDraftDigest(exitText) : null;
1646
+ const questionRefDigest = keyed && exitAuth === "verified" ? parseExitQuestionRefDigest(exitText) : null;
1070
1647
  const exitLineCode = parseExitCode(exitText);
1071
1648
  const exitWhy = parseExitWhy(exitText);
1649
+ const modelRoles = keyed && exitAuth === "verified" ? parseExitModelRoles(exitText) : null;
1650
+ const providerFailure = keyed && exitAuth === "verified" ? parseExitProviderFailure(exitText) : null;
1651
+ const toolsStarted = keyed && exitAuth === "verified" ? parseExitToolsStarted(exitText) : null;
1652
+ const replaySafe = keyed && exitAuth === "verified" ? parseExitReplaySafe(exitText) : null;
1072
1653
  const turns = parseExitTurns(exitText);
1073
1654
  const tokens = parseExitTokens(exitText);
1074
1655
  const session = parseExitSession(exitText);
@@ -1076,6 +1657,14 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
1076
1657
  const context = parseExitContext(exitText);
1077
1658
  const resources = parseExitResources(exitText);
1078
1659
  const oomKilled = parseExitOomKilled(exitText);
1660
+ // Issue #645: a recovered run's runner counts, off the same decisive (and, with a key, signed) line.
1661
+ const recovery = parseExitRecovery(exitText);
1662
+ // Issue #646: a steer-armed runner's inbox counts, off the same line.
1663
+ const steer = parseExitSteer(exitText);
1664
+ const canaryLoad = parseExitCanaryLoad(exitText);
1665
+ // Issue #800: never off an unsigned line, a read any tool's write could feed. The signature is necessary and not
1666
+ // sufficient: what is on the line is judged by `rebuildLoaded`, because an extension shares the signer's process.
1667
+ const loaded = keyed && exitAuth === "verified" ? parseExitLoaded(exitText) : null;
1079
1668
  try {
1080
1669
  if (stream !== null) {
1081
1670
  const s = stream;
@@ -1102,13 +1691,44 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
1102
1691
  // `exitAuth` only when a key was issued, so a keyless close returns exactly the object it always did. `exitWhy`
1103
1692
  // (issue #507) only when the line named one, for the same reason.
1104
1693
  // `resources` (issue #596) only when the line carried a block, and `exitOomKilled` only when true, for the same reason.
1105
- return { turns, tokens, session, usage, context, exitReason, exitLineCode, ...(exitWhy !== null ? { exitWhy } : {}), ...(resources !== null ? { resources } : {}), ...(oomKilled ? { exitOomKilled: true } : {}), ...(keyed ? { exitAuth } : {}) };
1694
+ // `recovery` (issue #645) only when the line carried one, for the same reason, and `steer` (issue #646) the same.
1695
+ return { ...(canaryLoad !== null ? { canaryLoad } : {}), ...(loaded !== null ? { loaded } : {}), turns, tokens, session, usage, context, exitReason, exitLineCode, ...(answerDraftDigest ? { answerDraftDigest } : {}), ...(questionRefDigest ? { questionRefDigest } : {}), ...(modelRoles ? { modelRoles } : {}), ...(providerFailure ? { providerFailure } : {}), ...(toolsStarted !== null ? { toolsStarted } : {}), ...(replaySafe !== null ? { replaySafe } : {}), ...(approval ? { approval } : {}), ...(exitWhy !== null ? { exitWhy } : {}), ...(resources !== null ? { resources } : {}), ...(oomKilled ? { exitOomKilled: true } : {}), ...(recovery !== null ? { recovery } : {}), ...(steer !== null ? { steer } : {}), ...(keyed ? { exitAuth } : {}) };
1106
1696
  }
1107
1697
 
1108
1698
  return { write, close };
1109
1699
  };
1110
1700
  }
1111
1701
 
1702
+ /**
1703
+ * The name of a record's temporary file (`makeRecordWriter`): `<sanitized id>.json.<pid>.<12 hex>.tmp`, beside the
1704
+ * record it becomes. It never ends in `.json`, so no reader of the history (each lists `*.json` or opens `<id>.json`)
1705
+ * can take one for a record, and the reaper knows a crash's leftover by this pattern (`RECORD_TMP_MAX_AGE_MS`). A test
1706
+ * holds the writer's own name, made with its real `random`, to it.
1707
+ *
1708
+ * It is not this writer's alone: it is the temporary-name shape the other atomic JSON writers use too
1709
+ * (`<name>.<pid>.<12 hex>.tmp`: `writeOverlay`, the console tokens writer, the admin's pause windows writer and its
1710
+ * `replaceFile`), so their temporary file matches it if one of their files is kept in `PI_LOGS_DIR`. That is harmless:
1711
+ * the reaper removes only a temporary file older than `RECORD_TMP_MAX_AGE_MS`, and every writer of this shape is
1712
+ * synchronous, so its temporary file lives for milliseconds. One an hour old was left by a process that died between
1713
+ * its write and its rename, and nothing can still be using it.
1714
+ */
1715
+ export const RECORD_TMP_RE = /^[A-Za-z0-9._-]+\.json\.\d+\.[0-9a-f]{12}\.tmp$/;
1716
+
1717
+ /**
1718
+ * How old a record's temporary file must be before the reaper removes it: one hour. A write lives for milliseconds
1719
+ * (it is synchronous), so a file this old was left by a process that died between the write and the rename, and no
1720
+ * write can still be using it. Its own age rather than the retention window, which may be days or forever (0).
1721
+ */
1722
+ export const RECORD_TMP_MAX_AGE_MS = 60 * 60 * 1000;
1723
+
1724
+ /**
1725
+ * The in-place fallback's open flags (`makeRecordWriter`): today's `"w"` (`O_WRONLY | O_CREAT | O_TRUNC`, the same
1726
+ * default mode) plus `O_NOFOLLOW`, so a symbolic link at the record's name fails the open with ELOOP instead of being
1727
+ * written through. Windows has no `O_NOFOLLOW` (`?? 0`, as this repo's other `O_NOFOLLOW` opens read it): there it
1728
+ * opens as before.
1729
+ */
1730
+ const IN_PLACE_FLAGS = nodeFs.constants.O_WRONLY | nodeFs.constants.O_CREAT | nodeFs.constants.O_TRUNC | (nodeFs.constants.O_NOFOLLOW ?? 0);
1731
+
1112
1732
  /**
1113
1733
  * The durable record writer: serialises a finished run's `buildRecord` output to a per-job JSON sidecar.
1114
1734
  *
@@ -1118,15 +1738,35 @@ export function makeLogSink({ logsDir, enabled, fs = nodeFs, log = () => {} }) {
1118
1738
  * mirroring `makeLogSink`.
1119
1739
  *
1120
1740
  * The write is SYNCHRONOUS by design: it must complete before `process.exit(0)` on shutdown
1121
- * (`index.mjs:83`), and an async write loses that race. `fs.writeFileSync` truncates by default, so a
1122
- * re-run of the same job id overwrites -- last write wins.
1741
+ * (`index.mjs:83`), and an async write loses that race.
1742
+ *
1743
+ * WRITTEN BESIDE, THEN RENAMED OVER (issue #761). The bytes go to a temporary file of this write's own in the same
1744
+ * folder (`RECORD_TMP_RE`, created exclusively with `wx`, so a name already taken is never written through), and a
1745
+ * rename puts it in place. A rename within one folder replaces the name in one step, so a worker that dies mid-write, or
1746
+ * a backup copy taken then, finds the previous record or the new one, never a cut one. The in-place write it replaced
1747
+ * truncated the file first, so a death between the truncate and the last byte left a record no reader could parse, or
1748
+ * none. There is no fsync: a power cut can still lose a write the disk had not flushed, as it always could. A re-run of
1749
+ * the same job id still replaces the record: the last write wins, as before.
1750
+ *
1751
+ * The rename is retried once on EPERM, as `writeOverlay` does: on Windows an antivirus scanner or the indexer can hold
1752
+ * the destination for a moment. If the temporary file cannot be written or still cannot be renamed, the record is
1753
+ * written IN PLACE, the way it always was, and the temporary file is removed: the safer path never loses a record that
1754
+ * the old one would have kept (a job id long enough that the longer temporary name is refused, a folder that takes no
1755
+ * new file, a disk too full for a second copy). Only a write that takes the fallback keeps the old exposure to a crash
1756
+ * mid-write, and it is logged as `run_record_in_place { jobId, reason }`, the reason an error code, so a host that lost
1757
+ * the crash safety says so; `run_record_failed` is logged when nothing could be written for any other reason.
1758
+ *
1759
+ * The fallback opens the record's name with `O_NOFOLLOW` (`IN_PLACE_FLAGS`): a symbolic link planted at that name is
1760
+ * never written through, so a folder that refuses the rename cannot be made to overwrite a file outside it. The rename
1761
+ * replaces a link rather than following it, so the safe path never could. Such a write writes nothing and is logged as
1762
+ * `run_record_in_place` with the reason `ELOOP`, the code the open refused it with.
1123
1763
  *
1124
1764
  * The failure log carries only `jobId` and `reason`; never the record object or its serialized JSON,
1125
1765
  * which embed target/flow fields (`no-pii-in-logs`). `sanitizeJobId` is applied only at the filename
1126
- * boundary; the record body keeps the raw id. `fs` is injectable so the writer is testable with a fake
1127
- * and no disk.
1766
+ * boundary; the record body keeps the raw id. `fs` and `random` are injectable so the writer is testable with a
1767
+ * fake and no disk, and a test can aim at the temporary name.
1128
1768
  */
1129
- export function makeRecordWriter({ logsDir, fs = nodeFs, log = () => {} }) {
1769
+ export function makeRecordWriter({ logsDir, fs = nodeFs, log = () => {}, random = randomBytes }) {
1130
1770
  try {
1131
1771
  fs.mkdirSync(logsDir, { recursive: true });
1132
1772
  } catch (err) {
@@ -1134,15 +1774,57 @@ export function makeRecordWriter({ logsDir, fs = nodeFs, log = () => {} }) {
1134
1774
  }
1135
1775
 
1136
1776
  return function writeRecord(record) {
1777
+ let path;
1778
+ let data;
1137
1779
  try {
1138
1780
  // Custom: flat JSON sidecar per job over a DB/logging lib -- records are immutable, filename-keyed
1139
1781
  // by jobId, no cross-record queries; DES-RUN-HISTORY-FLAT-FILES-NO-DB; specs/interfaces.md:11
1140
1782
  // (there is deliberately no database).
1141
- const path = join(logsDir, `${sanitizeJobId(record.jobId)}.json`);
1142
- const data = `${JSON.stringify(record)}\n`;
1143
- fs.writeFileSync(path, data);
1783
+ path = join(logsDir, `${sanitizeJobId(record.jobId)}.json`);
1784
+ data = `${JSON.stringify(record)}\n`;
1144
1785
  } catch (err) {
1145
1786
  log("run_record_failed", { jobId: record?.jobId, reason: err?.message });
1787
+ return;
1788
+ }
1789
+ let tmp = null;
1790
+ const drop = () => {
1791
+ try {
1792
+ fs.unlinkSync(tmp);
1793
+ } catch {
1794
+ // already gone, or never made
1795
+ }
1796
+ };
1797
+ let why = null;
1798
+ try {
1799
+ tmp = `${path}.${process.pid}.${random(6).toString("hex")}.tmp`;
1800
+ fs.writeFileSync(tmp, data, { flag: "wx" });
1801
+ } catch (err) {
1802
+ // EEXIST: the name belongs to someone else (the exclusive create is what makes it this write's), so it is left
1803
+ // alone. Any other failure after the create (a full disk) left a file of this write's own, which goes.
1804
+ if (tmp !== null && err?.code !== "EEXIST") drop();
1805
+ why = err?.code ?? "write-error";
1806
+ }
1807
+ if (why === null) {
1808
+ try {
1809
+ try {
1810
+ fs.renameSync(tmp, path);
1811
+ } catch (err) {
1812
+ if (err?.code !== "EPERM") throw err;
1813
+ fs.renameSync(tmp, path); // single retry: a Windows AV/indexer lock is transient
1814
+ }
1815
+ return;
1816
+ } catch (err) {
1817
+ drop();
1818
+ why = err?.code ?? "rename-error";
1819
+ }
1820
+ }
1821
+ try {
1822
+ fs.writeFileSync(path, data, { flag: IN_PLACE_FLAGS });
1823
+ log("run_record_in_place", { jobId: record.jobId, reason: why });
1824
+ } catch (err) {
1825
+ // A link at the record's name, refused by `O_NOFOLLOW`: nothing was written, and the log says why by its code.
1826
+ if (err?.code === "ELOOP") log("run_record_in_place", { jobId: record.jobId, reason: "ELOOP" });
1827
+ else log("run_record_failed", { jobId: record.jobId, reason: err?.message });
1146
1828
  }
1147
1829
  };
1148
1830
  }
@@ -1178,7 +1860,7 @@ export function makeFindPreviousRun({ logsDir, fs = nodeFs }) {
1178
1860
  try {
1179
1861
  if (typeof beforeMillis !== "number" || !Number.isFinite(beforeMillis)) return null;
1180
1862
  const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1181
- const pattern = new RegExp(`^(?:${escape(sanitizeJobId(`repeat:${schedulerId}:`))}|${escape(sanitizeJobId(`manual:${schedulerId}:`))})(\\d+)\\.json$`);
1863
+ const pattern = new RegExp(`^(?:${cronRunPrefixes(schedulerId).map((prefix) => escape(sanitizeJobId(prefix))).join("|")})(\\d+)\\.json$`);
1182
1864
  let best = null;
1183
1865
  for (const name of fs.readdirSync(logsDir)) {
1184
1866
  const m = pattern.exec(name);
@@ -1220,6 +1902,11 @@ export const UNREADABLE_RECORD = Object.freeze({ unreadable: true });
1220
1902
  *
1221
1903
  * The id inside is NOT checked here: `sanitizeJobId` maps `a:b` and `a_b` to one name, and `recordVerdict` refuses
1222
1904
  * the other job's record as `id-mismatch`, which is the one place that can also say so. NEVER throws.
1905
+ *
1906
+ * A record from a newer pi-dispatch (a `v` above `RUN_RECORD_VERSION`) is returned as it is, not refused: the worker's
1907
+ * verdicts on it (`recordVerdict`, a recovery's settled check in processor.mjs) read only the stable core
1908
+ * (`RECORD_CORE_FIELDS`), which keeps its meaning in every version, and `earlierFrom` reads no more than that core of
1909
+ * one (issue #761).
1223
1910
  */
1224
1911
  export function makeReadRecord({ logsDir, fs = nodeFs }) {
1225
1912
  return function readRecord(jobId) {
@@ -1253,9 +1940,9 @@ export function makeReadRecord({ logsDir, fs = nodeFs }) {
1253
1940
  export const RECORD_CLOCK_SKEW_MS = 5 * 60 * 1000;
1254
1941
 
1255
1942
  /**
1256
- * Does this record say that THIS attempt of THIS job finished without failing? Returns `null` when it does,
1257
- * `"absent"` when there is no record, and otherwise the reason it does not, from a fixed set:
1258
- * `unreadable`, `id-mismatch`, `other-attempt`, `failed`, `older-than-job`. Pure; never throws.
1943
+ * Why `record` is not THIS job's record of `attempt`, from a fixed set (`unreadable`, `id-mismatch`, `other-attempt`,
1944
+ * `older-than-job`), or null when it is. The one rule for "this attempt's record" (`recordOfThisJob`, `recordVerdict`).
1945
+ * Pure; never throws.
1259
1946
  *
1260
1947
  * `attempt` is the 1-based attempt number the record would carry, which is BullMQ's `attemptsMade + 1` while the
1261
1948
  * job is processing (see `buildRecord`) and `attemptsMade` after BullMQ failed it (its moveToFailed adds one
@@ -1263,19 +1950,63 @@ export const RECORD_CLOCK_SKEW_MS = 5 * 60 * 1000;
1263
1950
  *
1264
1951
  * `since` is the job's creation time in millis (BullMQ's `job.timestamp`). A job id can be used again once the
1265
1952
  * old job is gone (a fixed `--trigger` id, a redelivered webhook), while its record lives for the retention
1266
- * window; a record that started before this job existed (less `RECORD_CLOCK_SKEW_MS`) belongs to the old one. A
1267
- * missing or unparseable `startedAt` is not trusted for the same reason.
1953
+ * window; a record from before this job existed (less `RECORD_CLOCK_SKEW_MS`) belongs to the old one. The record's
1954
+ * time is its `startedAt`, or its `endedAt` when it has no start: the stall listener writes a crashed attempt's
1955
+ * record with `startedAt: null` when it cannot tell that attempt's start (start.mjs `recoverStall`), and that record
1956
+ * is this job's all the same, written after the job existed. A time that does not parse is not trusted. With no
1957
+ * `since` (not a finite number) the age is not judged.
1958
+ *
1959
+ * Every field it reads is in the stable core (`RECORD_CORE_FIELDS`: `jobId`, `attempt`, `startedAt`, `endedAt`), so
1960
+ * a record from a NEWER pi-dispatch is judged like any other (issue #761). Keep it to the core.
1961
+ */
1962
+ function whyNotThisJob(record, { jobId, attempt, since }) {
1963
+ if (record === UNREADABLE_RECORD || record === null || typeof record !== "object" || Array.isArray(record)) return "unreadable";
1964
+ if (record.jobId !== jobId) return "id-mismatch";
1965
+ if (!Number.isInteger(attempt) || record.attempt !== attempt) return "other-attempt";
1966
+ if (Number.isFinite(since)) {
1967
+ const at = Date.parse(record.startedAt ?? record.endedAt ?? "");
1968
+ if (!Number.isFinite(at) || at < since - RECORD_CLOCK_SKEW_MS) return "older-than-job";
1969
+ }
1970
+ return null;
1971
+ }
1972
+
1973
+ /**
1974
+ * Is `record` THIS job's record of `attempt` (`whyNotThisJob` says what that takes: the id, the attempt, and not
1975
+ * older than the job)? Whatever its outcome. Every place that asks whether a record is a job's own asks this, so a
1976
+ * record an older job left under a reused id answers for nobody anywhere. Pure; never throws.
1977
+ */
1978
+ export function recordOfThisJob(record, { jobId, attempt, since } = {}) {
1979
+ try {
1980
+ return whyNotThisJob(record, { jobId, attempt, since }) === null;
1981
+ } catch {
1982
+ return false;
1983
+ }
1984
+ }
1985
+
1986
+ /**
1987
+ * Does this record say that THIS attempt of THIS job finished without failing? Returns `null` when it does,
1988
+ * `"absent"` when there is no record, and otherwise the reason it does not, from a fixed set:
1989
+ * `unreadable`, `id-mismatch`, `other-attempt`, `failed`, `older-than-job`. Pure; never throws.
1990
+ *
1991
+ * Whose record it is goes by `whyNotThisJob`, the rule `recordOfThisJob` shares; the answers keep their order, so a
1992
+ * record of another job or attempt is named as that before its outcome is looked at, and its age after, judged by its
1993
+ * start alone.
1268
1994
  *
1269
1995
  * `failed` (an outcome of `failed`, or none) is refused on purpose: a record written by the catch path says the run
1270
1996
  * threw, and the queue's verdict (failed, or run again) is then the right one.
1997
+ *
1998
+ * Every field it reads is in the stable core (`RECORD_CORE_FIELDS`: `jobId`, `attempt`, `outcome`, `startedAt`,
1999
+ * `endedAt`), so a record from a NEWER pi-dispatch is judged like any other (issue #761): a host that finished the run
2000
+ * on a newer release still settles it for an older host that meets the stalled job. Keep it to the core.
1271
2001
  */
1272
2002
  export function recordVerdict(record, { jobId, attempt, since } = {}) {
1273
2003
  try {
1274
2004
  if (record === null || record === undefined) return "absent";
1275
- if (record === UNREADABLE_RECORD || typeof record !== "object" || Array.isArray(record)) return "unreadable";
1276
- if (record.jobId !== jobId) return "id-mismatch";
1277
- if (!Number.isInteger(attempt) || record.attempt !== attempt) return "other-attempt";
2005
+ const whose = whyNotThisJob(record, { jobId, attempt, since });
2006
+ if (whose === "unreadable" || whose === "id-mismatch" || whose === "other-attempt") return whose;
1278
2007
  if (typeof record.outcome !== "string" || record.outcome === "failed") return "failed";
2008
+ // A record that settles an attempt is judged by its START alone, as `CONST-RETRY-INFRA-ONLY` states the gate: the
2009
+ // end fallback in `whyNotThisJob` is for the stall listener's own `failed` record, which never settles anything.
1279
2010
  if (Number.isFinite(since)) {
1280
2011
  const started = Date.parse(record.startedAt ?? "");
1281
2012
  if (!Number.isFinite(started) || started < since - RECORD_CLOCK_SKEW_MS) return "older-than-job";
@@ -1340,23 +2071,33 @@ export function makeSettledRecord({ readRecord, readMirrored = null }) {
1340
2071
  * unlink failure is caught -- logged as `log_reaper_skipped` -- and boot continues. The per-file
1341
2072
  * try/catch is what keeps one bad entry from aborting the whole sweep.
1342
2073
  *
1343
- * `retentionDays === 0` is the documented keep-forever sentinel: the sweep returns early and touches no
1344
- * file. Age comes from the file's `mtimeMs`, never from any date parsed out of the filename -- the
2074
+ * `retentionDays === 0` is the documented keep-forever sentinel: no `.log` or `.json` file is touched. Age comes
2075
+ * from the file's `mtimeMs`, never from any date parsed out of the filename -- the
1345
2076
  * on-disk mtime is the authority. The `isFile()` guard skips a stray `foo.log/` directory so a
1346
2077
  * mis-shaped entry raises no EISDIR/EPERM. `fs` and `now` are injectable so the reaper is testable with
1347
2078
  * a fake and no disk.
2079
+ *
2080
+ * A record's TEMPORARY file (issue #761, `RECORD_TMP_RE`) that a crash left between the write and the rename is swept
2081
+ * by its own age, `RECORD_TMP_MAX_AGE_MS`, and ALSO under keep-forever: the retention window is a promise about
2082
+ * records, and this file is none (no reader reads it), so keeping it forever would only let crashes pile up. A `.tmp`
2083
+ * of any other shape is never touched. One of this shape that another atomic writer left here matches too, and goes by
2084
+ * the same rule, which is harmless: every writer of that shape is synchronous, so a temporary file an hour old belongs
2085
+ * to a write that died (`RECORD_TMP_RE`).
1348
2086
  */
1349
2087
  export function makeLogReaper({ logsDir, retentionDays, fs = nodeFs, log = () => {}, now = () => Date.now() }) {
1350
2088
  return function reapLogs() {
1351
- if (retentionDays === 0) return; // keep-forever sentinel: no sweep
2089
+ const keepForever = retentionDays === 0; // keep-forever sentinel: no record or log is swept
1352
2090
  try {
1353
2091
  const names = fs.readdirSync(logsDir);
1354
- const cutoff = now() - retentionDays * 86400000;
2092
+ const at = now();
2093
+ const cutoff = at - retentionDays * 86400000;
2094
+ const tmpCutoff = at - RECORD_TMP_MAX_AGE_MS;
1355
2095
  for (const name of names) {
1356
- if (!name.endsWith(".log") && !name.endsWith(".json")) continue;
2096
+ const tmp = RECORD_TMP_RE.test(name);
2097
+ if (!tmp && (keepForever || (!name.endsWith(".log") && !name.endsWith(".json")))) continue;
1357
2098
  try {
1358
2099
  const st = fs.statSync(join(logsDir, name));
1359
- if (st.isFile() && st.mtimeMs < cutoff) {
2100
+ if (st.isFile() && st.mtimeMs < (tmp ? tmpCutoff : cutoff)) {
1360
2101
  fs.unlinkSync(join(logsDir, name));
1361
2102
  log("reaped_log", { file: name });
1362
2103
  }
@@ -1369,3 +2110,8 @@ export function makeLogReaper({ logsDir, retentionDays, fs = nodeFs, log = () =>
1369
2110
  }
1370
2111
  };
1371
2112
  }
2113
+
2114
+ function sourceModel(result, error) {
2115
+ const model = (result ?? error)?.model;
2116
+ return typeof model === "string" && USAGE_ID_PATTERN.test(model.toLowerCase()) ? model : null;
2117
+ }