@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
package/src/index.mjs CHANGED
@@ -1,28 +1,42 @@
1
+ import { resolveModelRoles } from "./model-roles.mjs";
2
+ import { SubscriptionWait } from "./subscription-wait.mjs";
3
+ import { zeroRatedFor } from "./reserve-estimate.mjs";
4
+ import { isTransientOverlayRead } from "./model-catalog.mjs";
5
+ import { checkFailoverCandidate, markModelCooldown, readModelAttempt, selectFailoverJob, writeModelAttempt } from "./model-failover.mjs";
6
+ import { modelAttemptIdOf } from "./model-attempts.mjs";
1
7
  import { availableParallelism } from "node:os";
2
8
  import { DelayedError, UnrecoverableError, Worker } from "bullmq";
3
9
  import { assertJudgedConnection, onValkeyError } from "./connection.mjs";
4
10
  import { jobContainerName } from "./backend-local.mjs";
5
11
  import { scrubCredentials } from "./redact.mjs";
12
+ import { cleanTouch } from "./touch-state.mjs";
6
13
  import { BACKEND_NOT_REGISTERED } from "./backend-registry.mjs";
7
- import { CANCEL_ACK_TTL_MS, cancelAckKey, cancelReqKey } from "./cancel-state.mjs";
8
- import { InfraRetry, NETNS_KEEPER_CRASH_LOOP, NETNS_KEEPER_NOT_HOLDING, TERMINAL_COMMENTS, runJob } from "./processor.mjs";
14
+ import { CANCEL_ACK_TTL_MS, cancelAckKey, cancelReqKey, agentCancelReqKey, agentCancelAckKey } from "./cancel-state.mjs";
15
+ import { InfraRetry, NETNS_KEEPER_CRASH_LOOP, NETNS_KEEPER_NOT_HOLDING, NewerReleaseHold, TERMINAL_COMMENTS, runJob, checkDraftAnswerSource } from "./processor.mjs";
16
+ import { CANCELLED_BEFORE_START_COMMENT, SIZE_REFUSAL_COMMENTS } from "./comment-tables.mjs";
9
17
  import { NETNS_KEEPER_YOUNG_HOLD_MAX_MS, netnsKeeperCrashLoopSentence, netnsKeeperLoopAgainSentence } from "./netns-keeper.mjs";
10
18
  import { PODMAN_RESTART_HOLD_EXPIRED, PODMAN_RESTART_HOLD_MAX_MS, PODMAN_RESTART_HOLD_RECHECK_MS } from "./runtime-observations.mjs";
11
- import { earlierFrom, targetFor } from "./run-history.mjs";
19
+ import { earlierFrom, recordOfThisJob, recordReadable, targetFor } from "./run-history.mjs";
12
20
  import { isPerMachineHost } from "./backends.mjs";
13
21
  import { hash16 } from "./fleet-lease.mjs";
14
22
  import { endpointsForModel } from "./model-endpoints.mjs";
15
23
  import { splitModelEntry } from "./model-ref.mjs";
16
24
  import { effectiveCostCapMicros } from "./money.mjs";
17
- import { dollarWindowCaps } from "./dollar-budget.mjs";
25
+ import { dollarWindowCaps, valkeyEndpointTag } from "./dollar-budget.mjs";
18
26
  import { concurrencyFor, dollarCapsFor, makeInFlight, modelDollarRows, projectDollarCapsFor, projectRowFor, rowScopeFor, scopedLedgers } from "./scoped-limits.mjs";
19
27
  import { memberScopeOf, projectOf } from "./projects.mjs";
20
28
  import { resolveJobSize } from "./job-size.mjs";
21
29
  import { BUDGET_RECHECK_MS, HOST_BUDGET_TICK_MS, NEVER_FITS_RECHECK_MS, makeHostBudget } from "./host-budget.mjs";
22
30
  import { governedDollars } from "./allocation.mjs";
23
31
  import { WAIT_AFTER_MAX_DEFAULT_MS, WAIT_INTERVAL_FLOOR_MS, afterMs, unreadableConditions, waitArmed, waitBackoffMs, waitLabel, waitProfileNames } from "./wait-for.mjs";
24
- import { makeWaitState } from "./wait-state.mjs";
32
+ import { answerOf, makeWaitState } from "./wait-state.mjs";
33
+ import { makeDeferTo } from "./defer-write.mjs";
34
+ import { jobDataNewer } from "./job-data-version.mjs";
25
35
  import { releaseSemanticWindow } from "./queue.mjs";
36
+ import { runIdOf } from "./recovery-queue.mjs";
37
+ import { isFinalOutcome, settleInbox } from "./inbox-settle.mjs";
38
+ import { makeAwaitKeeper } from "./held-settle.mjs";
39
+ import { isProjectId } from "./project-id.mjs";
26
40
 
27
41
 
28
42
  export const QUEUE = "pi-jobs";
@@ -49,11 +63,18 @@ export const REJECTED_SEEN_MAX = 1000;
49
63
  // docker daemon bounds any herd by its own concurrency, and a contended wake just re-defers.
50
64
  export const SCOPE_BUSY_RECHECK_MS = 5_000;
51
65
 
66
+ // The newer-release re-check (issue #761, DES-STORE-VERSION-WHEN-NEEDED): a job this worker cannot read, or one whose
67
+ // settings, triggers or pause windows file a newer pi-dispatch wrote, waits for this host's upgrade. Nothing here can
68
+ // end that wait but an upgrade or a rollback of the file, so it asks rarely: ten minutes keeps a fleet mid-upgrade
69
+ // from spinning on jobs it cannot run, and an upgraded worker picks the job up on its next look.
70
+ export const NEWER_RELEASE_RECHECK_MS = 600_000;
71
+
52
72
  // The endpoint-busy re-check (issue #503): a job whose model server has every slot held re-tests on a fixed
53
73
  // cadence, for the scope re-check's reason (a held slot has no natural "until"). Its own value rather than a
54
- // borrow of 5s, because nothing records WHY a job sits in the delayed set and the wake instant is the only
55
- // evidence an operator has: 7s is distinct from the scope re-check, the wait throttle floor and the supersede
56
- // re-ask, and a test keeps all four apart. Short, because a local model run is often short too.
74
+ // borrow of 5s: 7s is distinct from the scope re-check, the wait throttle floor and the supersede re-ask, and a
75
+ // test keeps all four apart. Since issue #737 every deferral records its reason (`defer-write.mjs`), and the
76
+ // distinct instants stay the evidence for a reader older than that record, and for a job whose record could not
77
+ // be written. Short, because a local model run is often short too.
57
78
  export const ENDPOINT_BUSY_RECHECK_MS = 7_000;
58
79
 
59
80
  // How long a job waits before re-asking whether a target's holder is still alive (issue #230). Reached only
@@ -73,9 +94,10 @@ const THROTTLE_ALARM = 5;
73
94
 
74
95
  // The floor under a throttled or aborted re-ask. Its own constant rather than a borrow of
75
96
  // SUPERSEDE_RECHECK_MS, which documents an unrelated concern. Deliberately NOT 5s: that is
76
- // SCOPE_BUSY_RECHECK_MS, and INT-WAIT-PROFILES-CONTRACT rests on wait deferrals being distinguishable from
77
- // scope deferrals by wake instant -- nothing records WHY a job sits in the delayed set, so the instants are
78
- // the only evidence there is. A test pins the two apart.
97
+ // SCOPE_BUSY_RECHECK_MS, and INT-WAIT-PROFILES-CONTRACT has wait deferrals distinguishable from scope
98
+ // deferrals by wake instant. The defer record (`defer-write.mjs`, issue #737) now says why a job sits in the
99
+ // delayed set; the instants remain the evidence for a reader older than it and for a record that could not be
100
+ // written. A test pins the two apart.
79
101
  const THROTTLE_FLOOR_MS = 11_000;
80
102
 
81
103
  /**
@@ -87,6 +109,13 @@ const THROTTLE_FLOOR_MS = 11_000;
87
109
  */
88
110
  const ABORT_GRACE_MS = 30_000;
89
111
 
112
+ /**
113
+ * The longest `boundAfterAbort` waits for its give-up hook (the host budget hold, and since issue #644 the live
114
+ * publisher's finish, itself bounded at 3 s plus 5 s) before it resolves anyway: a hook that hangs must not undo the
115
+ * bound this function exists for.
116
+ */
117
+ const GIVE_UP_FINISH_MS = 10_000;
118
+
90
119
  /**
91
120
  * Resolve `run` normally, but stop waiting once the abort has fired and the grace has passed.
92
121
  *
@@ -110,9 +139,21 @@ function boundAfterAbort(run, signal, job, log, graceMs = ABORT_GRACE_MS, onStop
110
139
  if (settled) return;
111
140
  settled = true;
112
141
  log("stop_did_not_take", { job: job.id, graceMs });
113
- // Issue #596, phase 2: the container may still run, so its host budget hold must outlive this job.
114
- onStopDidNotTake();
115
- resolve({ code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null });
142
+ // Issue #596, phase 2: the container may still run, so its host budget hold must outlive this job. Issue
143
+ // #644: the hook also ends the attempt's live publisher, and is awaited (bounded) BEFORE this resolves,
144
+ // because the processor removes the job directory the publisher's last read needs once it has the result.
145
+ let wait = null;
146
+ Promise.race([
147
+ Promise.resolve()
148
+ .then(onStopDidNotTake)
149
+ .catch(() => {}),
150
+ new Promise((done) => {
151
+ wait = setTimeout(done, GIVE_UP_FINISH_MS);
152
+ wait.unref?.();
153
+ }),
154
+ ])
155
+ .finally(() => clearTimeout(wait))
156
+ .then(() => resolve({ code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null }));
116
157
  }, graceMs);
117
158
  // A boot-blocking handle is not wanted here: the worker should be able to exit if everything else
118
159
  // has finished, and this timer only matters while a job is still in flight.
@@ -145,7 +186,9 @@ function boundAfterAbort(run, signal, job, log, graceMs = ABORT_GRACE_MS, onStop
145
186
  * runs inside runJob against the freshly passed caps (CONST-BUDGET-BEFORE-TOKENS).
146
187
  *
147
188
  * The read happens once per pickup, right after the scope gate and before the model endpoint gate (issue #503),
148
- * because that gate needs the effective provider and model, which the overlay can supply.
189
+ * because that gate needs the effective provider and model, which the overlay can supply. One more read sits at the
190
+ * processor's head (issue #761) and asks only whether a newer pi-dispatch wrote the file; every value a job runs with
191
+ * still comes from the pickup's read.
149
192
  */
150
193
  /**
151
194
  * A job's endpoint set (issues #503, #502): the endpoints its main model AND every model on its effective allowed
@@ -218,6 +261,11 @@ export function makeRunningJobs() {
218
261
  entries.set(next, { ...entry });
219
262
  return next;
220
263
  },
264
+ /** Update only this pickup: a retry with the same job id has a different key. */
265
+ update(key, patch) {
266
+ const entry = entries.get(key);
267
+ if (entry) entries.set(key, { ...entry, ...patch });
268
+ },
221
269
  /** Removes one pickup's entry; true only the first time. */
222
270
  remove(key) {
223
271
  return entries.delete(key);
@@ -228,13 +276,44 @@ export function makeRunningJobs() {
228
276
  };
229
277
  }
230
278
 
231
- export function makeProcessor({ cancelJob, stopContainer, containerName = (job) => jobContainerName(job.id), redis, getSettings, hostBudget = null, abortGraceMs = ABORT_GRACE_MS, applyConcurrency = () => {}, pauseUntil = () => null, scopedLimits = () => [], projects = () => [], jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), hostBound = null, checkLease = null, scopeLease = null, endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels = () => null, endpointSetFor = mainModelEndpoints, deps, recordRun = () => {}, settledRecord = null, previousRecord = null, timeoutMs = JOB_TIMEOUT_MS, cancelPollMs = 2_000, cancelStopBoundMs = CANCEL_STOP_BOUND_MS, hostName = "", multiHost = false, now = () => Date.now(), waitState = makeWaitState({ redis, now }), afterMaxMs = () => WAIT_AFTER_MAX_DEFAULT_MS, checkSlots = makeInFlight(), checkSlotCount = () => 1, checkTimeoutMs = () => 10_000, concurrencyNow = () => 3, intervalMs = () => WAIT_INTERVAL_FLOOR_MS * 2, maxWaitMs = () => 24 * 3600 * 1000, maxChecks = () => 96, maxFaults = () => 5, random = Math.random, cpus = availableParallelism, runningJobs = makeRunningJobs() }) {
279
+ // A free pre-pickup refusal needs its own project snapshot, before settlement can await a live configuration edit.
280
+ // It never enters pickup, whose one snapshot must stay after lost-lock checks and immediately before pause gates.
281
+ function makeBeforePickupRecorder({ projects, settleSteerBeforePickup, recordRun, now }) {
282
+ return async (job, boundary) => {
283
+ const project = projectOf(job.data, projects());
284
+ const result = { ...boundary, budgetReserved: false };
285
+ await settleSteerBeforePickup({ job, result });
286
+ await recordRun({ job, result, project, startedAt: new Date(now()).toISOString(), endedAt: new Date(now()).toISOString() });
287
+ return result;
288
+ };
289
+ }
290
+
291
+ export function makeProcessor({ cancelJob, stopContainer, containerName = (job) => jobContainerName(job.id), redis, getSettings, readModelRoles = () => ({}), hostBudget = null, abortGraceMs = ABORT_GRACE_MS, applyConcurrency = () => {}, pauseUntil = () => null, pauseWindowsNewer = () => false, triggersNewer = () => false, scopedLimits = () => [], projects = () => [], jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), hostBound = null, checkLease = null, scopeLease = null, endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels = () => null, endpointSetFor = mainModelEndpoints, deps, recordRun = () => {}, settledRecord = null, previousRecord = null, timeoutMs = JOB_TIMEOUT_MS, cancelPollMs = 2_000, cancelStopBoundMs = CANCEL_STOP_BOUND_MS, hostName = "", multiHost = false, now = () => Date.now(), waitState = makeWaitState({ redis, now }), afterMaxMs = () => WAIT_AFTER_MAX_DEFAULT_MS, checkSlots = makeInFlight(), checkSlotCount = () => 1, checkTimeoutMs = () => 10_000, concurrencyNow = () => 3, intervalMs = () => WAIT_INTERVAL_FLOOR_MS * 2, maxWaitMs = () => 24 * 3600 * 1000, maxChecks = () => 96, maxFaults = () => 5, random = Math.random, cpus = availableParallelism, runningJobs = makeRunningJobs(), valkeyTag = null }) {
232
292
  // The lost-lock gate's rejection lines, said ONCE per job id, stall count and attempt. The gate runs before every
233
293
  // deferral (pause window, wait, scope or endpoint busy), and BullMQ never resets a job's stall count, so a stalled
234
294
  // scheduled job whose record is refused meets the gate again on every deferred pickup: a 30-minute scope-busy hold
235
295
  // is about 360 of them. The verdict cannot change between them (same record, same attempt), so one line says it.
236
296
  // Bounded, oldest out first, so a long-lived worker holds at most REJECTED_SEEN_MAX keys.
237
297
  const rejectedSeen = new Set();
298
+ // Watch producer refusals finish before pickup and cannot carry a steering session.
299
+ const recordWatchOutcome = recordRun;
300
+ // A free boundary refusal happens before pickup creates a keeper or takes a host slot. Its existing inbox and
301
+ // held replies still need a final close before the record, just as every terminal path inside pickup does.
302
+ const settleSteerBeforePickup = async ({ job, result }) => {
303
+ let held = null;
304
+ if (job.data?.awaitInput === true && typeof deps?.settleHeld === "function") {
305
+ try { held = await deps.settleHeld({ job, result, afterTake: false }); } catch {}
306
+ }
307
+ if (job.data?.steer !== true) return;
308
+ const steered = await settleInbox({ redis, job, result, log: deps?.log ?? (() => {}), lateContext: { heldResumed: Number(held?.resumed) > 0 }, ...(typeof deps?.onLateForge === "function" ? { onLateForge: deps.onLateForge } : {}) });
309
+ if (steered) result.steered = steered;
310
+ };
311
+ const recordBeforePickup = makeBeforePickupRecorder({ projects, settleSteerBeforePickup, recordRun, now });
312
+ // THE ONE DEFERRAL (issue #737, `defer-write.mjs`): every gate below that puts the job back on the delayed set goes
313
+ // through `deferTo`, which records the gate's reason in `defer:job:<id>` (bounded, fail-open) and then makes the move
314
+ // and the `DelayedError` throw the gates always made; `noteRetry` records an infrastructure retry's backoff before its
315
+ // throw. Built once per processor, so its log episode (one line per outage, not per wake) spans every job it defers.
316
+ const { deferTo, noteRetry } = makeDeferTo({ redis, DelayedError, now, hostName, log: (event, fields) => deps?.log?.(event, fields) });
238
317
  const firstRejection = (key) => {
239
318
  if (rejectedSeen.has(key)) return false;
240
319
  rejectedSeen.add(key);
@@ -266,51 +345,187 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
266
345
  };
267
346
  const record = await settledRecord(job.id, { attempt, since: job.timestamp, onReject });
268
347
  if (record) {
348
+ // A record a NEWER pi-dispatch wrote (issue #761) settles the attempt by its stable core, as `recordVerdict`
349
+ // judged it, and is replayed by that core alone: its `reason`, `exitCode`, `turns`, `tokens` and
350
+ // `budgetReserved` may mean something this build does not know, and the completed listener pages and logs on
351
+ // them. So the job ends as the record's outcome with every other field null: `pagesAsPolicy` needs a reason
352
+ // it knows and so pages nobody, and the `job_completed` line names the outcome alone.
353
+ if (!recordReadable(record)) {
354
+ deps?.log?.("job_lost_lock_after_completion", { jobId: job.id, outcome: record.outcome });
355
+ return { outcome: record.outcome, reason: null, exitCode: null, turns: null, tokens: null, budgetReserved: null };
356
+ }
269
357
  deps?.log?.("job_lost_lock_after_completion", { jobId: job.id, outcome: record.outcome, ...(record.reason ? { reason: record.reason } : {}) });
270
358
  return { outcome: record.outcome, reason: record.reason ?? null, exitCode: record.exitCode ?? null, turns: record.turns ?? null, tokens: record.tokens ?? null, budgetReserved: record.budgetReserved ?? null };
271
359
  }
272
360
  }
