@edgehero/pi-dispatch 4.3.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/.env.example +84 -3
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,482 @@
1
+ import { renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { sanitizeJobId } from "./run-history.mjs";
4
+ import { STEER_STDIN_BUDGET_BYTES, steerFileJson, steerFrame } from "./steer-framing.mjs";
5
+ import * as inboxModule from "./steer-inbox.mjs";
6
+
7
+ /**
8
+ * The worker's transport into a running job's inbox (issue #646, DES-STEER-VIA-READONLY-INBOX-MOUNT,
9
+ * INT-STEER-INBOX-CONTRACT): opened by run-container.mjs for a steer-ARMED attempt, beside the live transcript tail.
10
+ *
11
+ * WHAT IT DOES, every `pollMs` (the cancel poll's shape: re-entrancy guarded, every fault swallowed and logged by code):
12
+ * 1. refreshes the run's liveness (`refreshLive`: the open key and, for a forge job, its target pointer), which is what
13
+ * lets a push be accepted at all;
14
+ * 2. writes each `queued` message, in the inbox's order, into the host directory mounted read-only at `/inbox`:
15
+ * framed (steer-framing.mjs), written to `.<id>.tmp` with mode 0444, marked `written`, then renamed to
16
+ * `<seq:06d>-<id>.json`. The runner only ever sees complete files under the final pattern;
17
+ * 3. unlinks the file of every message that is no longer waiting (`withdrawn`, `delivered`, `late`).
18
+ * At its stop it HOLDS the liveness keys for STEER_HOLD_TTL_MS rather than dropping them (`holdLive`), so the run still
19
+ * takes messages until its settle closes the inbox or its next attempt registers again.
20
+ *
21
+ * MARK BEFORE RENAME, not after. A `queued` message's withdrawal is promised as GUARANTEED (steer-inbox.mjs), so a file
22
+ * the runner can read must never exist for a message that could still be withdrawn as `queued`. Marking `written` first
23
+ * and renaming only when that compare and set succeeded makes the promise true: a withdraw that wins the race leaves
24
+ * the temporary file, which is removed, and nothing was visible. A rename that fails after the mark leaves a `written`
25
+ * message with no file, which the next tick writes again (a `written` message not on this attempt's disk is placed
26
+ * whatever its history: a retry's directory is new and empty).
27
+ *
28
+ * WRITING WAITS FOR THE BASELINE. `afterBaseline(prior)` is called once the live tail has read the staged transcript
29
+ * to its starting size (steer-ack.mjs `onBaseline`). Until then nothing is written, so a #645-restored transcript's
30
+ * deliveries are known before any file appears. At that moment the relay RE-BASELINES: a message the inbox calls
31
+ * `delivered` whose id is not among the transcript's prior lines goes back to `written`, and is handed over again,
32
+ * because the transcript this attempt starts from never saw it (a checkpoint older than the delivery, or a plain
33
+ * retry whose session starts empty).
34
+ *
35
+ * THE SECOND TRANSPORT, STDIN FRAMES (issue #686, INT-STEER-INBOX-CONTRACT). Given `frames` (`{ exitKey, macRunId }`,
36
+ * a stdin-steered attempt on a venue that copies its files), the relay writes each waiting message as a frame on the
37
+ * exec's stdin instead of a file (steer-framing.mjs `steerFrame`), once `attach(stdin, { written })` hands it the pipe
38
+ * the key frame went down. Everything else is the same pass: the same baseline wait, the same order, the same marks
39
+ * (`queued` to `written` BEFORE the write, so a message withdrawn while queued is never written), the same
40
+ * `steer_frame_refused` for an entry it cannot frame. What differs:
41
+ * - THE BUDGET. The relay counts every byte it wrote, the key frame's included, and a frame that would take the total
42
+ * past STEER_STDIN_BUDGET_BYTES is NOT written: the message moves to `late`, logged `steer_frame_refused {jobId,
43
+ * id}`, and is never cut. The venue's exec refuses a stdin past its limit by failing the whole run (measured).
44
+ * - NO FILE TO UNLINK. A message withdrawn after its frame was written is still handed over by the runner; its
45
+ * verified ack then moves it to `delivered`, as for the mount's best-effort `written` withdrawal.
46
+ * - BACKPRESSURE. A write the pipe did not take at once is waited out (`drain`), so the relay never buffers more than
47
+ * one frame ahead of the pipe; the wait ends early at the stop, a close or an error.
48
+ * - THE PIPE'S END. The stop closes stdin (`end`), which the runner reads as EOF, no more messages; an attempt that
49
+ * ended first has closed it already. A write error (EPIPE: the exec gone) is caught on the stream and logged once
50
+ * as the step `stdin`, never thrown: nothing a dead pipe does may take the worker down.
51
+ *
52
+ * Never throws; `start` and `stop` resolve whatever Valkey or the disk does. Logs carry the job id, message ids, step
53
+ * names, codes and counts, never a message's text.
54
+ */
55
+
56
+ export const RELAY_POLL_MS = 2_000;
57
+
58
+ // The reset of a CLOSED inbox for a new run under the same run id. KEYS: list, state, fallback, closed. One script, so
59
+ // it is all or nothing, and it acts only while `closed` exists: while it does every push is refused (`closed`), so no
60
+ // message accepted for the new run can be in what it deletes. An open inbox (a #645 recovery, an InfraRetry) is left
61
+ // exactly as it is.
62
+ const RESET_SCRIPT = `if redis.call('EXISTS', KEYS[4]) == 0 then return 0 end
63
+ redis.call('DEL', KEYS[1], KEYS[2], KEYS[3], KEYS[4])
64
+ return 1`;
65
+
66
+ /**
67
+ * Reset a closed inbox (review finding F1). A console retry of a failed job keeps its job id, so its run id, and found
68
+ * the previous run's CLOSED inbox: every push answered `closed` for an hour, and the rebaseline moved that run's
69
+ * `delivered` messages back to `written` and handed them to a run that was never sent them. The previous run's outcome
70
+ * is already recorded (the close happens before the record write), so its messages are not needed any more.
71
+ * `{ reset: boolean }` or `{ error }`; never throws.
72
+ */
73
+ export async function resetClosedInbox({ redis, runId, inbox = inboxModule } = {}) {
74
+ try {
75
+ const k = inbox.inboxKeys(runId);
76
+ const r = await redis.eval(RESET_SCRIPT, 4, k.list, k.state, k.fallback, k.closed);
77
+ return { reset: Number(r) === 1 };
78
+ } catch (e) {
79
+ return { error: codeOf(e) };
80
+ }
81
+ }
82
+
83
+ const defaultFs = { writeFileSync, renameSync, unlinkSync };
84
+ const codeOf = (e) => (typeof e?.code === "string" && e.code !== "" ? e.code : typeof e?.error === "string" ? e.error : "error");
85
+
86
+ /** The inbox file's name for the entry at list position `index` (0-based): the runner reads them in name order. */
87
+ export function inboxFileName(index, id) {
88
+ return `${String(index + 1).padStart(6, "0")}-${id}.json`;
89
+ }
90
+
91
+ /**
92
+ * The target pointer, the framing ref and the forge word for a job (job data as queued): a forge job's target, or none
93
+ * for a local or cron job, which only the operator surfaces can reach. Never throws.
94
+ */
95
+ export function steerTargetOf(job, { steerTargetKey = inboxModule.steerTargetKey } = {}) {
96
+ const kind = typeof job?.kind === "string" ? job.kind : null;
97
+ const target = job?.target !== null && typeof job?.target === "object" ? job.target : null;
98
+ if (kind === null || kind === "local" || target === null) return { targetKey: null, ref: null, forge: null };
99
+ const n = typeof target.number === "number" ? target.number : typeof target.number === "string" && /^[1-9][0-9]{0,15}$/.test(target.number) ? Number(target.number) : null;
100
+ const isPr = target.type === "pull_request";
101
+ const targetKey = n !== null && typeof job.repo === "string" ? steerTargetKey({ kind, repo: job.repo, targetType: isPr ? "pull_request" : "issue", number: n }) : null;
102
+ let ref = null;
103
+ if (Number.isSafeInteger(n) && n > 0) {
104
+ if (kind === "gitlab") ref = isPr ? `merge request !${n}` : `issue #${n}`;
105
+ else if (kind === "azure") ref = isPr ? `pull request !${n}` : `work item #${n}`;
106
+ else ref = isPr ? `PR #${n}` : `issue #${n}`;
107
+ }
108
+ return { targetKey, ref, forge: /^[a-z]{1,16}$/.test(kind) ? kind : null };
109
+ }
110
+
111
+ /**
112
+ * `makeInboxRelay({ redis, runId, jobId, inboxDir, targetKey, host, ref, forge, now, log, pollMs, fs, frames })` ->
113
+ * `{ start(), afterBaseline(prior), delivered(id), tick(), stop(), counts(), attach(stdin, { written }) }`.
114
+ * `frames` (issue #686): `{ exitKey, macRunId, budget? }` selects the stdin frame transport (the header); absent, the
115
+ * file transport, byte-identical to before. `macRunId` is the container's PI_JOB_ID, which the runner MACs a frame by.
116
+ */
117
+ export function makeInboxRelay({ redis, runId, jobId = runId, inboxDir, targetKey = null, host, ref = null, forge = null, now = Date.now, log = () => {}, pollMs = RELAY_POLL_MS, fs = defaultFs, inbox = inboxModule, frames = null, onDelivered = null } = {}) {
118
+ const framed = frames !== null && typeof frames === "object" && typeof frames.exitKey === "string" && frames.exitKey !== "" && typeof frames.macRunId === "string" && frames.macRunId !== "";
119
+ const budget = framed && Number.isSafeInteger(frames.budget) && frames.budget > 0 ? frames.budget : STEER_STDIN_BUDGET_BYTES;
120
+ // The frame transport's own state: the pipe, whether it can still take a write, the bytes written (the key frame's
121
+ // included, from `attach`), the next frame's position (the key frame is 1), and the writes waiting on `drain`.
122
+ let sink = null;
123
+ let sinkDead = false;
124
+ let sinkEnded = false;
125
+ let streamBytes = 0;
126
+ let nextSeq = 2;
127
+ const drainWaiters = new Set();
128
+ const logId = jobId === null || jobId === undefined ? null : sanitizeJobId(jobId);
129
+ let timer = null;
130
+ let started = false;
131
+ let stopped = false;
132
+ let baselined = false;
133
+ let rebaseline = null; // the prior ids, until the first tick after the baseline has applied them
134
+ let priorAcked = new Set(); // ids whose prior transcript line carries an ack verified under this attempt's key (F5)
135
+ let closedSaid = false;
136
+ let ready = false; // `start` has reset and registered (L6)
137
+ let running = null;
138
+ const onDisk = new Map(); // id -> file path this attempt placed and has not unlinked
139
+ const counts = { written: 0, rewritten: 0, unlinked: 0, refused: 0, rebaselined: 0, delivered: 0, faults: 0 };
140
+ const said = new Set();
141
+
142
+ const fault = (step, code) => {
143
+ counts.faults += 1;
144
+ const key = `${step}:${code}`;
145
+ if (said.has(key)) return;
146
+ said.add(key);
147
+ log("steer_relay_fault", { jobId: logId, step, code });
148
+ };
149
+
150
+ const unlinkQuiet = (path) => {
151
+ try {
152
+ fs.unlinkSync(path);
153
+ return true;
154
+ } catch (e) {
155
+ if (e?.code !== "ENOENT") fault("unlink", codeOf(e));
156
+ return false;
157
+ }
158
+ };
159
+
160
+ const unlinkId = (id) => {
161
+ const path = onDisk.get(id);
162
+ if (path === undefined) return;
163
+ onDisk.delete(id);
164
+ // A frame has no file: what was written is in the pipe, and only the runner's ack says what became of it.
165
+ if (framed) return;
166
+ if (unlinkQuiet(path)) counts.unlinked += 1;
167
+ };
168
+
169
+ /** Write one frame's bytes, waiting out backpressure. True when the pipe took them (buffered counts); never rejects. */
170
+ const writeBytes = (bytes) =>
171
+ new Promise((resolve) => {
172
+ if (sink === null || sinkDead || sinkEnded) {
173
+ resolve(false);
174
+ return;
175
+ }
176
+ let more;
177
+ try {
178
+ more = sink.write(bytes);
179
+ } catch (e) {
180
+ sinkDead = true;
181
+ fault("stdin", codeOf(e));
182
+ resolve(false);
183
+ return;
184
+ }
185
+ if (more !== false) {
186
+ resolve(true);
187
+ return;
188
+ }
189
+ // Accepted into the stream's buffer, so `true` whatever ends the wait: the drain, the stop, a close or an error.
190
+ let done = false;
191
+ const settle = () => {
192
+ if (done) return;
193
+ done = true;
194
+ drainWaiters.delete(settle);
195
+ try {
196
+ sink?.off?.("drain", settle);
197
+ sink?.off?.("close", settle);
198
+ } catch {}
199
+ resolve(true);
200
+ };
201
+ drainWaiters.add(settle);
202
+ try {
203
+ sink.once("drain", settle);
204
+ sink.once("close", settle);
205
+ } catch {
206
+ settle();
207
+ }
208
+ });
209
+
210
+ /** The frame transport's `place`: one waiting message as a frame on the pipe, under the budget, marked first. */
211
+ const writeFrame = async (entry, state) => {
212
+ if (sink === null || sinkDead || sinkEnded) return; // not attached yet, or the pipe is gone: it stays waiting
213
+ const body = steerFileJson({ entry, ref, forge });
214
+ const frame = body === null ? null : steerFrame({ exitKey: frames.exitKey, runId: frames.macRunId, seq: nextSeq, body });
215
+ if (frame === null || streamBytes + frame.length > budget) {
216
+ // Unframable (a defect or a hand-written entry), or past what the venue's stdin holds: answered as late, never
217
+ // cut and never written as something the person did not send.
218
+ counts.refused += 1;
219
+ log("steer_frame_refused", { jobId: logId, id: entry.id });
220
+ const m = await inbox.markState({ redis, runId, id: entry.id, from: [state], to: "late" });
221
+ if (m?.error) fault("mark-late", codeOf(m));
222
+ return;
223
+ }
224
+ if (state === "queued") {
225
+ const m = await inbox.markState({ redis, runId, id: entry.id, from: ["queued"], to: "written" });
226
+ if (m?.error || m?.ok !== true) {
227
+ // Withdrawn (or otherwise moved) in the meantime: the frame is never written.
228
+ if (m?.error) fault("mark-written", codeOf(m));
229
+ return;
230
+ }
231
+ }
232
+ nextSeq += 1;
233
+ streamBytes += frame.length;
234
+ onDisk.set(entry.id, nextSeq - 1);
235
+ if (!(await writeBytes(frame))) return;
236
+ if (state === "queued") counts.written += 1;
237
+ else counts.rewritten += 1;
238
+ };
239
+
240
+ /** Close the pipe once (the runner reads EOF: no more messages), releasing any write waiting on `drain` first. */
241
+ const endSink = () => {
242
+ for (const settle of [...drainWaiters]) settle();
243
+ if (sink === null || sinkEnded) return;
244
+ sinkEnded = true;
245
+ try {
246
+ sink.end();
247
+ } catch (e) {
248
+ fault("stdin", codeOf(e));
249
+ }
250
+ };
251
+
252
+ /** Place one waiting message's file. `state` is what the inbox said: `queued` is marked first, `written` re-placed. */
253
+ const place = async (entry, index, state) => {
254
+ if (framed) return writeFrame(entry, state);
255
+ const body = steerFileJson({ entry, ref, forge });
256
+ if (body === null) {
257
+ // The inbox refuses at push what could not be framed, so this is a defect or a hand-written entry: the
258
+ // message is answered as late (the operator sees it), never written as something the person did not send.
259
+ counts.refused += 1;
260
+ log("steer_frame_refused", { jobId: logId, id: entry.id });
261
+ const m = await inbox.markState({ redis, runId, id: entry.id, from: [state], to: "late" });
262
+ if (m?.error) fault("mark-late", codeOf(m));
263
+ return;
264
+ }
265
+ const tmp = join(inboxDir, `.${entry.id}.tmp`);
266
+ const final = join(inboxDir, inboxFileName(index, entry.id));
267
+ try {
268
+ try {
269
+ fs.unlinkSync(tmp);
270
+ } catch {}
271
+ fs.writeFileSync(tmp, body, { mode: 0o444, flag: "wx" });
272
+ } catch (e) {
273
+ fault("write", codeOf(e));
274
+ return;
275
+ }
276
+ if (state === "queued") {
277
+ const m = await inbox.markState({ redis, runId, id: entry.id, from: ["queued"], to: "written" });
278
+ if (m?.error || m?.ok !== true) {
279
+ // Withdrawn (or otherwise moved) in the meantime: the file never becomes visible.
280
+ unlinkQuiet(tmp);
281
+ if (m?.error) fault("mark-written", codeOf(m));
282
+ return;
283
+ }
284
+ }
285
+ try {
286
+ fs.renameSync(tmp, final);
287
+ } catch (e) {
288
+ unlinkQuiet(tmp);
289
+ fault("rename", codeOf(e));
290
+ return;
291
+ }
292
+ onDisk.set(entry.id, final);
293
+ if (state === "queued") counts.written += 1;
294
+ else counts.rewritten += 1;
295
+ };
296
+
297
+ const pass = async () => {
298
+ // Review round 1, L6: nothing before `start` has reset a closed inbox and registered the run. The stdin transport
299
+ // releases the baseline at the spawn, which can come before `start`'s reset resolves: a pass then would read, and
300
+ // write into, the previous run's closed inbox. `start` runs the pass this skipped once it is ready.
301
+ if (!ready) return;
302
+ const r = await inbox.refreshLive({ redis, runId, targetKey, host, now });
303
+ if (r?.error) fault("refresh", codeOf(r));
304
+ if (!baselined || stopped) return;
305
+ const read = await inbox.readInbox({ redis, runId });
306
+ if (read?.error) {
307
+ fault("read", codeOf(read));
308
+ return;
309
+ }
310
+ // A CLOSED inbox is never written into and never re-baselined (F1): its run has ended, and only `start`'s reset may
311
+ // reopen it for a new run. Reached when that reset failed, or when something closed the inbox under a live run.
312
+ if (read.closed === true) {
313
+ if (!closedSaid) {
314
+ closedSaid = true;
315
+ log("steer_inbox_closed", { jobId: logId });
316
+ }
317
+ return;
318
+ }
319
+ const state = { ...read.state };
320
+ if (rebaseline !== null) {
321
+ // A message still waiting whose ack is already in the staged transcript, VERIFIED (F5): it was delivered, the
322
+ // worker just never marked it (a crash between pi writing the line and the mark). Marked, never re-written.
323
+ for (const id of priorAcked) {
324
+ if (state[id] !== "queued" && state[id] !== "written") continue;
325
+ const m = await inbox.markState({ redis, runId, id, from: ["queued", "written"], to: "delivered" });
326
+ if (m?.error) {
327
+ fault("rebaseline", codeOf(m));
328
+ return;
329
+ }
330
+ if (m?.ok === true) {
331
+ state[id] = "delivered";
332
+ counts.delivered += 1;
333
+ }
334
+ }
335
+ const prior = rebaseline;
336
+ for (const [id, st] of Object.entries(state)) {
337
+ if (st !== "delivered" || prior.has(id)) continue;
338
+ const m = await inbox.markState({ redis, runId, id, from: ["delivered"], to: "written" });
339
+ if (m?.error) {
340
+ fault("rebaseline", codeOf(m));
341
+ return; // tried again on the next tick, before anything is written
342
+ }
343
+ if (m?.ok === true) {
344
+ state[id] = "written";
345
+ counts.rebaselined += 1;
346
+ }
347
+ }
348
+ rebaseline = null;
349
+ }
350
+ for (let i = 0; i < read.entries.length; i++) {
351
+ if (stopped) return;
352
+ const entry = read.entries[i];
353
+ const st = state[entry.id];
354
+ if (st === "withdrawn" || st === "delivered" || st === "late") {
355
+ unlinkId(entry.id);
356
+ continue;
357
+ }
358
+ if ((st === "queued" || st === "written") && !onDisk.has(entry.id)) await place(entry, i, st);
359
+ }
360
+ };
361
+
362
+ const tick = () => {
363
+ if (running || stopped) return running ?? Promise.resolve();
364
+ running = (async () => {
365
+ try {
366
+ await pass();
367
+ } catch (e) {
368
+ fault("tick", codeOf(e));
369
+ }
370
+ })().finally(() => {
371
+ running = null;
372
+ });
373
+ return running;
374
+ };
375
+
376
+ return {
377
+ /** Register the run as live and begin the poll. Idempotent; never rejects. */
378
+ async start() {
379
+ if (started || stopped) return;
380
+ started = true;
381
+ // F1: a closed inbox under this run id belongs to a run that already ended (a console retry keeps the job id).
382
+ // Reset BEFORE registering, so no push can be accepted into the old run's keys.
383
+ const reset = await (typeof inbox.resetClosedInbox === "function" ? inbox.resetClosedInbox : resetClosedInbox)({ redis, runId, inbox });
384
+ if (reset?.error) fault("reset", codeOf(reset));
385
+ else if (reset?.reset === true) log("steer_inbox_reset", { jobId: logId });
386
+ try {
387
+ const r = await inbox.registerLive({ redis, runId, targetKey, host, now });
388
+ if (r?.error) fault("register", codeOf(r));
389
+ } catch (e) {
390
+ fault("register", codeOf(e));
391
+ }
392
+ if (stopped) return;
393
+ ready = true;
394
+ timer = setInterval(() => void tick(), pollMs);
395
+ timer.unref?.();
396
+ // A baseline that came first (the stdin transport's, at the spawn) is acted on now rather than a poll later.
397
+ if (baselined) void tick();
398
+ },
399
+ /** The tail's first pass is done: re-baseline against `prior` (ids in the staged transcript) and start writing. */
400
+ afterBaseline(prior = new Set(), verified = new Set()) {
401
+ if (baselined || stopped) return;
402
+ baselined = true;
403
+ rebaseline = prior instanceof Set ? prior : new Set(prior ?? []);
404
+ priorAcked = verified instanceof Set ? verified : new Set(verified ?? []);
405
+ void tick();
406
+ },
407
+ /** A verified ack (steer-ack.mjs): mark the message delivered and unlink its file. Never rejects. */
408
+ async delivered(id) {
409
+ // `withdrawn` too: a written message is withdrawn best effort, and a verified ack is proof the agent got it.
410
+ const m = await inbox.markState({ redis, runId, id, from: ["written", "queued", "withdrawn"], to: "delivered" });
411
+ if (m?.error) fault("mark-delivered", codeOf(m));
412
+ else if (m?.ok === true) {
413
+ counts.delivered += 1;
414
+ try { await onDelivered?.({ runId, id, at: now() }); } catch { fault("touch-delivered", "touch-index-unavailable"); }
415
+ }
416
+ unlinkId(id);
417
+ },
418
+ tick,
419
+ /**
420
+ * Stop for good: the timer, a pass in flight, then HOLD the liveness keys (review round 1, F3; steer-inbox.mjs
421
+ * `holdLive`). Not an unregister: until the settle closes the inbox (or the next attempt's relay re-registers the
422
+ * run), a comment must still land in it rather than fall through to an enqueue the run's semantic window swallows.
423
+ * What lands after this point is written by the next attempt or settles as `late`. Not a close either.
424
+ */
425
+ async stop() {
426
+ if (stopped) return { ...counts };
427
+ stopped = true;
428
+ if (timer) clearInterval(timer);
429
+ timer = null;
430
+ // A write waiting on `drain` would hold the pass in flight forever on a pipe nobody reads: released first.
431
+ for (const settle of [...drainWaiters]) settle();
432
+ try {
433
+ await running;
434
+ } catch {}
435
+ endSink();
436
+ if (started) {
437
+ try {
438
+ const r = await inbox.holdLive({ redis, runId, targetKey, host, now });
439
+ if (r?.error) fault("hold", codeOf(r));
440
+ } catch (e) {
441
+ fault("hold", codeOf(e));
442
+ }
443
+ }
444
+ return { ...counts };
445
+ },
446
+ counts: () => ({ ...counts }),
447
+ /**
448
+ * End the frame transport's pipe now, the relay otherwise running on (its marks and acks still land): the runner's
449
+ * exit line is out, so nothing more is read from the pipe, and a CLI that waited for EOF before it exits must not
450
+ * wait on this relay (review round 1, M1). Idempotent; a no-op on the file transport.
451
+ */
452
+ closeStdin() {
453
+ if (framed) endSink();
454
+ },
455
+ /**
456
+ * The frame transport's pipe (issue #686): the exec's stdin, after the key frame (`written`, its bytes, counted
457
+ * against the budget) went down it. Errors on it are caught here for good, so an EPIPE never reaches the process.
458
+ * A no-op on the file transport or once stopped (then the pipe is closed at once).
459
+ */
460
+ attach(stdin, { written = 0 } = {}) {
461
+ if (!framed || sink !== null || stdin === null || typeof stdin !== "object" || typeof stdin.write !== "function") return;
462
+ sink = stdin;
463
+ streamBytes = Number.isSafeInteger(written) && written > 0 ? written : 0;
464
+ try {
465
+ stdin.on?.("error", (e) => {
466
+ sinkDead = true;
467
+ fault("stdin", codeOf(e));
468
+ for (const settle of [...drainWaiters]) settle();
469
+ });
470
+ stdin.on?.("close", () => {
471
+ sinkDead = true;
472
+ for (const settle of [...drainWaiters]) settle();
473
+ });
474
+ } catch {}
475
+ if (stopped) {
476
+ endSink();
477
+ return;
478
+ }
479
+ if (baselined) void tick();
480
+ },
481
+ };
482
+ }