@edgehero/pi-dispatch 4.3.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +118 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +136 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -1,18 +1,32 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { readFileSync, rmSync } from "node:fs";
3
+ import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
4
5
  import { DOCKER_NEVER_STARTED_EXITS } from "./backends.mjs";
5
- import { CONTAINER_HOME, memoryBytesOfArgs } from "./container-spec.mjs";
6
+ import { CONTAINER_GLOBAL_PI_DIR, CONTAINER_HOME, memoryBytesOfArgs } from "./container-spec.mjs";
7
+ import { layoutFor } from "./job-layout.mjs";
6
8
  import { cpuCeilingCenti, cpuRangeRefusal, DEFAULT_JOB_SIZE } from "./job-size.mjs";
7
9
  import { buildDockerRunArgs, CONTAINER_SESSION_FILE, insideDir } from "./docker-run.mjs";
8
- import { createJobNetwork, networkNameFor, removeJobNetwork } from "./egress.mjs";
10
+ import { configError } from "./config.mjs";
11
+ import { createJobNetwork, egressEnv, networkNameFor, removeJobNetwork } from "./egress.mjs";
9
12
  import { buildContainerEnv } from "./env-allowlist.mjs";
13
+ import { authenticExitLines } from "./run-history.mjs";
14
+ import { extensionsNotHere, leavesOut, leftOutOf, rebuildLoaded } from "./loaded-record.mjs";
15
+ import { withCanaryPath } from "./package-canary.mjs";
16
+ import { parsePackageCanary } from "./package-canary-record.mjs";
17
+ import { EXCLUDABLE_TOOL_NAMES } from "./triggers.mjs";
10
18
  import { resolveJobImage } from "./image-preflight.mjs";
19
+ import { LIVE_FILE_NAME, stageLiveFile } from "./live-transcript.mjs";
20
+ import { steerTargetOf } from "./inbox-relay.mjs";
11
21
  import { InfraRetry } from "./processor.mjs";
22
+ import { makeRedactor, secretValuesOf } from "./redact-secrets.mjs";
23
+ import { makeSteerAck } from "./steer-ack.mjs";
24
+ import { keyFrame } from "./steer-framing.mjs";
25
+ import { makeStderrTranscript } from "./stderr-transcript.mjs";
12
26
 