273
361
 
362
+ // THE RUN'S INBOX AT ITS FINAL OUTCOME (issue #646, inbox-settle.mjs). A steer-armed job's inbox is closed, and what
363
+ // became of its messages counted, IMMEDIATELY BEFORE EVERY RECORD WRITE in this function, so the record's `steered`
364
+ // is the inbox's final word and a message can no longer be pushed into a run whose outcome is being written. ONE
365
+ // helper, called on the line before each `recordRun`/`recordAfterGate` (a bolt in steer-settle.test.mjs refuses a
366
+ // record site without it), which itself decides whether the outcome is final: an InfraRetry with attempts left and
367
+ // a #645 recovery handoff leave the inbox open for the next attempt. It decorates the result (or the error) with
368
+ // `steered` and the attempt's `steerSkipped`, which the record rebuilds; never throws, and a non-steer job costs
369
+ // nothing. `steerRan` is what this attempt's runContainer said about steering (null before any container).
370
+ let steerRan = null;
371
+ // Issue #712: this attempt's named live-transcript skip (runContainer's `liveSkipped`), null before any container.
372
+ // Carried onto the result here, at the one call every record site makes (the bolt above holds them to it), rather
373
+ // than at each of the processor's return statements, so no record site can be given it and another not.
374
+ let liveRan = null;
375
+ // Issue #712: the OpenShell venues' policy facts (runContainer's `openshell`, backend-openshell.mjs), carried the same
376
+ // way and for the same reason; the record rebuilds them (run-history.mjs `recordedOpenshell`), so nothing is trusted here.
377
+ let openshellRan = null;
378
+ let selectedEffective = null;
379
+ let providerFailureRan = null;
380
+ let modelRolesRan = null;
381
+ const carryLiveSkip = (target) => {
382
+ if (target === null || typeof target !== "object") return;
383
+ try {
384
+ if (liveRan !== null && target.liveSkipped === undefined) target.liveSkipped = liveRan;
385
+ if (openshellRan !== null && target.openshell === undefined) target.openshell = openshellRan;
386
+ if (selectedEffective?.modelRole) target.modelRole = selectedEffective.modelRole;
387
+ if (selectedEffective?.modelRole) { target.provider = selectedEffective.provider; target.model = selectedEffective.model; }
388
+ if (selectedEffective?.modelWhy) { target.modelWhy = selectedEffective.modelWhy; target.provider = selectedEffective.provider; target.model = selectedEffective.model; }
389
+ if (providerFailureRan) target.providerFailure = providerFailureRan;
390
+ if (modelRolesRan) target.modelRoles = modelRolesRan;
391
+ // Every attempt replaces the same record. Carry intervention fallback and uncertainty from the
392
+ // prior attempt, or a later cold start would erase the only proof of an earlier resumed answer.
393
+ // Only from a record this build can read (issue #761): a newer one's touches are outside its stable core and
394
+ // may mean something else, and this attempt's record would present them as this build's own. And only from
395
+ // this job's own previous attempt's record (`priorAttemptRecord` passed `recordOfThisJob` at the pickup's
396
+ // read, issue #811): an older job's interventions under a reused id are not this run's.
397
+ const prior = recordReadable(priorAttemptRecord) ? priorAttemptRecord : null;
398
+ if (prior?.touchLineageSkipped === "source-unavailable") target.touchLineageSkipped = "source-unavailable";
399
+ const touches = [...(Array.isArray(prior?.touches) ? prior.touches : []), ...(Array.isArray(target.touches) ? target.touches : [])].map(cleanTouch).filter(Boolean);
400
+ if (touches.length) target.touches = [...new Map(touches.map((event) => [event.id, event])).values()].sort((a, b) => a.at - b.at || a.id.localeCompare(b.id)).slice(0, 16);
401
+ } catch {}
402
+ };
403
+ const settleSteer = async ({ result = null, error = null } = {}) => {
404
+ const held = await settleHeldReplies({ result, error });
405
+ carryLiveSkip(result ?? error);
406
+ if (job.data?.steer !== true) return;
407
+ // Issue #638: a run both steer-armed and awaitInput can end parked with a held reply AND a late steer message.
408
+ // Only one of them becomes the resumed job: the held reply, settled first, and the late messages are then
409
+ // answered (`heldResumed`), never a second job for the same parked run.
410
+ const steered = await settleInbox({ redis, job, result, error, log: deps?.log ?? (() => {}), lateContext: { heldResumed: Number(held?.resumed) > 0 || Number(heldTaken?.resumed) > 0 || heldTaken?.taken === "other" }, ...(typeof deps?.onLateForge === "function" ? { onLateForge: deps.onLateForge } : {}) });
411
+ const skipped = typeof steerRan?.steerSkipped === "string" ? steerRan.steerSkipped : null;
412
+ const target = result ?? error;
413
+ if (target === null || typeof target !== "object") return;
414
+ try {
415
+ if (steered) target.steered = steered;
416
+ if (skipped !== null) target.steerSkipped = skipped;
417
+ } catch {}
418
+ };
419
+
420
+ // HELD REPLIES AT THE SAME FINAL OUTCOME (issue #638, held-settle.mjs), called first by `settleSteer` so that every
421
+ // record site the bolt above holds to the steer settle settles an awaitInput run's held replies too: one call per
422
+ // site, so no site can be given one and not the other. At a final outcome the keeper stops BEFORE the close, so no
423
+ // refresh in flight can put the run back on its target after the close removed it. `deps.settleHeld` is start.mjs's
424
+ // `makeHeldSettle`; unwired, nothing is held and nothing is settled. Never throws.
425
+ let awaitKeeper = null;
426
+ // What the park's take started (issue #638 review, H1: the take runs INSIDE the park sequence, before this settle),
427
+ // kept so the steer settle can tell its late messages a held reply already became the resumed job.
428
+ let heldTaken = null;
429
+ // Whether the take ran at all: the backstop settle then only drops (a take whose close failed already answered).
430
+ let heldTakeRan = false;
431
+ const takeHeldReplies = async () => {
432
+ if (job.data?.awaitInput !== true || typeof deps?.takeHeld !== "function") return null;
433
+ heldTakeRan = true;
434
+ await awaitKeeper?.stop();
435
+ heldTaken = await deps.takeHeld({ job });
436
+ return heldTaken;
437
+ };
438
+ const settleHeldReplies = async ({ result = null, error = null } = {}) => {
439
+ if (job.data?.awaitInput !== true) return;
440
+ try {
441
+ if (!isFinalOutcome({ job, result, error })) return;
442
+ await awaitKeeper?.stop();
443
+ if (typeof deps?.settleHeld === "function") return await deps.settleHeld({ job, result, error, afterTake: heldTakeRan });
444
+ } catch {}
445
+ return null;
446
+ };
447
+
274
448
  // THE EARLIER ATTEMPTS' SLOT TIME (issue #599): a retry, or a pickup after a stall, writes its record over the one
275
449
  // before it (one file and one mirror key per job id), so that record is read NOW, before anything here can replace
276
450
  // it, and its slot interval (with the ones it carried) rides every record this pickup writes (`earlier`). Only on
277
451
  // such a pickup, so a first attempt reads nothing; bounded by the reader, and a fault reads as none.
452
+ // The record counts only when it is THIS job's previous attempt's (`recordOfThisJob`, the rule the lost-lock checks
453
+ // share; issue #811): ids are reused (a lost queue, a hand redelivery), and a record an older job left under the
454
+ // same id would hand this job that job's slot time (`earlier`), its interventions (the touches), and a
455
+ // never-started verdict the failover retry would start over from the primary on (`retryNeverStarted`). The
456
+ // previous attempt is `attemptsMade` after a failure, which BullMQ counts, or this pickup's own number,
457
+ // `attemptsMade + 1`, after a stall, which it does not (a scheduled job's stall puts it back to wait with only its
458
+ // `stalledCounter` raised). Both are kept, the higher winning, rather than one chosen from `stalledCounter`:
459
+ // BullMQ never lowers that count, so a job that stalled once and later failed would look for the stalled
460
+ // attempt's number, miss its own failed record, and a failover retry with no model attempt key would then be
461
+ // refused `model-failover-no-candidate` (a comment, a used-up delivery, a disarmed one-shot). What that leaves: a
462
+ // stall that wrote no record leaves the attempt before it to answer, which is still this job's own.
463
+ // Each side (this host's file, the fleet's copy) is held to the rule BEFORE the higher attempt is kept
464
+ // (`previousRecord`'s `keep`), so a stale record at a higher attempt cannot hide this job's own on the other side,
465
+ // and the answer is judged again after, so a reader that ignores `keep` cannot hand the stale record back. The count
466
+ // is read as `buildRecord` reads it for the record's own `attempt`, so the two cannot disagree.
467
+ const attemptsBefore = Number.isInteger(job.attemptsMade) && job.attemptsMade >= 0 ? job.attemptsMade : 0;
468
+ const ownPrior = (record) => [attemptsBefore, attemptsBefore + 1].some((attempt) => recordOfThisJob(record, { jobId: job.id, attempt, since: job.timestamp }));
278
469
  let earlier = null;
470
+ let priorAttemptRecord = null;
279
471
  if (typeof previousRecord === "function" && ((Number.isInteger(job.attemptsMade) && job.attemptsMade > 0) || Number(job.stalledCounter) > 0)) {
280
472
  try {
281
- earlier = earlierFrom(await previousRecord(job.id));
473
+ const read = await previousRecord(job.id, { keep: ownPrior });
474
+ priorAttemptRecord = ownPrior(read) ? read : null;
475
+ earlier = earlierFrom(priorAttemptRecord);
282
476
  } catch {
477
+ priorAttemptRecord = null;
283
478
  earlier = null;
284
479
  }
285
480
  }
286
481
 
287
482
  // Scoped pause windows (REQ-SCOPED-PAUSE-WINDOWS): if this job's folder/repo is inside an active pause
288
483
  // window, DEFER it to the window end via BullMQ's delayed set -- the job keeps its identity/dedup and
289
- // auto-resumes when re-picked. This is FIRST, before the kill timer, the settings read, and the budget
290
- // reservation, so a deferred job arms no timer, reserves no slot, and spends nothing
291
- // (CONST-BUDGET-BEFORE-TOKENS). `moveToDelayed` needs the worker's `token`; the `> now + 1s` guard keeps
292
- // a boundary tick from busy-deferring. A thrown `DelayedError` is how BullMQ learns the job was deferred
293
- // (worker.js recognises it) rather than completed or failed.
484
+ // auto-resumes when re-picked. This is FIRST of the deferral gates, before the kill timer, the settings read, and
485
+ // the budget reservation, so a deferred job arms no timer, reserves no slot, and spends nothing
486
+ // (CONST-BUDGET-BEFORE-TOKENS). `deferTo` records the reason, then moves the job with the worker's `token` and
487
+ // throws the `DelayedError` by which BullMQ learns the job was deferred (worker.js recognises it) rather than
488
+ // completed or failed; the `> now + 1s` guard keeps a boundary tick from busy-deferring.
294
489
  // One clock snapshot for both the window lookup and the guard, so they cannot disagree across the
295
490
  // call (and a test can inject a fixed clock). `now` defaults to the real wall clock in production.
296
491
  const nowMs = now();
