@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,215 @@
1
+ /**
2
+ * The worker's own doctor runs (issue #712, `DES-DOCTOR-RESULTS-PUBLISHED`).
3
+ *
4
+ * `pi-dispatch doctor` publishes what it found to Valkey (`doctor-results.mjs`), so the console and the `/dispatch`
5
+ * panel can say WHY a host is unhealthy instead of "run pi-dispatch doctor for the reason". An operator runs doctor by
6
+ * hand when something is already wrong, and a host nobody looked at would then publish nothing at all, so the worker
7
+ * runs it too: once shortly after boot and then every `PI_DOCTOR_INTERVAL_HOURS` (24 by default, 0 = never).
8
+ *
9
+ * A CHILD PROCESS, the same CLI entry an operator types (`process.execPath <cli.mjs> doctor --publish`), and never the
10
+ * doctor module called in-process. REJECTED: importing `runDoctor` here. Doctor is ten thousand lines of synchronous
11
+ * file reads, `docker`/`podman` spawns and its own Valkey clients; inside the worker its stalls would land on the event
12
+ * loop that renews BullMQ's job locks (`index.mjs` sets `maxStalledCount: 0`, so a loop blocked past the lock fails a
13
+ * paid job), and a doctor fault would be the worker's fault. A child has its own loop, its own crash, and a kill.
14
+ * It also judges exactly what an operator's run judges: one code path, one set of labels, one record shape.
15
+ *
16
+ * WHAT THE CHILD IS NOT GIVEN: `--live` (it starts real containers to read declarations back), `--secrets` (it runs
17
+ * every bundle resolver against the operator's secret manager) and `--fix` (it changes the host). A plain doctor run
18
+ * reads, and where the egress policy is armed it starts the egress canary's short-lived probe, which every plain
19
+ * `pi-dispatch doctor` already does; nothing the worker runs here costs a provider call.
20
+ *
21
+ * The interval idiom is `retention-sweep.mjs`'s, down to the injected timer seams and the posture of `close()`:
22
+ * unref'd so it never holds the process open, closed through `extraClosers` so a clean shutdown clears it. Two
23
+ * divergences, marked at their sites: the first run is DELAYED rather than immediate, and a run in flight is KILLED on
24
+ * close rather than drained.
25
+ */
26
+ import { spawn as nodeSpawn } from "node:child_process";
27
+
28
+ /** The default cadence, as `SWEEP_INTERVAL_HOURS` is the sweep's. */
29
+ export const DOCTOR_INTERVAL_HOURS = 24;
30
+
31
+ /** The ceiling, for `SWEEP_INTERVAL_MAX_HOURS`'s reason: `setInterval` clamps a delay past 2^31-1 ms to 1 ms. */
32
+ export const DOCTOR_INTERVAL_MAX_HOURS = 168;
33
+
34
+ /**
35
+ * How long one doctor run may take before the child is killed. A plain run on a healthy host takes seconds; its
36
+ * slowest steps are bounded by doctor itself (30 s per runtime command, 5 s per Valkey connect, the egress canary's own
37
+ * step bounds), so two minutes is a run that is wedged, not a run that is slow.
38
+ */
39
+ export const DOCTOR_RUN_TIMEOUT_MS = 120_000;
40
+
41
+ /**
42
+ * How long after boot the first run starts. DIVERGENCE from `host-registry.start`, which beats at once, and from the
43
+ * sweep, which waits a whole interval. Not at once, because a worker that crash-loops under its service manager would
44
+ * otherwise start one doctor per restart, each spawning runtime commands beside a boot that is already failing; a
45
+ * minute is past every restart delay the shipped units use. Not a whole interval, because the first published result
46
+ * is the one an operator opening the console after an install is looking for.
47
+ */
48
+ export const DOCTOR_BOOT_DELAY_MS = 60_000;
49
+
50
+ /**
51
+ * The one line doctor writes to stderr when it could not publish (`doctor.mjs`, `publishNote`). The spawner reads the
52
+ * child's stderr for this prefix alone, so its log line can say whether the run reached Valkey without carrying any of
53
+ * doctor's text: the rest of stderr is discarded unread.
54
+ */
55
+ export const DOCTOR_PUBLISH_SKIPPED_PREFIX = "doctor: results not published";
56
+
57
+ /** How much of the child's stderr is kept to look for that prefix. Doctor writes nothing else there on a plain run. */
58
+ const STDERR_KEEP_BYTES = 4096;
59
+
60
+ /** How long `close()` waits for a killed child to be reaped, not unref'd for the registry's reason. */
61
+ const CLOSE_REAP_TIMEOUT_MS = 5_000;
62
+
63
+ /**
64
+ * Build the spawner. `cliPath` is the worker's own `cli.mjs`; `env` and `cwd` are the worker's, so the child reads the
65
+ * deployment the service reads (doctor resolves `.env` in its working directory).
66
+ */
67
+ export function makeDoctorSpawner({
68
+ cliPath,
69
+ execPath = process.execPath,
70
+ cwd = process.cwd(),
71
+ env = process.env,
72
+ intervalMs,
73
+ bootDelayMs = DOCTOR_BOOT_DELAY_MS,
74
+ timeoutMs = DOCTOR_RUN_TIMEOUT_MS,
75
+ log = () => {},
76
+ spawnFn = nodeSpawn,
77
+ setIntervalFn = setInterval,
78
+ clearIntervalFn = clearInterval,
79
+ setTimeoutFn = setTimeout,
80
+ clearTimeoutFn = clearTimeout,
81
+ now = Date.now,
82
+ platform = process.platform,
83
+ // Signals a whole process group (`process.kill(-pid, sig)`), a seam so a test sees the group kill without a process.
84
+ killGroup = (pid, sig) => process.kill(-pid, sig),
85
+ // Issue #796: the same idiom for another child of the CLI. Its arguments and its log events, and a free check that says whether to start one at all.
86
+ args = null,
87
+ events = { overlapped: "doctor_overlapped", spawnFailed: "doctor_spawn_failed", finished: "doctor_published" },
88
+ shouldRun = null,
89
+ }) {
90
+ // The interval in whole hours, handed to the child so its record carries the cadence it really runs at.
91
+ const everyHours = Math.round(intervalMs / 3_600_000);
92
+ // POSIX: the child leads its own process group, so a kill reaches what doctor itself started (a `docker` call, the
93
+ // egress canary's probe) and not doctor alone (review round 1). Windows has no groups to signal: the plain kill.
94
+ const grouped = platform !== "win32";
95
+ const kill = (child) => {
96
+ try {
97
+ if (grouped && Number.isSafeInteger(child.pid) && child.pid > 0) killGroup(child.pid, "SIGKILL");
98
+ else child.kill?.("SIGKILL");
99
+ } catch {
100
+ try {
101
+ child.kill?.("SIGKILL");
102
+ } catch {
103
+ // a child that is already gone needs no kill
104
+ }
105
+ }
106
+ };
107
+ let timer = null;
108
+ let bootTimer = null;
109
+ let closed = false;
110
+ // The run in flight, `{ child, done }`, or null.
111
+ let running = null;
112
+
113
+ async function runOnce() {
114
+ if (closed) return;
115
+ // A free answer first, where the caller has one: asked before the overlap check would log an overlap for a tick with nothing to do.
116
+ if (shouldRun && !running) {
117
+ let wanted = false;
118
+ try { wanted = await shouldRun(); } catch { /* could not ask: next tick */ }
119
+ if (!wanted || closed) return;
120
+ }
121
+ // No overlap, for the sweep's reason: a short interval against a slow host must not stack doctors that each spawn
122
+ // the same runtime commands and race to publish. Said once per skipped tick, never queued.
123
+ if (running) {
124
+ log(events.overlapped, {});
125
+ return;
126
+ }
127
+ const startedAt = now();
128
+ let child;
129
+ try {
130
+ child = spawnFn(execPath, [cliPath, ...(args ?? ["doctor", "--publish", `--by-worker=${everyHours}`])], { cwd, env, stdio: ["ignore", "ignore", "pipe"], ...(grouped ? { detached: true } : {}) });
131
+ } catch (err) {
132
+ log(events.spawnFailed, { reason: err?.code ?? "spawn threw" });
133
+ return;
134
+ }
135
+ let stderr = "";
136
+ let timedOut = false;
137
+ let settled = false;
138
+ // The TAIL is kept (review round 1): doctor writes its "not published" line last, after anything else a run may
139
+ // put on stderr, so a kept head could miss it.
140
+ child.stderr?.on?.("data", (chunk) => {
141
+ stderr = (stderr + String(chunk)).slice(-STDERR_KEEP_BYTES);
142
+ });
143
+ // The kill timer is NOT unref'd: an unref'd timer does not fire when the hung child is the only thing left, which is
144
+ // the case it exists for. It is cleared the moment the child settles.
145
+ const killTimer = setTimeoutFn(() => {
146
+ timedOut = true;
147
+ kill(child);
148
+ }, timeoutMs);
149
+ const done = new Promise((resolve) => {
150
+ const settle = (fields) => {
151
+ if (settled) return;
152
+ settled = true;
153
+ clearTimeoutFn(killTimer);
154
+ running = null;
155
+ // Gated on `closed`, `host-registry`'s posture: a run that ends after the worker said it stopped must not speak
156
+ // through the boot's log closure.
157
+ if (!closed) log(fields.event, fields.data);
158
+ resolve();
159
+ };
160
+ child.on?.("error", (err) => settle({ event: events.spawnFailed, data: { reason: err?.code ?? "spawn failed" } }));
161
+ child.on?.("close", (code, signal) => {
162
+ const ms = now() - startedAt;
163
+ if (timedOut) return settle({ event: events.spawnFailed, data: { reason: "timeout", ms } });
164
+ // `code` is doctor's own verdict (0 ready, 1 a check failed) and is reported as it is; whether the run reached
165
+ // Valkey is the one stderr line doctor writes when it did not. No label, no path: names and integers only.
166
+ settle({ event: events.finished, data: { code: code ?? null, signal: signal ?? null, ms, published: !stderr.includes(DOCTOR_PUBLISH_SKIPPED_PREFIX) } });
167
+ });
168
+ });
169
+ running = { child, done };
170
+ // The child must not hold the worker's event loop open either: a shutdown that reaches `process.exit` kills it.
171
+ child.unref?.();
172
+ return done;
173
+ }
174
+
175
+ return {
176
+ runOnce,
177
+
178
+ start() {
179
+ if (closed || timer || bootTimer) return; // a second start would leak the first timers
180
+ // A non-positive interval arms NOTHING, the sweep's guard; the caller already gates on the knob being > 0.
181
+ if (!(intervalMs > 0)) return;
182
+ bootTimer = setTimeoutFn(() => {
183
+ bootTimer = null;
184
+ void runOnce();
185
+ }, bootDelayMs);
186
+ bootTimer?.unref?.();
187
+ timer = setIntervalFn(() => void runOnce(), intervalMs);
188
+ timer?.unref?.();
189
+ },
190
+
191
+ /**
192
+ * `closed` first, so a tick already past its check cannot spawn after the closer reported done. DIVERGENCE from the
193
+ * sweep, which drains: a doctor run in flight is KILLED, because nothing it would still do matters to a worker that
194
+ * is stopping (its publish is best effort by contract, and a later run republishes), while waiting for it could hold
195
+ * a shutdown for its whole two-minute bound. The reap is bounded, so a child that ignores SIGKILL cannot hang exit.
196
+ */
197
+ async close() {
198
+ closed = true;
199
+ if (timer) clearIntervalFn(timer);
200
+ if (bootTimer) clearTimeoutFn(bootTimer);
201
+ timer = null;
202
+ bootTimer = null;
203
+ const inFlight = running;
204
+ if (!inFlight) return;
205
+ kill(inFlight.child);
206
+ await new Promise((resolve) => {
207
+ const t = setTimeout(resolve, CLOSE_REAP_TIMEOUT_MS);
208
+ inFlight.done.then(() => {
209
+ clearTimeout(t);
210
+ resolve();
211
+ });
212
+ });
213
+ },
214
+ };
215
+ }