13
27
  /**
14
28
  * The real `runContainer` the processor injects. Launches one job container and returns
15
- * `{ code, aborted, turns, tokens, session, usage, context, exitReason, resources?, exitOomKilled?, memoryLimit? }`, where `aborted` records whether the WORKER initiated the stop (docker stop on
29
+ * `{ code, aborted, turns, tokens, session, usage, context, exitReason, resources?, exitOomKilled?, memoryLimit?, progress? }`, where `aborted` records whether the WORKER initiated the stop (docker stop on
16
30
  * the 30-min timeout or graceful shutdown), which the processor classifies as POLICY (no retry) per
17
31
  * INT-RUNNER-EXIT-CODE-PROTOCOL. The numeric `code` alone cannot say this: a worker SIGKILL and a
18
32
  * kernel OOM both surface as 137, so the abort FLAG -- not the code -- is the discriminator.
@@ -42,7 +56,14 @@ export function makeRunContainer({
42
56
  // REQ-GLOBAL-PI-OVERLAY: container paths of the operator-staged packages. An array, or a RESOLVER called
43
57
  // once per job (issue #102): the wired worker passes a resolver so a re-stage lands on the next job with
44
58
  // no restart, while the array form stays valid for every caller that has a fixed set.
59
+ // Issue #800: a resolver may carry `rowOf(path)`, the `{ name, version }` its last read holds for a path, which names
60
+ // the runner's positional rows, and `stageRead()`, false when this read could not say what is staged. On the resolver,
61
+ // not a second option, because every venue passes this one function through untouched, and a second option could reach a
62
+ // venue without it and record a job's packages with no names.
45
63
  packagePaths = [],
64
+ // Issue #800: the names in `<overlay>/extensions`, which an overlay name on the exit line has to be one of. A seam for
65
+ // the tests. A folder that cannot be read holds nothing the worker can vouch for, so a name then reads as invalid.
66
+ readOverlayExtensions = (dir) => readdirSync(dir),
46
67
  forwardEnv = [],
47
68
  authFromPi = false, // fall back to ~/.pi/agent/auth.json for the provider key when the env has none
48
69
  forgeHosts = {}, // per-forge self-hosted instance URLs, so a forge CLI in the container talks to the right one
@@ -69,7 +90,72 @@ export function makeRunContainer({
69
90
  // the wiring drops the cached runtime facts the ceiling came from and the next pickup reads them again. The local
70
91
  // venue's is the job-user resolver's `invalidate`; a no-op elsewhere (Podman accepts any `--cpus`, measured).
71
92
  onCpuCeilingStale = () => {},
93
+ // Issue #643: `({ file, jobId, redact, job }) => { start, finish, progressSummary? } | null`, the live transcript
94
+ // publisher's opener (live-transcript.mjs `makeLiveTranscriptOpener`), which since issue #644 also reads run progress.
95
+ // Null publishes nothing: a deployment with PI_LIVE_TRANSCRIPTS=0 and PI_RUN_PROGRESS=0, one without a Valkey client,
96
+ // and every caller that predates the feature.
97
+ openLiveTranscript = null,
98
+ // Issue #645: stages the live transcript file for a `run.recover` job the preparer staged none for (both live switches
99
+ // off). A seam so a test can refuse it.
100
+ stageLive = stageLiveFile,
101
+ // Issue #645: the bound on each step of stopping a container whose unpause never took.
102
+ checkpointStopTimeoutMs = 15_000,
103
+ // Issue #646: `({ runId, jobId, inboxDir, targetKey, ref, forge }) => relay`, the inbox relay's opener (inbox-relay.mjs
104
+ // `makeInboxRelay`, bound in start.mjs to the worker's Valkey client and host name). Null arms no job: a steer job is
105
+ // then run without steering and its record says `steerSkipped: "no-transcript"`, never a silent no-op. Issue #686: a
106
+ // stdin-steered attempt opens it with `inboxDir: null` and `frames: { exitKey, macRunId }`, then `attach`es the pipe.
107
+ openInboxRelay = null,
108
+ // Issue #646: the host fs the inbox directory is made and removed through. A seam for the tests.
109
+ inboxFs = { mkdirSync, rmSync },
110
+ // Issue #683: the venue's file layout (job-layout.mjs). `mounts` for every binding venue, byte-identical to before;
111
+ // `sandbox` for a copying one (`openshell`), which moves the session file and the staged packages' container paths
112
+ // under /sandbox and writes PI_JOB_LAYOUT.
113
+ layout = "mounts",
114
+ // Issue #683: the container's memory bound in bytes, given the argv and the options it was built from. Both binding
115
+ // builders spell it `--memory=`, so the default reads the argv; a venue whose argv carries no such flag (an `exec`
116
+ // into a sandbox sized at create) says it from the job's size instead.
117
+ memoryLimitOf = (args) => memoryBytesOfArgs(args),
118
+ // Issue #683: a copying venue's own steps around the CLI child, each awaited. `stageRun({ args, opts })` runs after the
119
+ // argv is built and before the spawn (the venue's create, policy read-back and upload); a throw there is
120
+ // `container-never-started`, refunded and retried, since nothing ran. `collectRun(result)` runs once the child has
121
+ // closed (the download), and returns the result to resolve with. `teardownRun()` runs in the `finally`, on every
122
+ // path a `stageRun` was begun on (the delete). Each is handed THIS attempt's build options (`opts`, the name among
123
+ // them), so a venue running several jobs at once acts on its own sandbox only. Absent, a binding venue's run is
124
+ // exactly what it was.
125
+ stageRun = null,
126
+ collectRun = null,
127
+ teardownRun = null,
128
+ // Issue #686: how this venue's session file reaches the host while the job runs. `file` for every binding venue (the
129
+ // live tail reads the bind-mounted file, byte-identical to before); `stdio` for a copying one (`openshell`), whose
130
+ // runner mirrors the file's lines over the CLI's stderr as records signed with the exit key, which this side
131
+ // verifies and pushes into the same publisher (stderr-transcript.mjs). A CONSTRUCTION option, the venue's own
132
+ // statement beside `layout`, rather than read off the call bag's `binds`: `binds` reaches a steer job only (the
133
+ // registry adds it for that one question), while every job of a copying venue needs this answer, and a venue that
134
+ // copies but cannot mirror (none today) is then named for what it is (`venue-copies`) instead of guessed from a flag.
135
+ transcript = "file",
136
+ // Issue #686: the stderr demux's factory (stderr-transcript.mjs). A seam so a test can make it fail.
137
+ makeDemux = makeStderrTranscript,
138
+ // Review round 1, M1: how long a stdin-steered attempt's pipe stays open after an exit-shaped line that does not verify.
139
+ stdinExitGraceMs = 5_000,
140
+ // Issue #686 part 3, review round 2 of #714: `({ opts, aborted }) => boolean`, a binding venue whose box OUTLIVES its CLI
141
+ // child (`openshell-bind`: the sandbox stays after the exec, and a process the agent left behind can still write the
142
+ // bound host tree). Awaited once the child has closed and BEFORE the transcript's final read and the sink's close, so
143
+ // the last lines and the processor's reads both see a tree nothing writes any more. `false` (not confirmed gone) puts
144
+ // `lingering: true` on the result, which the processor reads (no exit checkpoint; the named result where a retry
145
+ // would be wrong). Absent, every venue's run is exactly what it was.
146
+ settleRun = null,
147
+ // Issue #704 part 3: this venue hands a project secret bundle's plain variables to the runner by FILE (the job
148
+ // directory's bundle-env.json, which its `stageRun` writes), so an attempt with plain bundle variables gets
149
+ // PI_BUNDLE_ENV=1. A construction option, the venue's own statement like `transcript`: every binding venue passes
150
+ // them as env and leaves it off, so its env is byte-identical.
151
+ bundleEnvFile = false,
152
+ // Issue #730: this venue's connections are mediated by a broker that must read the caller's memory (OpenShell), so
153
+ // every attempt gets PI_RUNNER_OBSERVABLE=1 and its key holders start from the readable node. Every other venue
154
+ // leaves it off and its env is byte-identical.
155
+ observableRunner = false,
72
156
  }) {
157
+ // Refused at construction rather than written into a job's env: a guessed layout reads nothing.
158
+ const paths = layoutFor(layout);
73
159
  // async so a synchronous throw (e.g. buildContainerEnv on an unconfigured provider) surfaces as
74
160
  // a rejection, uniformly awaitable by the processor and by tests.
75
161
  // `relabel` (issue #355) is the processor's, off the same job-user answer as `user`: true where the daemon confines
@@ -88,7 +174,27 @@ export function makeRunContainer({
88
174
  // `unenforced` (issue #596) is the size flags this runtime said it drops (`unenforcedSizeFlags`: `--memory-swap` where
89
175
  // Docker reports SwapLimit false, `--cpu-shares` where it reports CPUShares false), logged per job as
90
176
  // `size_bound_unenforced` so a run past its size's bound is named where it happens. The flags stay on the argv.
91
- return async function runContainer({ job, token, prepared, secrets = {}, name, signal, user = null, home = null, relabel = false, modelEndpoints = null, exitAuth = false, size = DEFAULT_JOB_SIZE, hostCpus = null, cpuBudgetCenti = null, unenforced = [] }) {
177
+ // `jobId` (issue #643) is the queue's job id, which names the live transcript stream as it names the run record
178
+ // (`run:live:` and `runs:rec:`, both through `sanitizeJobId`); absent, no stream is published.
179
+ // `onLive(finish)` (issue #644) hears this attempt's bounded, idempotent publisher finish once the publisher is open, so
180
+ // the processor can end it when it stops waiting for a container whose stop did not take (index.mjs
181
+ // `boundAfterAbort`). Without it the publisher ends only on the CLI child's `close` or `error`, which never comes
182
+ // for that container: the progress key's heartbeat rewrote it as running for its whole 7-day window.
183
+ // `checkpoint` (issue #645) is the processor's open args for the run checkpointer (`{ runId, attempt, baseSha, branch,
184
+ // secretValues, meta }`), honoured only beside `recover: true`; `pause`/`unpause` are this job's venue's freezer verbs,
185
+ // which the backend registry puts on the call bag (absent for a venue that cannot freeze: no live checkpoints then).
186
+ // `binds` (issue #646) is the venue's own statement that it bind-mounts (the backend bundle's `binds`, which the registry
187
+ // puts on the call bag of a steer job only): a copying venue would upload `/inbox` once, at the start, so no message
188
+ // written later would ever arrive. Absent is false: a job is steer-armed only on a venue that SAYS it binds.
189
+ // `transcriptStderr` (issue #686) is the processor's, off the image preflight like `exitAuth`: true when the job image
190
+ // declares `transcriptStderr`, so its runner can mirror the session file over stderr. Read only on a `stdio` venue.
191
+ // `steerStdin` (issue #686, part 2) is the processor's too: true when the job image declares `steerStdin`, so its
192
+ // runner reads steer messages as MAC'd frames on stdin. Read only for a steer job on a venue that copies (`binds` not
193
+ // true); a binding venue keeps the read-only mount whatever the image declares.
194
+ // `bundleKeys` (issue #704 part 3) is the processor's `{ endpoints, plain }` split of a project secret bundle's received
195
+ // variables: handed on in the build options for a venue whose own layer holds credentials (openshell), which binds the
196
+ // first kind to their hosts and passes the second as values. Every other venue ignores it: the values are in `secrets`.
197
+ return async function runContainer({ job, token, prepared, secrets = {}, bundleKeys = null, name, signal, user = null, home = null, relabel = false, modelEndpoints = null, exitAuth = false, transcriptStderr = false, steerStdin = false, size = DEFAULT_JOB_SIZE, hostCpus = null, cpuBudgetCenti = null, unenforced = [], jobId = null, onLive = null, recover = false, recovering = false, checkpoint = null, pause = null, unpause = null, binds = false, subscriptionAuth = [] }) {
92
198
  if (signal?.aborted) return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null }; // killed before it could start
93
199
  // Minted per attempt, held in this closure and the sink's, and handed to the container on its stdin only: never in
94
200
  // the argv (a host `ps` shows it), never in the env (the container's /proc/1/environ shows it), never logged.
@@ -100,15 +206,193 @@ export function makeRunContainer({
100
206
  throw new Error(`runContainer: a job user (${user}) must be paired with HOME=${CONTAINER_HOME}`);
101
207
  }
102
208
 
209
+ // Issue #643: the directory holding this job's transcript file, mounted at /session. A resume job's is its staged
210
+ // transcript, exactly as before; every other job's is the empty live file the preparer staged beside it
211
+ // (`prepared.live`, live-transcript.mjs `stageLiveFile`). Resume itself stays keyed on `prepared.session` alone:
212
+ // `awaitInput` below, the promotion and the record's `session` read nothing else.
213
+ let transcriptDir = prepared.session?.hostDir ?? prepared.live?.hostDir ?? null;
214
+ // Issue #645: an armed job keeps its transcript on the host whatever the live switches say. The checkpoints copy it,
215
+ // a salvage reads it from a crashed attempt's job dir, and the live checkpoints are driven by its lines, so with
216
+ // PI_LIVE_TRANSCRIPTS=0 and PI_RUN_PROGRESS=0 (no file staged by the preparer) it is staged here. Not for a resume
217
+ // job, whose staged transcript already is the file.
218
+ const armed = recover === true && checkpoint !== null && typeof checkpoint === "object";
219
+
220
+ // Issue #686 (DES-OPENSHELL-VENUE): whether this attempt's transcript comes over the CLI's stderr. Decided BEFORE
221
+ // steering, because a stdin-steered attempt's acks come back through it. Asked only where
222
+ // something would read it (an opener and a job id) on a venue whose session file is not on the host while it runs,
223
+ // and armed only when every part exists: the venue mirrors (`transcript: "stdio"`), the image's runner can
224
+ // (`transcriptStderr`), and there is an exit key to verify the records with. A missing part is a NAMED skip
225
+ // (`live_transcript_skipped`); the job still runs, with no live transcript and no progress, as before this issue.
226
+ const liveReadable = typeof openLiveTranscript === "function" && jobId !== null && jobId !== undefined;
227
+ let liveSkipped = null;
228
+ if (liveReadable && (transcript === "stdio" || layout !== "mounts")) {
229
+ if (transcript !== "stdio") liveSkipped = "venue-copies";
230
+ else if (transcriptStderr !== true) liveSkipped = "no-capability";
231
+ else if (exitKey === null) liveSkipped = "no-exit-key";
232
+ }
233
+ const stdioArmed = liveReadable && transcript === "stdio" && liveSkipped === null;
234
+ if (liveSkipped !== null) log("live_transcript_skipped", { container: name, reason: liveSkipped });
235
+
236
+ // Issue #646 (DES-STEER-VIA-READONLY-INBOX-MOUNT): whether this attempt is steer-ARMED. Asked by the job's data
237
+ // (`steer: true`, which the trigger's `run.steer` puts there and the image preflight already checked the image for),
238
+ // and armed only when every part of the channel exists: a venue that binds mounts, an exit key to verify the
239
+ // delivery acks with, and something to read those acks (a transcript tail) and to write the files (the relay).
240
+ // Each missing part is a NAMED skip on the record (`steerSkipped`); the job still runs, as it would without
241
+ // `run.steer`, and a message for it is refused at push because nothing ever registers it as open.
242
+ //
243
+ // Issue #686 (INT-STEER-INBOX-CONTRACT's second transport): on a venue that COPIES (`binds` not true) the inbox part
244
+ // is the exec's stdin instead of the mount, when the image's runner reads frames (`steerStdin`). Its parts are the
245
+ // same key, a relay, and the stdio transcript (`stdioArmed`, above), which is where the acks come back; without that
246
+ // the attempt is `no-transcript`, as a binding venue without a tail is. An image without `steerStdin` there is
247
+ // skipped exactly as before, `venue-copies`. A binding venue never takes this path, whatever the image declares.
248
+ const steerAsked = job?.steer === true;
249
+ const viaStdin = binds !== true && steerStdin === true;
250
+ let steerSkipped = null;
251
+ if (steerAsked) {
252
+ if (binds !== true && steerStdin !== true) steerSkipped = "venue-copies";
253
+ else if (exitKey === null) steerSkipped = "no-exit-key";
254
+ else if (typeof openInboxRelay !== "function" || typeof openLiveTranscript !== "function" || jobId === null || jobId === undefined) steerSkipped = "no-transcript";
255
+ else if (viaStdin && !stdioArmed) steerSkipped = "no-transcript";
256
+ }
257
+ if (transcriptDir === null && (recover === true || (steerAsked && steerSkipped === null)) && typeof stageLive === "function") {
258
+ try {
259
+ transcriptDir = stageLive(prepared.jobDir)?.hostDir ?? null;
260
+ } catch {
261
+ transcriptDir = null;
262
+ }
263
+ }
264
+ if (steerAsked && steerSkipped === null && transcriptDir === null) steerSkipped = "no-transcript";
265
+ // The inbox: `<jobDir>.inbox`, BESIDE the job directory (`/job` is merge-gated input only, and the processor removes
266
+ // the job directory itself), mode 0700, made fresh per attempt (a retry's messages are written again into its own),
267
+ // removed in this function's `finally`. Then the relay, built now (it starts with the container) because the env
268
+ // and the argv below depend on whether the attempt is armed.
269
+ const inboxDir = `${prepared.jobDir}.inbox`;
270
+ let inboxMade = false;
271
+ let relay = null;
272
+ // The stdin transport has no directory: its messages go down the pipe.
273
+ if (steerAsked && steerSkipped === null && !viaStdin) {
274
+ try {
275
+ inboxFs.rmSync(inboxDir, { recursive: true, force: true });
276
+ inboxFs.mkdirSync(inboxDir, { mode: 0o700 });
277
+ inboxMade = true;
278
+ } catch {
279
+ steerSkipped = "no-inbox";
280
+ }
281
+ }
282
+ if (steerAsked && steerSkipped === null) {
283
+ try {
284
+ // `runId` is the RUN's id (`jobId` here is `runIdOf(job)`, index.mjs), so a #645 recovery writes the same inbox.
285
+ // Issue #686: the stdin transport's relay MACs each frame under this attempt's key and the container's PI_JOB_ID
286
+ // (`name`, as the ack's `ackRunId`); a binding venue's relay is opened with exactly the arguments it always was.
287
+ relay = openInboxRelay({ runId: jobId, jobId, inboxDir: viaStdin ? null : inboxDir, ...steerTargetOf(job), ...(viaStdin ? { frames: { exitKey, macRunId: name } } : {}) }) ?? null;
288
+ } catch {
289
+ relay = null;
290
+ }
291
+ if (relay === null) steerSkipped = "no-transcript";
292
+ }
293
+ const steerArmed = steerAsked && steerSkipped === null;
294
+ // Issue #686: this attempt's key goes down stdin as frame 1 and the pipe stays OPEN for the relay's frames.
295
+ const stdinSteered = steerArmed && viaStdin;
296
+ if (steerSkipped !== null) log("steer_skipped", { container: name, reason: steerSkipped });
297
+ // The ack consumer, handed to the live tail below. It verifies under THIS attempt's exit key, against the very string
298
+ // put in the container's PI_JOB_ID (`jobId: name` in the env below), which is what the runner MACs: one value on both
299
+ // sides by construction. The per-attempt key is the domain separation between attempts; the string only has to match.
300
+ const steerAck = steerArmed ? makeSteerAck({ exitKey, ackRunId: name, relay, jobId, log }) : null;
301
+
302
+ const removeInbox = () => {
303
+ if (!inboxMade) return;
304
+ try {
305
+ inboxFs.rmSync(inboxDir, { recursive: true, force: true });
306
+ } catch {
307
+ // best effort: a leftover directory holds framed text the worker wrote, beside the job dir the sweep removes
308
+ }
309
+ };
310
+
103
311
  // Closed env allowlist: only the provider key + the declared PI_* vars. Throws (config) if
104
312
  // the provider is unconfigured, which the processor turns into a policy refusal that refunds the
105
313
  // reserve. That sentence was aspirational until issue #310: the processor did not read the
106
314
  // `piDispatchConfig` tag at all, so the throw fell through to a bare rethrow with the budget kept.
107
315
  // The common case no longer reaches here either, because the processor probes the same resolution
108
316
  // among its free gates, ahead of the mint and the clone.
109
- const env = buildContainerEnv({
317
+ // Issue #646: a throw from here to the spawn (an unconfigured provider, a refused argv, a network that would not build)
318
+ // happens before the `finally` below exists, so the inbox directory made above is removed on the way out.
319
+ const beforeSpawn = (fn) => {
320
+ try {
321
+ return fn();
322
+ } catch (error) {
323
+ removeInbox();
324
+ throw error;
325
+ }
326
+ };
327
+ // Issue #796: a canary's candidate in place of the staged copy, by this layout's own `/job` path, so that it means the same on every venue.
328
+ const canaryPlan = prepared.packageCanary ?? null;
329
+ const withCanary = (list) => (canaryPlan ? withCanaryPath(list, canaryPlan, paths.job).paths : list);
330
+ const canaryPathOf = () => (canaryPlan ? withCanaryPath([], canaryPlan, paths.job).candidate : undefined);
331
+ // The staged set, read once for this job (issue #102), so the rows that name a package below are the read that chose
332
+ // its path. `!== false`, because staged packages LOAD unless a trigger explicitly opts out
333
+ // (INT-TRIGGERS-FILE-CONTRACT). The strictness that used to live in this `=== true` did not disappear, it moved:
334
+ // parseTriggers refuses any non-boolean run.packages fail-loud at load, so a hand-edited string "false" never becomes
335
+ // job data this comparison could misread as an opt-out. The opt-out short-circuits BEFORE the resolver runs: a trigger
336
+ // that withheld the staged set has no reason to make the worker read the manifest on its behalf.
337
+ const everyStaged = beforeSpawn(() => (job.packages === false ? [] : typeof packagePaths === "function" ? packagePaths() : packagePaths));
338
+ // Issue #800: run.excludeExtensions, by the manifest name of the read that chose these paths. Here and not in the resolver, which has
339
+ // no job. A path this cannot name is refused rather than loaded: a resolver with no `rowOf` is no wired worker, and loading what the
340
+ // file takes away is the one answer this must never give. The processor reads the tag as a free refusal.
341
+ const excludedNames = Array.isArray(job.excludeExtensions) ? job.excludeExtensions : [];
342
+ const stagedRowOf = (p) => (typeof packagePaths === "function" ? (packagePaths.rowOf?.(p) ?? null) : null);
343
+ const stagedPaths = excludedNames.length === 0 ? everyStaged : beforeSpawn(() => everyStaged.filter((p) => {
344
+ const row = stagedRowOf(p);
345
+ if (row === null) throw configError("run.excludeExtensions names extensions, and this worker cannot tell which staged package a path is");
346
+ return !excludedNames.includes(row.name);
347
+ }));
348
+ // Issue #683: the staged packages' container paths are the overlay's, so on the sandbox layout their prefix moves with
349
+ // it (`/opt/pi-global` to `/sandbox/pi-global`). A path not under the overlay is passed as it was.
350
+ const movedPath = (p) => (layout !== "mounts" && typeof p === "string" && p.startsWith(`${CONTAINER_GLOBAL_PI_DIR}/`) ? `${paths.global}${p.slice(CONTAINER_GLOBAL_PI_DIR.length)}` : p);
351
+ const jobPackagePaths = withCanary(stagedPaths.map(movedPath));
352
+ // Issue #800: what the runner's `loaded` is checked against (loaded-record.mjs), from what this attempt hands the job.
353
+ const stagedRows = new Map(stagedPaths.map((p) => [movedPath(p), stagedRowOf(p)]));
354
+ let overlayExtensions = [];
355
+ let overlayListed = true;
356
+ try {
357
+ overlayExtensions = globalPiDir ? readOverlayExtensions(join(globalPiDir, "extensions")) : [];
358
+ } catch (error) {
359
+ overlayExtensions = [];
360
+ // No folder is no extensions, as `readOverlayExtensionRows` reads it: an overlay of packages alone holds none, so a typo there is a name this host does
361
+ // not hold, and reading it as could-not-say would record it as left out. Only a read that failed some other way holds nothing to vouch for.
362
+ overlayListed = error?.code === "ENOENT";
363
+ }
364
+ // Issue #800: which of those names this host holds neither way, for the record. Only where both sides were read: a job with its packages
365
+ // withheld never reads the stage, a manifest that did not read says nothing is staged only by accident, and a folder that could not be listed
366
+ // holds nothing this can vouch for, so none of them says a name is absent. `=== false`, so an array of paths or a resolver with no such word stands as read.
367
+ const stageRead = typeof packagePaths !== "function" || packagePaths.stageRead?.() !== false;
368
+ const excludedNotHere = job.packages !== false && overlayListed && stageRead ? extensionsNotHere(excludedNames, { packages: everyStaged.map(stagedRowOf), overlay: overlayExtensions }) : [];
369
+ const excludedTools = Array.isArray(job.excludeExtensionTools) ? job.excludeExtensionTools : [];
370
+ // Whether a name in run.excludeExtensionTools that no staged package or overlay extension brought stops this job (`exclude-tool-not-loaded`): only one that should have
371
+ // held every extension it could. A trigger withholding packages or leaving one out, a host with the overlay shut, a chained child (its
372
+ // parent's list, for a job that may load less) and a restricted run may each lack the tool with the name right, so there it is only said.
373
+ const excludeToolsStrict = excludedTools.length > 0 && job.packages !== false && excludedNames.length === 0 && allowGlobalExtensions !== false && job.parentJobId === undefined && !(job.watch || job.answerDraft || job.questionGrouping);
374
+ const loadedKnown = {
375
+ packages: jobPackagePaths.map((p) => (p === canaryPathOf() ? parsePackageCanary(job.packageCanary) : (stagedRows.get(p) ?? null))),
376
+ overlayExtensions,
377
+ servers: Array.isArray(job.mcp) ? job.mcp : [],
378
+ ownTools: EXCLUDABLE_TOOL_NAMES,
379
+ // The closure's value, not the job's below: this worker's switch (the host's, or a canary replay's) and a restricted
380
+ // job's forced one are different statements.
381
+ leftOut: leftOutOf(job, { allowGlobalExtensions, excludedNotHere }),
382
+ // What the line's `unmatched` positions index: PI_EXCLUDE_EXTENSION_TOOLS, as this attempt hands it over.
383
+ excludeExtensionTools: excludedTools,
384
+ // A word from a closed list, never line text: the line is what is not trusted here.
385
+ onViolation: (why) => log("loaded_invalid", { container: name, why }),
386
+ // How many and no name: the record says only that a name was left out, and this says why.
387
+ onWithheld: (count) => log("loaded_names_withheld", { container: name, count }),
388
+ };
389
+ const env = beforeSpawn(() => buildContainerEnv({
390
+ size,
110
391
  provider: job.provider,
392
+ ...(job.answerDraft && job.answerDecisionModel ? { secondaryProvider: job.answerDecisionModel.provider } : job.questionGrouping && job.questionGroupModel ? { secondaryProvider: job.questionGroupModel.provider } : job.watch && job.watchDecisionModel ? { secondaryProvider: job.watchDecisionModel.provider } : {}),
111
393
  model: job.model,
394
+ fork: job.fork ?? null,
395
+ thinkingLevel: job.thinkingLevel ?? null,
112
396
  maxTurns: job.maxTurns,
113
397
  maxTokens: job.maxTokens, // optional per-job token budget (issue #25); undefined => runner meter only
114
398
  maxCostMicros: job.maxCostMicros ?? null, // issue #501: effectiveJobOf's min of trigger and deployment; null => no cap
@@ -120,21 +404,20 @@ export function makeRunContainer({
120
404
  hostEnv,
121
405
  egress, // REQ-EGRESS-ALLOWLIST: emits HTTPS_PROXY/HTTP_PROXY/NO_PROXY/NODE_USE_ENV_PROXY, or nothing
122
406
  egressProxy,
123
- allowGlobalExtensions, // REQ-GLOBAL-PI-OVERLAY: false emits the explicit PI_GLOBAL_ALLOW_EXTENSIONS=0 opt-out
407
+ allowGlobalExtensions: job.answerDraft || job.watch || job.questionGrouping ? false : allowGlobalExtensions,
124
408
  // REQ-GLOBAL-PI-OVERLAY: the per-job value comes off `job` (like maxTurns), the staged set off
125
409
  // the closure (like allowGlobalExtensions) -- so a trigger can withhold what the operator staged.
126
- // `!== false`, because staged packages LOAD unless a trigger explicitly opts out
127
- // (INT-TRIGGERS-FILE-CONTRACT). The strictness that used to live in this `=== true` did not
128
- // disappear, it moved: parseTriggers refuses any non-boolean run.packages fail-loud at load, so a
129
- // hand-edited string "false" never becomes job data this comparison could misread as an opt-out.
130
- // The opt-out short-circuits BEFORE the resolver runs: a trigger that withheld the staged set has no
131
- // reason to make the worker read the manifest on its behalf.
132
- packagePaths: job.packages === false ? [] : typeof packagePaths === "function" ? packagePaths() : packagePaths,
133
- forwardEnv, // extra host var names to forward (e.g. a custom provider's key)
134
- // REQ-RESUMABLE-SESSION: the fixed container path, emitted only when this job HAS a transcript.
135
- // The constant is imported rather than re-typed so the mount below and this variable name one
136
- // path -- two literals is how they drift with both suites green.
137
- sessionFile: prepared.session ? CONTAINER_SESSION_FILE : undefined,
410
+ packagePaths: jobPackagePaths,
411
+ packageCanaryPath: canaryPathOf(),
412
+ forwardEnv: job.answerDraft || job.watch || job.questionGrouping ? [] : forwardEnv,
413
+ // REQ-RESUMABLE-SESSION: the fixed container path, emitted only when this job HAS a transcript file, which
414
+ // since issue #643 is every prepared job (the live file), resume or not. The constant is imported rather than
415
+ // re-typed so the mount below and this variable name one path -- two literals is how they drift with both
416
+ // suites green. On an empty file the runner opens a fresh session there (image/runner/src/session.mjs), the
417
+ // same path a cold-starting resume job takes, so the image needs no change.
418
+ // Issue #683: under the layout's session directory, which on `mounts` is CONTAINER_SESSION_FILE exactly.
419
+ sessionFile: transcriptDir ? (layout === "mounts" ? CONTAINER_SESSION_FILE : `${paths.session}/current.jsonl`) : undefined,
420
+ layout,
138
421
  // Issue #189: the flow name, structurally, so the runner can verify it against the loaded
139
422
  // skill set. Off `job` like maxTurns; absent (a bare run.task cron job) emits no variable.
140
423
  flow: typeof job.flow === "string" && job.flow.trim() !== "" ? job.flow : undefined,
@@ -146,15 +429,33 @@ export function makeRunContainer({
146
429
  // non-empty validated array; the guard is the same defensive shape `flow` above wears, so a
147
430
  // hand-built job with junk in the field emits no variable rather than an empty one.
148
431
  excludeTools: Array.isArray(job.excludeTools) && job.excludeTools.length > 0 ? job.excludeTools : undefined,
432
+ excludeExtensions: excludedNames.length > 0 ? excludedNames : undefined,
433
+ excludeExtensionTools: excludedTools.length > 0 ? excludedTools : undefined,
434
+ excludeToolsStrict,
149
435
  // REQ-MCP-OPERATOR-SERVERS: the trigger's MCP server names, off `job` like excludeTools.
150
436
  mcpServers: Array.isArray(job.mcp) && job.mcp.length > 0 ? job.mcp : undefined,
151
437
  // Issue #625: whether the runner offers the tool a parked run ends on. Only beside a staged transcript: the
152
438
  // runner refuses the flag without one (await-input-without-session), so a job whose resume degraded to no
153
439
  // mount is not armed into that refusal.
154
440
  awaitInput: job.awaitInput === true && prepared.session ? true : undefined,
441
+ approvalGates: job.gates ?? null,
442
+ approvalDecision: job.approvalDecision ?? null,
443
+ // Issue #644: whether the runner offers `report_progress`, off `job` like awaitInput (env-allowlist.mjs maps it to
444
+ // PI_REPORT_PROGRESS=1). The worker reads the phases it reports only for the same job (`job.progress` below).
445
+ reportProgress: job.progress === true ? true : undefined,
446
+ // Issue #645, durable runs: off the call bag, NOT off `job`. Whether this attempt keeps checkpoints and whether it
447
+ // is the continuation of a lost one are the processor's decisions, made against the checkpoint store and the
448
+ // image's `recover` capability; a `job.recover` read here would arm a container the processor never cleared.
449
+ // Strict `=== true` both ways, so a truthy non-boolean arms nothing.
450
+ recover: recover === true,
451
+ recovering: recovering === true,
452
+ // Issue #646: PI_STEER=1, only for an ARMED attempt (above), never off `job.steer` alone: the runner refuses the
453
+ // flag without a readable /inbox and a key, and a job the worker could not arm must not be refused for it.
454
+ steer: steerArmed,
155
455
  // Issue #502: the effective allowed-model list, off `job` like excludeTools. index.mjs `effectiveJobOf` folds
156
456
  // the deployment's PI_ALLOWED_MODELS in under the trigger's own, so `job.models` is already the one list.
157
457
  allowedModels: Array.isArray(job.models) && job.models.length > 0 ? job.models : null,
458
+ subscriptionAuth,
158
459
  authFromPi, // source the provider key from pi's auth.json when the env has none
159
460
  // REQ-TRIGGER-SECRETS: this trigger's resolved secrets, fetched by the processor BEFORE anything
160
461
  // spent. Off the call bag rather than off `job` or the closure: it is neither a per-job fact the
@@ -163,7 +464,22 @@ export function makeRunContainer({
163
464
  home: user !== null ? home : null, // issue #341: HOME only beside --user, assigned after the forward loops
164
465
  modelEndpoints, // issue #503: the keyless branch of the credential, from the gate's own snapshot
165
466
  exitAuth: exitKey !== null, // issue #545: PI_EXIT_AUTH=stdin, only when a key is actually handed over below
166
- });
467
+ // Issue #686: PI_TRANSCRIPT=stderr, only for an attempt armed above, never off the image's label alone: the
468
+ // worker must be reading the records, or they would reach the job log as noise.
469
+ transcriptStderr: stdioArmed,
470
+ // Issue #686: PI_EXIT_AUTH=stdin-frames, only for an attempt armed above for stdin steering (beside PI_STEER=1).
471
+ steerStdin: stdinSteered,
472
+ bundleEnvFile: bundleEnvFile === true && Array.isArray(bundleKeys?.plain) && bundleKeys.plain.length > 0,
473
+ observableRunner: observableRunner === true,
474
+ }));
475
+ // Issue #800: a tool name is the one string on the line nothing here can compare with anything, so at least it must
476
+ // not hold this job's own secrets. Built off THIS job's env, as the transcript's is below. If it cannot be built, nothing
477
+ // can be judged: every name reads as one it would change and is withheld, and a `pi` reads as invalid, rather than as fine.
478
+ try {
479
+ loadedKnown.redact = makeRedactor({ values: secretValuesOf(env, [token, exitKey], { workerSet: egressEnv({ proxy: egressProxy, armed: egress }) }) });
480
+ } catch {
481
+ loadedKnown.redact = () => "";
482
+ }
167
483
 
168
484
  // `-net` on this container's own name (egress.mjs). null when no policy is armed, and docker-run's
169
485
  // guard then omits the flag entirely, so the argv is byte-identical to one built before this feature.
@@ -172,6 +488,9 @@ export function makeRunContainer({
172
488
  // Issue #345: BESIDE the job directory, never inside the `/job:ro` mount. Removed first because the docker CLI refuses
173
489
  // to start with an existing cidfile; every attempt's job dir is a fresh mkdtemp, so a leftover is not expected, and
174
490
  // this keeps one from turning into a start failure if a caller ever reuses a directory.
491
+ // env-internal PI_MODEL_ROLES_FILE: closed read-only job input, set by the worker.
492
+ // env-internal PI_AGENTS_FILE: base-commit agent snapshot beside the role map.
493
+ if (job.modelRoles) { env.PI_MODEL_ROLES_FILE = `${paths.job}/model-roles.json`; env.PI_AGENTS_FILE = `${paths.job}/agents.json`; }
175
494
  const cidFile = `${prepared.jobDir}.cid`;
176
495
  try {
177
496
  fs.rmSync(cidFile, { force: true });
@@ -179,7 +498,7 @@ export function makeRunContainer({
179
498
  // an unremovable stale file makes the run itself fail to start, which is reported as that
180
499
  }
181
500
 
182
- const args = buildArgs({
501
+ const buildOpts = {
183
502
  // Same split as packagePaths above: the per-job value off `job`, the deployment value off the closure,
184
503
  // so a trigger can name its own toolchain (INT-TRIGGERS-FILE-CONTRACT). Resolved through the SAME
185
504
  // function the pre-spend preflight uses (image-preflight.mjs), so the tag that was checked is the tag
@@ -189,9 +508,11 @@ export function makeRunContainer({
189
508
  jobDir: prepared.jobDir,
190
509
  workspace: prepared.workspace,
191
510
  outboxDir: prepared.outboxDir, // undefined for github jobs -> docker-run's guard skips the /outbox mount
192
- // The job's OWN copy, under jobDir -- never the shared store. Undefined when the trigger did not
193
- // arm run.resume or no key resolved, and docker-run's guard then skips the mount entirely.
194
- sessionDir: prepared.session?.hostDir,
511
+ // The job's OWN copy, under jobDir -- never the shared store. Undefined only when no transcript file was staged
512
+ // (a caller that predates issue #643, or a staging that failed), and docker-run's guard then skips the mount.
513
+ sessionDir: transcriptDir ?? undefined,
514
+ // Issue #646: the inbox, read-only at /inbox, for an armed attempt only; undefined leaves the argv byte-identical.
515
+ steerInboxDir: steerArmed && !stdinSteered ? inboxDir : undefined,
195
516
  globalPiDir, // undefined/null -> docker-run's guard skips the /opt/pi-global mount
196
517
  name,
197
518
  size, // issue #596: memory, swap equal to it, the CPU weight and /dev/shm, through containerSpec's one function
@@ -207,15 +528,19 @@ export function makeRunContainer({
207
528
  // Both facts, the kind the preparers branch on AND containment in this job's own directory, so a kind added later
208
529
  // that works on a folder in place fails closed rather than relabelling it.
209
530
  workspaceOwned: job?.kind !== "local" && insideDir(prepared.jobDir, prepared.workspace),
531
+ workspaceReadOnly: Boolean(job.answerDraft || job.watch || job.questionGrouping),
210
532
  // Issue #545: `-i`, so the container's stdin is the pipe the key is written to. Absent otherwise, and the argv is
211
533
  // byte-identical to one built before the signed exit line.
212
534
  ...(exitKey !== null ? { extraFlags: ["-i"] } : {}),
213
- });
535
+ // Issue #704 part 3: absent without a bundle, so every other build is byte-identical.
536
+ ...(bundleKeys !== null && typeof bundleKeys === "object" ? { bundleKeys } : {}),
537
+ };
538
+ const args = beforeSpawn(() => buildArgs(buildOpts));
214
539
 
215
540
  // Issue #596: the memory bound this container actually got, in bytes, read off the argv the runtime is handed (both
216
541
  // builders spell it `--memory=`, from the job's size), never re-derived from the environment or the size. The
217
542
  // processor confirms an OOM only when the run's peak reached 90% of it. null when the argv names none.
218
- const memoryLimit = memoryBytesOfArgs(args);
543
+ const memoryLimit = memoryLimitOf(args, buildOpts);
219
544
  if (!args.some((a) => typeof a === "string" && a.startsWith("--cpus="))) log("cpu_ceiling_unknown", { container: name });
220
545
  if (Array.isArray(unenforced) && unenforced.length > 0) log("size_bound_unenforced", { container: name, flags: unenforced.filter((f) => typeof f === "string" && /^--[a-z-]{1,32}$/.test(f)) });
221
546
 
@@ -228,20 +553,228 @@ export function makeRunContainer({
228
553
  // A failure to build it is INFRA, not policy: nothing has been spent, a retry may well succeed, and
229
554
  // `container-never-started` is literally true, so the reservation is given back (processor.mjs).
230
555
  if (network && !(await createJobNetwork(spawnFn, { network, proxy: egressProxy, bin }))) {
556
+ removeInbox();
231
557
  throw new InfraRetry("container-never-started", { reason: "container-never-started" });
232
558
  }
233
559
 
560
+ // Issue #683: a copying venue's create and upload, before anything spawns the job. Its teardown is owed from the
561
+ // moment it begins, so a stage that fails half way (a sandbox made, an upload refused) is still deleted.
562
+ let staged = false;
563
+ const teardown = async () => {
564
+ if (!staged || typeof teardownRun !== "function") return;
565
+ try {
566
+ await teardownRun({ opts: buildOpts });
567
+ } catch {
568
+ // best effort: the reaper deletes what this left, by the job's label
569
+ }
570
+ };
571
+ if (typeof stageRun === "function") {
572
+ staged = true;
573
+ try {
574
+ // Issue #684: the job and the pickup's endpoint snapshot ride with it, for the venue's per-job network policy.
575
+ await stageRun({ args, opts: buildOpts, signal, job, modelEndpoints });
576
+ } catch (cause) {
577
+ await teardown();
578
+ removeInbox();
579
+ if (network) await removeJobNetwork(spawnFn, { network, proxy: egressProxy, bin });
580
+ if (cause instanceof InfraRetry) throw cause;
581
+ throw new InfraRetry("container-never-started", { cause, reason: "container-never-started" });
582
+ }
583
+ // A stop that landed while the venue was still creating or uploading (the job's timeout, a shutdown): its own
584
+ // stop found nothing to delete yet, so nothing would stop the exec. The run ends here, as stopped, and the
585
+ // teardown deletes what the stage made.
586
+ if (signal?.aborted) {
587
+ await teardown();
588
+ removeInbox();
589
+ if (network) await removeJobNetwork(spawnFn, { network, proxy: egressProxy, bin });
590
+ return { code: 137, aborted: true, turns: null, tokens: null, session: null, usage: null, context: null, exitReason: null };
591
+ }
592
+ }
593
+
594
+ // Issue #643: the live transcript publisher, opened only beside a transcript file, an opener and a job id. Its
595
+ // redactor is built from THIS job's env (the secret-bearing keys, redact-secrets.mjs) plus the two credentials that
596
+ // are not in it by name or at all: the minted token and the exit-line key. An opener that throws costs the stream,
597
+ // never the run.
598
+ let live = null;
599
+ // Question grouping reads the canonical (raw) transcript after promotion. Keep the exact job-env
600
+ // redactor available even when live streaming is disabled: token shapes alone miss forwarded values.
601
+ try { Object.defineProperty(prepared, "questionRedact", { value: makeRedactor({ values: secretValuesOf(env, [token, exitKey], { workerSet: egressEnv({ proxy: egressProxy, armed: egress }) }) }), configurable: true }); }
602
+ catch { log("question_redactor_unavailable", {}); }
603
+ if (job.watch) Object.defineProperty(prepared, "watchRedact", { value: makeRedactor({ values: secretValuesOf(env, [token, exitKey], { workerSet: egressEnv({ proxy: egressProxy, armed: egress }) }) }), configurable: true });
604
+ // Issue #645: a container whose unpause failed UNPAUSE_TRIES times never runs again, and the worker would wait on it
605
+ // for the whole job timeout. It is killed (SIGKILL reaches a frozen container: the runtime thaws it to deliver it),
606
+ // then removed if the kill was refused, and the run ends as an infrastructure exit (`checkpointStopped`).
607
+ let checkpointStopped = false;
608
+ const stopFrozen = async () => {
609
+ checkpointStopped = true;
610
+ log("checkpoint_container_stopped", { container: name });
611
+ const killed = await dockerStep(spawnFn, ["kill", name], checkpointStopTimeoutMs, bin);
612
+ if (killed.code !== 0) await dockerStep(spawnFn, ["rm", "-f", name], checkpointStopTimeoutMs, bin);
613
+ };
614
+ // Issue #686: on a copying venue the host file is the upload's copy, never written while the job runs, so it is not
615
+ // tailed: the transcript is the stderr records' (`stdioArmed`) or there is none (`liveSkipped`).
616
+ if (liveReadable && liveSkipped === null && (stdioArmed || transcriptDir)) {
617
+ try {
618
+ // `workerSet`: the proxy variables count as structural only with the values this worker wrote for this job's
619
+ // egress; a proxy URL an operator forwarded with PI_EGRESS=0 is redacted like any other forwarded value.
620
+ const workerSet = egressEnv({ proxy: egressProxy, armed: egress });
621
+ // `job` (issue #644) for its two switches, `progress` (read phases) and `statusComment` (the forge comment).
622
+ // `checkpoint` (issue #645): the open args this side adds to the processor's, the container's name and venue
623
+ // verbs, the workspace and job dir, and `onStop` for an unpause that never took. Only for an armed job.
624
+ const checkpointArgs = armed
625
+ ? {
626
+ ...checkpoint,
627
+ containerName: name,
628
+ pause: typeof pause === "function" ? pause : null,
629
+ unpause: typeof unpause === "function" ? unpause : null,
630
+ workspace: prepared.workspace ?? null,
631
+ jobDir: prepared.jobDir ?? null,
632
+ onStop: () => stopFrozen(),
633
+ }
634
+ : null;
635
+ // The stdio source has no file; a checkpoint copies a host file, so none is opened on it (a copying venue refuses
636
+ // `run.recover` before spend). The steer acks ride it as they ride a tailed file (issue #686): the records are
637
+ // the session's lines, so a delivered message's `custom_message` reaches the ack consumer the same way.
638
+ const source = stdioArmed ? { source: "stdio", file: null } : { file: join(transcriptDir, LIVE_FILE_NAME) };
639
+ live = openLiveTranscript({ ...source, jobId, redact: makeRedactor({ values: secretValuesOf(env, [token, exitKey], { workerSet }) }), job, ...(checkpointArgs && !stdioArmed ? { checkpoint: checkpointArgs } : {}), ...(steerAck ? { steer: steerAck } : {}) }) ?? null;
640
+ } catch {
641
+ live = null;
642
+ }
643
+ }
644
+ // Issue #644: no publisher means no progress, so a trigger's `run.statusComment` cannot be honoured. Said once, by
645
+ // reason: both switches off (no opener), or no transcript file or job id to read progress from. Issue #686: a
646
+ // copying venue's skipped transcript is `no-transcript` too (its own reason is on `live_transcript_skipped`); before
647
+ // the openshell bundle was handed the opener it read `progress-off` there, which named the wrong switch.
648
+ if (job?.statusComment === true && live === null) log("status_comment_skipped", { jobId: jobId ?? null, reason: typeof openLiveTranscript === "function" ? "no-transcript" : "progress-off" });
649
+ // Bounded and never throwing (live-transcript.mjs); called at most once, and always BEFORE this function resolves,
650
+ // so before the processor's `finally` removes the job directory the file lives in.
651
+ const finishLive = async (outcome) => {
652
+ try {
653
+ await live?.finish(outcome);
654
+ } catch {}
655
+ };
656
+ // Issue #646: an armed attempt whose tail could not be opened can deliver messages that no ack will ever count; they
657
+ // settle as late. Said, never silent.
658
+ if (steerArmed && live === null) log("steer_acks_unread", { container: name });
659
+ // Issue #646: the relay's end, AFTER the tail's final read (`finishLive`), so a last-moment ack is handed to the relay
660
+ // and marked before the liveness keys go; the settle step's close (index.mjs) then sees it as delivered. Once.
661
+ let relayStopping = null;
662
+ const stopRelay = () => {
663
+ relayStopping ??= (async () => {
664
+ try {
665
+ await steerAck?.drain();
666
+ } catch {}
667
+ try {
668
+ await relay?.stop();
669
+ } catch {}
670
+ })();
671
+ return relayStopping;
672
+ };
673
+ // Issue #645: the checkpoint handle the opener built for an armed job, or null. Never throws (run-checkpoint.mjs).
674
+ const ckpt = live?.checkpoint && typeof live.checkpoint.close === "function" ? live.checkpoint : null;
675
+ const closeCheckpoint = async () => {
676
+ try {
677
+ return await ckpt?.close();
678
+ } catch {
679
+ return null;
680
+ }
681
+ };
682
+ // What the processor is handed: `exit()` once the container is dead (an infra exit of an armed job), `close()`
683
+ // before its cleanup, `stats()` for the record's counts. Spread only when there is a handle, so every other run
684
+ // resolves the very object it always did.
685
+ const checkpointResult = ckpt
686
+ ? {
687
+ checkpoint: {
688
+ exit: (opts) => ckpt.exit(opts),
689
+ close: () => closeCheckpoint(),
690
+ stats: () => ckpt.stats(),
691
+ },
692
+ }
693
+ : {};
694
+ if (live !== null && typeof onLive === "function") {
695
+ try {
696
+ onLive(finishLive);
697
+ } catch {}
698
+ }
699
+
234
700
  // Host-side per-job log sink, teed off `onOutput`. `name` is `pi-job-<jobId>`; the sink
235
701
  // sanitizes internally. No container mount, no env var -- the sink lives on this side only.
236
- const sink = openJobLog(name, { exitKey });
702
+ // Issue #683: a staged venue's teardown is owed from here, so a synchronous throw before the `try` below still
703
+ // deletes what the stage made.
704
+ let sink;
705
+ try {
706
+ sink = openJobLog(name, { exitKey, ...(job.watch || job.answerDraft ? { suppressCapture: true } : {}) });
707
+ } catch (error) {
708
+ await teardown();
709
+ removeInbox();
710
+ throw error;
711
+ }
237
712
  // Issue #596: the head of the CLI's stderr, kept only to recognise the runtime's refusal of a stale `--cpus`
238
713
  // (`cpuRangeRefusal`). Bounded, and never logged: only the count it names leaves this function.
239
714
  let stderrHead = "";
240
715
  const STDERR_HEAD_MAX = 4096;
716
+ // Issue #686: an armed attempt's stderr goes through the record demux first. Records are verified under THIS
717
+ // attempt's exit key against the string in the container's PI_JOB_ID (`name`, as the steer ack's `ackRunId`), and
718
+ // their lines pushed into the publisher; every other byte is passed on unchanged to the tee and the head below.
719
+ // Demuxed even when the opener returned no publisher, so the records never reach the job log or the terminal.
720
+ const demux = stdioArmed
721
+ ? makeDemux({
722
+ key: exitKey,
723
+ runId: name,
724
+ onBaseline: (size) => live?.setBaseline?.(size),
725
+ onLine: (line, end) => live?.push?.(line, end),
726
+ onGap: (missing) => live?.skip?.(missing),
727
+ onReset: (size) => live?.reset?.(size),
728
+ })
729
+ : null;
730
+ let demuxEnded = false;
731
+ // A demux that threw is not asked again for this attempt (none is known to throw). Its error carries what that chunk
732
+ // had already passed on and the part of it not yet read, so the output is exactly: those passed bytes, then what it
733
+ // held (`end()`), then the unread rest, then every later chunk as it comes. No byte twice, and no completed
734
+ // record's bytes. A throw that carries neither (not the demux's own) passes the chunk on whole.
735
+ let demuxFailed = false;
736
+ const endDemux = () => {
737
+ if (demux === null || demuxEnded) return Buffer.alloc(0);
738
+ demuxEnded = true;
739
+ const counts = demux.stats();
740
+ // Counts only, never a payload: a forged record is the agent's text.
741
+ if (counts.unverified > 0) log("transcript_record_unverified", { container: name, count: counts.unverified });
742
+ if (counts.replayed > 0 || counts.gaps > 0 || counts.oversize > 0) log("transcript_record_dropped", { container: name, replayed: counts.replayed, gaps: counts.gaps, oversize: counts.oversize });
743
+ return demux.end();
744
+ };
241
745
 
242
746
  const run = new Promise((resolve, reject) => {
243
747
  const child = spawnFn(bin, args, { stdio: [exitKey !== null ? "pipe" : "ignore", "pipe", "pipe"] });
244
- if (exitKey !== null) {
748
+ // Issue #686: a stdin-steered attempt's key is frame 1, written at the spawn, and the pipe is NOT closed: it is
749
+ // handed to the relay, which writes each message as a frame after it and closes the pipe at its stop. The relay
750
+ // writes nothing before the publisher's baseline (the runner's signed baseline record and the lines up to it, so a
751
+ // resumed transcript's acks are known first). The runner reads exactly frame 1 before pi loads, so the key is out
752
+ // of the pipe before any tool exists, as on every other attempt.
753
+ if (stdinSteered) {
754
+ child.stdin?.on("error", () => {});
755
+ const first = keyFrame(exitKey);
756
+ try {
757
+ child.stdin?.write(first);
758
+ } catch {}
759
+ relay?.attach?.(child.stdin, { written: first.length });
760
+ // The baseline, when this side already knows it: a job with no staged session (not a resume, not a recovery)
761
+ // uploads the empty live file the preparer made, so its transcript has no prior line and nothing to
762
+ // re-baseline against. Released here, at the spawn, rather than when the runner's baseline record comes back
763
+ // over stderr (about 600 ms in, measured on Docker): a message queued before the start then reaches the runner
764
+ // before its first agent_start, where pi polls its steering queue once before the first model call, instead
765
+ // of racing the run. A staged transcript waits for the record as before (the ack's `onBaseline` is once only).
766
+ if (!prepared.session && recover !== true) {
767
+ try {
768
+ steerAck?.onBaseline();
769
+ } catch {}
770
+ }
771
+ }
772
+ try {
773
+ live?.start();
774
+ } catch {}
775
+ // Issue #646: registered live with the container, so a push is accepted only while there is a run to read it.
776
+ void relay?.start();
777
+ if (exitKey !== null && !stdinSteered) {
245
778
  // Written and closed at once: the runner drains stdin to EOF before anything else runs, so the key is out of
246
779
  // the pipe before any tool exists. A CLI that died first makes this EPIPE, which is the run failing, reported
247
780
  // by `error` or `close` below; the listener only keeps the write's own error from crashing the worker.
@@ -250,25 +783,122 @@ export function makeRunContainer({
250
783
  }
251
784
  // A throwing sink.write is swallowed so a misbehaving sink cannot break the tee or hang the run.
252
785
  const tee = (chunk) => {
253
- onOutput(chunk);
786
+ if (!job.watch && !job.answerDraft) onOutput(chunk);
254
787
  try {
255
788
  sink.write(chunk);
256
789
  } catch {}
257
790
  };
258
791
  child.stdout?.on("data", tee);
259
- child.stderr?.on("data", (chunk) => {
792
+ // Review round 1, M1: a stdin-steered attempt's pipe is ended as soon as the runner's exit line is out, so a CLI
793
+ // that waited for EOF before it exits could never wait on the relay (OpenShell 0.1.4 does not wait, measured on
794
+ // the real gateway; this is the defence, and the close at the relay's stop stays). A line that VERIFIES under the
795
+ // attempt's key ends it at once; an exit-shaped line that does not (the supervisor's unkeyed one, a forgery) ends
796
+ // it after `stdinExitGraceMs`, so a forged line can only cut steering short, never hold the pipe open. Lines are
797
+ // cut on newlines from a bounded buffer; a line past the bound is skipped.
798
+ if (stdinSteered) {
799
+ let held = "";
800
+ let graceTimer = null;
801
+ const STDIN_LINE_MAX = 256 * 1024;
802
+ let stdinClosed = false;
803
+ // Said once, by which line ended it: a pipe ended early is steering cut short for the rest of the attempt, and
804
+ // every message accepted after it settles `late` (the relay writes nothing more).
805
+ const closeNow = (reason) => {
806
+ if (graceTimer) clearTimeout(graceTimer);
807
+ graceTimer = null;
808
+ if (stdinClosed) return;
809
+ stdinClosed = true;
810
+ log("steer_stdin_closed", { container: name, reason });
811
+ try {
812
+ relay?.closeStdin?.();
813
+ } catch {}
814
+ };
815
+ child.stdout?.on("data", (chunk) => {
816
+ held += String(chunk);
817
+ let nl = held.indexOf("\n");
818
+ while (nl !== -1) {
819
+ const line = held.slice(0, nl);
820
+ held = held.slice(nl + 1);
821
+ if (line.includes('{"event":"exit"')) {
822
+ if (authenticExitLines(line, exitKey) !== "") closeNow("exit-verified");
823
+ else if (graceTimer === null && !stdinClosed) {
824
+ graceTimer = setTimeout(() => closeNow("exit-unverified"), stdinExitGraceMs);
825
+ graceTimer.unref?.();
826
+ }
827
+ }
828
+ nl = held.indexOf("\n");
829
+ }
830
+ if (held.length > STDIN_LINE_MAX) held = "";
831
+ });
832
+ child.on("close", () => {
833
+ if (graceTimer) clearTimeout(graceTimer);
834
+ });
835
+ }
836
+ const stderrOut = (chunk) => {
837
+ if (chunk.length === 0) return;
260
838
  if (stderrHead.length < STDERR_HEAD_MAX) stderrHead = (stderrHead + String(chunk)).slice(0, STDERR_HEAD_MAX);
261
839
  tee(chunk);
840
+ };
841
+ child.stderr?.on("data", (chunk) => {
842
+ if (demux === null || demuxFailed) {
843
+ if (stderrHead.length < STDERR_HEAD_MAX) stderrHead = (stderrHead + String(chunk)).slice(0, STDERR_HEAD_MAX);
844
+ tee(chunk);
845
+ return;
846
+ }
847
+ let out;
848
+ try {
849
+ out = demux.push(chunk);
850
+ } catch (error) {
851
+ demuxFailed = true;
852
+ log("transcript_demux_failed", { container: name });
853
+ const carried = Buffer.isBuffer(error?.passed) && Buffer.isBuffer(error?.rest);
854
+ out = carried ? Buffer.concat([error.passed, endDemux(), error.rest]) : Buffer.concat([endDemux(), Buffer.from(chunk)]);
855
+ }
856
+ stderrOut(out);
262
857
  });
263
858
  // docker not found / daemon down -- a transient infra fault, so tag it retryable
264
859
  // (CONST-RETRY-INFRA-ONLY). `reason` also cues the processor to release the budget slot,
265
860
  // since a container that never started spent nothing.
266
861
  child.on("error", (err) => {
862
+ stderrOut(endDemux());
863
+ void closeCheckpoint();
864
+ void finishLive("failed").then(stopRelay);
267
865
  sink.close().catch(() => {}); // best-effort teardown; a rejecting close cannot leak an unhandled rejection
268
866
  reject(new InfraRetry("container-never-started", { cause: err, reason: "container-never-started" }));
269
867
  });
270
868
  child.on("close", async (code) => {
271
869
  const aborted = signal?.aborted === true; // capture BEFORE the await
870
+ // Issue #686: the demux's held tail (a last line with no newline) goes out before anything closes, and every
871
+ // record it completed is in the publisher before the final flush below.
872
+ stderrOut(endDemux());
873
+ // Issue #686 part 3, review round 2: the venue's box ended and confirmed gone first (see `settleRun`).
874
+ let settled = null;
875
+ if (typeof settleRun === "function") {
876
+ try {
877
+ settled = (await settleRun({ opts: buildOpts, aborted })) === true;
878
+ } catch {
879
+ settled = false;
880
+ }
881
+ }
882
+ const lingered = settled === false ? { lingering: true } : {};
883
+ // Issue #645: no live checkpoint starts once the container is gone (a quiet point in the final read would
884
+ // pause a dead container). Closing first, and awaiting it after the final read, lets a live one in flight
885
+ // finish, its pointer included, before this promise resolves and the processor's cleanup can run.
886
+ const ckptClosed = closeCheckpoint();
887
+ // Issue #643, ORDER LOAD-BEARING: the transcript's last lines are read and published HERE, before the sink
888
+ // closes and before this promise resolves, because the processor removes the job directory (and the file in
889
+ // it) as soon as it has the result. The outcome is the exit's own reading, the most this side knows.
890
+ await finishLive(liveOutcomeOf(code, aborted, neverStartedExits));
891
+ // Issue #644: the run record's `progress`, read once the final snapshot was taken. Only when the opener ran the
892
+ // progress reducer for this job; absent otherwise, so every other run resolves the object it always did.
893
+ let progress = null;
894
+ try {
895
+ progress = live?.progressSummary?.() ?? null;
896
+ } catch {
897
+ progress = null;
898
+ }
899
+ const ranProgress = progress !== null && typeof progress === "object" ? { progress } : {};
900
+ await ckptClosed;
901
+ const stoppedForCheckpoint = checkpointStopped ? { checkpointStopped: true } : {};
272
902
  // A rejecting sink.close is swallowed so a misbehaving sink cannot hang the run; turns/tokens/session/usage/context/exitReason fall back to null.
273
903
  let turns = null;
274
904
  let tokens = null;
@@ -279,23 +909,39 @@ export function makeRunContainer({
279
909
  // RUNNER_POLICY_REASONS set and to a line that itself said code 2. The processor still decides
280
910
  // the retry class from `code` alone; this only picks the label inside exit 2.
281
911
  let exitReason = null;
912
+ let approval = null;
913
+ let answerDraftDigest = null;
914
+ let questionRefDigest = null;
282
915
  // Issue #501 (PR #542's review, round 3): the decisive exit line's own `code` (decisiveExitLine), which the
283
916
  // dollar settlement compares with the container's real exit code before it trusts that line's cost.
284
917
  let exitLineCode = null;
285
918
  // Issue #507: the cost guard's rule on a `cost-cap` line, already filtered by parseExitWhy to COST_CAP_WHYS.
286
919
  let exitWhy = null;
920
+ let providerFailure = null;
921
+ let modelRoles = null;
922
+ let toolsStarted = null;
923
+ let replaySafe = null;
287
924
  // Issue #545: null (no key issued), "verified" or "unverified" (a key, and no exit line carried it).
288
925
  let exitAuthResult = null;
289
926
  // Issue #596: what the container used, off its exit line (parseExitResources), or null.
290
927
  let resources = null;
291
928
  // Issue #596: the image's supervisor reported the runner killed for memory (parseExitOomKilled).
292
929
  let exitOomKilled = false;
930
+ // Issue #645: a recovered run's runner counts (parseExitRecovery), `{ orphans, replayed, interrupted, notStarted }`
931
+ // each an integer or null, or null when the line carried none.
932
+ let recovery = null;
933
+ // Issue #646: a steer-armed runner's inbox counts (parseExitSteer), `{ delivered, pending }`, or null.
934
+ let steerCounts = null;
935
+ // Issue #796: what the runner said of a canary's candidate (parseExitCanaryLoad), or null.
936
+ let canaryLoad = null;
937
+ // Issue #800: the runner's `loaded` as it wrote it, off a verified line only, for `rebuildLoaded` below.
938
+ let lineLoaded = null;
293
939
  try {
294
940
  // `context = null` is a DEFAULT rather than a plain destructure: an injected sink that
295
941
  // predates the field returns no such key, and `undefined` would then reach the record's
296
942
  // shape where every other absence is spelled `null`.
297
943
  // `exitReason` defaults the same way, for the same reason.
298
- ({ turns, tokens, session, usage, context = null, exitReason = null, exitLineCode = null, exitWhy = null, exitAuth: exitAuthResult = null, resources = null, exitOomKilled = false } = await sink.close());
944
+ ({ canaryLoad = null, loaded: lineLoaded = null, turns, tokens, session, usage, context = null, exitReason = null, exitLineCode = null, exitWhy = null, modelRoles = null, providerFailure = null, toolsStarted = null, replaySafe = null, exitAuth: exitAuthResult = null, resources = null, exitOomKilled = false, recovery = null, steer: steerCounts = null, approval = null, answerDraftDigest = null, questionRefDigest = null } = await sink.close());
299
945
  } catch {
300
946
  turns = null;
301
947
  tokens = null;
@@ -303,10 +949,19 @@ export function makeRunContainer({
303
949
  usage = null;
304
950
  context = null;
305
951
  exitReason = null;
952
+ approval = null;
306
953
  exitLineCode = null;
307
954
  exitWhy = null;
955
+ providerFailure = null;
956
+ modelRoles = null;
957
+ toolsStarted = null;
958
+ replaySafe = null;
308
959
  resources = null;
309
960
  exitOomKilled = false;
961
+ recovery = null;
962
+ steerCounts = null;
963
+ canaryLoad = null;
964
+ lineLoaded = null;
310
965
  // The sink could not say, and a key was issued: nothing it returned was verified.
311
966
  exitAuthResult = exitKey !== null ? "unverified" : null;
312
967
  }
@@ -319,19 +974,57 @@ export function makeRunContainer({
319
974
  usage = null;
320
975
  context = null;
321
976
  exitReason = null;
977
+ approval = null;
322
978
  exitLineCode = null;
323
979
  exitWhy = null;
980
+ providerFailure = null;
981
+ modelRoles = null;
982
+ toolsStarted = null;
983
+ replaySafe = null;
324
984
  resources = null;
325
985
  exitOomKilled = false;
986
+ recovery = null;
987
+ steerCounts = null;
988
+ canaryLoad = null;
989
+ lineLoaded = null;
326
990
  }
327
991
  // Spread only when a key was issued, so a run without one resolves the very object it always did.
328
992
  const auth = exitKey !== null ? { exitAuth: exitAuthResult } : {};
993
+ if (job.answerDraft && code === 0 && exitKey !== null && exitAuthResult === "verified" && /^[a-f0-9]{64}$/.test(answerDraftDigest ?? "")) Object.defineProperty(prepared, "answerDraftDigest", { value: answerDraftDigest, configurable: true });
994
+ if (job.awaitInput && code === 0 && exitReason === "awaiting-input" && exitKey !== null && exitAuthResult === "verified" && /^[a-f0-9]{64}$/.test(questionRefDigest ?? "")) Object.defineProperty(prepared, "questionRefDigest", { value: questionRefDigest, configurable: true });
329
995
  // `exitWhy` only when the line named one, so every other run resolves the object it always did.
330
- const why = exitWhy !== null ? { exitWhy } : {};
996
+ const why = { ...(exitKey !== null && exitAuthResult === "verified" && modelRoles ? { modelRoles } : {}), ...(exitWhy !== null ? { exitWhy } : {}), ...(exitKey !== null && exitAuthResult === "verified" && providerFailure ? { providerFailure } : {}), ...(exitKey !== null && exitAuthResult === "verified" && typeof toolsStarted === "boolean" ? { toolsStarted } : {}), ...(exitKey !== null && exitAuthResult === "verified" && typeof replaySafe === "boolean" ? { replaySafe } : {}) };
331
997
  // `resources` (issue #596) only when the exit line carried a block, so every other run resolves the object it always did.
332
998
  // `memoryLimit` only beside the OOM report, the one place it is read, for the same reason.
333
999
  const used = { ...(resources !== null && resources !== undefined ? { resources } : {}), ...(exitOomKilled === true ? { exitOomKilled: true, memoryLimit } : {}) };
334
- resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage, context, exitReason, exitLineCode, ...why, ...used, ...auth } : { code: code ?? 1, aborted: false, turns, tokens, session, usage, context, exitReason, exitLineCode, ...why, ...used, ...auth });
1000
+ // `recovery` (issue #645) only when the exit line carried the block, so every other run resolves the object it always did.
1001
+ const recovered = recovery !== null && typeof recovery === "object" ? { recovery } : {};
1002
+ // Issue #646, review finding F3: the deliveries the runner's SIGNED exit line names are marked before the relay
1003
+ // stops. The transcript is the agent's to break (a symlink swapped in makes the tail refuse it), so the acks
1004
+ // it carries are not the only witness: the exit line is signed with the key the agent never sees, and with a
1005
+ // key issued every field above is nulled unless it verified. The tail's acks are drained first, so these marks
1006
+ // only add what the transcript could not say. Then the relay stops, after which the settle step closes.
1007
+ if (steerArmed && exitAuthResult === "verified" && Array.isArray(steerCounts?.deliveredIds)) {
1008
+ try {
1009
+ await steerAck?.drain();
1010
+ } catch {}
1011
+ for (const id of steerCounts.deliveredIds) {
1012
+ try {
1013
+ await relay?.delivered(id);
1014
+ } catch {}
1015
+ }
1016
+ }
1017
+ await stopRelay();
1018
+ // Issue #646: only for a job that asked for steering, so every other run resolves the object it always did.
1019
+ // `steer` is the runner's own count (advisory, like `recovery`), without the ids; `steerSkipped` the verdict.
1020
+ const steerShown = steerCounts !== null && typeof steerCounts === "object" ? { delivered: steerCounts.delivered ?? null, pending: steerCounts.pending ?? null } : null;
1021
+ const steered = steerAsked ? { ...(steerArmed && steerShown !== null ? { steer: steerShown } : {}), ...(steerSkipped !== null ? { steerSkipped } : {}) } : {};
1022
+ // Issue #712: the named live-transcript skip, for the record's `liveSkipped`, only when there was one.
1023
+ // Issue #800: what the job loaded, when the line said or the worker has something of its own to say; otherwise the run
1024
+ // resolves the object it always did, and the processor's image-only value stands.
1025
+ const loadedShown = lineLoaded !== null || leavesOut(loadedKnown.leftOut) ? { loaded: rebuildLoaded(lineLoaded, loadedKnown) } : {};
1026
+ const liveShown = { ...(liveSkipped !== null ? { liveSkipped } : {}), ...(canaryLoad !== null ? { canaryLoad } : {}), ...loadedShown };
1027
+ resolve(aborted ? { code: code ?? 137, aborted: true, turns, tokens, session, usage, context, exitReason, exitLineCode, ...(approval ? { approval } : {}), ...why, ...used, ...auth, ...ranProgress, ...recovered, ...steered, ...liveShown, ...checkpointResult, ...stoppedForCheckpoint, ...lingered } : { code: code ?? 1, aborted: false, turns, tokens, session, usage, context, exitReason, exitLineCode, ...(approval ? { approval } : {}), ...why, ...used, ...auth, ...ranProgress, ...recovered, ...steered, ...liveShown, ...checkpointResult, ...stoppedForCheckpoint, ...lingered });
335
1028
  });
336
1029
  });
337
1030
 
@@ -358,8 +1051,11 @@ export function makeRunContainer({
358
1051
  if (!result.aborted && (neverStartedExits ?? []).includes(result.code) && (await stopDetached({ spawnFn, cidFile, fs, bin, ...detachedCheck }))) {
359
1052
  return { ...result, detached: true };
360
1053
  }
1054
+ // Issue #683: a copying venue brings its outputs back before the processor reads them. Its own throw stands.
1055
+ if (typeof collectRun === "function") return await collectRun(result, { opts: buildOpts });
361
1056
  return result;
362
1057
  } finally {
1058
+ await teardown();
363
1059
  if (network) {
364
1060
  await removeJobNetwork(spawnFn, {
365
1061
  network,
@@ -374,10 +1070,30 @@ export function makeRunContainer({
374
1070
  } catch {
375
1071
  // best effort: a leftover file is removed before this job's next attempt starts
376
1072
  }
1073
+ // Issue #646: the relay is stopped on every path (a throw before the spawn included), then its directory goes.
1074
+ await stopRelay();
1075
+ removeInbox();
377
1076
  }
378
1077
  };
379
1078
  }
380
1079
 
1080
+ /**
1081
+ * The `end` entry's outcome from what `runContainer` knows when the container exits (issue #643): a worker stop and
1082
+ * the runner's exit 2 are policy, exit 0 completed, exit 1 failed, and anything else (an OOM kill, a runtime's own
1083
+ * code) `unknown`, since only the processor's classification can say more. One exception to `unknown`: an exit this
1084
+ * runtime spells "never started" (`neverStartedExits`, 125 to 127 on Docker) is `failed`, the same word the spawn
1085
+ * error path uses, because the container never ran: the attempt is over and did nothing, and the processor retries it
1086
+ * as infrastructure under the same job id, whose first write then replaces this stream.
1087
+ */
1088
+ export function liveOutcomeOf(code, aborted, neverStartedExits = []) {
1089
+ if (aborted) return "policy";
1090
+ if (code === 0) return "completed";
1091
+ if (code === 2) return "policy";
1092
+ if (code === 1) return "failed";
1093
+ if (Array.isArray(neverStartedExits) && neverStartedExits.includes(code)) return "failed";
1094
+ return "unknown";
1095
+ }
1096
+
381
1097
  /** The detached check's whole bound, retries included, so a daemon that stopped answering cannot hold the slot for long. */
382
1098
  export const DETACHED_CHECK_TIMEOUT_MS = 10_000;
383
1099
  /** The least time any single step of it is given, so the last one tried at the deadline is not killed before it is sent. */