297
- const until = pauseUntil(job.data, nowMs);
298
- if (until && until > nowMs + 1000) {
299
- await job.moveToDelayed(until, token);
300
- throw new DelayedError();
492
+ // The job's project (issue #499, INT-PROJECTS-FILE-CONTRACT), resolved ONCE here from one read of the projects ref
493
+ // (an in-memory ref, so the read is free), and for the limits snapshot's reason below: the gate, the ledger and the
494
+ // record agree for this attempt, whatever an operator does to projects.json mid-run. A retry or a deferral is a new
495
+ // pickup and resolves again. Above the pause gate since issue #737, so every deferral's record names it, the pause
496
+ // window's included. Every record from the never-fits check on carries it (the refusal there by hand, every one
497
+ // past the wait gate through `recordAfterGate`); the wait gate's refusals carry none and are resolved from the live
498
+ // ref (start.mjs). An id or null, never a name.
499
+ const pickupProjects = projects();
500
+ const grouping = job.data?.purpose === "zero-touch-grouping" ? job.data.questionGrouping : null;
501
+ const sourceProject = grouping?.project;
502
+ // The classifier has an empty internal workspace, so folder membership would bill it to "other".
503
+ // Its private runtime resolved the source's historical project; reject a deleted binding before any
504
+ // reserve instead of silently charging another project's allowance.
505
+ if (grouping && (sourceProject !== null && (!isProjectId(sourceProject) || !pickupProjects.some((row) => row.id === sourceProject)))) {
506
+ const result = { outcome: "policy", reason: "question-group-config-changed", budgetReserved: false };
507
+ await settleSteer({ result });
508
+ await recordRun({ job, result, startedAt: new Date(nowMs).toISOString(), endedAt: new Date(nowMs).toISOString(), project: null });
509
+ return result;
510
+ }
511
+ const project = grouping ? sourceProject : projectOf(job.data, pickupProjects);
512
+ // BullMQ marks this pickup active before entering here. The canary takes its exclusive
513
+ // lease before proving all active lists empty, so either side of that race sees the other.
514
+ if (await deps?.canaryBusy?.()) await deferTo(job, token, nowMs + SCOPE_BUSY_RECHECK_MS, "canary", { project });
515
+ // Issue #761: the pause windows file on disk was written by a newer pi-dispatch. The windows the gate below reads
516
+ // are the LAST GOOD ones, and the newer file may pause this very job (a field this build would drop narrows when
517
+ // a window applies), so judging it by them would run a job the operator paused. Every job waits for this host's
518
+ // upgrade or a readable file instead, right before the pause gate and so before every hold and spend.
519
+ if (pauseWindowsNewer() === true) {
520
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: "the pause windows file", delayMs: NEWER_RELEASE_RECHECK_MS });
521
+ await deferTo(job, token, nowMs + NEWER_RELEASE_RECHECK_MS, "newer-release", { project });
301
522
  }
523
+ const until = pauseUntil(job.data, nowMs);
524
+ if (until && until > nowMs + 1000) await deferTo(job, token, until, "pause-window", { project });
302
525
 
303
- // The limits snapshot, the project and the size, read here, right after the pause gate and ABOVE the wait gate, so
304
- // the never-fits check below can refuse a size before a job waits (issue #596, gate round 1 of phase 2).
526
+ // The limits snapshot and the size, read here, right after the pause gate and ABOVE the wait gate, so the
527
+ // never-fits check below can refuse a size before a job waits (issue #596, gate round 1 of phase 2).
305
528
  const limits = scopedLimits();
306
- // The job's project (issue #499, INT-PROJECTS-FILE-CONTRACT), resolved ONCE here from one read of the projects ref,
307
- // beside the limits snapshot and for its reason: the gate, the ledger and the record agree for this attempt,
308
- // whatever an operator does to projects.json mid-run. A retry or a deferral is a new pickup and resolves again.
309
- // Every record from the never-fits check on carries it (the refusal there by hand, every one past the wait gate
310
- // through `recordAfterGate`); the wait gate's refusals carry none and are resolved from the live ref (start.mjs).
311
- // An id or null, never a name.
312
- const pickupProjects = projects();
313
- const project = projectOf(job.data, pickupProjects);
314
529
  // THE JOB'S SIZE (issue #596, `job-size.mjs`), resolved ONCE here from the same limits snapshot and pickup project
315
530
  // every gate below reads: the project row's memory and CPUs, else the deployment's PI_JOB_MEMORY and PI_JOB_CPUS
316
531
  // (`jobSizeEnv`, validated at boot), else 4g and 2. It reaches the container as an ARGUMENT (runJob's `jobSize`,
@@ -353,12 +568,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
353
568
  const result = { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, budgetReserved: false, hostBudget: { memMiB: budgetNow.memMiB, cpuCenti: budgetNow.cpuCenti, hostShare: share } };
354
569
  // Above the wait gate, so through the recorder's own arguments rather than the bound one below it: the
355
570
  // same pickup project and size, by hand once.
571
+ await settleSteer({ result });
356
572
  recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString(), project, size, earlier });
357
573
  return result;
358
574
  }
359
575
  deps?.log?.("job_size_never_fits_here_deferred", { jobId: job.id, project, misfit, delayMs: NEVER_FITS_RECHECK_MS, ...sizeFields });
360
- await job.moveToDelayed(nowMs + NEVER_FITS_RECHECK_MS, token);
361
- throw new DelayedError();
576
+ await deferTo(job, token, nowMs + NEVER_FITS_RECHECK_MS, "size-never-fits", { project });
362
577
  }
363
578
  }
364
579
 
@@ -395,6 +610,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
395
610
  // resolver path or a vault topology -- `secret-profile-unknown` sets that rule.
396
611
  if (sentence && deps?.comment) await Promise.resolve(deps.comment(job.data, sentence)).catch(() => {});
397
612
  const result = { outcome: "policy", reason, exitCode: null, turns: null, tokens: null, budgetReserved: false };
613
+ await settleSteer({ result });
398
614
  recordRun({ job, result, startedAt: at, endedAt: new Date().toISOString(), earlier });
399
615
  return result;
400
616
  };
@@ -464,14 +680,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
464
680
  // for both; refusing would drop a delivery over a holder that may be gone. So decide
465
681
  // nothing: re-defer briefly and ask again once the probe can answer.
466
682
  deps?.log?.("wait_supersede_unverified", { jobId: job.id, heldBy: claim.holder ?? null, delayMs: SUPERSEDE_RECHECK_MS });
467
- await job.moveToDelayed(nowMs + SUPERSEDE_RECHECK_MS, token);
468
- throw new DelayedError();
683
+ await deferTo(job, token, nowMs + SUPERSEDE_RECHECK_MS, "wait-supersede", { project, holder: claim.holder });
469
684
  }
470
685
  if (claim.tookOverFrom) deps?.log?.("wait_lease_taken_over", { jobId: job.id, from: claim.tookOverFrom });
471
- await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: holdUntil });
686
+ await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: holdUntil, afterMs: holdUntil });
472
687
  deps?.log?.("wait_deferred", { jobId: job.id, until: new Date(holdUntil).toISOString(), label: waitLabel(job.data) });
473
- await job.moveToDelayed(holdUntil, token);
474
- throw new DelayedError();
688
+ await deferTo(job, token, holdUntil, "wait-after", { project });
475
689
  }
476
690
 
477
691
  // TIER 2: the polled conditions. Last, because it is the only part of this gate that spawns a
@@ -497,7 +711,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
497
711
  // the lease, so a job that never wins one has no `since`, never reaches the maximum, and
498
712
  // re-wakes forever with no record and no bound. There is no deciding check to run first
499
713
  // here -- that is the whole condition -- so the bound applies directly.
500
- await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + maxWaitMs() });
714
+ await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + maxWaitMs(), afterMs: holdUntil });
501
715
  if (held >= maxWaitMs()) {
502
716
  return await refuseWait("wait-expired", "wait_expired", { reason: "max-wait-unchecked", denials, heldForMs: held }, `Gave up waiting: this deployment could not run the check often enough to answer within the maximum wait. Not run.`);
503
717
  }
@@ -507,8 +721,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
507
721
  const wait = Math.max(THROTTLE_FLOOR_MS, Math.floor(waitBackoffMs(intervalMs(), held) / 4));
508
722
  const delay = wait + Math.floor(wait * 0.1 * random());
509
723
  deps?.log?.("wait_check_throttled", { jobId: job.id, delayMs: delay, slots });
510
- await job.moveToDelayed(nowMs + delay, token);
511
- throw new DelayedError();
724
+ await deferTo(job, token, nowMs + delay, "wait-throttled", { project });
512
725
  }
513
726
 
514
727
  // Declared outside the try below because the branches AFTER it read both.
@@ -537,12 +750,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
537
750
  checkSlots.release(WAIT_CHECK_KEY);
538
751
  const denials = await waitState.noteThrottle(job.id, { denied: true });
539
752
  if (denials === THROTTLE_ALARM) deps?.log?.("wait_capacity_exceeded", { jobId: job.id, denials, slots: checkSlotCount(), where: "fleet", hint: "raise PI_WAIT_CHECK_SLOTS or PI_CONCURRENCY, lengthen PI_WAIT_INTERVAL_MS, or hold fewer jobs" });
540
- await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + maxWaitMs() });
753
+ await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + maxWaitMs(), afterMs: holdUntil });
541
754
  const wait = Math.max(THROTTLE_FLOOR_MS, Math.floor(waitBackoffMs(intervalMs(), held) / 4));
542
755
  const delay = wait + Math.floor(wait * 0.1 * random());
543
756
  deps?.log?.("wait_check_throttled", { jobId: job.id, delayMs: delay, slots: checkSlotCount(), where: "fleet" });
544
- await job.moveToDelayed(nowMs + delay, token);
545
- throw new DelayedError();
757
+ await deferTo(job, token, nowMs + delay, "wait-throttled", { project });
546
758
  }
547
759
  }
548
760
  // THE LEASE IS HELD FROM THE `tryAcquire` ABOVE, so every exit from here down must release it.
@@ -563,8 +775,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
563
775
  }
564
776
  if (claim.retry) {
565
777
  deps?.log?.("wait_supersede_unverified", { jobId: job.id, heldBy: claim.holder ?? null, delayMs: SUPERSEDE_RECHECK_MS });
566
- await job.moveToDelayed(nowMs + SUPERSEDE_RECHECK_MS, token);
567
- throw new DelayedError();
778
+ await deferTo(job, token, nowMs + SUPERSEDE_RECHECK_MS, "wait-supersede", { project, holder: claim.holder });
568
779
  }
569
780
 
570
781
  await waitState.noteThrottle(job.id, { denied: false }); // granted: the run of denials ends here
@@ -574,6 +785,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
574
785
  for (const profile of profiles) {
575
786
  checked = profile;
576
787
  verdict = await deps.checkWait(profile, targetFor(job.data?.kind, job.data), { signal });
788
+ // What this check said, for a console to show beside the hold (issue #737), in the gate's own words;
789
+ // nothing for a check that said nothing about the condition. Fail-open. Optional-called: an injected
790
+ // wait state written before the answers existed records none.
791
+ const answer = answerOf(verdict);
792
+ if (answer !== null) await waitState.noteAnswer?.(job.id, { profile, answer });
577
793
  if (verdict?.profileUnknown || verdict?.verdict !== "go") break;
578
794
  }
579
795
  } finally {
@@ -602,8 +818,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
602
818
  // fault, or a rolling deploy would spend a job's whole budget on its own restarts and then
603
819
  // blame the operator's script for it.
604
820
  deps?.log?.("wait_check_aborted", { jobId: job.id, profile: checked });
605
- await job.moveToDelayed(nowMs + THROTTLE_FLOOR_MS, token);
606
- throw new DelayedError();
821
+ await deferTo(job, token, nowMs + THROTTLE_FLOOR_MS, "wait-aborted", { project });
607
822
  }
608
823
 
609
824
  if (verdict?.verdict === "hold") {
@@ -644,10 +859,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
644
859
  const jittered = base + Math.floor(base * 0.1 * random());
645
860
  const remaining = Math.max(0, maxWaitMs() - held);
646
861
  const delay = Math.max(1000, Math.min(jittered, remaining));
647
- await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + delay });
862
+ await waitState.hold(job.id, { dedupId, target: targetFor(job.data?.kind, job.data), label: waitLabel(job.data), untilMs: nowMs + delay, afterMs: holdUntil });
648
863
  deps?.log?.("wait_deferred", { jobId: job.id, profile: checked, fault, heldForMs: held, delayMs: delay });
649
- await job.moveToDelayed(nowMs + delay, token);
650
- throw new DelayedError();
864
+ await deferTo(job, token, nowMs + delay, "wait-hold", { project });
651
865
  }
652
866
 
653
867
  // FAIL CLOSED on anything that is not literally go. Everything above tests for a specific
@@ -661,8 +875,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
661
875
  deps?.log?.("wait_check_unintelligible", { jobId: job.id, profile: checked });
662
876
  await waitState.noteCheck(job.id, { fault: true });
663
877
  const base = waitBackoffMs(intervalMs(), held);
664
- await job.moveToDelayed(nowMs + base + Math.floor(base * 0.1 * random()), token);
665
- throw new DelayedError();
878
+ await deferTo(job, token, nowMs + base + Math.floor(base * 0.1 * random()), "wait-unclear", { project });
666
879
  }
667
880
 
668
881
  // Every profile answered go. Record the last check so the count bound sees it.
@@ -684,9 +897,10 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
684
897
  // not burn re-check wakes) and after the wait gate (a job holding until tomorrow must not sit on a
685
898
  // folder while it does), and STRICTLY above the `try` below, like the pause gate and for the same two
686
899
  // reasons: a DelayedError thrown inside the try would be converted to UnrecoverableError by the
687
- // catch, and a moveToDelayed rejection here must escape RAW into BullMQ's normal failed-attempt
688
- // handling exactly as the pause gate's does (inside the try it would become a permanent failure
689
- // plus a failure record for what was a transient blip). The limits snapshot is read ONCE here and
900
+ // catch, and a move rejection here must escape RAW into BullMQ's normal failed-attempt handling
901
+ // exactly as the pause gate's does (inside the try it would become a permanent failure plus a failure
902
+ // record for what was a transient blip). `deferTo` rethrows a move's rejection unchanged after
903
+ // removing the reason it wrote for it, so that holds through it. The limits snapshot is read ONCE here and
690
904
  // shared with `scopedLedgers` below, so the gate and the money ledger cannot disagree mid-job.
691
905
  // tryAcquire is a synchronous check-and-increment -- no await between read and take, so Node's
692
906
  // single thread makes it atomic at any concurrency -- and the local-folder limit is a structural 1
@@ -727,6 +941,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
727
941
  // This pickup's entry in the running jobs (issue #599, phase 2), null until it is admitted below.
728
942
  let runningKey = null;
729
943
  let stopDidNotTake = false;
944
+ // Issue #644: the attempt's live publisher finish, handed over by runContainer's `onLive` (null until it is open).
945
+ let finishLive = null;
730
946
  let name;
731
947
  let venue;
732
948
  // THE SCOPE HOLDS, in acquire order (issue #499 part B): the repo or folder slot, then the project slot. Each is
@@ -739,12 +955,13 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
739
955
  };
740
956
  // THE ENDPOINT HOLDS (issue #503), `{ id, fleet }` each, in id order.
741
957
  const endpointHolds = [];
