@edgehero/pi-dispatch 4.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/.env.example +94 -4
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +199 -30
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,318 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * The worker's half of the transcript records (issue #686, `DES-OPENSHELL-VENUE`, `INT-RUNNER-EXIT-CODE-PROTOCOL`'s
5
+ * transcript records): splits a copying venue's CLI stderr into the runner's signed transcript records and everything
6
+ * else, verifies each record under this attempt's exit key, and hands the verified ones, in order, to the live
7
+ * transcript's stdio source (live-transcript.mjs `makeLiveTranscript({ source: "stdio" })`).
8
+ *
9
+ * The runner's half is image/runner/src/transcript-mirror.mjs, whose header states the format
10
+ * (`\x1epidt <seq> <type> <end> <mac> <base64>\n`, types `b` baseline, `l` line, `r` reset). The MAC is restated here
11
+ * (the worker cannot import the image at run time) and pinned to the runner's by a test, as the steer ack's is. A
12
+ * verified record proves the CHANNEL (the runner's mirror wrote it, in this order), never the content: the session
13
+ * file is the agent's to write, so a line has the trust of a line the file tail reads on a binding venue.
14
+ *
15
+ * WHY A STREAM DEMUX AND NOT A LINE SPLIT. OpenShell's `sandbox exec` forwards stderr in reads of up to 4 KiB that are
16
+ * not line-aligned (measured, 0.1.4), so a record arrives cut anywhere, its marker included.
17
+ *
18
+ * THE PASS-THROUGH RULE. A record begins at the marker (RS, then `pidt `) wherever it appears, at a line start or after
19
+ * a foreign write that left no newline (a progress bar's `\r50%`). A byte that is not part of a record is passed on as
20
+ * soon as it is known not to be one, byte for byte. Only two things are held: the first bytes that still match the
21
+ * marker (at most its 6), and a record while it still fits the record's grammar (digits, one type letter, digits, 64
22
+ * hex, base64). The grammar is checked as the bytes arrive, so a record cut by another writer ends at the FIRST byte
23
+ * that cannot belong to it: the record's bytes before that byte are dropped (counted, never written out), and that
24
+ * byte and everything after it on the line are passed on as ordinary output. What that means, exactly:
25
+ * - a verified record, and a record's bytes before its break, never reach the job log;
26
+ * - a broken record's bytes AFTER its break byte do (a foreign `\r` mid-payload sends the rest of the base64 there);
27
+ * - a foreign write that lands inside a record and whose head fits the field being read (hex inside the MAC, base64
28
+ * inside the payload, digits inside a number) is taken into the record and lost with it, up to the first byte that
29
+ * does not fit; a newline where a record cannot end is taken as the broken record's own;
30
+ * - every other byte reaches the job log and the terminal unchanged.
31
+ *
32
+ * WHAT IS REFUSED, and counted, never published, never logged with its payload: a record that does not parse, one whose
33
+ * MAC does not verify (a forgery from a tool writing to the container's stderr, or a record another process's write
34
+ * broke), and one whose `seq` is not above the last accepted one (a replay). A gap in `seq` is allowed (the runner
35
+ * skips a line past its bound, and a broken record leaves one) and counted; the stdio source shows it as `skipped`.
36
+ */
37
+
38
+ export const TRANSCRIPT_RECORD_MARKER = "\x1epidt ";
39
+ const MARKER = Buffer.from(TRANSCRIPT_RECORD_MARKER, "latin1");
40
+ const TRANSCRIPT_DOMAIN = "pi-dispatch-transcript";
41
+ const TYPES = new Set(["b", "l", "r"]);
42
+
43
+ /**
44
+ * The most payload bytes one record holds before it is given up on. A genuine record's payload is at most 4/3 of the
45
+ * runner's longest mirrored line (`TRANSCRIPT_LINE_MAX_BYTES`, 1 MiB) as base64, about 1.4 MiB; 8 MiB is far above
46
+ * that, so no genuine record meets it, and it bounds what a forged unterminated "record" can make this process hold.
47
+ * Past it the record is still read to its end (its bytes dropped as they arrive, never held, never passed on) and
48
+ * counted `oversize`.
49
+ */
50
+ export const TRANSCRIPT_RECORD_MAX_BYTES = 8 * 1024 * 1024;
51
+
52
+ /** The runner's `transcriptRecordMac`, restated. */
53
+ export function transcriptRecordMac(key, runId, seq, type, end, payload) {
54
+ return createHmac("sha256", key).update(`${TRANSCRIPT_DOMAIN}\0${runId}\0${seq}\0${type}\0${end}\0`, "utf8").update(payload).digest("hex");
55
+ }
56
+
57
+ const NUM_RE = /^(?:0|[1-9][0-9]{0,15})$/;
58
+ const MAC_RE = /^[0-9a-f]{64}$/;
59
+ const B64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
60
+
61
+ /**
62
+ * One record's body (the bytes between the marker and the newline) verified: `{ seq, type, end, payload }` or null.
63
+ * Constant-time on the MAC; numbers and base64 canonical, so one record has exactly one spelling. A baseline or a
64
+ * reset carries no payload.
65
+ */
66
+ export function verifyTranscriptRecord(body, { key, runId }) {
67
+ if (typeof key !== "string" || key === "" || typeof runId !== "string" || runId === "") return null;
68
+ const text = Buffer.isBuffer(body) ? body.toString("latin1") : String(body ?? "");
69
+ const parts = text.split(" ");
70
+ if (parts.length !== 5) return null;
71
+ const [seqText, type, endText, mac, b64] = parts;
72
+ if (!NUM_RE.test(seqText) || seqText === "0" || !TYPES.has(type) || !NUM_RE.test(endText) || !MAC_RE.test(mac) || !B64_RE.test(b64)) return null;
73
+ const seq = Number(seqText);
74
+ const end = Number(endText);
75
+ if (!Number.isSafeInteger(seq) || !Number.isSafeInteger(end)) return null;
76
+ const payload = Buffer.from(b64, "base64");
77
+ // Canonical only: base64 has spellings that differ in their unused trailing bits and decode to the same bytes.
78
+ if (payload.toString("base64") !== b64) return null;
79
+ if (type !== "l" && payload.length > 0) return null;
80
+ if (!timingSafeEqual(Buffer.from(transcriptRecordMac(key, runId, seqText, type, endText, payload), "hex"), Buffer.from(mac, "hex"))) return null;
81
+ return { seq, type, end, payload };
82
+ }
83
+
84
+ const isDigit = (c) => c >= 0x30 && c <= 0x39;
85
+ const isHex = (c) => isDigit(c) || (c >= 0x61 && c <= 0x66);
86
+ const isB64 = (c) => isDigit(c) || (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x2b || c === 0x2f || c === 0x3d;
87
+ const isType = (c) => c === 0x62 || c === 0x6c || c === 0x72;
88
+ // Each field before the payload: the bytes it admits and its longest length. Field 4, the payload, is base64 to the newline.
89
+ const FIELDS = [
90
+ { ok: isDigit, max: 16 },
91
+ { ok: isType, max: 1 },
92
+ { ok: isDigit, max: 16 },
93
+ { ok: isHex, max: 64 },
94
+ ];
95
+
96
+ /**
97
+ * `makeStderrTranscript({ key, runId, onLine, onBaseline, onReset, onGap, maxRecordBytes })` ->
98
+ * `{ push(chunk), end(), stats() }`.
99
+ *
100
+ * - `push(chunk)` takes one stderr chunk and returns the bytes to pass on (a Buffer, possibly empty), in order. The
101
+ * verified records it completed are handed out before it returns: `onBaseline(size)`, `onLine(lineBytes, end)`,
102
+ * `onReset(size)`, and `onGap(missing)` first when `seq` jumped. A throw from `push` (none is known) leaves the
103
+ * state as it was before the call, so the caller can `end()` and pass the chunk on itself without a byte twice.
104
+ * - `end()` returns what is still held and is not a record (the head of a line that matched the marker's first
105
+ * bytes), so nothing the CLI wrote is lost at close. An unterminated record is dropped and counted.
106
+ * - `stats()`: `{ records, unverified, replayed, gaps, oversize }`.
107
+ *
108
+ * Every callback is guarded: a throw in one costs that record only, never the demux or the bytes passed on.
109
+ */
110
+ export function makeStderrTranscript({ key, runId, onLine = () => {}, onBaseline = () => {}, onReset = () => {}, onGap = () => {}, maxRecordBytes = TRANSCRIPT_RECORD_MAX_BYTES } = {}) {
111
+ // `start`: at a line start, `probe` holding the bytes so far that match the marker's head. `pass`: inside a line that
112
+ // is not a record, passed through to its newline. `record`: inside a record, in field `field` (0 to 4).
113
+ let mode = "start";
114
+ let probe = Buffer.alloc(0);
115
+ let parts = [];
116
+ let field = 0;
117
+ let fieldLen = 0;
118
+ let held = 0;
119
+ let discarding = false; // a payload past the bound: read to its end, never held
120
+ let last = 0;
121
+ const counts = { records: 0, unverified: 0, replayed: 0, gaps: 0, oversize: 0 };
122
+ const call = (fn, ...args) => {
123
+ try {
124
+ fn(...args);
125
+ } catch {}
126
+ };
127
+
128
+ const settle = (body) => {
129
+ const verified = verifyTranscriptRecord(body, { key, runId });
130
+ if (verified === null) {
131
+ counts.unverified += 1;
132
+ return;
133
+ }
134
+ if (verified.seq <= last) {
135
+ counts.replayed += 1;
136
+ return;
137
+ }
138
+ if (verified.seq > last + 1) {
139
+ // `seq` 1 is always the baseline: when it is the one lost, no LINE is missing for it.
140
+ const missing = verified.seq - last - 1 - (last === 0 ? 1 : 0);
141
+ if (missing > 0) {
142
+ counts.gaps += missing;
143
+ call(onGap, missing);
144
+ }
145
+ }
146
+ last = verified.seq;
147
+ counts.records += 1;
148
+ if (verified.type === "b") call(onBaseline, verified.end);
149
+ else if (verified.type === "r") call(onReset, verified.end);
150
+ else if (verified.payload.length > 0) call(onLine, verified.payload, verified.end);
151
+ };
152
+
153
+ const openRecord = () => {
154
+ mode = "record";
155
+ probe = Buffer.alloc(0);
156
+ parts = [];
157
+ field = 0;
158
+ fieldLen = 0;
159
+ held = 0;
160
+ discarding = false;
161
+ };
162
+ /** The record ended before its grammar did: its bytes are dropped and counted, never passed on. */
163
+ const breakRecord = () => {
164
+ counts.unverified += 1;
165
+ parts = [];
166
+ held = 0;
167
+ };
168
+
169
+ const step = (buf) => {
170
+ const out = [];
171
+ let i = 0;
172
+ try {
173
+ while (i < buf.length) {
174
+ if (mode === "pass") {
175
+ // To the line's end, or to the next RS, whichever is first: a record can follow a foreign write that left
176
+ // no newline (a progress bar's `\r50%`), and must still be read as one. An RS that does not begin the
177
+ // marker (`\x1e{` of an RS-delimited JSON stream) is passed on by the probe below, unchanged.
178
+ const nl = buf.indexOf(0x0a, i);
179
+ const rs = buf.indexOf(0x1e, i);
180
+ if (rs !== -1 && (nl === -1 || rs < nl)) {
181
+ if (rs > i) out.push(buf.subarray(i, rs));
182
+ i = rs;
183
+ mode = "start";
184
+ continue;
185
+ }
186
+ if (nl === -1) {
187
+ out.push(buf.subarray(i));
188
+ i = buf.length;
189
+ } else {
190
+ out.push(buf.subarray(i, nl + 1));
191
+ i = nl + 1;
192
+ mode = "start";
193
+ }
194
+ continue;
195
+ }
196
+ if (mode === "start") {
197
+ const take = buf.subarray(i, i + (MARKER.length - probe.length));
198
+ const candidate = probe.length > 0 ? Buffer.concat([probe, take]) : take;
199
+ if (MARKER.subarray(0, candidate.length).equals(candidate)) {
200
+ i += take.length;
201
+ if (candidate.length === MARKER.length) openRecord();
202
+ else probe = Buffer.from(candidate);
203
+ continue;
204
+ }
205
+ // Not a record: what was held goes out first, then this line from `i` on, newline included. The marker
206
+ // holds no newline, so the held head never does either, and the line's own newline is found in `pass`.
207
+ // An RS that failed the probe is passed on here, so `pass` never stops at the same byte twice.
208
+ if (probe.length > 0) out.push(probe);
209
+ else if (buf[i] === 0x1e) {
210
+ out.push(buf.subarray(i, i + 1));
211
+ i += 1;
212
+ }
213
+ probe = Buffer.alloc(0);
214
+ mode = "pass";
215
+ continue;
216
+ }
217
+ // mode === "record"
218
+ if (field < 4) {
219
+ const c = buf[i];
220
+ const spec = FIELDS[field];
221
+ if (c === 0x20 && fieldLen > 0 && (field !== 3 || fieldLen === 64)) {
222
+ parts.push(buf.subarray(i, i + 1));
223
+ held += 1;
224
+ field += 1;
225
+ fieldLen = 0;
226
+ i += 1;
227
+ continue;
228
+ }
229
+ if (spec.ok(c) && fieldLen < spec.max) {
230
+ parts.push(buf.subarray(i, i + 1));
231
+ held += 1;
232
+ fieldLen += 1;
233
+ i += 1;
234
+ continue;
235
+ }
236
+ breakRecord();
237
+ if (c === 0x0a) {
238
+ i += 1;
239
+ mode = "start";
240
+ } else {
241
+ mode = "pass";
242
+ }
243
+ continue;
244
+ }
245
+ // The payload: base64 up to the newline, scanned in one run.
246
+ let j = i;
247
+ while (j < buf.length && isB64(buf[j])) j += 1;
248
+ if (j > i) {
249
+ if (!discarding && held + (j - i) > maxRecordBytes) {
250
+ discarding = true;
251
+ parts = [];
252
+ held = 0;
253
+ }
254
+ if (!discarding) {
255
+ parts.push(buf.subarray(i, j));
256
+ held += j - i;
257
+ }
258
+ i = j;
259
+ continue;
260
+ }
261
+ if (buf[i] === 0x0a) {
262
+ i += 1;
263
+ mode = "start";
264
+ if (discarding) {
265
+ counts.oversize += 1;
266
+ discarding = false;
267
+ parts = [];
268
+ held = 0;
269
+ continue;
270
+ }
271
+ const body = Buffer.concat(parts);
272
+ parts = [];
273
+ held = 0;
274
+ settle(body);
275
+ continue;
276
+ }
277
+ // A byte no record holds: the record was broken there. Dropped, and this byte on is ordinary output.
278
+ if (discarding) {
279
+ counts.oversize += 1;
280
+ discarding = false;
281
+ parts = [];
282
+ held = 0;
283
+ } else {
284
+ breakRecord();
285
+ }
286
+ mode = "pass";
287
+ }
288
+ } catch (cause) {
289
+ // None is known. What this chunk already passed on, and the part not yet read, ride the error, so the caller
290
+ // can send exactly those (and `end()`'s held bytes between them): no byte twice, and no completed record's.
291
+ throw Object.assign(new Error("stderr transcript demux fault", { cause }), { passed: Buffer.concat(out), rest: buf.subarray(i) });
292
+ }
293
+ return out.length === 0 ? Buffer.alloc(0) : out.length === 1 ? Buffer.from(out[0]) : Buffer.concat(out);
294
+ };
295
+
296
+ return {
297
+ push(chunk) {
298
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""), "utf8");
299
+ // Copies, because `parts` holds views into the chunk: the caller may reuse its buffer.
300
+ return step(Buffer.from(buf));
301
+ },
302
+ end() {
303
+ let out = Buffer.alloc(0);
304
+ if (mode === "start" && probe.length > 0) out = probe;
305
+ if (mode === "record") {
306
+ if (discarding) counts.oversize += 1;
307
+ else counts.unverified += 1;
308
+ }
309
+ mode = "start";
310
+ probe = Buffer.alloc(0);
311
+ parts = [];
312
+ held = 0;
313
+ discarding = false;
314
+ return out;
315
+ },
316
+ stats: () => ({ ...counts }),
317
+ };
318
+ }
@@ -0,0 +1,122 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { STEER_ID_RE } from "./steer-framing.mjs";
3
+
4
+ /**
5
+ * The worker's half of a steer delivery ack (issue #646, INT-STEER-INBOX-CONTRACT): a third `onParsed` consumer of the
6
+ * live transcript tail (live-transcript.mjs `makeLiveTranscriptOpener`, beside the progress sink and the checkpoint
7
+ * handle), which turns each `pi-dispatch-inbox` custom message the runner delivered into `delivered` in the run's inbox.
8
+ *
9
+ * WHY A MAC AND NOT THE LINE ITSELF. `/session` is mounted read-write into the job, so the agent can append a
10
+ * `custom_message` of our type to its own transcript, naming any message id. A delivery counts only when the line's
11
+ * `details.ack` is `HMAC-SHA256(exitKey, "pi-dispatch-inbox-ack\0" + PI_JOB_ID + "\0" + id)`, compared in constant
12
+ * time, under the exit key this attempt minted (issue #545): handed to the runner on stdin, held in its non-dumpable
13
+ * process, never in the agent's reach. A line that does not verify is ignored and counted (`steer_ack_unverified`).
14
+ * The domain string is the runner's (image/runner/src/inbox.mjs `inboxAck`), and `ackRunId` is exactly the string the
15
+ * worker put in PI_JOB_ID for this container (run-container.mjs passes the container name), so both sides MAC one
16
+ * message by construction rather than by two call sites that happen to agree.
17
+ *
18
+ * PRIOR LINES. A line that ended at or before the tail's BASELINE (the file's size when this attempt began: a resumed
19
+ * or a #645-restored transcript) was written by an EARLIER attempt, whose exit key is gone, so its MAC cannot be
20
+ * checked here. Such a line is never counted as a delivery. Its id only joins the "already in this transcript" set
21
+ * the relay re-baselines against (inbox-relay.mjs): a message the inbox says is `delivered` whose id is NOT in that
22
+ * set goes back to `written`, so a recovery from a checkpoint older than the delivery hands it over again. The
23
+ * residual is deliberate and narrow: the agent can write a prior-looking line only into a transcript a later attempt
24
+ * restores, and all it buys is that a message which WAS delivered (verified, under that attempt's key) stays so.
25
+ *
26
+ * Never throws; the tail swallows a throw anyway, and nothing here may cost the run. Logs carry ids and counts only.
27
+ */
28
+
29
+ export const INBOX_CUSTOM_TYPE = "pi-dispatch-inbox";
30
+ const ACK_DOMAIN = "pi-dispatch-inbox-ack";
31
+
32
+ /** The ack for one message: the runner's `inboxAck`, restated (the worker cannot import the image). Pinned by a test. */
33
+ export function inboxAck(key, ackRunId, id) {
34
+ return createHmac("sha256", key).update(`${ACK_DOMAIN}\0${ackRunId}\0${id}`, "utf8").digest("hex");
35
+ }
36
+
37
+ /** Whether `ack` is the ack for `id` under `key`, in constant time. False on any malformed input, never a throw. */
38
+ export function ackVerifies(key, ackRunId, id, ack) {
39
+ if (typeof key !== "string" || key === "" || typeof ackRunId !== "string" || ackRunId === "") return false;
40
+ if (typeof id !== "string" || !STEER_ID_RE.test(id)) return false;
41
+ if (typeof ack !== "string" || !/^[0-9a-f]{64}$/.test(ack)) return false;
42
+ return timingSafeEqual(Buffer.from(inboxAck(key, ackRunId, id), "hex"), Buffer.from(ack, "hex"));
43
+ }
44
+
45
+ /**
46
+ * `makeSteerAck({ exitKey, ackRunId, relay, jobId, log })` -> `{ consume, onBaseline, reset, drain, stats }`.
47
+ *
48
+ * - `consume(parsed, ts, flags)`: the tail's `onParsed`. A prior line feeds the prior set; any other line of our type
49
+ * is verified and, when it verifies, handed to `relay.delivered(id)` (which marks the inbox and unlinks the file).
50
+ * - `onBaseline()`: the tail's first pass over the staged transcript is done; calls `relay.afterBaseline(prior)`, so
51
+ * the relay re-baselines and only then starts writing files.
52
+ * - `drain()`: waits for every mark in flight (runContainer awaits it after the tail's final read, before it stops the
53
+ * relay, so a last-moment ack is counted before the settle step reads the states). Bounded by the relay's own calls.
54
+ */
55
+ export function makeSteerAck({ exitKey, ackRunId, relay, jobId = null, log = () => {} } = {}) {
56
+ const prior = new Set();
57
+ const priorVerified = new Set();
58
+ const acked = new Set();
59
+ const inFlight = new Set();
60
+ const counts = { verified: 0, unverified: 0, prior: 0 };
61
+ let baselined = false;
62
+ let unverifiedSaid = false;
63
+
64
+ const hand = (id) => {
65
+ if (typeof relay?.delivered !== "function") return;
66
+ let p;
67
+ try {
68
+ p = Promise.resolve(relay.delivered(id)).catch(() => {});
69
+ } catch {
70
+ return;
71
+ }
72
+ inFlight.add(p);
73
+ p.finally(() => inFlight.delete(p));
74
+ };
75
+
76
+ return {
77
+ consume(parsed, _ts, flags) {
78
+ if (parsed?.type !== "custom_message" || parsed.customType !== INBOX_CUSTOM_TYPE) return;
79
+ const details = parsed.details !== null && typeof parsed.details === "object" ? parsed.details : {};
80
+ const id = typeof details.id === "string" && STEER_ID_RE.test(details.id) ? details.id : null;
81
+ if (flags?.prior === true) {
82
+ if (id !== null && !prior.has(id)) {
83
+ prior.add(id);
84
+ counts.prior += 1;
85
+ }
86
+ // F5: a prior line whose ack verifies under THIS attempt's key is a delivery the inbox may not have recorded;
87
+ // the relay marks it at the baseline instead of writing it again. NOTE the reach: the exit key is minted per
88
+ // attempt, so a line an EARLIER attempt wrote never verifies here, and such a message is handed over again
89
+ // (a duplicate, framed, never a loss). Counting an unverified prior line instead would let the agent mark a
90
+ // message delivered that it was never sent, by writing a line into a transcript a recovery restores.
91
+ if (id !== null && ackVerifies(exitKey, ackRunId, id, details.ack)) priorVerified.add(id);
92
+ return;
93
+ }
94
+ if (id === null || !ackVerifies(exitKey, ackRunId, id, details.ack)) {
95
+ counts.unverified += 1;
96
+ // Said once per attempt, then counted: a job that writes forged lines in a loop must not flood the log.
97
+ if (!unverifiedSaid) {
98
+ unverifiedSaid = true;
99
+ log("steer_ack_unverified", { jobId, ...(id !== null ? { id } : {}) });
100
+ }
101
+ return;
102
+ }
103
+ if (acked.has(id)) return;
104
+ acked.add(id);
105
+ counts.verified += 1;
106
+ hand(id);
107
+ },
108
+ onBaseline() {
109
+ if (baselined) return;
110
+ baselined = true;
111
+ try {
112
+ relay?.afterBaseline?.(new Set(prior), new Set(priorVerified));
113
+ } catch {}
114
+ },
115
+ // A rewritten transcript (the tail's reset) keeps what was already verified: a delivery that happened stays one.
116
+ reset() {},
117
+ async drain() {
118
+ while (inFlight.size > 0) await Promise.allSettled([...inFlight]);
119
+ },
120
+ stats: () => ({ ...counts }),
121
+ };
122
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * `pi-dispatch steer <jobId> [--later] <text…|->` and `pi-dispatch steer --withdraw <jobId> <msgId>` (issue #646,
3
+ * REQ-STEER-RUNNING-JOB): an operator's message into a running, steer-armed job, and taking one back.
4
+ *
5
+ * Modelled on cancel-cli.mjs, and on its footing: VALKEY_URL only (resolved by cli.mjs with the kill switch's rule),
6
+ * never loadConfig, the job found across every queue this deployment drains. What differs is what the job must BE:
7
+ *
8
+ * - active. A queued job has no run to steer, and a message waiting for it would arrive as the opening of a run
9
+ * the person did not start it for. The message is refused, never parked.
10
+ * - open: `run:steer:open:<runId>` present. Only the worker's relay sets it, and only for a run that is armed
11
+ * (`run.steer`), running, and on a venue that can deliver (bind mounts, an exit key, an image with `steer`). An
12
+ * active job without it is refused with the one sentence that names all three causes, since this side cannot
13
+ * tell them apart, rather than accepting a message no one will ever write into the run.
14
+ *
15
+ * The push is the inbox's own script (steer-inbox.mjs), so a run that closes in the same moment refuses the message
16
+ * (`closed`/`gone`) instead of swallowing it. After `ok` the command polls up to 10 s for the relay to write it
17
+ * (`written`) or the agent to take it (`delivered`) and says which; a message still `queued` at the end of the window
18
+ * was accepted and is said so, with exit 0. A message that turns `late` (the run ended first) is exit 1: it was not seen.
19
+ *
20
+ * NEVER PRINTS THE TEXT. Not on success, not in an error: the id, the mode and the state are enough to find it again,
21
+ * and a terminal scrollback or a CI log is no place for words meant for one run.
22
+ */
23
+
24
+ import * as inbox from "./steer-inbox.mjs";
25
+
26
+ /** Same budget the kill switch and cancel give the host registry. */
27
+ const FLEET_READ_TIMEOUT_MS = 2_000;
28
+ /** The most stdin may carry before scrubbing: four times the text bound, so control bytes the scrub removes still fit. */
29
+ const STDIN_MAX_BYTES = 32 * 1024;
30
+
31
+ /**
32
+ * Read stdin up to `maxBytes`: `{ text }`, or `{ over: true }` the moment it passes the bound (the rest is not read).
33
+ * `stdin` is the seam; it must be an async iterable of Buffers or strings.
34
+ */
35
+ export async function readStdinBounded(maxBytes = STDIN_MAX_BYTES, stdin = process.stdin) {
36
+ const chunks = [];
37
+ let n = 0;
38
+ for await (const chunk of stdin) {
39
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
40
+ n += buf.length;
41
+ if (n > maxBytes) {
42
+ stdin.destroy?.();
43
+ return { over: true };
44
+ }
45
+ chunks.push(buf);
46
+ }
47
+ return { text: Buffer.concat(chunks).toString("utf8") };
48
+ }
49
+
50
+ const USAGE_LINE = "pi-dispatch steer <jobId> [--later] <text…|-> or pi-dispatch steer --withdraw <jobId> <msgId>";
51
+
52
+ /** The line for a message's state after the push and the poll. */
53
+ function stateLine(id, jobId, mode, state) {
54
+ const head = `message ${id} for ${jobId} (${mode === "followUp" ? "follow-up" : "steer"}):`;
55
+ if (state === "delivered") return `${head} delivered. The agent has received it\n`;
56
+ if (state === "written") {
57
+ return mode === "followUp"
58
+ ? `${head} written. The run holds it, and the agent reads it once it would otherwise stop\n`
59
+ : `${head} written. The run holds it, and the agent reads it after its current tool calls\n`;
60
+ }
61
+ return `${head} queued. The worker has not written it into the run yet; it is delivered while the run is open, or reported late if the run ends first. See its state with the console\n`;
62
+ }
63
+
64
+ /**
65
+ * Run the verb. `positionals` are what follows `steer` once cli.mjs has taken `--valkey-url`, `--later` and
66
+ * `--withdraw` out. Returns the exit code. Every collaborator is a seam with the production default, cancel's shape.
67
+ */
68
+ export async function runSteer({ positionals = [], later = false, withdraw = false } = {}, url, { write = (chunk) => process.stdout.write(chunk), errWrite = (chunk) => process.stderr.write(chunk), redisFn, queueFn, parseConnectionFn, readLiveHostsFn, discoverHostQueuesFn, fleetQueueNamesFn, unionQueueNamesFn, readStdin = readStdinBounded, randomBytes, now = Date.now, ackTimeoutMs = 10_000, pollMs = 250, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), refusalFn } = {}) {
69
+ const fail = (message) => {
70
+ errWrite(`error: ${message}\n`);
71
+ return 1;
72
+ };
73
+
74
+ // Everything that can be refused for free is refused before Valkey is touched.
75
+ const jobId = positionals[0];
76
+ if (typeof jobId !== "string" || jobId === "") return fail(`a job id is required: ${USAGE_LINE} (ids are in \`pi-dispatch status\`, the panel, and the run log)`);
77
+ let msgId = null;
78
+ let text = null;
79
+ if (withdraw) {
80
+ if (later) return fail("--later and --withdraw do not go together: --withdraw takes a message back, whatever its mode");
81
+ if (positionals.length !== 2) return fail(`--withdraw takes a job id and one message id: ${USAGE_LINE}`);
82
+ msgId = positionals[1];
83
+ if (!inbox.STEER_ID_RE.test(msgId)) return fail("the message id is 32 lowercase hex characters, as `pi-dispatch steer` printed it");
84
+ } else {
85
+ const words = positionals.slice(1);
86
+ if (words.length === 0) return fail(`no message: ${USAGE_LINE} (- reads it from stdin)`);
87
+ let raw;
88
+ if (words.length === 1 && words[0] === "-") {
89
+ const read = await readStdin(STDIN_MAX_BYTES).catch((error) => ({ error }));
90
+ if (read.error) return fail(`could not read the message from stdin: ${read.error?.message ?? read.error}`);
91
+ if (read.over) return fail(`stdin carried more than ${STDIN_MAX_BYTES} bytes; a message is at most ${inbox.STEER_TEXT_MAX_BYTES} bytes. Nothing was sent`);
92
+ raw = read.text;
93
+ } else {
94
+ raw = words.join(" ");
95
+ }
96
+ const bounded = inbox.boundSteerText(raw);
97
+ if (bounded.refusal) return fail(bounded.refusal);
98
+ text = bounded.text;
99
+ }
100
+
101
+ const { parseConnection, makeRedisClient } = await import("./connection.mjs");
102
+ const { fleetQueueNames, discoverHostQueues, unionQueueNames, makeQueue } = await import("./queue.mjs");
103
+ const { readLiveHosts } = await import("./host-registry.mjs");
104
+ const { runIdOf } = await import("./recovery-queue.mjs");
105
+ const parseConn = parseConnectionFn ?? parseConnection;
106
+ const mkRedis = redisFn ?? makeRedisClient;
107
+ const mkQueue = queueFn ?? makeQueue;
108
+ const liveHosts = readLiveHostsFn ?? readLiveHosts;
109
+ const discover = discoverHostQueuesFn ?? discoverHostQueues;
110
+ const fleetNames = fleetQueueNamesFn ?? fleetQueueNames;
111
+ const unionNames = unionQueueNamesFn ?? unionQueueNames;
112
+
113
+ // cancel's rule (issue #464): a refused Valkey is said as the refusal it is, judged only where this command builds
114
+ // its own clients.
115
+ const refusalOf =
116
+ refusalFn ??
117
+ (redisFn
118
+ ? async () => null
119
+ : async (u) => {
120
+ const { judgeValkeyAtStart, defaultValkeyContext } = await import("./connection.mjs");
121
+ try {
122
+ await judgeValkeyAtStart(u, defaultValkeyContext(), { waitMs: 0 });
123
+ return null;
124
+ } catch (error) {
125
+ return error?.valkeyRefused ? error.message : null;
126
+ }
127
+ });
128
+ const valkeyRefused = await refusalOf(url);
129
+ if (valkeyRefused) return fail(valkeyRefused);
130
+ const probe = mkRedis(url);
131
+ probe.on?.("error", () => {});
132
+ const queues = [];
133
+ try {
134
+ const [fleet, existing] = await Promise.all([
135
+ liveHosts(probe, { timeoutMs: FLEET_READ_TIMEOUT_MS }).catch((error) => ({ unreachable: error?.message ?? String(error) })),
136
+ discover(probe, { timeoutMs: FLEET_READ_TIMEOUT_MS }),
137
+ ]);
138
+ const blind = fleet?.unreachable ?? null;
139
+ const names = unionNames(fleetNames(fleet?.hosts), existing);
140
+ let job = null;
141
+ for (const name of names) {
142
+ const q = mkQueue(parseConn(url, { failFast: true }), { name });
143
+ queues.push(q);
144
+ job = await q.getJob(jobId);
145
+ if (job) break;
146
+ }
147
+ if (!job) return fail(`no job ${jobId} in ${names.length} queue(s)${blind ? ` [registry unreadable: ${blind}; a host that is not registered may still hold it]` : ""}`);
148
+ const runId = runIdOf(job);
149
+
150
+ if (withdraw) {
151
+ const res = await inbox.withdrawSteer({ redis: probe, runId, id: msgId });
152
+ if (res.error) return res.error === "refused" ? fail(res.reason) : fail(`could not withdraw message ${msgId}: Valkey said ${res.error}`);
153
+ if (res.prior === "queued") {
154
+ write(`withdrawn message ${msgId} from ${jobId}: it was queued, so the run never sees it (guaranteed)\n`);
155
+ return 0;
156
+ }
157
+ if (res.prior === "written") {
158
+ write(`withdrawn message ${msgId} from ${jobId}, best effort: it was already written into the run's inbox, so the agent may have read it before the worker removed it. Its transcript says whether it did\n`);
159
+ return 0;
160
+ }
161
+ if (res.prior === "withdrawn") {
162
+ write(`message ${msgId} of ${jobId} was already withdrawn; nothing was changed\n`);
163
+ return 0;
164
+ }
165
+ if (res.prior === "delivered") return fail(`message ${msgId} was already delivered: the agent has received it, so it cannot be withdrawn. Nothing was changed`);
166
+ if (res.prior === "late") return fail(`message ${msgId} arrived after the run had ended (late): the agent never saw it, and there is nothing to withdraw`);
167
+ return fail(`no message ${msgId} in the inbox of ${jobId}`);
168
+ }
169
+
170
+ const state = await job.getState().catch(() => "unknown");
171
+ if (state !== "active") return fail(`job ${jobId} is ${state}: only a running job takes messages. Nothing was sent`);
172
+ const open = await inbox.isOpen({ redis: probe, runId });
173
+ if (open !== true) {
174
+ if (open === false) return fail("the run is not accepting messages: it is not steer-armed, not running, or its venue cannot deliver");
175
+ throw Object.assign(new Error(`Valkey said ${open.error}`), { code: open.error });
176
+ }
177
+ const mode = later ? "followUp" : "steer";
178
+ const id = inbox.operatorMessageId(...(randomBytes ? [randomBytes] : []));
179
+ const entry = { v: 1, id, mode, source: "cli", author: { kind: "operator", by: "cli" }, text, at: now() };
180
+ const pushed = await inbox.pushSteer({ redis: probe, runId, entry, now });
181
+ if (pushed?.error === "refused") return fail(pushed.reason);
182
+ if (pushed?.error) throw Object.assign(new Error(`Valkey said ${pushed.error}`), { code: pushed.error });
183
+ if (pushed === "gone") return fail("the run stopped accepting messages before this one arrived (it ended, or its worker stopped refreshing it). Nothing was sent");
184
+ if (pushed === "closed") return fail("the run has finished and its inbox is closed. Nothing was sent");
185
+ if (pushed === "full") return fail(`the run's inbox is full (at most ${inbox.STEER_MAX_MESSAGES} messages and ${inbox.STEER_TOTAL_MAX_BYTES} bytes per run, withdrawn ones included). Nothing was sent`);
186
+ if (pushed !== "ok") return fail(`the inbox answered ${String(pushed)} for a new message id; nothing was sent`);
187
+
188
+ // Poll for the relay's write. A read fault ends the poll, not the command: the message IS accepted.
189
+ // Counted in polls rather than against `now()`: a test's fixed clock would otherwise poll forever, and the
190
+ // window only needs to be roughly ten seconds.
191
+ let seen = "queued";
192
+ const polls = Math.max(1, Math.ceil(ackTimeoutMs / pollMs));
193
+ for (let i = 0; i < polls; i++) {
194
+ await sleep(pollMs);
195
+ const s = await inbox.messageState({ redis: probe, runId, id });
196
+ if (typeof s !== "string") break;
197
+ seen = s;
198
+ if (s !== "queued") break;
199
+ }
200
+ if (seen === "late") return fail(`message ${id} for ${jobId}: the run ended before the worker could write it (late). The agent did not see it`);
201
+ if (seen === "withdrawn") {
202
+ write(`message ${id} for ${jobId}: withdrawn before it was written\n`);
203
+ return 0;
204
+ }
205
+ write(stateLine(id, jobId, mode, seen));
206
+ return 0;
207
+ } catch (error) {
208
+ return fail(error?.valkeyRefused ? error.message : `could not reach Valkey at ${(await import("./connection.mjs")).urlShown(url)}: ${(await import("./valkey-auth.mjs")).valkeyDownHint(url)}\n ${error.message}`);
209
+ } finally {
210
+ probe.disconnect?.();
211
+ for (const q of queues) await q.close().catch(() => {});
212
+ }
213
+ }