742
- const releaseEndpointHolds = () => {
743
- const taken = endpointHolds.splice(0).reverse();
958
+ const releaseEndpointHolds = (subset = null) => {
959
+ const taken = subset === null ? endpointHolds.splice(0).reverse() : subset.splice(0).reverse();
744
960
  for (const hold of taken) endpointSlots.release(hold.id);
745
961
  return Promise.all(taken.map((hold) => hold.fleet?.release?.()));
746
962
  };
747
- const releaseAllHolds = ({ orphan = false } = {}) => {
963
+ const releaseAllHolds = ({ orphan = false, candidateEndpoints = null } = {}) => {
964
+ if (candidateEndpoints !== null) return releaseEndpointHolds(candidateEndpoints);
748
965
  // The running-jobs entry first (issue #599, phase 2): this pickup no longer runs the job, orphan or not.
749
966
  if (runningKey !== null) {
750
967
  runningJobs.remove(runningKey);
@@ -770,8 +987,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
770
987
  if (hostBound && !hostBudget) {
771
988
  if (!hostBound.slots.tryAcquire(HOST_SLOT_KEY, hostBound.limit())) {
772
989
  deps?.log?.("host_busy_deferred", { jobId: job.id, delayMs: SCOPE_BUSY_RECHECK_MS });
773
- await job.moveToDelayed(nowMs + SCOPE_BUSY_RECHECK_MS, token);
774
- throw new DelayedError();
990
+ await deferTo(job, token, nowMs + SCOPE_BUSY_RECHECK_MS, "host-slot", { project });
775
991
  }
776
992
  hostHeld = true;
777
993
  }
@@ -800,29 +1016,33 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
800
1016
  // `capacity` (issue #599) rides the same binding, and is null until the job is admitted below (where `startedAt` is
801
1017
  // set): every record of a refusal before that point says, by its null, that the job never held a slot.
802
1018
  let capacity = null;
803
- const recordAfterGate = (args) => recordRun({ ...args, project, size, capacity, earlier });
1019
+ let pickupMaxTurns = null;
1020
+ const recordAfterGate = (args) => recordRun({ ...args, project, size, capacity, earlier, maxTurns: pickupMaxTurns });
804
1021
 
805
1022
  // The MATCHED ROW's scope keys both the in-process slot and the fleet lease (issue #498), the same string
806
1023
  // `budgetCapsFor` hashes below and the boot sweeper hashes from the file: a qualified `github:acme/web` row holds
807
1024
  // GitHub jobs only, a bare `acme/web` row holds every forge's under the key it always had. With no row it is the
808
1025
  // job's canonical scope, so the folder mutex is keyed exactly as before.
809
1026
  const scope = rowScopeFor(job.data, limits);
810
- // Give every hold back (scope and host), then defer. Every scope-gate deferral goes through here.
811
- const deferScope = async (fields) => {
1027
+ // Give every hold back (scope and host), then defer. Every scope-gate deferral goes through here, with the reason
1028
+ // its caller names (issue #737); `chained` names the parent as its holder, the run whose scope it waits for.
1029
+ const deferScope = async (why, fields) => {
812
1030
  // Every hold goes back before we defer, the host slot too: `makeInFlight().release` is not idempotent, so a
813
1031
  // slot held across a deferral would be a slot this machine never gets back.
814
1032
  await releaseAllHolds();
815
1033
  deps?.log?.(fields.event, { jobId: job.id, kind: job.data?.kind === "local" ? "local" : "forge", delayMs: SCOPE_BUSY_RECHECK_MS, ...fields.extra });
816
- await job.moveToDelayed(nowMs + SCOPE_BUSY_RECHECK_MS, token);
817
- throw new DelayedError();
1034
+ await deferTo(job, token, nowMs + SCOPE_BUSY_RECHECK_MS, why, { project, ...(why === "chained" ? { holder: job.data.parentJobId } : {}) });
818
1035
  };
1036
+ // The repo or folder slot's reason. A chained child (its parent enqueued it before the parent's finally gave the
1037
+ // folder back) waits on that parent's run; any other job on the scope's own limit, by its kind.
1038
+ const scopeWhy = typeof job.data?.parentJobId === "string" ? "chained" : job.data?.kind === "local" ? "scope-folder" : "scope-repo";
819
1039
  if (scope) {
820
1040
  const ceiling = concurrencyFor(job.data, limits);
821
1041
  if (!inFlight.tryAcquire(scope, ceiling)) {
822
1042
  // Optional-chained: makeProcessor gives `deps` no default and bare wirings pass deps: {}.
823
1043
  // The scope itself stays out of the log line (no-pii-in-logs -- a local scope is a full
824
1044
  // host path); the delayed count and the job id are what an operator needs to see it.
825
- await deferScope({ event: "scope_busy_deferred" });
1045
+ await deferScope(scopeWhy, { event: "scope_busy_deferred" });
826
1046
  }
827
1047
  const repoHold = { key: scope, fleet: null };
828
1048
  scopeHolds.push(repoHold);
@@ -843,7 +1063,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
843
1063
  // matching row, so a deployment with no scoped-limits file issues no command at all.
844
1064
  if (scopeLease && job.data?.kind !== "local" && Number.isFinite(ceiling)) {
845
1065
  repoHold.fleet = await scopeLease.acquire(job.id, { slots: ceiling, keyArgs: [hash16(scope)] });
846
- if (!repoHold.fleet) await deferScope({ event: "scope_busy_deferred", extra: { where: "fleet" } });
1066
+ if (!repoHold.fleet) await deferScope(scopeWhy, { event: "scope_busy_deferred", extra: { where: "fleet" } });
847
1067
  }
848
1068
  }
849
1069
 
@@ -857,13 +1077,13 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
857
1077
  const projectRow = projectRowFor(limits, project);
858
1078
  if (projectRow && Number.isSafeInteger(projectRow.concurrent)) {
859
1079
  if (!inFlight.tryAcquire(projectRow.scope, projectRow.concurrent)) {
860
- await deferScope({ event: "scope_busy_deferred", extra: { ledger: "project" } });
1080
+ await deferScope("scope-project", { event: "scope_busy_deferred", extra: { ledger: "project" } });
861
1081
  }
862
1082
  const projectHold = { key: projectRow.scope, fleet: null };
863
1083
  scopeHolds.push(projectHold);
864
1084
  if (scopeLease) {
865
1085
  projectHold.fleet = await scopeLease.acquire(job.id, { slots: projectRow.concurrent, keyArgs: [hash16(projectRow.scope)] });
866
- if (!projectHold.fleet) await deferScope({ event: "scope_busy_deferred", extra: { ledger: "project", where: "fleet" } });
1086
+ if (!projectHold.fleet) await deferScope("scope-project", { event: "scope_busy_deferred", extra: { ledger: "project", where: "fleet" } });
867
1087
  }
868
1088
  }
869
1089
 
@@ -881,6 +1101,145 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
881
1101
  settingsThrew = true;
882
1102
  settingsError = error;
883
1103
  }
1104
+ // Issue #761: an overlay written by a newer pi-dispatch is not a broken file, it is one this build cannot read. The
1105
+ // settings-overlay-invalid refusal below is for a broken file; this job instead waits for this host's upgrade (or a
1106
+ // readable file), deferred `newer-release`: a refusal would use up a forge delivery and spend a one-shot over a file
1107
+ // nobody got wrong. HERE, right after the read and above every gate that reads the settings, and outside the main
1108
+ // `try` (a DelayedError inside it would become an UnrecoverableError), every hold given back first, as each gate's
1109
+ // deferral does. Nothing is settled, recorded, minted or reserved. The processor's head asks the same question
1110
+ // before any branch runs, so a file that was newer then never gets here; this one covers a file that turned newer
1111
+ // between that read and this one.
1112
+ if (!settingsThrew && settings?.invalid && settings.newer === true) {
1113
+ await releaseAllHolds();
1114
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: "the settings file", delayMs: NEWER_RELEASE_RECHECK_MS });
1115
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", { project });
1116
+ }
1117
+
1118
+ // Failover selection precedes endpoint leases and every paid gate. Capture failures here and report them
1119
+ // inside the ordinary record/release try below; no early throw may strand the scope slots already held.
1120
+ let selectionError = null;
1121
+ let selectionRefused = null;
1122
+ let selectionWait = null;
1123
+ let failoverSnapshot = null;
1124
+ let chosenModel = null;
1125
+ // Set by the selection when a retry can be judged only by a record a newer pi-dispatch wrote (issue #761): the job
1126
+ // then waits below, outside this block's `try`, instead of being refused.
1127
+ let priorRecordNewer = false;
1128
+ const modelAttemptId = modelAttemptIdOf(job.queueName ?? QUEUE, job.id);
1129
+ if (!settingsThrew && !settings?.invalid) {
1130
+ try {
1131
+ let effective = resolveModelRoles(effectiveJobOf(job.data, settings, deps?.allowedModels ?? null, (event, fields) => deps?.log?.(event, { jobId: job.id, ...fields })), job.data.watch || job.data.answerDraft ? {} : await readModelRoles(), project, { authoredProvider: job.data.provider });
1132
+ selectedEffective = effective;
1133
+ if (effective.fallbackModels !== undefined || (effective.recover === true && effective.recoveryOf)) {
1134
+ const endpoints = modelEndpoints?.() ?? [];
1135
+ let models;
1136
+ try { models = overlayModels(); }
1137
+ catch (error) {
1138
+ if (error?.piDispatchConfig || error?.overlayLink || error?.overlayNotAFile || (typeof error?.code === "string" && !isTransientOverlayRead(error.code))) {
1139
+ error.modelSelectionRefused = true;
1140
+ }
1141
+ throw error;
1142
+ }
1143
+ failoverSnapshot = { endpoints, models, set: [] };
1144
+ let recoveryModel = null;
1145
+ if (effective.recover === true && effective.recoveryOf) {
1146
+ if (!deps?.workspaceCheckpointer) { const error = new Error("the recovery store is absent"); error.modelSelectionReason = "recovery-checkpoint-absent"; throw error; }
1147
+ const read = await deps.workspaceCheckpointer.readManifest(effective.recoveryOf);
1148
+ if (read?.ok && read.manifest?.provider && read.manifest?.model) recoveryModel = `${read.manifest.provider}/${read.manifest.model}`;
1149
+ // Issue #761: a checkpoint a newer pi-dispatch wrote is not a missing one. The recovery waits for a worker
1150
+ // that reads it (`newer-release`, deferred below as the processor's own pickup would), never refused here,
1151
+ // where the refusal would use up its one attempt over a checkpoint nothing is wrong with.
1152
+ else if (read?.newer === true) throw new NewerReleaseHold("the run's checkpoint");
1153
+ // A missing checkpoint is refused by runJob's existing free gate, never replaced by a fresh model.
1154
+ else { const error = new Error("the recovery model is absent"); error.modelSelectionReason = "recovery-checkpoint-absent"; throw error; }
1155
+ const ref = splitModelEntry(recoveryModel);
1156
+ if (!ref) { const error = new Error("the recovery model is invalid"); error.modelSelectionReason = "recovery-checkpoint-absent"; throw error; }
1157
+ effective = { ...effective, provider: ref.provider, model: ref.model };
1158
+ }
1159
+ const retry = Number(job.attemptsMade) > 0 || Number(job.stalledCounter) > 0;
1160
+ // Issue #761: a retry whose previous attempt's record a newer pi-dispatch wrote, with no model attempt key to
1161
+ // lock to. That record's `reason`, `budgetReserved` and `exitCode` are outside its stable core, so it cannot
1162
+ // say the attempt never started a container, and the selection would refuse the retry
1163
+ // (`model-failover-no-candidate`) rather than start over from the primary. That refusal comments, records,
1164
+ // uses up a forge delivery and disarms a one-shot over work nothing is wrong with; the job waits instead, as
1165
+ // it does for every store this build cannot read, until a host that reads the record takes it or a later
1166
+ // record replaces it. Exactly that case and no other: a model attempt key keeps the lock to its model, and a
1167
+ // record this build can read keeps today's answers. The key is read here only in that case (the selection
1168
+ // reads it again when it is there), and a throw is the selection's own read failing, handled below as such.
1169
+ // The record is the pickup's (`priorAttemptRecord`), which counts only when it is this job's previous
1170
+ // attempt's (`recordOfThisJob`, `ownPrior` at the pickup's read): one an older job left under a reused id, or an
1171
+ // earlier attempt's, says nothing about the attempt being retried, so the retry is judged as one with no
1172
+ // record, never held by it. That read held each side to the rule before the higher attempt was kept, so a
1173
+ // stale record at a higher attempt on one side hides nothing on the other, and one read serves the hold,
1174
+ // `retryNeverStarted` below, `earlier` and the touches alike (issue #811).
1175
+ const waitMayApply = effective.fallbackModels !== undefined && retry && !recoveryModel;
1176
+ priorRecordNewer = waitMayApply && priorAttemptRecord !== null && !recordReadable(priorAttemptRecord) && (await readModelAttempt(redis, modelAttemptId)) === null;
1177
+ const selection = priorRecordNewer ? {} : effective.fallbackModels === undefined ? { job: effective } : await selectFailoverJob(effective, {
1178
+ redis, id: modelAttemptId, retry,
1179
+ // Read from a record this build can read only (issue #761): a newer one's `reason`, `budgetReserved` and
1180
+ // `exitCode` are outside its stable core. Such a record is no usable prior record, so the retry goes by the
1181
+ // model attempt key alone, locked to the model it names (with no key the job waited above), never started
1182
+ // over from the primary on a record this build cannot vouch for. Nor on another job's (issue #811): the
1183
+ // record is this job's previous attempt's or none, so an older job's `container-never-started` under a
1184
+ // reused id cannot start this retry over from the primary after an attempt that may have spent.
1185
+ recoveryModel, retryNeverStarted: recordReadable(priorAttemptRecord) && priorAttemptRecord.reason === "container-never-started" && priorAttemptRecord.budgetReserved === false && priorAttemptRecord.exitCode === null, models, limits, now, log: deps?.log,
1186
+ reservationFor: (candidate) => zeroRatedFor(candidate, { modelEndpoints: failoverSnapshot, builtinModel: deps?.builtinModel }).zeroRated ? 0 : candidate.maxCostMicros,
1187
+ subscriptionCheck: deps?.subscriptionRuntime ? (candidate) => deps.subscriptionRuntime.check(candidate) : null,
1188
+ checkCandidate: (candidate) => deps?.checkFailoverCandidate ? deps.checkFailoverCandidate(candidate, { models, endpoints }) : checkFailoverCandidate(candidate, { models, endpoints, credential: deps?.checkProviderCredential, lookup: deps?.builtinModel }),
1189
+ acquireEndpoints: async (candidate) => {
1190
+ const set = [...new Map(endpointSetFor({ models, job: candidate, endpoints }).map((endpoint) => [endpoint.id, endpoint])).values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
1191
+ const held = [];
1192
+ for (const endpoint of set) {
1193
+ if (!endpointSlots.tryAcquire(endpoint.id, endpoint.slots)) {
1194
+ await releaseAllHolds({ candidateEndpoints: held });
1195
+ return false;
1196
+ }
1197
+ let fleet = null;
1198
+ try { if (endpointLease && !isPerMachineHost(endpoint.host)) fleet = await endpointLease.acquire(job.id, { slots: endpoint.slots, keyArgs: [hash16(endpoint.id)] }); }
1199
+ catch (error) { held.push({ id: endpoint.id, fleet: null }); await releaseAllHolds({ candidateEndpoints: held }); throw error; }
1200
+ held.push({ id: endpoint.id, fleet });
1201
+ if (endpointLease && !isPerMachineHost(endpoint.host) && !fleet) {
1202
+ await releaseAllHolds({ candidateEndpoints: held });
1203
+ return false;
1204
+ }
1205
+ if (fleet?.degraded) deps?.log?.("endpoint_lease_degraded", { jobId: job.id, endpoint: endpoint.id });
1206
+ }
1207
+ endpointHolds.push(...held);
1208
+ failoverSnapshot.set = set;
1209
+ return true;
1210
+ },
1211
+ });
1212
+ selectedEffective = selection.job ?? null;
1213
+ chosenModel = selection.ref ?? null;
1214
+ if (chosenModel) await writeModelAttempt(redis, modelAttemptId, { ref: chosenModel, modelWhy: selectedEffective.modelWhy });
1215
+ selectionRefused = selection.refused ?? null;
1216
+ selectionWait = selection.wait ? selection : null;
1217
+ }
1218
+ if (selectedEffective && !selectionRefused && !selectionWait && deps?.subscriptionRuntime) {
1219
+ const subscription = await deps.subscriptionRuntime.check(selectedEffective);
1220
+ if (!subscription.ok) {
1221
+ if (subscription.unavailable) throw new Error("subscription state unavailable");
1222
+ if (subscription.wait) selectionWait = subscription;
1223
+ else selectionRefused = subscription.reason;
1224
+ }
1225
+ }
1226
+ } catch (error) {
1227
+ if (error?.modelSelectionReason) selectionRefused = error.modelSelectionReason;
1228
+ else if (error?.modelSelectionRefused) selectionRefused = "model-failover-no-candidate";
1229
+ else selectionError = error instanceof InfraRetry ? error : new InfraRetry("the model candidates could not be read", { cause: error, reason: "container-never-started", budgetReserved: false });
1230
+ }
1231
+ }
1232
+ // The selection's two deferrals, through one exit that gives every hold back first: a retry whose previous record a
1233
+ // newer pi-dispatch wrote (issue #761, above; the selection did not run, so it named no wait), or a wait the
1234
+ // selection or the subscription check named.
1235
+ if (priorRecordNewer || selectionWait) {
1236
+ await releaseAllHolds();
1237
+ if (priorRecordNewer) {
1238
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: "the previous attempt's run record", delayMs: NEWER_RELEASE_RECHECK_MS });
1239
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", { project });
1240
+ }
1241
+ await deferTo(job, token, selectionWait.until, selectionWait.wait, { project });
1242
+ }
884
1243
 
885
1244
  // THE MODEL ENDPOINT GATE (issue #503, INT-MODEL-ENDPOINTS-FILE-CONTRACT). A declared local model server
886
1245
  // has a fixed number of parallel slots, and nothing else bounds how many jobs pile onto it: three per host
@@ -909,8 +1268,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
909
1268
  // One snapshot per pickup (the endpoints, the overlay models, the derived set), handed to runJob as
910
1269
  // `modelEndpoints` so a later gate reads the same declaration this one leased against. With no endpoints
911
1270
  // declared the overlay is not even read, and the job touches nothing new: no command, no key, no field.
912
- let endpointSnapshot = null;
913
- if (modelEndpoints && !settingsThrew && !settings?.invalid) {
1271
+ let endpointSnapshot = failoverSnapshot;
1272
+ if (failoverSnapshot === null && !selectionError && !selectionRefused && modelEndpoints && !settingsThrew && !settings?.invalid) {
914
1273
  let endpoints = [];
915
1274
  let models = null;
916
1275
  let set = [];
@@ -941,7 +1300,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
941
1300
  // ONE hold per endpoint id: a set naming an endpoint twice would take its slot and then wait on itself,
942
1301
  // forever on `slots: 1`. Deduplicated before the sort, so the order rule sees each endpoint once.
943
1302
  const byId = new Map();
944
- for (const e of endpointSetFor({ models, job: effectiveJobOf(job.data, settings, deps?.allowedModels ?? null), endpoints })) if (!byId.has(e.id)) byId.set(e.id, e);
1303
+ for (const e of endpointSetFor({ models, job: selectedEffective ?? effectiveJobOf(job.data, settings, deps?.allowedModels ?? null), endpoints })) if (!byId.has(e.id)) byId.set(e.id, e);
945
1304
  set = [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
946
1305
  }
947
1306
  } catch (err) {
@@ -971,8 +1330,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
971
1330
  if (where !== null) {
972
1331
  await releaseAllHolds();
973
1332
  deps?.log?.("endpoint_busy_deferred", { jobId: job.id, endpoint: endpoint.id, where, delayMs: ENDPOINT_BUSY_RECHECK_MS });
974
- await job.moveToDelayed(nowMs + ENDPOINT_BUSY_RECHECK_MS, token);
975
- throw new DelayedError();
1333
+ await deferTo(job, token, nowMs + ENDPOINT_BUSY_RECHECK_MS, "endpoint", { project, endpoint: endpoint.id });
976
1334
  }
977
1335
  endpointHolds.push({ id: endpoint.id, fleet });
978
1336
  }
@@ -1005,14 +1363,14 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1005
1363
  await releaseAllHolds();
1006
1364
  budgetState.budgetDeferred = true;
1007
1365
  deps?.log?.("host_budget_deferred", { jobId: job.id, project, why: verdict.why, rank: verdict.rank, memMiB: size.memMiB, cpuCenti: size.cpuCenti, delayMs: BUDGET_RECHECK_MS });
1008
- await job.moveToDelayed(nowMs + BUDGET_RECHECK_MS, token);
1009
- throw new DelayedError();
1366
+ await deferTo(job, token, nowMs + BUDGET_RECHECK_MS, "host-budget", { project, budgetWhy: verdict.why });
1010
1367
  }
1011
1368
  budgetHeld = true;
1012
1369
  }
1013
1370
 
1014
1371
  let startedAt;
1015
1372
  let timer;
1373
+ let subscriptionDeadlineAt = null;
1016
1374
  let cancelPoll;
1017
1375
  let cancelPolling = false;
1018
1376
  // The poll tick in flight, KEPT (gate round 2 of PR #479), so the error handler can stop the poll and wait for it
@@ -1069,7 +1427,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1069
1427
  capacity = capacityNow();
1070
1428
  // This host runs the job from here on (issue #599, phase 2): its entry, at the same instant as `startedAt`, given
1071
1429
  // back by `releaseAllHolds` on every exit. `add` is total, so this block still cannot throw.
1072
- runningKey = runningJobs.add({ id: job.id, project, memMiB: size.memMiB, cpuCenti: size.cpuCenti, at: Date.parse(startedAt) });
1430
+ runningKey = runningJobs.add({ id: job.id, project, memMiB: size.memMiB, cpuCenti: size.cpuCenti, at: Date.parse(startedAt), holdMicros: 0, holdAt: Date.parse(startedAt) });
1073
1431
  // The producer of the name both boot reapers sweep by substring. Built from the shared prefix
1074
1432
  // rather than typed here, so a rename cannot land in the producer and not in the sweeps (#227).
1075
1433
  // From the VENUE that will build the container, not from the local adapter reached for directly:
@@ -1105,6 +1463,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1105
1463
  if (err?.code !== BACKEND_NOT_REGISTERED) throw err;
1106
1464
  name = null;
1107
1465
  }
1466
+ subscriptionDeadlineAt = now() + timeoutMs;
1108
1467
  timer = setTimeout(() => {
1109
1468
  // BullMQ has no per-job kill timer; this is ours. cancelJob raises the AbortSignal.
1110
1469
  Promise.resolve(cancelJob(job.id, "job-timeout-30m")).catch(() => {});
@@ -1146,7 +1505,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1146
1505
  cancelPolling = true;
1147
1506
  cancelTick = (async () => {
1148
1507
  try {
1149
- const req = await redis.get(cancelReqKey(job.id));
1508
+ const humanReq = await redis.get(cancelReqKey(job.id));
1509
+ const agentReq = humanReq == null ? await redis.get(agentCancelReqKey(job.id)) : null;
1510
+ const req = humanReq ?? agentReq;
1150
1511
  if (req === null || req === undefined) return;
1151
1512
  // Stopped while this read was in flight: the outcome is being decided, so this request is left
1152
1513
  // unanswered (its requester's timeout re-reads the job), never acknowledged after the fact.
@@ -1154,12 +1515,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1154
1515
  // Bound to this worker (the injection comment in createWorker): `false` means the job
1155
1516
  // left the tracked map in this same instant, in which case the finally below clears
1156
1517
  // this interval anyway and the request's TTL reaps the key.
1157
- const took = await Promise.resolve(cancelJob(job.id, "operator-cancel")).catch(() => false);
1518
+ const took = await Promise.resolve(cancelJob(job.id, agentReq === null ? "operator-cancel" : "agent-cancel")).catch(() => false);
1158
1519
  if (took === false) return;
1159
1520
  // Ack first, then consume the request: losing the DEL to a blip costs one redundant
1160
1521
  // re-read, losing the ack costs the operator a false "nobody answered".
1161
- await redis.set(cancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS).catch(() => {});
1162
- await redis.del(cancelReqKey(job.id)).catch(() => {});
1522
+ await redis.set(agentReq === null ? cancelAckKey(job.id) : agentCancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS).catch(() => {});
1523
+ await redis.del(agentReq === null ? cancelReqKey(job.id) : agentCancelReqKey(job.id)).catch(() => {});
1163
1524
  clearInterval(cancelPoll);
1164
1525
  } catch {
1165
1526
  // Fail open: the next tick re-asks.
@@ -1180,9 +1541,27 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1180
1541
  throw error;
1181
1542
  }
1182
1543
 
1544
+ // HELD REPLIES (issue #638, DES-AWAIT-INPUT-ENDS-THE-JOB): an awaitInput run is live on its await target from here,
1545
+ // after the pause, size, wait, scope, endpoint and host-slot gates have admitted it and right before the settings
1546
+ // overlay check and `runJob` (so before the mint and the container), until the park's take or the settle stop it,
1547
+ // so a reply posted between the agent's question and the park is held instead of being deduplicated against this
1548
+ // run or dropped for the missing marker. Only with a wired settle (unwired, a held reply would never be answered)
1549
+ // and a Valkey client that can hold one. Never throws.
1550
+ if (job.data?.awaitInput === true && typeof deps?.settleHeld === "function" && typeof redis?.multi === "function" && typeof redis?.eval === "function") {
1551
+ awaitKeeper = makeAwaitKeeper({ redis, job, host: hostName || "worker", now, log: deps?.log ?? (() => {}) });
1552
+ await awaitKeeper.start().catch(() => {});
1553
+ }
1554
+
1183
1555
  try {
1184
1556
  // The read itself happened once, above the endpoint gate; its throw lands HERE, where it always did.
1185
1557
  if (settingsThrew) throw settingsError;
1558
+ if (selectionError) throw selectionError;
1559
+ if (selectionRefused) {
1560
+ const result = { outcome: "policy", reason: selectionRefused, exitCode: null, turns: null, tokens: null, budgetReserved: false };
1561
+ await settleSteer({ result });
1562
+ recordAfterGate({ job, result, startedAt, endedAt: new Date().toISOString() });
1563
+ return result;
1564
+ }
1186
1565
  if (settings.invalid) {
1187
1566
  // A present-but-invalid overlay is a POLICY refusal, RETURNED (never thrown) so BullMQ marks the
1188
1567
  // job completed and does not retry a file that can never parse (CONST-RETRY-INFRA-ONLY). Resolved
@@ -1192,6 +1571,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1192
1571
  // refusals are the others): each is decided before or during the settings read, so no honest effective
1193
1572
  // value exists yet -- buildRecord defaults both null.
1194
1573
  const result = { outcome: "policy", reason: "settings-overlay-invalid", exitCode: null, turns: null, tokens: null, budgetReserved: false };
1574
+ await settleSteer({ result });
1195
1575
  recordAfterGate({ job, result, startedAt, endedAt: new Date().toISOString() });
1196
1576
  return result;
1197
1577
  }
@@ -1208,7 +1588,8 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1208
1588
  // values reserveBudget checks, never when it runs.
1209
1589
  // The one call that logs a malformed queued cap (#501): the endpoint gate above computes the same job and
1210
1590
  // stays quiet, so it is reported once per pickup.
1211
- const effectiveJob = effectiveJobOf(job.data, settings, deps?.allowedModels ?? null, (event, fields) => deps?.log?.(event, { jobId: job.id, ...fields }));
1591
+ const effectiveJob = selectedEffective ?? effectiveJobOf(job.data, settings, deps?.allowedModels ?? null, (event, fields) => deps?.log?.(event, { jobId: job.id, ...fields }));
1592
+ pickupMaxTurns = effectiveJob.maxTurns;
1212
1593
 
1213
1594
  // THE ENVELOPE (issue #504 part B, DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE). With one loaded, every job is governed:
1214
1595
  // its project's, or `_other`'s, share of the applied split narrows the dollar ledgers below, read ONCE here
@@ -1236,7 +1617,12 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1236
1617
  }
1237
1618
 
1238
1619
  const result = await runJob(effectiveJob, {
1620
+ queueJobId: String(job.id),
1621
+ deploymentMaxCostUsd: settings.maxCostUsd,
1622
+ rawApprovalJob: job.data,
1239
1623
  redis,
1624
+ // Issue #761: the tag of the Valkey the dollar counters are in, for the record's `dollars.reservedIn`.
1625
+ valkeyTag,
1240
1626
  // The three spend windows (week/month null when disabled) and the soft-hold band, resolved this
1241
1627
  // job-start under overlay > env. reserveBudget checks them before the container.
1242
1628
  caps: { day: settings.dailyCap, week: settings.weeklyCap, month: settings.monthlyCap },
@@ -1262,6 +1648,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1262
1648
  // folder it RESOLVES. The processor asks which project the resolved folder belongs to, from the same projects
1263
1649
  // snapshot, and refuses before any reserve when it is another project's.
1264
1650
  pickupProject: project,
1651
+ // Issue #704 review: a bundle profile this host lacks defers a job on a multi-host worker's SHARED queue
1652
+ // (another host may serve it), the never-fits gate's predicate above; elsewhere it refuses.
1653
+ bundleProfileDeferrable: multiHost && (job.queueName ?? QUEUE) === QUEUE,
1265
1654
  folderProject: (folder) => projectOf({ kind: "local", folder }, pickupProjects),
1266
1655
  // Issue #596: the size resolved at pickup above, for the retained run's manifest and the container's argv.
1267
1656
  jobSize: size,
@@ -1281,12 +1670,19 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1281
1670
  // runJob context is unchanged.
1282
1671
  ...(endpointSnapshot ? { modelEndpoints: endpointSnapshot } : {}),
1283
1672
  ...deps,
1673
+ ...(deps?.subscriptionRuntime ? {
1674
+ prepareSubscriptionAuth: async (j) => {
1675
+ try { return await deps.subscriptionRuntime.prepare(j, { deadlineAt: subscriptionDeadlineAt, signal, attemptId: `${modelAttemptId}:${job.attemptsMade ?? 0}:${job.stalledCounter ?? 0}` }); }
1676
+ catch (cause) { throw new InfraRetry("subscription authentication could not be prepared", { cause, reason: "container-never-started", budgetReserved: false }); }
1677
+ },
1678
+ validateSubscriptionAuth: (snapshots) => deps.subscriptionRuntime.validate(snapshots),
1679
+ } : {}),
1284
1680
  // Issue #502: the skew check must see `models` as the job ARRIVED, not as `effectiveJobOf` filled it from
1285
1681
  // PI_ALLOWED_MODELS, or a trigger list a stale receiver dropped would read as present and the job would run on
1286
1682
  // the deployment's list. Only when the wiring supplies the check, so a bare processor keeps the default.
1287
1683
  // The same for `maxCostUsd` (#501): `effectiveJobOf` keeps the arrived value under its own key and writes the
1288
1684
  // resolved cap as `maxCostMicros`, but it is read off `job.data` here too, so no later fill can hide a drop.
1289
- ...(deps?.checkWaitSkew ? { checkWaitSkew: (j, ...rest) => deps.checkWaitSkew({ ...j, models: job.data?.models, maxCostUsd: job.data?.maxCostUsd }, ...rest) } : {}),
1685
+ ...(deps?.checkWaitSkew ? { checkWaitSkew: (j, ...rest) => deps.checkWaitSkew({ ...j, models: job.data?.models, fallbackModels: job.data?.fallbackModels, maxCostUsd: job.data?.maxCostUsd }, ...rest) } : {}),
1290
1686
  // #227. BOUNDED AFTER THE ABORT, and this is what makes `abortable` an honest declaration.
1291
1687
  //
1292
1688
  // `makeRunContainer`'s promise settles ONLY on the docker child's `close` or `error`. Nothing
@@ -1318,16 +1714,45 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1318
1714
  // left for a cancel to stop. A cancel acknowledged before that moment raced the exit and lost nothing it was
1319
1715
  // promised: the run is recorded as that cancel (the existing path, "partial work may exist") even when the
1320
1716
  // container happened to exit on its own, because the operator was told the record would say operator-cancel.
1321
- runContainer: (ctx) =>
1322
- boundAfterAbort(deps.runContainer({ ...ctx, name, signal }), signal, job, deps.log ?? (() => {}), abortGraceMs, () => {
1717
+ runContainer: async (ctx) => {
1718
+ if (chosenModel) {
1719
+ try { await writeModelAttempt(redis, modelAttemptId, { ref: chosenModel, modelWhy: selectedEffective.modelWhy }); }
1720
+ catch (cause) { throw new InfraRetry("the model attempt could not be recorded", { cause, reason: "container-never-started" }); }
1721
+ }
1722
+ // `jobId` (issue #643) names the live transcript stream. Since issue #645 it is the RUN id (`runIdOf`): a recovery
1723
+ // (`.rN`) continues its run's stream, progress key and status comment, while its record stays its own.
1724
+ // Issue #644: `onLive` hands over the attempt's publisher finish, so giving up on a container whose stop did not
1725
+ // take also ends its progress key (outcome `unknown`: nothing saw it exit) instead of leaving its heartbeat to
1726
+ // rewrite the key as running for as long as the worker lives.
1727
+ return boundAfterAbort(deps.runContainer({ ...ctx, name, signal, jobId: runIdOf(job), onLive: (finish) => (finishLive = finish) }), signal, job, deps.log ?? (() => {}), abortGraceMs, () => {
1323
1728
  stopDidNotTake = true;
1729
+ return finishLive?.("unknown");
1324
1730
  }).then(async (r) => {
1731
+ // Only signed, matching terminal metadata can permit a later cross-model replay. The pessimistic
1732
+ // marker written before spawn survives crashes and tools that terminate the runner.
1733
+ if (r?.exitAuth === "verified" && r?.exitLineCode === r?.code && r?.aborted !== true) modelRolesRan = r.modelRoles ?? null;
1734
+ if (r?.exitAuth === "verified" && r?.exitLineCode === r?.code && r?.code === 1 && r?.aborted !== true) {
1735
+ providerFailureRan = r.providerFailure ?? null;
1736
+ try {
1737
+ if (chosenModel) await writeModelAttempt(redis, modelAttemptId, { ref: chosenModel, modelWhy: selectedEffective.modelWhy, safe: r.toolsStarted === false && r.replaySafe === true && !!r.providerFailure, failure: r.providerFailure });
1738
+ if (deps?.subscriptionRuntime) await deps.subscriptionRuntime.observe(effectiveJob, r, ctx.subscriptionAuth ?? []);
1739
+ if (r.providerFailure) await markModelCooldown(redis, `${effectiveJob.provider}/${effectiveJob.model}`, r.providerFailure, { now });
1740
+ } catch (cause) {
1741
+ // Issue #800: a verified line was read, and the processor never sees this result, so the throw carries what it said.
1742
+ throw new InfraRetry("the provider failure could not be recorded", { cause, reason: "container-infra-exit", exitCode: r.code, turns: r.turns, tokens: r.tokens, usage: r.usage, provider: effectiveJob.provider, model: effectiveJob.model, loaded: r.loaded });
1743
+ }
1744
+ }
1745
+ // Issue #646: what this attempt said about steering, for the record's `steerSkipped` (settleSteer above).
1746
+ steerRan = r && typeof r === "object" ? { steerSkipped: r.steerSkipped ?? null } : null;
1747
+ liveRan = r && typeof r === "object" && typeof r.liveSkipped === "string" ? r.liveSkipped : null;
1748
+ openshellRan = r && typeof r === "object" && r.openshell !== null && typeof r.openshell === "object" ? r.openshell : null;
1325
1749
  await stopCancelPoll();
1326
- const raced = r && r.aborted !== true && signal.aborted === true && signal.reason === "operator-cancel";
1750
+ const raced = r && r.aborted !== true && signal.aborted === true && ["operator-cancel", "agent-cancel"].includes(signal.reason);
1327
1751
  if (raced) deps.log?.("cancel_acked_as_container_exited", { jobId: job.id, exitCode: r.code ?? null });
1328
1752
  const run = raced ? { ...r, aborted: true } : r;
1329
1753
  return run?.aborted ? { ...run, abortReason: typeof signal.reason === "string" ? signal.reason : null } : run;
1330
- }),
1754
+ });
1755
+ },
1331
1756
  // REQ-TRIGGER-SECRETS. The resolver runs INSIDE the 30-minute kill timer armed above, so it has
1332
1757
  // to be abortable for the same reason runContainer does: a resolver blocking on an unreachable
1333
1758
  // vault would otherwise hold its slot until its own timeout, and an abort landing mid-resolution
@@ -1339,6 +1764,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1339
1764
  // tunables above. It is bound here rather than at construction for the reason the overlay exists:
1340
1765
  // an operator who declares a profile in the panel must not have to restart the worker.
1341
1766
  ...(deps.resolveSecrets ? { resolveSecrets: (j) => deps.resolveSecrets(j, { signal, overlayProfiles: settings.secretProfiles ?? {} }) } : {}),
1767
+ // Issue #704: the bundle seam, bound the same way and for the same two reasons: the read runs inside the kill
1768
+ // timer, so it takes the signal, and the overlay half of the bundle profiles is this job-start's settings read.
1769
+ ...(deps.secretBundles ? { secretBundles: { lookup: (profile) => deps.secretBundles.lookup(profile, { overlayBundles: settings.secretBundles ?? {} }), resolve: (j, binding) => deps.secretBundles.resolve(j, binding, { signal, overlayBundles: settings.secretBundles ?? {} }) } } : {}),
1342
1770
  // collectChain (INT-OUTBOX-CONTRACT) reads the completed parent's REAL BullMQ job: its `.id`
1343
1771
  // (the parent id children carry) and `.data` (kind/chainDepth). runJob's own `job` is the
1344
1772
  // effectiveJob -- a spread of job.data with no `.id`/`.data` -- so inject the real wrapper here,
@@ -1350,6 +1778,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1350
1778
  // unconditionally, AFTER the `...deps` spread like the injections around it: whether the key may outlive a park
1351
1779
  // is not a choice a wiring gets to make by omission.
1352
1780
  releaseParkedWindow: () => releaseSemanticWindow(job, deps?.log),
1781
+ // Issue #638: the held replies, taken at the park on the REAL job (its id is the run's, its `queueName` the queue
1782
+ // the resumed job goes to), bound unconditionally like the release; it takes nothing unless wired.
1783
+ takeHeldReplies: () => takeHeldReplies().catch(() => null),
1353
1784
  // The plan collector (issue #505) the same way and for the same reason: the writer it records is the REAL job's
1354
1785
  // `.id`, and the authority it checks is `.data` as queued. The pickup's `portfolio` decision rides in `ctx`.
1355
1786
  ...(deps.collectPlan ? { collectPlan: (ctx) => deps.collectPlan({ ...ctx, job }) } : {}),
@@ -1368,6 +1799,7 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1368
1799
  // The processor tests inject `prepareWorkspace` directly and never see this wrapper, which is
1369
1800
  // why nothing caught it (REQ-RESUMABLE-SESSION).
1370
1801
  ...(deps.prepareWorkspace ? { prepareWorkspace: (j, t, opts) => deps.prepareWorkspace(j, t, { ...opts, queueJobId: job.id }) } : {}),
1802
+ replyReceivedAt: Number.isSafeInteger(job.data?.replyReceivedAt) && job.data.replyReceivedAt >= 0 ? job.data.replyReceivedAt : Number.isSafeInteger(job.timestamp) ? job.timestamp : null,
1371
1803
  // The one-shot pre-spend check (issue #231) needs the REAL BullMQ job's `.id` to excuse this
1372
1804
  // delivery's own earlier attempt -- runJob's effectiveJob has no `.id`, prepareWorkspace's
1373
1805
  // own injection above states why, and this one mirrors it. Omitted when unwired so a bare
@@ -1376,6 +1808,15 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1376
1808
  // this one no options today, so there is nothing to lose yet -- and the day it does, a
1377
1809
  // replacing wrapper would lose it in the same silence.
1378
1810
  ...(deps.checkOnceSpent ? { checkOnceSpent: (j, opts) => deps.checkOnceSpent(j, { ...opts, queueJobId: job.id }) } : {}),
1811
+ // Issue #645: a recovery is enqueued from the REAL BullMQ job (its `.id` is the run id of an original, its `.name`
1812
+ // the add's name, its `.data` what the recovery copies), which only this scope holds; runJob's effectiveJob has
1813
+ // none of them. Bound like collectChain above, and omitted when unwired so runJob keeps its inert default.
1814
+ ...(deps.enqueueRecovery ? { enqueueRecovery: (args) => deps.enqueueRecovery({ ...args, job }) } : {}),
1815
+ // The jobs running on this host (`{ list() }`, ids and admission instants), so a recovery whose base job still runs
1816
+ // HERE is delayed rather than started beside it. The processor's own ledger, unless a wiring hands one in.
1817
+ runningJobs: deps.runningJobs ?? runningJobs,
1818
+ // Observability follows the actual reservation, never the configured cap before admission.
1819
+ onDollarHold: (hold) => runningJobs.update?.(runningKey, { holdMicros: hold?.amountMicros ?? 0, holdAt: hold?.at ?? Date.parse(startedAt) }),
1379
1820
  });
1380
1821
  // Gate round 4 of PR #479: stopped again as runJob returns, before anything is recorded (a result with no
1381
1822
  // container never passed the exit stop above). A cancel that lands after is never acknowledged, and its
@@ -1384,10 +1825,11 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1384
1825
  // the cancel the operator was told of, the result it replaced named in the log.
1385
1826
  await stopCancelPoll();
1386
1827
  let outcome = result;
1387
- if (signal.aborted === true && signal.reason === "operator-cancel" && result?.reason !== "operator-cancel") {
1828
+ if (signal.aborted === true && ["operator-cancel", "agent-cancel"].includes(signal.reason) && result?.reason !== signal.reason) {
1388
1829
  deps.log?.("cancel_acked_before_result", { jobId: job.id, was: `${result?.outcome}/${result?.reason ?? ""}` });
1389
- outcome = { ...result, outcome: "policy", reason: "operator-cancel" };
1830
+ outcome = { ...result, outcome: "policy", reason: signal.reason };
1390
1831
  }
1832
+ await settleSteer({ result: outcome });
1391
1833
  recordAfterGate({ job, result: outcome, startedAt, endedAt: new Date().toISOString() });
1392
1834
  return outcome;
1393
1835
  } catch (error) {
@@ -1395,8 +1837,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1395
1837
  // delayed set WITHOUT spending an attempt, the pause gate's move, since a service held up past one retry backoff
1396
1838
  // failed a job that heals by itself (measured). Bounded by the hold's own start, stored on the job because a
1397
1839
  // deferral leaves `attemptsMade` alone and a worker restart must not reset it; past the bound it fails for good
1398
- // with its own reason token, whose comment names the restart. Nothing is recorded per deferral: the job never
1399
- // started, and one record a minute for an hour would bury the run history.
1840
+ // with its own reason token, whose comment names the restart. No run record is written per deferral (its defer
1841
+ // record says why it waits, and is replaced by the next): the job never started, and one run record a minute
1842
+ // for an hour would bury the run history.
1400
1843
  //
1401
1844
  // Gate round 3: the hour counts only while the worker kept checking. A check that comes more than two recheck
1402
1845
  // periods after the last one (the queue was paused, `pi-dispatch pause`, or no worker ran) starts the hold
@@ -1415,22 +1858,25 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1415
1858
  // so it survives the restart.
1416
1859
  // `readPending: false` asks only whether the poll ALREADY acknowledged a cancel (the signal), and leaves a request it
1417
1860
  // had not read unanswered.
1861
+ let cancellationReason = signal?.reason === "agent-cancel" ? "agent-cancel" : "operator-cancel";
1418
1862
  const operatorCancelled = async ({ readPending = true } = {}) => {
1419
- if (signal?.aborted === true) return signal.reason === "operator-cancel";
1863
+ if (signal?.aborted === true) return ["operator-cancel", "agent-cancel"].includes(signal.reason);
1420
1864
  if (!readPending) return false;
1421
1865
  if (typeof redis?.get !== "function") return false;
1422
1866
  let req = null;
1423
1867
  try {
1424
1868
  req = await redis.get(cancelReqKey(job.id));
1869
+ if (req == null) { req = await redis.get(agentCancelReqKey(job.id)); if (req != null) cancellationReason = "agent-cancel"; }
1425
1870
  } catch {
1426
1871
  return false;
1427
1872
  }
1428
1873
  if (req === null || req === undefined) return false;
1429
- await Promise.resolve(redis.set?.(cancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS)).catch(() => {});
1430
- await Promise.resolve(redis.del?.(cancelReqKey(job.id))).catch(() => {});
1874
+ await Promise.resolve(redis.set?.(cancellationReason === "agent-cancel" ? agentCancelAckKey(job.id) : cancelAckKey(job.id), hostName, "PX", CANCEL_ACK_TTL_MS)).catch(() => {});
1875
+ await Promise.resolve(redis.del?.(cancellationReason === "agent-cancel" ? agentCancelReqKey(job.id) : cancelReqKey(job.id))).catch(() => {});
1431
1876
  return true;
1432
1877
  };
1433
- // A retry that already spent (a container that ran and exited as infra) keeps what it spent on the record, and
1878
+ // A retry that already spent (a container that ran and exited as infra) keeps what it spent on the record (and, issue
1879
+ // #644, where its run got to, `progress`, which the processor put on the error beside `resources`), and
1434
1880
  // says the processor's own operator-cancel sentence; one that never started says it was cancelled before it did.
1435
1881
  // The session rides along as the aborted-container path keeps it (`mergeSession`, already applied to the throw).
1436
1882
  // A NON-retryable error (gate round 3) is not known to have started nothing, so it says the processor's own
@@ -1438,9 +1884,10 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1438
1884
  const endCancelled = async ({ retryable }) => {
1439
1885
  const spent = error?.budgetReserved === true;
1440
1886
  const beforeStart = retryable && !spent;
1441
- const result = { outcome: "policy", reason: "operator-cancel", exitCode: spent ? (error.exitCode ?? null) : null, turns: spent ? (error.turns ?? null) : null, tokens: spent ? (error.tokens ?? null) : null, ...(spent && error.usage ? { usage: error.usage } : {}), provider: error?.provider ?? null, model: error?.model ?? null, session: error?.session ?? null, budgetReserved: retryable ? spent : (error?.budgetReserved ?? null), ...(error?.dollars ? { dollars: error.dollars } : {}), ...(spent && error?.resources ? { resources: error.resources } : {}) };
1887
+ const result = { outcome: "policy", reason: cancellationReason, exitCode: spent ? (error.exitCode ?? null) : null, turns: spent ? (error.turns ?? null) : null, tokens: spent ? (error.tokens ?? null) : null, ...(spent && error.usage ? { usage: error.usage } : {}), provider: error?.provider ?? null, model: error?.model ?? null, session: error?.session ?? null, budgetReserved: retryable ? spent : (error?.budgetReserved ?? null), ...(error?.dollars ? { dollars: error.dollars } : {}), ...(spent && error?.resources ? { resources: error.resources } : {}), ...(spent && error?.progress ? { progress: error.progress } : {}), ...(error?.loaded ? { loaded: error.loaded } : {}) };
1442
1888
  deps?.log?.("job_cancelled_instead_of_retry", { jobId: job.id, spent, retryable, ...(retryable ? {} : { failure: scrubCredentials(String(error?.message ?? error)).slice(0, 300) }) });
1443
- if (deps?.comment) await Promise.resolve(deps.comment(job.data, beforeStart ? CANCELLED_BEFORE_START_COMMENT : TERMINAL_COMMENTS["operator-cancel"])).catch(() => {});
1889
+ if (deps?.comment) await Promise.resolve(deps.comment(job.data, beforeStart && cancellationReason !== "agent-cancel" ? CANCELLED_BEFORE_START_COMMENT : TERMINAL_COMMENTS[cancellationReason])).catch(() => {});
1890
+ await settleSteer({ result });
1444
1891
  recordAfterGate({ job, result, startedAt, endedAt: new Date().toISOString() });
1445
1892
  return result;
1446
1893
  };
@@ -1461,7 +1908,13 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1461
1908
  // other recorded. A retryable error reads that request too, since the job would otherwise run again.
1462
1909
  await stopCancelPoll();
1463
1910
  const retryable = error instanceof InfraRetry;
1464
- if (await operatorCancelled({ readPending: retryable })) return await endCancelled({ retryable });
1911
+ if (await operatorCancelled({ readPending: retryable || error instanceof SubscriptionWait })) return await endCancelled({ retryable });
1912
+ if (error instanceof SubscriptionWait) {
1913
+ await releaseAllHolds();
1914
+ if (error.wait === "subscription-auth") await deferTo(job, token, error.until, "subscription-auth", { project });
1915
+ if (error.wait === "subscription-quota") await deferTo(job, token, error.until, "subscription-quota", { project });
1916
+ if (error.wait === "subscription-owner") await deferTo(job, token, error.until, "subscription-owner", { project });
1917
+ }
1465
1918
  if (error?.holdUntilRestart === true) {
1466
1919
  const heldAt = now();
1467
1920
  const last = job.data?.podmanRestartHoldLastMs;
@@ -1470,13 +1923,47 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1470
1923
  if (heldAt - since < PODMAN_RESTART_HOLD_MAX_MS) {
1471
1924
  await job.updateData({ ...job.data, podmanRestartHoldSinceMs: since, podmanRestartHoldLastMs: heldAt });
1472
1925
  deps?.log?.("podman_restart_hold", { jobId: job.id, heldForMs: heldAt - since, delayMs: PODMAN_RESTART_HOLD_RECHECK_MS, reason: String(error.message).slice(0, 300) });
1473
- await job.moveToDelayed(heldAt + PODMAN_RESTART_HOLD_RECHECK_MS, token);
1474
- throw new DelayedError();
1926
+ await deferTo(job, token, heldAt + PODMAN_RESTART_HOLD_RECHECK_MS, "podman-restart", { project });
1475
1927
  }
1476
1928
  const expired = Object.assign(new UnrecoverableError(`held ${Math.round((heldAt - since) / 60_000)} min for rootful Podman's service to restart, and it did not: ${error.message}`), { reason: PODMAN_RESTART_HOLD_EXPIRED, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false });
1929
+ await settleSteer({ error: expired });
1477
1930
  recordAfterGate({ job, error: expired, startedAt, endedAt: new Date().toISOString() });
1478
1931
  throw expired;
1479
1932
  }
1933
+ // Issue #645: a recovery job whose base attempt is still running on this host waits for it (`RecoveryWaitHold`, a
1934
+ // never-started InfraRetry carrying `recoveryWaitMs`). A recovery job has ONE attempt, so a plain throw would fail
1935
+ // it while the run is alive; the delay costs no attempt. It ends when the base settles, or at the run's recovery
1936
+ // deadline, which the host-lost sweep enforces.
1937
+ // Issue #704 review: a secret bundle profile this host does not serve (or declares twice), on a multi-host
1938
+ // worker's SHARED queue (`SecretBundleProfileHold`): handed back for the never-fits gate's recheck without
1939
+ // spending an attempt, so a host that declares the profile can take it. On a single host, or for a job on a
1940
+ // host's own queue, the processor refuses it instead (`secret-bundle-profile-unknown` / `-ambiguous`). Like
1941
+ // the never-fits deferral it waits for as long as no host serves it, and doctor fails on every host that
1942
+ // takes the project's jobs without the profile.
1943
+ //
1944
+ // RAISED IN THE PROCESSOR, not beside the never-fits gate above, deliberately (review round 2 asked): the
1945
+ // decision needs the per-job settings read (the overlay half of the bundle profiles), which comes after the
1946
+ // wait gate and the scope acquire, and the skip rules (chained, manual, opted-out, a changed trigger) that
1947
+ // must run first live in the processor's free gate. Hoisting the lookup alone would judge a job the processor
1948
+ // then skips, and would read the overlay a second time per pickup. The cost of staying is that each recheck
1949
+ // re-runs the gates above it (a held wait is re-asked, the scope is re-acquired, the image and egress
1950
+ // preflights re-run), all free reads, once a minute per held job.
1951
+ if (error?.bundleProfileHold === true) {
1952
+ deps?.log?.("secret_bundle_profile_hold", { jobId: job.id, delayMs: NEVER_FITS_RECHECK_MS, reason: typeof error.reason === "string" ? error.reason : null });
1953
+ await deferTo(job, token, now() + NEVER_FITS_RECHECK_MS, "bundle-profile", { project });
1954
+ }
1955
+ if (Number.isFinite(error?.recoveryWaitMs) && error.recoveryWaitMs > 0) {
1956
+ deps?.log?.("recovery_wait_hold", { jobId: job.id, delayMs: error.recoveryWaitMs });
1957
+ await deferTo(job, token, now() + error.recoveryWaitMs, "recovery-wait", { project, holder: job.data?.recoveryOf });
1958
+ }
1959
+ // Issue #761: a free gate of the processor read something a newer pi-dispatch wrote (`NewerReleaseHold`: the live
1960
+ // triggers file, for the one-shot, skew and approval checks, or a recovery's checkpoint), before the mint and every
1961
+ // reserve. Deferred like the recovery hold above, without spending an attempt, and with nothing settled, recorded or
1962
+ // posted: the job waits for a worker that reads the file. The log names the store, never the error's words.
1963
+ if (error?.newerRelease === true) {
1964
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: typeof error.what === "string" ? error.what : null, delayMs: NEWER_RELEASE_RECHECK_MS });
1965
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", { project });
1966
+ }
1480
1967
  // Issue #476: a job whose egress preflight found the rootless network keeper running on its own bridge but younger
1481
1968
  // than the minimum age is HELD until it is old enough (one `moveToDelayed`, no attempt), the move the hold above
1482
1969
  // makes, since a keeper merely young is not a keeper that does not hold: every joint start of the stack read it
@@ -1493,20 +1980,23 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1493
1980
  if (!restarted && heldMs < NETNS_KEEPER_YOUNG_HOLD_MAX_MS) {
1494
1981
  await job.updateData({ ...job.data, netnsKeeperHoldSinceMs: keeperHold.since, netnsKeeperHoldLastMs: keeperHold.at, netnsKeeperHoldStartedMs: young.startedMs });
1495
1982
  deps?.log?.("netns_keeper_young_hold", { jobId: job.id, keeperAgeMs: young.ageMs, heldForMs: heldMs, delayMs: young.waitMs });
1496
- await job.moveToDelayed(keeperHold.at + young.waitMs, token);
1497
- throw new DelayedError();
1983
+ await deferTo(job, token, keeperHold.at + young.waitMs, "netns-keeper", { project });
1498
1984
  }
1499
1985
  const was = keeperHold.startedMs ?? young.startedMs;
1500
1986
  await markKeeperLoop(job, was === young.startedMs ? [was] : [was, young.startedMs]);
1501
- const loop = new InfraRetry(netnsKeeperCrashLoopSentence({ was, now: young.startedMs, heldMs, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false });
1987
+ const loop = new InfraRetry(netnsKeeperCrashLoopSentence({ was, now: young.startedMs, heldMs, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false, loaded: error.loaded });
1988
+ await settleSteer({ error: loop });
1502
1989
  recordAfterGate({ job, error: loop, startedAt, endedAt: new Date().toISOString() });
1990
+ await noteRetry(job, { project });
1503
1991
  throw loop;
1504
1992
  }
1505
1993
  if (error?.reason === NETNS_KEEPER_NOT_HOLDING && keeperHold.startedMs !== null) {
1506
1994
  // The keeper this job was waiting on is no longer running on its bridge: it died young, the loop's other face.
1507
1995
  await markKeeperLoop(job, [keeperHold.startedMs]);
1508
- const loop = new InfraRetry(netnsKeeperCrashLoopSentence({ was: keeperHold.startedMs, now: null, problem: error.keeperProblem ?? null, heldMs: keeperHold.at - keeperHold.since, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false });
1996
+ const loop = new InfraRetry(netnsKeeperCrashLoopSentence({ was: keeperHold.startedMs, now: null, problem: error.keeperProblem ?? null, heldMs: keeperHold.at - keeperHold.since, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false, loaded: error.loaded });
1997
+ await settleSteer({ error: loop });
1509
1998
  recordAfterGate({ job, error: loop, startedAt, endedAt: new Date().toISOString() });
1999
+ await noteRetry(job, { project });
1510
2000
  throw loop;
1511
2001
  }
1512
2002
  // A LATER ATTEMPT OF A JOB THAT SAW THE LOOP (gate of PR #479). The loop's retry comes after the queue's 60 s
@@ -1516,12 +2006,20 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1516
2006
  // failure of this job after it is still named the loop.
1517
2007
  const loopSeen = job.data?.netnsKeeperLoopSeen;
1518
2008
  if (error?.reason === NETNS_KEEPER_NOT_HOLDING && Array.isArray(loopSeen) && loopSeen.length > 0) {
1519
- const loop = new InfraRetry(netnsKeeperLoopAgainSentence({ seen: loopSeen, problem: error.keeperProblem ?? null, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false });
2009
+ const loop = new InfraRetry(netnsKeeperLoopAgainSentence({ seen: loopSeen, problem: error.keeperProblem ?? null, remedy: error.keeperRemedy }), { reason: NETNS_KEEPER_CRASH_LOOP, provider: error.provider ?? null, model: error.model ?? null, budgetReserved: false, loaded: error.loaded });
2010
+ await settleSteer({ error: loop });
1520
2011
  recordAfterGate({ job, error: loop, startedAt, endedAt: new Date().toISOString() });
2012
+ await noteRetry(job, { project });
1521
2013
  throw loop;
1522
2014
  }
2015
+ await settleSteer({ error });
1523
2016
  recordAfterGate({ job, error, startedAt, endedAt: new Date().toISOString() });
1524
- if (error instanceof InfraRetry) throw error; // retryable: BullMQ retries per attempts
2017
+ if (error instanceof InfraRetry) {
2018
+ // Retryable: BullMQ retries per attempts, after its backoff. The retry's reason is recorded first (issue #737),
2019
+ // since BullMQ delays the job itself after this throw, where no gate of ours can say why.
2020
+ await noteRetry(job, { project });
2021
+ throw error;
2022
+ }
1525
2023
  // A non-retryable, non-infra error (our bug) must not retry forever. UnrecoverableError
1526
2024
  // records it as failed-and-distinct in the queue's failed set without a retry.
1527
2025
  throw new UnrecoverableError(error.message);
@@ -1539,16 +2037,119 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1539
2037
  clearTimeout(timer);
1540
2038
  clearInterval(cancelPoll);
1541
2039
  signal.removeEventListener("abort", onAbort);
2040
+ // After the settle (issue #638): a final outcome stopped it already; an attempt that will be retried leaves its
2041
+ // keys to their 30 s expiry, so a reply in that gap is still held.
2042
+ await awaitKeeper?.stop().catch(() => {});
1542
2043
  }
1543
2044
  };
1544
2045
  return async function processor(job, token, signal) {
1545
- if (!hostBudget) return pickup(job, token, signal, { budgetDeferred: false });
2046
+ // First, before any gate reads the data: a job queued by a newer pi-dispatch, whose fields this build would drop
2047
+ // or misread, waits for a worker that can read it (issue #761). A deferral, never a refusal: a refusal would use up
2048
+ // a forge delivery (its id deduplicates a redelivery for `FORGE_JOB_RETENTION_DAYS`) and spend a one-shot
2049
+ // trigger, and its record would be built from data this build cannot read. Nothing is spent, recorded or posted.
2050
+ if (jobDataNewer(job.data)) {
2051
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: "job data", delayMs: NEWER_RELEASE_RECHECK_MS });
2052
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
2053
+ }
2054
+ // Issue #761: the triggers file on disk was written by a newer pi-dispatch (start.mjs's reload refused it and keeps
2055
+ // the last good schedules). ONE gate for every job, right after the job data's and before every branch below,
2056
+ // rather than a patch in each reader: the file is read live by many of them (the fork policy, the secret bundles,
2057
+ // the model roles, the answer rules, the question grouping, the approvals, the watch rules, the one-shot and skew
2058
+ // checks), and each would otherwise run on its last good copy or refuse. Waiting for this host's upgrade is the one
2059
+ // answer that neither misreads the file nor loses the work. A cron job minted from the last good schedules waits
2060
+ // here too, so no schedule the newer file changed runs as it was. Free, before anything is read, held or spent.
2061
+ if (triggersNewer() === true) {
2062
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: "the triggers file", delayMs: NEWER_RELEASE_RECHECK_MS });
2063
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
2064
+ }
2065
+ // Issue #761: the settings file was written by a newer pi-dispatch. Asked HERE, with the same `getSettings` the
2066
+ // pickup reads, because several refusals run before the pickup's own read (the question grouping's, the never-fits
2067
+ // size, the wait gate's) and each comments, records and disarms a one-shot: one gate makes "every job defers" true
2068
+ // for all of them, where a check in each branch would be one more place to forget. Only the newer answer is acted
2069
+ // on. A throw falls through to the pickup, which reads again and handles it as it always did; nothing is held yet,
2070
+ // so nothing is given back.
2071
+ let headSettings = null;
2072
+ try { headSettings = await getSettings(); } catch {}
2073
+ if (headSettings?.invalid && headSettings.newer === true) {
2074
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: "the settings file", delayMs: NEWER_RELEASE_RECHECK_MS });
2075
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
2076
+ }
2077
+ const sourceRefusal = await checkDraftAnswerSource(job.data, deps);
2078
+ if (sourceRefusal) {
2079
+ return recordBeforePickup(job, sourceRefusal);
2080
+ }
2081
+ if (job.data?.kind === "answer-draft" || job.data?.answerDraft) {
2082
+ if (!deps.answerDraftRuntime) return { outcome: "policy", reason: "answer-draft-unavailable", budgetReserved: false };
2083
+ const resolved = await deps.answerDraftRuntime.resolve(job.data, job.id);
2084
+ // Issue #761: the runtime read a triggers file a newer pi-dispatch wrote (a write that landed before the watcher's
2085
+ // reload said so) and answered before writing anything: the job waits as at the head, without using an attempt.
2086
+ if (typeof resolved.newer === "string") {
2087
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: resolved.newer, delayMs: NEWER_RELEASE_RECHECK_MS });
2088
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
2089
+ }
2090
+ if (resolved.result) {
2091
+ await recordWatchOutcome({ job, result: resolved.result, startedAt: new Date(now()).toISOString(), endedAt: new Date(now()).toISOString() });
2092
+ return resolved.result;
2093
+ }
2094
+ job.data = resolved.data;
2095
+ }
2096
+ if (job.data?.kind === "zero-touch-grouping" || job.data?.questionGrouping) {
2097
+ if (!deps.questionGroupRuntime) return { outcome: "policy", reason: "question-group-unavailable", budgetReserved: false };
2098
+ const resolved = await deps.questionGroupRuntime.resolve(job.data, job.id);
2099
+ // Issue #761: the runtime read a triggers or settings file a newer pi-dispatch wrote (one that turned newer after
2100
+ // the head's checks) and answered before setting any status: the job waits as at the head, never refused.
2101
+ if (typeof resolved.newer === "string") {
2102
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: resolved.newer, delayMs: NEWER_RELEASE_RECHECK_MS });
2103
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
2104
+ }
2105
+ if (resolved.result) {
2106
+ await recordWatchOutcome({ job, result: resolved.result, startedAt: new Date(now()).toISOString(), endedAt: new Date(now()).toISOString() });
2107
+ return resolved.result;
2108
+ }
2109
+ job.data = resolved.data;
2110
+ }
2111
+ if (job.data?.kind === "watch" || job.data?.watch) {
2112
+ if (!deps.watchRuntime) return { outcome: "policy", reason: "watch-unavailable" };
2113
+ // Issue #761: the watch runtime judges its own pause window below (`pauseUntil`, from the last good windows), and
2114
+ // it takes the finding, or files the issue, as soon as that says the target is not paused, before pickup's pause
2115
+ // gate ever runs. A pause windows file written by a newer pi-dispatch may pause the target, so the job waits for
2116
+ // this host's upgrade first, before anything is taken, as every other job does at the pause gate.
2117
+ if (pauseWindowsNewer() === true) {
2118
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: "the pause windows file", delayMs: NEWER_RELEASE_RECHECK_MS });
2119
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
2120
+ }
2121
+ const resolved = await deps.watchRuntime.resolve(job.data, job.id);
2122
+ // Issue #761: the runtime read the triggers file or the settings a newer pi-dispatch wrote (a write that landed
2123
+ // before the watcher's reload said so), and answered BEFORE taking the finding: the job waits as at the head.
2124
+ if (typeof resolved.newer === "string") {
2125
+ deps?.log?.("newer_release_hold", { jobId: job.id, what: resolved.newer, delayMs: NEWER_RELEASE_RECHECK_MS });
2126
+ await deferTo(job, token, now() + NEWER_RELEASE_RECHECK_MS, "newer-release", {});
2127
+ }
2128
+ if (resolved.deferUntil) await deferTo(job, token, resolved.deferUntil, "pause-window");
2129
+ if (resolved.result) {
2130
+ await recordWatchOutcome({ job, result: resolved.result, startedAt: new Date(now()).toISOString(), endedAt: new Date(now()).toISOString() });
2131
+ return resolved.result;
2132
+ }
2133
+ job.data = resolved.data;
2134
+ }
2135
+ // Kept workspaces can hold an arbitrary prepared ref; they never become agent workspaces (#771).
2136
+ const sandboxBoundary = await deps.checkSandboxWorkspace?.(job.data);
2137
+ if (sandboxBoundary?.unavailable) throw new InfraRetry("the local workspace sandbox boundary could not be read");
2138
+ if (sandboxBoundary?.outcome === "policy") {
2139
+ return recordBeforePickup(job, sandboxBoundary);
2140
+ }
2141
+ if (!hostBudget) {
2142
+ const result = await pickup(job, token, signal, { budgetDeferred: false });
2143
+ if (job.data?.answerDraft && result?.outcome !== "completed") await deps.answerDraftRuntime?.refused(job.data, result).catch(() => {});
2144
+ return result;
2145
+ }
1546
2146
  // The pickup's TICKET: the ledger entry this pickup takes carries it, and only this pickup's release or
1547
2147
  // orphan can act on that entry, so a second pickup of the same job id (a stalled scheduled job handed back while
1548
2148
  // its first attempt still runs here) can never give back the first's hold.
1549
2149
  const budgetState = { budgetDeferred: false, ticket: hostBudget.enter(job.id) };
1550
2150
  try {
1551
2151
  const result = await pickup(job, token, signal, budgetState);
2152
+ if (job.data?.answerDraft && result?.outcome !== "completed") await deps.answerDraftRuntime?.refused(job.data, result).catch(() => {});
1552
2153
  hostBudget.forget(job.id);
1553
2154
  return result;
1554
2155
  } catch (error) {
@@ -1569,19 +2170,9 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
1569
2170
  */
1570
2171
  export const CANCEL_STOP_BOUND_MS = 1_000;
1571
2172
 
1572
- /**
1573
- * The forge comments for the two never-fits refusals (issue #596, phase 2). GENERIC on purpose: never the size, the
1574
- * budget or the project, which are operator configuration an issue author can act on none of. The worker log, the run
1575
- * record and doctor name both sizes. There is no fleet refusal (gate round 1 of phase 2): a job on the shared
1576
- * queue waits for a host it fits on.
1577
- */
1578
- export const SIZE_REFUSAL_COMMENTS = Object.freeze({
1579
- "job-size-exceeds-host": "Refused: this job's size is larger than the worker host's job budget, so it could never start there. No container was started and nothing was spent. Ask the operator to lower this project's job size or raise the host budget. Not run.",
1580
- "job-size-exceeds-share": "Refused: this job's size is larger than the share of the worker host's job budget its project may use, so it could never start there. No container was started and nothing was spent. Ask the operator to lower this project's job size or raise its host share. Not run.",
1581
- });
1582
-
1583
- /** The comment for a job the operator cancelled before it started, where it would have been held or retried (gate of PR #479). */
1584
- export const CANCELLED_BEFORE_START_COMMENT = "Stopped: the operator cancelled this run before it started. Nothing was spent. Not retried.";
2173
+ // The never-fits refusals' comments (SIZE_REFUSAL_COMMENTS, issue #596) and the cancelled-before-start one live in
2174
+ // comment-tables.mjs since issue #710, re-exported here under the names they always had.
2175
+ export { CANCELLED_BEFORE_START_COMMENT, SIZE_REFUSAL_COMMENTS };
1585
2176
 
1586
2177
  /**
1587
2178
  * Store the keeper starts a crash loop was seen at on the job (gate of PR #479), outside the hold's 30 s window, so a
@@ -1608,7 +2199,7 @@ export function keeperHoldState(job, at) {
1608
2199
  return { at, since, startedMs };
1609
2200
  }
1610
2201
 
1611
- export function createWorker({ connection, name, stopContainer, containerName, hostQueue = null, checkLease = null, scopeLease = null, checkTimeoutMs, concurrency, getSettings, redis, deps, recordRun, settledRecord = null, previousRecord = null, limiter, pauseUntil, scopedLimits, projects, jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), waitState, afterMaxMs, checkSlots = makeInFlight(), checkSlotCount, concurrencyNow, intervalMs, maxWaitMs, maxChecks, maxFaults, hostSlots = makeInFlight(), endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels, extraClosers = [], hostBudget: hostBudgetOptions = null, runningJobs = makeRunningJobs() }) {
2202
+ export function createWorker({ connection, name, stopContainer, containerName, hostQueue = null, checkLease = null, scopeLease = null, checkTimeoutMs, concurrency, getSettings, readModelRoles, redis, deps, recordRun, settledRecord = null, previousRecord = null, limiter, pauseUntil, pauseWindowsNewer, triggersNewer, scopedLimits, projects, jobSizeEnv = {}, allocation = null, inFlight = makeInFlight(), waitState, afterMaxMs, checkSlots = makeInFlight(), checkSlotCount, concurrencyNow, intervalMs, maxWaitMs, maxChecks, maxFaults, hostSlots = makeInFlight(), endpointSlots = makeInFlight(), endpointLease = null, modelEndpoints = null, overlayModels, extraClosers = [], hostBudget: hostBudgetOptions = null, runningJobs = makeRunningJobs() }) {
1612
2203
  // One Worker per queue name (issue #57). A host-affine job -- one whose folder, secret resolver or wait
1613
2204
  // check lives on THIS machine -- is enqueued to `pi-jobs@<name>` rather than filtered for at pickup,
1614
2205
  // because BullMQ has no selective pop and the put-it-back alternative does not work: promotion out of
@@ -1651,6 +2242,10 @@ export function createWorker({ connection, name, stopContainer, containerName, h
1651
2242
  for (const queueName of names) {
1652
2243
  let worker; // referenced by cancelJob/applyConcurrency before assignment; only called later, so the TDZ is fine
1653
2244
  const processor = makeProcessor({
2245
+ // Issue #761: the endpoint tag of the Valkey this worker's clients dial (`valkeyEndpointTag`), from the judged
2246
+ // connection's own options, the address `parseConnection` read off the pinned URL, so it is the tag doctor computes
2247
+ // from the same URL. Null for a connection with none, which writes no `dollars.reservedIn`.
2248
+ valkeyTag: valkeyEndpointTag(connection),
1654
2249
  // Bound to THIS worker: a job on the host queue is cancelled by the worker draining that queue,
1655
2250
  // and the shared handle could not reach it.
1656
2251
  cancelJob: (id, reason) => worker.cancelJob(id, reason),
@@ -1666,6 +2261,7 @@ export function createWorker({ connection, name, stopContainer, containerName, h
1666
2261
  containerName,
1667
2262
  redis,
1668
2263
  getSettings,
2264
+ readModelRoles,
1669
2265
  // Issue #596, phase 2: the ONE host budget. `multiHost` is whether this worker declared a fleet (a host queue):
1670
2266
  // without one it declares no fleet and no other host is there to wait for, so a size that never fits here is refused rather than
1671
2267
  // deferred for a host that does not exist (gate round 2 of phase 2).
@@ -1679,6 +2275,12 @@ export function createWorker({ connection, name, stopContainer, containerName, h
1679
2275
  for (const w of workers) if (w.concurrency !== n) w.concurrency = n;
1680
2276
  },
1681
2277
  pauseUntil,
2278
+ // Issue #761: whether the pause windows file on disk was written by a newer pi-dispatch (start.mjs keeps it beside
2279
+ // the last good windows). Every job defers `newer-release` at the pause gate while it is.
2280
+ pauseWindowsNewer,
2281
+ // Issue #761: whether the triggers file on disk was written by a newer pi-dispatch (start.mjs keeps it beside the
2282
+ // running schedules). Every job defers `newer-release` at the processor's head while it is.
2283
+ triggersNewer,
1682
2284
  // SHARED across both workers, and that sharing is the point rather than an optimisation: the
1683
2285
  // folder mutex, the per-scope ceiling and the wait-check lease all bound the HOST, so two
1684
2286
  // independent maps would double every one of them exactly as two Workers double concurrency.
@@ -1767,8 +2369,11 @@ export function createWorker({ connection, name, stopContainer, containerName, h
1767
2369
  // what a stranded loop leaks is the rest of the list: `registry.close()` is the DEL that keeps a
1768
2370
  // stopped host from lingering as a ghost peer for its full TTL, and a ghost peer with a stale
1769
2371
  // `fpCron` is what makes a later `reconcileGated` refuse a legitimate reconcile. The comment above
1770
- // promised this isolation before the code delivered it (issue #295). It bounds nothing, though: a
1771
- // closer that never settles still blocks exit, which no closer here does.
2372
+ // promised this isolation before the code delivered it (issue #295). The loop itself bounds nothing:
2373
+ // a closer that never settles still blocks exit, so each closer bounds its own wait. The queues
2374
+ // `start.mjs` hands over did not until issue #813: each one's close waited for a start that never
2375
+ // comes when nothing answers VALKEY_URL, so this stop waited for its supervisor's kill. They now wait
2376
+ // a bounded time for the start and for the close, then disconnect (`closeQueueSettled`, `settleCloseOf`).
1772
2377
  //
1773
2378
  // Read LATE and deliberately: `start.mjs` pushes its live-edit watchers into this array AFTER handing
1774
2379
  // it over, because they are armed after the boot reconcile. Anything here that snapshots or copies