@intx/workflow-host 0.2.2

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 (81) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +287 -0
  3. package/dist/adapters/blob-substrate.d.ts +49 -0
  4. package/dist/adapters/blob-substrate.js +140 -0
  5. package/dist/adapters/repo-store.d.ts +39 -0
  6. package/dist/adapters/repo-store.js +344 -0
  7. package/dist/adapters/spawn-child.d.ts +74 -0
  8. package/dist/adapters/spawn-child.js +152 -0
  9. package/dist/adapters/step-invoker.d.ts +114 -0
  10. package/dist/adapters/step-invoker.js +360 -0
  11. package/dist/child/env-bootstrap.d.ts +56 -0
  12. package/dist/child/env-bootstrap.js +120 -0
  13. package/dist/child/from-process-env.d.ts +127 -0
  14. package/dist/child/from-process-env.js +183 -0
  15. package/dist/child/index.d.ts +9 -0
  16. package/dist/child/index.js +9 -0
  17. package/dist/child/outbound-mail-bridge.d.ts +36 -0
  18. package/dist/child/outbound-mail-bridge.js +143 -0
  19. package/dist/child/proxy-repo-store.d.ts +27 -0
  20. package/dist/child/proxy-repo-store.js +200 -0
  21. package/dist/child/run-child.d.ts +320 -0
  22. package/dist/child/run-child.js +900 -0
  23. package/dist/child/self-discovery.d.ts +29 -0
  24. package/dist/child/self-discovery.js +57 -0
  25. package/dist/child/substrate-write-bridge.d.ts +72 -0
  26. package/dist/child/substrate-write-bridge.js +188 -0
  27. package/dist/child/supervisor-backed-transport.d.ts +10 -0
  28. package/dist/child/supervisor-backed-transport.js +113 -0
  29. package/dist/child/warm-agent-cache.d.ts +78 -0
  30. package/dist/child/warm-agent-cache.js +112 -0
  31. package/dist/drain-controller.d.ts +37 -0
  32. package/dist/drain-controller.js +46 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/ipc/control-channel.d.ts +336 -0
  36. package/dist/ipc/control-channel.js +532 -0
  37. package/dist/ipc/crypto.d.ts +46 -0
  38. package/dist/ipc/crypto.js +126 -0
  39. package/dist/ipc/envelope.d.ts +53 -0
  40. package/dist/ipc/envelope.js +88 -0
  41. package/dist/ipc/event-channel.d.ts +677 -0
  42. package/dist/ipc/event-channel.js +278 -0
  43. package/dist/ipc/index.d.ts +4 -0
  44. package/dist/ipc/index.js +143 -0
  45. package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
  46. package/dist/mail-bus/hub-transport-adapter.js +76 -0
  47. package/dist/mail-bus/index.d.ts +1 -0
  48. package/dist/mail-bus/index.js +1 -0
  49. package/dist/seams/index.d.ts +3 -0
  50. package/dist/seams/index.js +3 -0
  51. package/dist/seams/scheduler-adapter.d.ts +3 -0
  52. package/dist/seams/scheduler-adapter.js +24 -0
  53. package/dist/seams/scheduler.d.ts +94 -0
  54. package/dist/seams/scheduler.js +397 -0
  55. package/dist/seams/signal-channel.d.ts +74 -0
  56. package/dist/seams/signal-channel.js +304 -0
  57. package/dist/supervisor/cancel-signing.d.ts +68 -0
  58. package/dist/supervisor/cancel-signing.js +144 -0
  59. package/dist/supervisor/child-termination.d.ts +51 -0
  60. package/dist/supervisor/child-termination.js +76 -0
  61. package/dist/supervisor/credentials.d.ts +101 -0
  62. package/dist/supervisor/credentials.js +153 -0
  63. package/dist/supervisor/dispatch-attribution.d.ts +37 -0
  64. package/dist/supervisor/dispatch-attribution.js +114 -0
  65. package/dist/supervisor/drain-timeout.d.ts +127 -0
  66. package/dist/supervisor/drain-timeout.js +231 -0
  67. package/dist/supervisor/index.d.ts +7 -0
  68. package/dist/supervisor/index.js +6 -0
  69. package/dist/supervisor/recycle.d.ts +212 -0
  70. package/dist/supervisor/recycle.js +440 -0
  71. package/dist/supervisor/run-event-compaction.d.ts +34 -0
  72. package/dist/supervisor/run-event-compaction.js +115 -0
  73. package/dist/supervisor/spawn-env.d.ts +39 -0
  74. package/dist/supervisor/spawn-env.js +36 -0
  75. package/dist/supervisor/supervisor.d.ts +202 -0
  76. package/dist/supervisor/supervisor.js +2244 -0
  77. package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
  78. package/dist/supervisor/terminal-broadcaster.js +184 -0
  79. package/dist/supervisor/types.d.ts +542 -0
  80. package/dist/supervisor/types.js +10 -0
  81. package/package.json +35 -0
@@ -0,0 +1,2244 @@
1
+ // Per-deployment supervisor.
2
+ //
3
+ // The supervisor is the host-side object that owns one workflow-process
4
+ // child for the lifetime of an active deployment: an in-host object,
5
+ // not a separate OS process. The host process holds one supervisor
6
+ // instance per deployment.
7
+ //
8
+ // Spawn lifecycle:
9
+ // 1. Mint a fresh `channelId` (16 bytes hex).
10
+ // 2. Mint a fresh 32-byte HMAC key for the event channel.
11
+ // 3. Mint a fresh Ed25519 keypair for the control channel (the
12
+ // "IPC signing key" -- orthogonal to the supervisor's principal-
13
+ // signing key the host's `signAsPrincipal` callback wraps).
14
+ // 4. Build a spawn-time env carrying only:
15
+ // - `IPC_CHANNEL_ID`
16
+ // - `IPC_HMAC_KEY` (hex)
17
+ // - `HOST_PUBKEY` (the IPC keypair's 32-byte public key, hex;
18
+ // NEVER the private key, NEVER the principal-signing key)
19
+ // plus the substrate-config keys the host injected.
20
+ // 5. Invoke `bindings.subprocessSpawner` with the binary path and
21
+ // env. The spawner returns a handle exposing the control
22
+ // channel writer/reader and the event channel reader.
23
+ // 6. Wire the control-channel sender (Ed25519-signed by the IPC
24
+ // private key) and the event-channel receiver (HMAC-verified).
25
+ // 7. Wait for the child's `ready` frame on the control channel;
26
+ // hold any inbound mail in the supervisor's buffer until then.
27
+ // 8. Register the deployment's mail address via the mail bus.
28
+ // 9. Forward inbound mail to the child via `trigger.fire` frames.
29
+ //
30
+ // The supervisor's `Bun.spawn` is invoked via the injected
31
+ // `bindings.subprocessSpawner` callback so tests stub it. The
32
+ // supervisor spawns the binary and does not depend on the
33
+ // `runWorkflowChild` body.
34
+ //
35
+ // CancelRequested signing:
36
+ // Every CancelRequested origin -- `self`, `supervisor-drain`,
37
+ // `supervisor-operator`, `hub-admin` -- flows through the same
38
+ // supervisor-signed path via `commitCancelRequested`. The `self`-
39
+ // origin case is the workflow-process forwarding its stated reason
40
+ // over the control IPC; the supervisor wraps it into a signed
41
+ // event without consulting the child for the signature.
42
+ import { type } from "arktype";
43
+ import { getLogger } from "@intx/log";
44
+ import { sampleStructuralCounters, forceRepack, } from "./dispatch-attribution.js";
45
+ import { generateKeyPair } from "@intx/crypto";
46
+ import { enqueueInbox as defaultEnqueueInbox, dequeueToProcessing as defaultDequeueToProcessing, markConsumed as defaultMarkConsumed, readOwnedMessageIds, replayProcessingToInbox as defaultReplayProcessingToInbox, DEFAULT_CONSUMED_RETENTION_MS, } from "@intx/hub-sessions/substrate";
47
+ import { base64Decode, base64Encode, hexEncode } from "@intx/types";
48
+ import { RepoId } from "@intx/types/sidecar";
49
+ import { createControlChannelSender, generateChannelId, generateHmacKey, receiveControlChannel, receiveEventChannel, } from "../ipc/index.js";
50
+ import { assembleCredentialsSnapshot, } from "./credentials.js";
51
+ import { commitCancelRequested } from "./cancel-signing.js";
52
+ import { buildChildSpawnEnv } from "./spawn-env.js";
53
+ import { compactRunEvents } from "./run-event-compaction.js";
54
+ import { createDrainTimeoutAccumulator, DEFAULT_DRAIN_TIMEOUT_MS, } from "./drain-timeout.js";
55
+ import { createRecyclePolicy, triggerRecycle, } from "./recycle.js";
56
+ import { createTerminalBroadcaster, } from "./terminal-broadcaster.js";
57
+ import { DEFAULT_KILL_TIMEOUT_MS, DEFAULT_READY_TIMEOUT_MS, defaultClearTimer, defaultSetTimer, killChildHandle, waitDeadline, } from "./child-termination.js";
58
+ const logger = getLogger(["workflow-host", "supervisor"]);
59
+ /**
60
+ * Default watchdog timeout for the supervisor's
61
+ * `synchronouslyDispatchTerminalWrite`. The handler holds the
62
+ * `substrate.write.response` back to the child until the dispatch
63
+ * loop's `markConsumed` settles for the matching terminal event; an
64
+ * unbounded wait would chain into a child / runtime / dispatch loop
65
+ * deadlock if `markConsumed` never armed (bug in the dispatch loop, a
66
+ * torn-down cohort, a stalled inbox primitive). 30s sits between the
67
+ * recycle path's `DEFAULT_KILL_TIMEOUT_MS` (5s, a hard process-level
68
+ * kill cap) and `DEFAULT_DRAIN_TIMEOUT_MS` (60s, the per-deployment
69
+ * drain budget) -- generous enough to absorb a slow legitimate
70
+ * markConsumed, tight enough to surface a real deadlock long before
71
+ * the drainTimeout would otherwise mask it.
72
+ */
73
+ export const DEFAULT_TERMINAL_WRITE_WATCHDOG_MS = 30_000;
74
+ /**
75
+ * Raised when a `pendingMerges` entry or a
76
+ * `markConsumedCompletionWaiters` waiter is rejected because the
77
+ * cohort it was registered against has been aborted (cohort transition
78
+ * during a recycle, or a supervisor shutdown). Callers awaiting the
79
+ * resolved value receive an instance of this error so the failure mode
80
+ * is recognisable from a generic substrate-merge or markConsumed
81
+ * failure.
82
+ */
83
+ export class MergeAbortedError extends Error {
84
+ constructor(reason) {
85
+ super(`supervisor cohort aborted before completion: ${reason}`);
86
+ this.name = "MergeAbortedError";
87
+ }
88
+ }
89
+ /**
90
+ * Construct a per-deployment supervisor. All host-specific
91
+ * dependencies are pulled in via `bindings`; nothing in the
92
+ * supervisor reaches into `process.env` or a singleton.
93
+ */
94
+ export function createWorkflowSupervisor(bindings) {
95
+ let state = { phase: "idle" };
96
+ /**
97
+ * In-flight runIds the supervisor knows about. A runId enters this
98
+ * set when the supervisor forwards a `trigger.fire` for it on the
99
+ * control channel; the runId leaves the set when the dispatch
100
+ * loop's terminal-event watcher fires `markConsumed`. The drain
101
+ * path arms one accumulator per entry here.
102
+ */
103
+ const inFlightRuns = new Set();
104
+ // D2 attribution (measurement-only): the runId the dispatch loop is
105
+ // currently servicing. Set at `dispatch-start`, cleared after
106
+ // `reply-produced`. The dispatch loop is strictly serial (one message
107
+ // in flight at a time -- the sustained interactive case the bench
108
+ // drives), so a child-proxied WAL `substrate.write.request` (whose
109
+ // `agent-state/<key>/...` preservePrefix carries no runId) is
110
+ // unambiguously attributable to this runId. Run-event writes carry the
111
+ // runId in their `runs/<runId>/events/` prefix and do not need it.
112
+ let currentDispatchRunId = null;
113
+ /**
114
+ * Per-run drainTimeout accumulators armed by `drain()`. Held so
115
+ * `shutdown()` can stop every accumulator cleanly before tearing
116
+ * the deployment down (an accumulator left running would otherwise
117
+ * fire `setTimeout` after the supervisor has been disposed).
118
+ */
119
+ const drainAccumulators = new Map();
120
+ const accumulatorFactory = bindings.drainTimeoutAccumulatorFactory ?? createDrainTimeoutAccumulator;
121
+ const drainNow = bindings.now ?? Date.now;
122
+ const drainSetTimer = bindings.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
123
+ const drainClearTimer = bindings.clearTimer ??
124
+ ((h) => {
125
+ // The production `drainSetTimer` returns the value of
126
+ // `setTimeout`, so the only handles flowing through
127
+ // `drainClearTimer` are `Timeout` objects. `clearTimeout`
128
+ // accepts `Timeout | undefined` -- the `undefined` branch is
129
+ // a no-op which is the right behaviour for the defensive
130
+ // path here.
131
+ if (h !== null && typeof h === "object") {
132
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- handle round-trip: the matching `drainSetTimer` returns `ReturnType<typeof setTimeout>`; the accumulator preserves opaqueness, which forces a re-assertion here
133
+ clearTimeout(h);
134
+ }
135
+ });
136
+ const drainTimeoutMs = bindings.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
137
+ const terminalWriteWatchdogMs = bindings.terminalWriteWatchdogMs ?? DEFAULT_TERMINAL_WRITE_WATCHDOG_MS;
138
+ // Pure observability: invoke the dispatch-timing hook (when wired) at
139
+ // the two per-message boundaries the 4.7 latency gate brackets. A
140
+ // throwing observer is swallowed and logged so a benchmark hook bug
141
+ // cannot wedge the dispatch loop.
142
+ function emitDispatchTiming(runId, marker, atMs) {
143
+ const observer = bindings.onDispatchTiming;
144
+ if (observer === undefined)
145
+ return;
146
+ try {
147
+ observer({ kind: "roundtrip", runId, marker, atMs });
148
+ }
149
+ catch (cause) {
150
+ const message = cause instanceof Error ? cause.message : String(cause);
151
+ logger.warn `onDispatchTiming observer threw for ${runId} (${marker}): ${message}`;
152
+ }
153
+ }
154
+ // D2 per-leg attribution (measurement-only). Emits a paired
155
+ // start/end mark around one of the five substrate legs so each leg's
156
+ // per-message slope/floor can be fit independently. The `end` mark
157
+ // carries the structural counters sampled at commit time (runs/ and
158
+ // consumed/ fan-out, loose-object count, .git byte size) so the slope
159
+ // can be correlated with the grower that explains it. Pure
160
+ // observability: a throwing observer is swallowed + logged so a
161
+ // benchmark hook bug cannot wedge dispatch, and no clock or directory
162
+ // is sampled when the observer is unwired.
163
+ function legMarkStart(runId, leg) {
164
+ if (bindings.onDispatchTiming === undefined)
165
+ return 0;
166
+ const atMs = performance.now();
167
+ try {
168
+ bindings.onDispatchTiming({
169
+ kind: "leg",
170
+ runId,
171
+ leg,
172
+ phase: "start",
173
+ atMs,
174
+ });
175
+ }
176
+ catch (cause) {
177
+ const message = cause instanceof Error ? cause.message : String(cause);
178
+ logger.warn `onDispatchTiming leg observer threw for ${runId} (${leg} start): ${message}`;
179
+ }
180
+ return atMs;
181
+ }
182
+ function legMarkEnd(runId, leg) {
183
+ const observer = bindings.onDispatchTiming;
184
+ if (observer === undefined)
185
+ return;
186
+ const atMs = performance.now();
187
+ let counters;
188
+ try {
189
+ counters = sampleStructuralCounters(bindings.repoStore.getRepoDir(bindings.workflowRunRepoId));
190
+ }
191
+ catch (cause) {
192
+ // A counter read that throws must not perturb the measured leg;
193
+ // surface it on the log and emit the end mark without counters so
194
+ // the timing slope is still recoverable.
195
+ const message = cause instanceof Error ? cause.message : String(cause);
196
+ logger.warn `structural-counter sample failed for ${runId} (${leg}): ${message}`;
197
+ }
198
+ try {
199
+ observer({
200
+ kind: "leg",
201
+ runId,
202
+ leg,
203
+ phase: "end",
204
+ atMs,
205
+ ...(counters !== undefined ? { counters } : {}),
206
+ });
207
+ }
208
+ catch (cause) {
209
+ const message = cause instanceof Error ? cause.message : String(cause);
210
+ logger.warn `onDispatchTiming leg observer threw for ${runId} (${leg} end): ${message}`;
211
+ }
212
+ }
213
+ // §10c forced-repack A/B (measurement-only). Absent toggle => never
214
+ // repacks; the dispatch path forks no `git gc`. When wired, the
215
+ // dispatch loop calls `maybeRepack` once per dispatched message after
216
+ // `markConsumed`, and every `everyMessages`-th message forces a repack
217
+ // of the workflow-run repo under the single-writer discipline (the
218
+ // dispatch loop is the sole writer and blocks on the synchronous gc, so
219
+ // no commit can interleave).
220
+ const repackToggle = bindings.repackEveryMessages;
221
+ let dispatchedSinceRepack = 0;
222
+ function maybeRepack(runId) {
223
+ if (repackToggle === undefined)
224
+ return;
225
+ dispatchedSinceRepack += 1;
226
+ if (dispatchedSinceRepack < repackToggle.everyMessages)
227
+ return;
228
+ dispatchedSinceRepack = 0;
229
+ const repoDir = bindings.repoStore.getRepoDir(bindings.workflowRunRepoId);
230
+ const result = forceRepack(repoDir);
231
+ if (!result.ok) {
232
+ logger.warn `forced repack failed after ${runId}: ${result.detail}`;
233
+ return;
234
+ }
235
+ // The repack itself is not a per-message leg: the A/B compares the
236
+ // per-leg slopes of a whole WITH-repack run against a whole
237
+ // WITHOUT-repack run. Logged (not emitted on the leg channel) so the
238
+ // repack cadence + duration are visible in the supervisor log without
239
+ // contaminating any leg's per-message series. The structural counters
240
+ // sampled right after confirm loose-object count collapsed -- the
241
+ // direct evidence the gc ran.
242
+ const after = sampleStructuralCounters(repoDir);
243
+ logger.info `forced repack after ${runId}: ${result.durationMs.toFixed(1)}ms; looseObjects now ${String(after.looseObjects)}, gitBytes now ${String(after.gitBytes)}`;
244
+ }
245
+ /**
246
+ * Classify a child-proxied `substrate.write.request` into the D2 leg it
247
+ * represents, plus the runId the per-message OLS fit groups on.
248
+ * `runs/<runId>/events/` is the run-event bracket commit (runId from the
249
+ * prefix); `agent-state/...` is the D1 conversation WAL append (no runId
250
+ * in the prefix -- attributed to the dispatch loop's current serial
251
+ * runId). Any other prefix is an unmarked proxied write. Returns `null`
252
+ * when no observer is wired (so the supervisor samples nothing) or the
253
+ * prefix is not an attributed leg.
254
+ */
255
+ function classifyProxiedWriteLeg(preservePrefix) {
256
+ if (bindings.onDispatchTiming === undefined)
257
+ return null;
258
+ const runEventMatch = /^runs\/([^/]+)\/events\/$/.exec(preservePrefix);
259
+ if (runEventMatch !== null) {
260
+ const runId = runEventMatch[1];
261
+ if (runId !== undefined)
262
+ return { leg: "runevent", runId };
263
+ }
264
+ if (preservePrefix.startsWith("agent-state/")) {
265
+ if (currentDispatchRunId !== null) {
266
+ return { leg: "wal", runId: currentDispatchRunId };
267
+ }
268
+ }
269
+ return null;
270
+ }
271
+ // Avoid sampling the clock when no observer is wired (the hot path in
272
+ // production). When an observer is present, the dispatch loop samples
273
+ // `performance.now()` BEFORE `dequeueToProcessing` so the claim-check
274
+ // READ falls inside the measured per-message interval, and stamps the
275
+ // `dispatch-start` mark with that pre-dequeue sample once the runId is
276
+ // known.
277
+ function dispatchTimingEnabled() {
278
+ return bindings.onDispatchTiming !== undefined;
279
+ }
280
+ const inboxPrimitives = bindings.inboxPrimitives ?? {
281
+ enqueueInbox: defaultEnqueueInbox,
282
+ dequeueToProcessing: defaultDequeueToProcessing,
283
+ markConsumed: defaultMarkConsumed,
284
+ replayProcessingToInbox: defaultReplayProcessingToInbox,
285
+ };
286
+ const deriveMailAuditRef = bindings.deriveMailAuditRef ?? defaultInProcessMailAuditRef;
287
+ const defaultInboxWritePrincipal = {
288
+ kind: "supervisor",
289
+ deploymentId: bindings.deploymentId,
290
+ };
291
+ const inboxWritePrincipal = bindings.inboxWritePrincipal ?? defaultInboxWritePrincipal;
292
+ // Resolve the consumed-dedup retention horizon once at the bindings
293
+ // edge (the layer that owns the operator config); every markConsumed
294
+ // is threaded the concrete value. See `WorkflowSupervisorBindings.
295
+ // consumedRetentionMs` for the operator-owned invariant.
296
+ const consumedRetentionMs = bindings.consumedRetentionMs ?? DEFAULT_CONSUMED_RETENTION_MS;
297
+ // Resolve the spawn ready-handshake timeout and its timers once at the
298
+ // bindings edge. The timers reuse the same injectable pair the drain
299
+ // path resolves (`bindings.setTimer`/`clearTimer`); the ready-timeout
300
+ // race and its kill-escalation drive them, and tests substitute a
301
+ // deterministic timer through the same bindings.
302
+ const readyTimeoutMs = bindings.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
303
+ const readySetTimer = bindings.setTimer ?? defaultSetTimer;
304
+ const readyClearTimer = bindings.clearTimer ?? defaultClearTimer;
305
+ /**
306
+ * Resolved on every successful `enqueueInbox`; the dispatch loop
307
+ * awaits this promise after a null dequeue so it returns to
308
+ * dequeueing the moment a fresh entry lands. Replaced with a fresh
309
+ * promise on every wake so the loop's next iteration starts from a
310
+ * clean signal.
311
+ */
312
+ let dispatchWake = makeDispatchWake();
313
+ function makeDispatchWake() {
314
+ let resolver = () => undefined;
315
+ const promise = new Promise((resolve) => {
316
+ resolver = resolve;
317
+ });
318
+ return { promise, resolve: resolver };
319
+ }
320
+ function wakeDispatch() {
321
+ const prev = dispatchWake;
322
+ dispatchWake = makeDispatchWake();
323
+ prev.resolve();
324
+ }
325
+ /**
326
+ * Cached per-spawn context the recycle path needs to respawn the
327
+ * child against the same deploy tree. Populated on `spawn(opts)`;
328
+ * cleared on `shutdown`. The recycle path never mutates the
329
+ * `stepOrder` or `definitionHash` -- the orthogonality with redeploy
330
+ * lives at this field: a deploy-tree change would land via a
331
+ * different code path that minted a new supervisor.
332
+ */
333
+ let spawnContext = null;
334
+ let recyclePolicy = null;
335
+ let recycleInProgress = false;
336
+ function onChildCrash(reason) {
337
+ logger.error `workflow-process control channel crash: {reason}`;
338
+ void shutdownInternal({ reason });
339
+ }
340
+ function onMailMessage(rawMessage) {
341
+ // Every inbound mail flows through the FIFO inbox claim-check
342
+ // queue, regardless of the supervisor's current phase. The
343
+ // dispatch loop (started by `spawn()` and restarted by the
344
+ // recycle path's `installNewChild`) drains the inbox in arrival
345
+ // order and forwards each entry to the child as a `trigger.fire`.
346
+ //
347
+ // The substrate's per-repo lock serializes concurrent enqueues
348
+ // against drains and replays; arrival ordering is preserved by
349
+ // the envelope's `receivedAt` prefix on the inbox filename.
350
+ if (state.phase === "idle" ||
351
+ state.phase === "stopping" ||
352
+ state.phase === "stopped") {
353
+ // The host's higher-level lifecycle is already tearing the
354
+ // deployment down; the message drops on the floor rather than
355
+ // landing in an inbox no live dispatch loop will service.
356
+ return;
357
+ }
358
+ void enqueueInboundMail(rawMessage).catch((cause) => {
359
+ const message = cause instanceof Error ? cause.message : String(cause);
360
+ logger.error `enqueueInbox failed: ${message}`;
361
+ });
362
+ }
363
+ async function enqueueInboundMail(rawMessage) {
364
+ const messageId = await deriveMessageId(rawMessage);
365
+ const mailAuditRef = deriveMailAuditRef(messageId, rawMessage);
366
+ const receivedAt = Date.now();
367
+ // Inline the raw mail bytes on the claim-check envelope so the
368
+ // workflow-process child can recover its step input by messageId at
369
+ // `trigger.fired` time. The supervisor is the sole mail owner (§3a)
370
+ // and has no separate durable byte store the child reads; the bytes
371
+ // survive the inbox->processing transition verbatim and are dropped
372
+ // when `markConsumed` writes the dedup index.
373
+ const rawMessageBase64 = base64Encode(rawMessage);
374
+ // D2 leg: `enqueueInbox` runs in `onMailMessage` BEFORE dispatch, so
375
+ // it is paid OUTSIDE the dispatch-start..reply-produced window -- its
376
+ // growth is invisible to the 4.7 bracket. The leg mark, keyed by the
377
+ // same messageId the dispatch loop later uses as the runId, makes the
378
+ // out-of-window cost visible and joinable to the in-window legs.
379
+ legMarkStart(messageId, "enqueue");
380
+ await inboxPrimitives.enqueueInbox(bindings.repoStore, inboxWritePrincipal, bindings.workflowRunRepoId, {
381
+ address: bindings.deploymentMailAddress,
382
+ messageId,
383
+ receivedAt,
384
+ mailAuditRef,
385
+ rawMessage: rawMessageBase64,
386
+ });
387
+ legMarkEnd(messageId, "enqueue");
388
+ wakeDispatch();
389
+ }
390
+ /**
391
+ * Pump child-initiated upstream control frames after `ready` has
392
+ * landed. The supervisor's primary `waitForReady` consumed the
393
+ * `ready` frame and returned; this generator continues the iterator
394
+ * and recognises the upstream variants the protocol allows from the
395
+ * child (today: `recycle.request`). Any frame the supervisor does
396
+ * not recognise on the upstream side is dropped after a logged
397
+ * warning -- the receiver iterator already validated the envelope
398
+ * and signature.
399
+ */
400
+ async function pumpUpstreamControl(iter, cohortBroadcaster) {
401
+ for await (const payload of iter) {
402
+ if (payload.type === "recycle.request") {
403
+ logger.info `workflow-process self-initiated recycle.request: ${payload.data.reason}`;
404
+ // Run the recycle off the iterator's loop so the iterator can
405
+ // continue draining frames the supervisor's drain step will
406
+ // produce. The recycle path tears the iterator down via the
407
+ // existing kill of the child handle.
408
+ void recycle({
409
+ reason: `self-initiated: ${payload.data.reason}`,
410
+ origin: "self",
411
+ }).catch((cause) => {
412
+ const message = cause instanceof Error ? cause.message : String(cause);
413
+ logger.error `self-initiated recycle failed: ${message}`;
414
+ });
415
+ return;
416
+ }
417
+ if (payload.type === "substrate.write.request") {
418
+ // Run the write off the iterator's loop so the iterator can
419
+ // continue draining other upstream frames (notably the
420
+ // substrate.merge.response that resolves the merge round-trip
421
+ // for this very write -- if the loop were blocked here, the
422
+ // merge response could not be consumed and the write would
423
+ // deadlock).
424
+ void handleSubstrateWriteRequest(payload.data).catch((cause) => {
425
+ const message = cause instanceof Error ? cause.message : String(cause);
426
+ logger.error `substrate.write.request handler crashed: ${message}`;
427
+ });
428
+ continue;
429
+ }
430
+ if (payload.type === "substrate.merge.response") {
431
+ // Resume the pending merge round-trip with the child's
432
+ // response. The handler resolves a per-write awaiter inside
433
+ // the substrate write handler's merge callback.
434
+ resolveMergeResponse(payload.data);
435
+ continue;
436
+ }
437
+ if (payload.type === "outbound.message") {
438
+ // OUTBOUND half of mailbox ownership (§3a). The child produced a
439
+ // reply or invoked a mail-send tool; the supervisor is the sole
440
+ // mail owner and performs the actual signed send through the
441
+ // host's real transport. Run it off the iterator's loop so the
442
+ // iterator keeps draining other upstream frames while the host
443
+ // transport assembles and signs the mail; the handler owns the
444
+ // `outbound.result` reply that resolves the child's awaiter.
445
+ void handleOutboundMessage(payload.data).catch((cause) => {
446
+ const message = cause instanceof Error ? cause.message : String(cause);
447
+ logger.error `outbound.message handler crashed: ${message}`;
448
+ });
449
+ continue;
450
+ }
451
+ if (payload.type === "terminal.event") {
452
+ // The workflow-process child mirrors every terminal-run commit
453
+ // over the control IPC. Fan it out to the COHORT'S broadcaster
454
+ // -- captured at pump-start time, not resolved dynamically
455
+ // against the supervisor's current `state`. The pump is one-
456
+ // to-one with its cohort's `controlIncoming` iterator: a
457
+ // buffered `terminal.event` the OLD child emitted before kill
458
+ // landed must NEVER route to the NEW cohort's broadcaster.
459
+ // Without this binding, a stale OLD-cohort frame for a runId
460
+ // the NEW cohort happens to be dispatching under the same id
461
+ // (the normal recycle/replay case) would falsely settle the
462
+ // NEW cohort's `waitForRunTerminal` and commit `markConsumed`
463
+ // on a run still in flight. The broadcaster's own `dispose()`
464
+ // on cohort teardown turns post-dispose notify into a no-op,
465
+ // so a stale frame dequeued after the cohort was torn down
466
+ // drops cleanly without leaking into any successor cohort.
467
+ const event = terminalEventFromPayload(payload.data);
468
+ cohortBroadcaster.notify(payload.data.runId, event);
469
+ continue;
470
+ }
471
+ logger.warn `workflow-process upstream control payload ignored: type=${payload.type}`;
472
+ }
473
+ }
474
+ const pendingMerges = new Map();
475
+ /**
476
+ * Reject every pending merge round-trip and every
477
+ * `markConsumed` completion waiter. Invoked on cohort transitions
478
+ * (shutdown, recycle's `installNewChild`) so closures awaiting these
479
+ * promises do not outlive the cohort that armed them. Without this,
480
+ * a `handleSubstrateWriteRequest` mid-merge or a dispatch-loop
481
+ * caller awaiting `markConsumed` would sit on a resolver that the
482
+ * dying control channel will never invoke.
483
+ */
484
+ function rejectCohortAwaiters(reason) {
485
+ for (const [requestId, entry] of pendingMerges) {
486
+ pendingMerges.delete(requestId);
487
+ entry.resolve({ ok: false, reason: `cohort aborted: ${reason}` });
488
+ }
489
+ for (const [runId, waiter] of markConsumedCompletionWaiters.entries()) {
490
+ markConsumedCompletionWaiters.delete(runId);
491
+ waiter.reject(new MergeAbortedError(`markConsumed waiter (${runId}): ${reason}`));
492
+ }
493
+ }
494
+ function resolveMergeResponse(data) {
495
+ const entry = pendingMerges.get(data.requestId);
496
+ if (entry === undefined) {
497
+ logger.warn `substrate.merge.response landed with no pending entry; requestId=${data.requestId} dropped`;
498
+ return;
499
+ }
500
+ pendingMerges.delete(data.requestId);
501
+ if (data.result.ok) {
502
+ const files = {};
503
+ try {
504
+ for (const file of data.result.files) {
505
+ files[file.path] = base64ToBytes(file.contentBase64);
506
+ }
507
+ }
508
+ catch (cause) {
509
+ // `base64ToBytes` throws loudly on malformed child-supplied
510
+ // content. This runs synchronously from `pumpUpstreamControl`'s
511
+ // `for await`, so an escaping throw would tear the pump down and
512
+ // stop draining every other upstream control frame for the
513
+ // cohort. Mirror the child-side `decodeMergeRequest` hardening:
514
+ // resolve the pending merge as a failure so the write handler
515
+ // surfaces it as a structured substrate.write.response.
516
+ const reason = cause instanceof Error ? cause.message : String(cause);
517
+ entry.resolve({
518
+ ok: false,
519
+ reason: `supervisor substrate.merge.response: decode failed: ${reason}`,
520
+ });
521
+ return;
522
+ }
523
+ entry.resolve({ ok: true, files });
524
+ return;
525
+ }
526
+ entry.resolve({ ok: false, reason: data.result.reason });
527
+ }
528
+ /**
529
+ * OUTBOUND half of mailbox ownership (§3a). The workflow-process child
530
+ * never holds the agent's signing key; it forwards the structured
531
+ * outbound message plus the sender (agent) address up over the control
532
+ * channel and the supervisor performs the actual signed send through
533
+ * the host's real transport (`bindings.mailBus.sendOutbound`). The
534
+ * host transport signs with the sender's `CryptoProvider` -- the same
535
+ * `executeSend` path the in-process agent uses -- so the outbound mail
536
+ * carries the AGENT's signature with full parity to the pre-supervisor
537
+ * path. A send failure (unregistered sender, signing failure,
538
+ * transport rejection) surfaces back to the child as a structured
539
+ * `{ ok: false, reason }` so the agent's mail-tool call fails loudly
540
+ * rather than silently dropping the send.
541
+ */
542
+ async function handleOutboundMessage(data) {
543
+ const controlSender = activeControlSender();
544
+ if (controlSender === null) {
545
+ // The request arrived after the control sender was cleared (the
546
+ // supervisor is mid-recycle or tearing down). The child's read end
547
+ // is being closed alongside this transition, so its pending
548
+ // mail-tool awaiter surfaces a pipe-close error on its own read.
549
+ // There is no sender to write the `outbound.result` on; dropping
550
+ // the frame is the only available action, logged loudly.
551
+ logger.warn `outbound.message received outside running phase; requestId=${data.requestId} dropped (child awaiter will fail on pipe close)`;
552
+ return;
553
+ }
554
+ try {
555
+ const message = outboundMessageFromPayload(data.message);
556
+ const receipt = await bindings.mailBus.sendOutbound(data.senderAddress, message);
557
+ await controlSender.send({
558
+ type: "outbound.result",
559
+ data: {
560
+ requestId: data.requestId,
561
+ result: {
562
+ ok: true,
563
+ messageId: receipt.messageId,
564
+ status: receipt.status,
565
+ },
566
+ },
567
+ });
568
+ }
569
+ catch (cause) {
570
+ const reason = cause instanceof Error ? cause.message : String(cause);
571
+ await controlSender.send({
572
+ type: "outbound.result",
573
+ data: {
574
+ requestId: data.requestId,
575
+ result: { ok: false, reason },
576
+ },
577
+ });
578
+ }
579
+ }
580
+ async function handleSubstrateWriteRequest(data) {
581
+ const controlSender = activeControlSender();
582
+ if (controlSender === null) {
583
+ // The request arrived after `activeControlSender()` was
584
+ // cleared (the supervisor is in `recycling` mid-swap, or
585
+ // `draining`/`stopping`/`stopped`). The child's read end of
586
+ // the IPC pipe is being torn down alongside this transition,
587
+ // so the child's pending waiter will surface a pipe-close
588
+ // error on its own read rather than wedge. Dropping the
589
+ // frame here is the only available action -- there is no
590
+ // sender to write the response on, and routing the response
591
+ // to whatever next-cohort sender exists would deliver it to
592
+ // the wrong child. Logged loudly so persistent occurrences
593
+ // surface in operator logs.
594
+ logger.warn `substrate.write.request received outside running phase; requestId=${data.requestId} dropped (child waiter will fail on pipe close)`;
595
+ return;
596
+ }
597
+ const validatedRepoId = RepoId(data.repoId);
598
+ if (validatedRepoId instanceof type.errors) {
599
+ onChildCrash(`substrate.write.request repoId failed validation: ${validatedRepoId.summary}`);
600
+ return;
601
+ }
602
+ // The child proxies workflow-run writes; an inbound request for a
603
+ // different repo kind is a protocol violation. The supervisor owns
604
+ // the write contract for the workflow-run repo specifically.
605
+ if (validatedRepoId.kind !== "workflow-run") {
606
+ await controlSender.send({
607
+ type: "substrate.write.response",
608
+ data: {
609
+ requestId: data.requestId,
610
+ result: {
611
+ ok: false,
612
+ reason: `supervisor substrate.write.request: repoId.kind must be "workflow-run", got ${JSON.stringify(validatedRepoId.kind)}`,
613
+ },
614
+ },
615
+ });
616
+ return;
617
+ }
618
+ // The substrate principal authoring the proxied write is the
619
+ // `workflow-process` principal scoped to this supervisor's
620
+ // deployment. The child has no write authority of its own (it
621
+ // holds no private key on the host process), but the workflow-run
622
+ // kind handler is the authority that accepts the
623
+ // `workflow-process` principal for `runs/<runId>/` writes
624
+ // (including the origin-specific CancelRequested checks that pin
625
+ // `self` to `workflow-process`). Authoring proxied writes under
626
+ // this kind preserves the on-disk audit semantics the original
627
+ // child-direct-write path produced; the only architectural change
628
+ // is which process owns the substrate write contract.
629
+ const writePrincipal = {
630
+ kind: "workflow-process",
631
+ deploymentId: bindings.deploymentId,
632
+ };
633
+ // The commit's terminal detection comes from the kind handler's
634
+ // typed `newlyTerminalRuns` signal (returned below), not a sniff of
635
+ // the merged files: the handler authoritatively determines, during
636
+ // validation, which runs reached a terminal event in this commit.
637
+ // Holding the substrate.write.response on that signal gates the
638
+ // child's runtime-body progress on the inbox transition landing,
639
+ // closing the window where a downstream consumer observes
640
+ // RunCompleted ahead of the matching consumed/ entry on this
641
+ // supervisor (the cross-process hub-pack ordering is still racy, but
642
+ // the local supervisor's state is self-consistent at the response
643
+ // boundary).
644
+ // D2 leg classification (measurement-only). The child proxies two
645
+ // distinct substrate commits through this one handler, discriminated
646
+ // by the write's `preservePrefix`:
647
+ // - `runs/<runId>/events/` -> the run-event bracket commit
648
+ // (RunStarted/StepStarted/StepCompleted/RunCompleted; one message
649
+ // may produce several, each a separate write -- the D2
650
+ // post-processing sums and counts them per message).
651
+ // - `agent-state/<key>/...` -> the D1 conversation WAL append /
652
+ // checkpoint (the control leg). No runId in the prefix; attributed
653
+ // to the dispatch loop's current serial runId.
654
+ // Any other prefix is a non-attributed proxied write (cancel/drain
655
+ // audit) and is left unmarked. The runId join key matches the leg the
656
+ // benchmark's per-message OLS fit groups on.
657
+ const legClassification = classifyProxiedWriteLeg(data.preservePrefix);
658
+ if (legClassification !== null) {
659
+ legMarkStart(legClassification.runId, legClassification.leg);
660
+ }
661
+ try {
662
+ const { commitSha, newlyTerminalRuns } = await bindings.repoStore.writeTreePreservingPrefix(writePrincipal, validatedRepoId, data.ref, {
663
+ preservePrefix: data.preservePrefix,
664
+ message: data.message,
665
+ merge: async (existing) => {
666
+ const sender = activeControlSender();
667
+ if (sender === null) {
668
+ throw new Error("supervisor substrate.write.request: control channel unavailable for merge round-trip");
669
+ }
670
+ const result = await new Promise((resolve) => {
671
+ pendingMerges.set(data.requestId, { resolve });
672
+ const wireExisting = [];
673
+ for (const [path, bytes] of existing) {
674
+ wireExisting.push({
675
+ path,
676
+ contentBase64: bytesToBase64(bytes),
677
+ });
678
+ }
679
+ void sender
680
+ .send({
681
+ type: "substrate.merge.request",
682
+ data: {
683
+ requestId: data.requestId,
684
+ existing: wireExisting,
685
+ },
686
+ })
687
+ .catch((cause) => {
688
+ pendingMerges.delete(data.requestId);
689
+ const reason = cause instanceof Error ? cause.message : String(cause);
690
+ resolve({
691
+ ok: false,
692
+ reason: `supervisor substrate.merge.request send failed: ${reason}`,
693
+ });
694
+ });
695
+ });
696
+ if (!result.ok) {
697
+ throw new Error(`supervisor substrate.write.request: child merge failed: ${result.reason}`);
698
+ }
699
+ return result.files;
700
+ },
701
+ });
702
+ // D2 leg end: the substrate commit (hash objects, write tree,
703
+ // advance ref under the per-repo lock) just resolved. Stamped here,
704
+ // before the terminal-write markConsumed-coupling wait below, so the
705
+ // run-event/wal leg measures only its own commit and not the
706
+ // dispatch loop's markConsumed (which the `markconsumed` leg owns).
707
+ if (legClassification !== null) {
708
+ legMarkEnd(legClassification.runId, legClassification.leg);
709
+ }
710
+ const watchdog = await synchronouslyDispatchTerminalWrite(newlyTerminalRuns);
711
+ if (!watchdog.ok) {
712
+ await controlSender.send({
713
+ type: "substrate.write.response",
714
+ data: {
715
+ requestId: data.requestId,
716
+ result: { ok: false, reason: watchdog.reason },
717
+ },
718
+ });
719
+ return;
720
+ }
721
+ await controlSender.send({
722
+ type: "substrate.write.response",
723
+ data: {
724
+ requestId: data.requestId,
725
+ result: { ok: true, commitSha },
726
+ },
727
+ });
728
+ // Seal each run that reached a terminal event in this commit: fold
729
+ // its per-event files into one combined events.jsonl. Off the hot
730
+ // path -- the child's write has already been acknowledged above -- so
731
+ // a failure is logged and does not block dispatch; the run is left in
732
+ // per-event form, which readers handle. There is no later trigger for
733
+ // a run whose fold is interrupted here (e.g. by a crash before the
734
+ // fold commits): the terminal signal fires once. A bounded recovery
735
+ // sweep is not yet implemented; until then such a run stays
736
+ // per-event. The fold commit carries no newly-added terminal event,
737
+ // so it does not re-fire this terminal-write coupling.
738
+ for (const { runId } of newlyTerminalRuns) {
739
+ void compactRunEvents({
740
+ substrate: bindings.repoStore,
741
+ repoId: validatedRepoId,
742
+ ref: data.ref,
743
+ deploymentId: bindings.deploymentId,
744
+ runId,
745
+ }).catch((cause) => {
746
+ logger.warn `compaction of run ${runId} failed: ${cause instanceof Error ? cause.message : String(cause)}`;
747
+ });
748
+ }
749
+ }
750
+ catch (cause) {
751
+ // Clean up any merge awaiter that the substrate may not have
752
+ // reached (e.g. the write threw before invoking the merge
753
+ // callback at all, leaving the map empty -- safe), and the
754
+ // common case where the write reached merge but then threw
755
+ // downstream (the awaiter is already resolved by the merge
756
+ // reply path, so the delete here is a no-op).
757
+ pendingMerges.delete(data.requestId);
758
+ const reason = cause instanceof Error ? cause.message : String(cause);
759
+ await controlSender.send({
760
+ type: "substrate.write.response",
761
+ data: {
762
+ requestId: data.requestId,
763
+ result: { ok: false, reason },
764
+ },
765
+ });
766
+ }
767
+ }
768
+ // Per-runId synchronization between the substrate-write handler
769
+ // and the dispatch loop's `markConsumed`. The handler arms a
770
+ // waiter when it commits a terminal-event blob and waits for the
771
+ // dispatch loop to fire `resolveMarkConsumedWaiter(runId)` before
772
+ // sending the substrate.write.response back to the child.
773
+ const markConsumedCompletionWaiters = new Map();
774
+ function resolveMarkConsumedWaiter(runId) {
775
+ const waiter = markConsumedCompletionWaiters.get(runId);
776
+ if (waiter === undefined)
777
+ return;
778
+ markConsumedCompletionWaiters.delete(runId);
779
+ waiter.resolve();
780
+ }
781
+ /**
782
+ * Hold the substrate.write.response until the dispatch loop's
783
+ * markConsumed settles for each run the kind handler reports as newly
784
+ * terminal in this commit. Terminal-ness comes from the handler's typed
785
+ * `newlyTerminalRuns` signal -- determined authoritatively during
786
+ * validation -- not re-derived from the committed path shape, so it
787
+ * survives the run-event layout changing (e.g. compaction folding a
788
+ * run's per-event files into one combined file). The wait is per-runId
789
+ * so multiple runs can proceed concurrently if a future dispatch loop
790
+ * ever processes more than one mail in parallel.
791
+ *
792
+ * A watchdog timeout (`terminalWriteWatchdogMs`) caps each wait so a
793
+ * never-arming markConsumed (a bug in the dispatch loop, a torn-down
794
+ * cohort, a stalled inbox primitive) does not deadlock the child's
795
+ * write -- and therefore the runtime body, and therefore the dispatch
796
+ * loop. On expiry the waiter is force-released and a structured failure
797
+ * propagates back to the child as
798
+ * `{ ok: false, reason: "terminal-write watchdog timeout: ..." }`.
799
+ */
800
+ async function synchronouslyDispatchTerminalWrite(newlyTerminalRuns) {
801
+ const holds = [];
802
+ for (const { runId, terminalEventJson } of newlyTerminalRuns) {
803
+ if (!inFlightRuns.has(runId))
804
+ continue;
805
+ holds.push(holdResponseForMarkConsumed(runId, terminalEventJson));
806
+ }
807
+ if (holds.length === 0)
808
+ return { ok: true };
809
+ const results = await Promise.all(holds);
810
+ return results.find((r) => !r.ok) ?? { ok: true };
811
+ }
812
+ async function holdResponseForMarkConsumed(runId, terminalEventJson) {
813
+ const completed = new Promise((resolve, reject) => {
814
+ markConsumedCompletionWaiters.set(runId, { resolve, reject });
815
+ });
816
+ const broadcaster = activeTerminalBroadcaster();
817
+ if (broadcaster !== null) {
818
+ const synthetic = synthesizeTerminalEvent(terminalEventJson);
819
+ if (synthetic !== null) {
820
+ broadcaster.notify(runId, synthetic);
821
+ }
822
+ }
823
+ let timeoutHandle = null;
824
+ const watchdog = new Promise((resolve) => {
825
+ timeoutHandle = setTimeout(() => {
826
+ timeoutHandle = null;
827
+ // Force-release the waiter so the dispatch loop's eventual
828
+ // resolve does not strand a dangling map entry, then surface
829
+ // the structured failure to the caller. The reason text is
830
+ // logged through the package logger so the watchdog is not
831
+ // silent on the host side.
832
+ const stillPending = markConsumedCompletionWaiters.get(runId) !== undefined;
833
+ if (stillPending) {
834
+ markConsumedCompletionWaiters.delete(runId);
835
+ }
836
+ const reason = `terminal-write watchdog timeout: markConsumed for runId=${runId} did not settle within ${String(terminalWriteWatchdogMs)}ms`;
837
+ logger.error `${reason}`;
838
+ resolve({ ok: false, reason });
839
+ }, terminalWriteWatchdogMs);
840
+ });
841
+ const result = await Promise.race([
842
+ completed.then(() => ({ ok: true })),
843
+ watchdog,
844
+ ]);
845
+ if (timeoutHandle !== null) {
846
+ clearTimeout(timeoutHandle);
847
+ }
848
+ return result;
849
+ }
850
+ function synthesizeTerminalEvent(terminalEventJson) {
851
+ let parsed;
852
+ try {
853
+ parsed = JSON.parse(terminalEventJson);
854
+ }
855
+ catch {
856
+ return null;
857
+ }
858
+ if (typeof parsed !== "object" ||
859
+ parsed === null ||
860
+ !("type" in parsed) ||
861
+ !("seq" in parsed)) {
862
+ return null;
863
+ }
864
+ const body = parsed;
865
+ if (typeof body.seq !== "number")
866
+ return null;
867
+ const at = typeof body.at === "string" ? body.at : new Date().toISOString();
868
+ if (body.type === "RunCompleted") {
869
+ return { kind: "RunCompleted", seq: body.seq, at };
870
+ }
871
+ if (body.type === "RunCancelled") {
872
+ return { kind: "RunCancelled", seq: body.seq, at };
873
+ }
874
+ if (body.type === "RunFailed") {
875
+ // The wire schema makes `error.message` required when the event
876
+ // type is `RunFailed`. An event that doesn't carry one is a
877
+ // contract violation upstream of the supervisor; coercing it to an
878
+ // empty string would silently hide the producer bug.
879
+ if (typeof body.error?.message !== "string") {
880
+ throw new Error(`synthesizeTerminalEvent: RunFailed event missing required error.message`);
881
+ }
882
+ return {
883
+ kind: "RunFailed",
884
+ seq: body.seq,
885
+ at,
886
+ error: { message: body.error.message },
887
+ };
888
+ }
889
+ return null;
890
+ }
891
+ function activeControlSender() {
892
+ if (state.phase === "starting" ||
893
+ state.phase === "running" ||
894
+ state.phase === "recycling") {
895
+ return state.controlSender;
896
+ }
897
+ return null;
898
+ }
899
+ function activeTerminalBroadcaster() {
900
+ if (state.phase === "starting" ||
901
+ state.phase === "running" ||
902
+ state.phase === "recycling") {
903
+ return state.terminalBroadcaster;
904
+ }
905
+ return null;
906
+ }
907
+ async function wireChild(args) {
908
+ const controlSender = createControlChannelSender({
909
+ privateKeySeed: args.ipcKeypair.privateKey,
910
+ channelId: args.channelId,
911
+ writer: args.handle.controlWriter,
912
+ });
913
+ const controlIncoming = receiveControlChannel({
914
+ publicKey: { bootstrapFromReady: true },
915
+ channelId: args.channelId,
916
+ reader: args.handle.controlReader,
917
+ onCrash: onChildCrash,
918
+ });
919
+ const readyPromise = waitForReady(controlIncoming);
920
+ const eventIter = receiveEventChannel({
921
+ hmacKey: args.hmacKey,
922
+ channelId: args.channelId,
923
+ reader: args.handle.eventReader,
924
+ onCrash: (reason) => {
925
+ logger.error `workflow-process event channel crash: {reason}`;
926
+ void shutdownInternal({ reason });
927
+ },
928
+ });
929
+ const eventPump = pumpEvents(eventIter, args.onInferenceEvent);
930
+ return {
931
+ wiring: {
932
+ handle: args.handle,
933
+ controlSender,
934
+ channelId: args.channelId,
935
+ eventPump,
936
+ },
937
+ readyPromise,
938
+ controlIncoming,
939
+ };
940
+ }
941
+ async function spawn(opts) {
942
+ if (state.phase !== "idle") {
943
+ throw new Error(`supervisor: spawn called in phase ${state.phase}; expected idle`);
944
+ }
945
+ const channelId = generateChannelId();
946
+ const hmacKey = generateHmacKey();
947
+ const ipcKeypair = await (bindings.ipcKeyPairFactory ?? generateKeyPair)();
948
+ const env = buildChildSpawnEnv({
949
+ substrateEnv: bindings.substrateEnv,
950
+ dynamicSpawnEnv: bindings.dynamicSpawnEnv,
951
+ channelId,
952
+ hmacKey,
953
+ hostPublicKey: ipcKeypair.publicKey,
954
+ deploymentId: bindings.deploymentId,
955
+ deploymentMailAddress: bindings.deploymentMailAddress,
956
+ stepCount: bindings.stepCount,
957
+ definitionHash: opts.definitionHash,
958
+ warmKeep: opts.warmKeep,
959
+ });
960
+ const handle = bindings.subprocessSpawner({
961
+ binaryPath: bindings.binaryPath,
962
+ env,
963
+ });
964
+ let wired;
965
+ try {
966
+ wired = await wireChild({
967
+ channelId,
968
+ hmacKey,
969
+ ipcKeypair,
970
+ handle,
971
+ onInferenceEvent: opts.onInferenceEvent,
972
+ });
973
+ }
974
+ catch (cause) {
975
+ // wireChild threw before any state record owns the handle, so
976
+ // shutdownInternal -- which reaches the handle through the
977
+ // active-state record -- would early-return on the "idle" phase
978
+ // without killing it. Kill the freshly-spawned child directly to
979
+ // avoid orphaning the OS process.
980
+ await killChildHandle(handle, DEFAULT_KILL_TIMEOUT_MS, {
981
+ setTimer: readySetTimer,
982
+ clearTimer: readyClearTimer,
983
+ logger,
984
+ });
985
+ throw cause;
986
+ }
987
+ // The ready handshake below folds `wired.readyPromise` into an
988
+ // outcome value, handling its rejection. But a startup teardown that
989
+ // fires BEFORE the handshake -- a throw during credentials assembly
990
+ // or mail registration -- kills the child, and that kill rejects
991
+ // `readyPromise` (the control channel ends). Attach a benign handler
992
+ // now so the rejection is never unhandled on that path; the
993
+ // handshake's own fold still observes the outcome when it runs.
994
+ void wired.readyPromise.catch(() => {
995
+ /* handled by the ready-handshake fold when the handshake runs */
996
+ });
997
+ // Cohort abort controller covers terminal-event watcher
998
+ // lifetime AND dispatch-loop lifetime; the abort fires on
999
+ // shutdown and on every recycle's `installNewChild`. The
1000
+ // controller is minted unconditionally so the dispatch loop
1001
+ // always has a cancellation source. The cohort broadcaster
1002
+ // matches the same lifetime: the supervisor's pumpUpstreamControl
1003
+ // fans `terminal.event` upstream frames into it, and consumers
1004
+ // (dispatch loop, drain accumulators) subscribe through its
1005
+ // `source` accessor.
1006
+ state = {
1007
+ phase: "starting",
1008
+ handle,
1009
+ controlSender: wired.wiring.controlSender,
1010
+ channelId,
1011
+ eventPump: wired.wiring.eventPump,
1012
+ onInferenceEvent: opts.onInferenceEvent,
1013
+ mailUnsubscribe: null,
1014
+ credentialsSnapshot: null,
1015
+ terminalCohortAbort: new AbortController(),
1016
+ terminalBroadcaster: createTerminalBroadcaster(),
1017
+ dispatchLoop: null,
1018
+ replayDone: null,
1019
+ };
1020
+ // Everything from here to the successful `return` runs with the state
1021
+ // record in "starting" (then "running"). A throw at any of these
1022
+ // steps -- credentials assembly, mail registration, the ready
1023
+ // handshake, the credentials push, the dispatch-loop start -- routes
1024
+ // through shutdownInternal, the single owner of starting/running
1025
+ // teardown: it kills the handle and releases the mail subscription
1026
+ // and address registration installed below.
1027
+ try {
1028
+ const credentialsSnapshot = await assembleCredentialsSnapshot({
1029
+ repoStore: bindings.repoStore,
1030
+ principal: bindings.readPrincipal,
1031
+ stepOrder: opts.stepOrder,
1032
+ deploymentId: bindings.deploymentId,
1033
+ deriveStepAddress: bindings.deriveStepAddress,
1034
+ ...(bindings.deriveStepRepoId !== undefined
1035
+ ? { deriveStepRepoId: bindings.deriveStepRepoId }
1036
+ : {}),
1037
+ });
1038
+ state.credentialsSnapshot = credentialsSnapshot;
1039
+ // Replay any orphaned `processing/` entries back to `inbox/`
1040
+ // BEFORE the dispatch loop's first dequeue. A crash mid-dispatch
1041
+ // in a prior supervisor incarnation can leave an entry in
1042
+ // `processing/` with no owner; the FIFO contract requires the
1043
+ // entry move back to `inbox/` so the next dispatch picks it up
1044
+ // in its original arrival position. The replay runs off the
1045
+ // spawn critical path (the substrate write may roundtrip through
1046
+ // the pack-pushing wrap and a slow hub), but `runDispatchLoop`
1047
+ // takes the promise as an argument and awaits it before its
1048
+ // first `dequeueToProcessing` so a fresh inbound mail that lands
1049
+ // during the replay window cannot ship ahead of the orphan once
1050
+ // the replay completes.
1051
+ const replayDone = readOwnedMessageIds(bindings.repoStore, bindings.workflowRunRepoId)
1052
+ .then((ownedMessageIds) => inboxPrimitives.replayProcessingToInbox(bindings.repoStore, inboxWritePrincipal, bindings.workflowRunRepoId, bindings.deploymentMailAddress, { ownedMessageIds }))
1053
+ .then(() => {
1054
+ wakeDispatch();
1055
+ })
1056
+ .catch((cause) => {
1057
+ // Documented best-effort: a failed replay leaves orphaned
1058
+ // `processing/` entries parked and the dispatch loop will
1059
+ // then ship newly-enqueued mail ahead of them, violating
1060
+ // the FIFO contract described in the comment above.
1061
+ // Tightening this to a fatal `onChildCrash` was attempted
1062
+ // but caused spurious crashes in the integration suite
1063
+ // where the first spawn legitimately has no
1064
+ // `processing/` directory to replay; resolving that
1065
+ // requires either a no-op-on-missing variant of
1066
+ // `replayProcessingToInbox` or a dispatch-loop periodic
1067
+ // sweep that picks up parked orphans. Left as logged
1068
+ // best-effort until that lands.
1069
+ const message = cause instanceof Error ? cause.message : String(cause);
1070
+ logger.warn `replayProcessingToInbox on spawn failed: ${message}`;
1071
+ });
1072
+ // Hold the replay promise on the active-state record so
1073
+ // `shutdownInternal` awaits its settlement before tearing the
1074
+ // bindings down. A shutdown that lands while the replay is in
1075
+ // flight would otherwise leave the substrate write pending past
1076
+ // the supervisor's exit.
1077
+ state.replayDone = replayDone;
1078
+ bindings.mailBus.registerAddress(bindings.deploymentMailAddress);
1079
+ const mailUnsubscribe = bindings.mailBus.subscribeMailForAddress(bindings.deploymentMailAddress, onMailMessage);
1080
+ state.mailUnsubscribe = mailUnsubscribe;
1081
+ // Bound the `ready` handshake. `wired.readyPromise` resolves on `ready`
1082
+ // and rejects when the control channel ends (the child exited); a child
1083
+ // that neither readies nor exits would block here forever. Fold all three
1084
+ // outcomes into values so the single `readyClearTimer` below runs on every
1085
+ // path -- ready, child-exit failure, and timeout -- before we act on the
1086
+ // result. A `Promise.race` that could reject would skip the clear on the
1087
+ // child-exit path and leak an armed deadline that keeps the event loop
1088
+ // alive for up to `readyTimeoutMs`. The deadline is resolve-only, so it
1089
+ // contributes no rejection of its own. Kill on timeout uses the
1090
+ // SIGTERM->SIGKILL escalation because a wedged child may ignore SIGTERM;
1091
+ // SIGKILL guarantees `exited` settles.
1092
+ const readyOutcome = wired.readyPromise.then((info) => ({ kind: "ready", info }), (err) => ({ kind: "failed", err }));
1093
+ const readyDeadline = waitDeadline(readySetTimer, readyTimeoutMs);
1094
+ const readyRace = await Promise.race([
1095
+ readyOutcome,
1096
+ readyDeadline.promise.then(() => ({ kind: "timeout" })),
1097
+ ]);
1098
+ readyClearTimer(readyDeadline.handle);
1099
+ if (readyRace.kind === "timeout") {
1100
+ await killChildHandle(wired.wiring.handle, DEFAULT_KILL_TIMEOUT_MS, {
1101
+ setTimer: readySetTimer,
1102
+ clearTimer: readyClearTimer,
1103
+ logger,
1104
+ });
1105
+ // The SIGTERM->SIGKILL escalation above is deliberate: a wedged
1106
+ // child may ignore the plain kill shutdownInternal issues. The
1107
+ // outer catch then runs shutdownInternal for the "starting"-phase
1108
+ // teardown (subscription + address release); its kill against the
1109
+ // already-killed handle is idempotent.
1110
+ throw new Error(`workflow-host supervisor: child did not emit ready within ${readyTimeoutMs}ms; killed`);
1111
+ }
1112
+ if (readyRace.kind === "failed") {
1113
+ // The child exited during the handshake; the outer catch releases
1114
+ // the subscription and registration via shutdownInternal.
1115
+ throw readyRace.err;
1116
+ }
1117
+ const readyInfo = readyRace.info;
1118
+ // Push the assembled credentialsSnapshot to the child before the
1119
+ // mail buffer drains. Without this, the child's
1120
+ // `createCredentialsBackedAuthorize` closure observes a null
1121
+ // snapshot ref on the first authorize call and throws "no
1122
+ // credentialsSnapshot active"; the run's first step fails before
1123
+ // the runtime body can commit `StepCompleted`. The send rides the
1124
+ // same control channel `trigger.fire` uses, so the ordering
1125
+ // guarantee (`grants-updated` lands before `trigger.fire`) holds
1126
+ // for buffered and post-ready inbound mail alike.
1127
+ await wired.wiring.controlSender.send({
1128
+ type: "grants-updated",
1129
+ data: {
1130
+ snapshot: {
1131
+ steps: credentialsSnapshot.steps.map((s) => ({
1132
+ stepId: s.stepId,
1133
+ address: s.address,
1134
+ grants: [...s.grants],
1135
+ contentHash: s.contentHash,
1136
+ })),
1137
+ },
1138
+ },
1139
+ });
1140
+ // Transition to running. The dispatch loop (started below)
1141
+ // picks up any pre-ready buffered mail through the FIFO inbox
1142
+ // queue rather than through an in-memory buffer; arrival order
1143
+ // is preserved by the envelope's `receivedAt` prefix on the
1144
+ // inbox filename.
1145
+ const startingPhaseCohortAbort = state.terminalCohortAbort;
1146
+ if (startingPhaseCohortAbort === null) {
1147
+ throw new Error("supervisor: terminalCohortAbort missing after spawn handshake");
1148
+ }
1149
+ const startingPhaseBroadcaster = state.terminalBroadcaster;
1150
+ const dispatchLoop = runDispatchLoop(wired.wiring.controlSender, startingPhaseCohortAbort, startingPhaseBroadcaster, replayDone);
1151
+ // Surface dispatch-loop failures via the logger; the loop's own
1152
+ // catch already swallows per-iteration faults, but a structural
1153
+ // failure (e.g. the cohort abort handler itself throws) lands
1154
+ // here.
1155
+ void dispatchLoop.catch((cause) => {
1156
+ const message = cause instanceof Error ? cause.message : String(cause);
1157
+ logger.error `dispatch loop terminated with error: ${message}`;
1158
+ });
1159
+ state = {
1160
+ phase: "running",
1161
+ handle,
1162
+ controlSender: wired.wiring.controlSender,
1163
+ channelId,
1164
+ eventPump: wired.wiring.eventPump,
1165
+ onInferenceEvent: opts.onInferenceEvent,
1166
+ mailUnsubscribe,
1167
+ credentialsSnapshot,
1168
+ terminalCohortAbort: startingPhaseCohortAbort,
1169
+ terminalBroadcaster: startingPhaseBroadcaster,
1170
+ dispatchLoop,
1171
+ replayDone,
1172
+ };
1173
+ // Kick the dispatch loop in case mail landed in the inbox
1174
+ // before the loop's first `await dispatchWake`. A wake against a
1175
+ // freshly-minted promise is a no-op; the dispatch loop's first
1176
+ // dequeue happens unconditionally.
1177
+ wakeDispatch();
1178
+ // Cache the spawn context for the recycle path. The recycle path
1179
+ // reuses the same stepOrder/definitionHash/onInferenceEvent on
1180
+ // every respawn -- those are the strict-orthogonality anchors
1181
+ // with redeploy, and the supervisor never mutates them.
1182
+ const now = bindings.recyclePolicyNow ?? defaultNow;
1183
+ spawnContext = {
1184
+ stepOrder: opts.stepOrder,
1185
+ definitionHash: opts.definitionHash,
1186
+ warmKeep: opts.warmKeep,
1187
+ onInferenceEvent: opts.onInferenceEvent,
1188
+ spawnedAt: now(),
1189
+ };
1190
+ // Start the upstream control pump so the supervisor sees the
1191
+ // child's `recycle.request` (and any future upstream variant) as
1192
+ // it arrives. The pump exits when the iterator ends, which
1193
+ // happens when the child closes its end of the control channel
1194
+ // -- either on shutdown or on recycle's `kill` step. The pump
1195
+ // closes over the cohort's broadcaster captured at pump-start
1196
+ // time so a `terminal.event` frame the iterator dequeues after a
1197
+ // recycle has minted a new cohort routes to THIS cohort's (now
1198
+ // disposed) broadcaster, not the successor's.
1199
+ void pumpUpstreamControl(wired.controlIncoming, startingPhaseBroadcaster).catch((cause) => {
1200
+ const message = cause instanceof Error ? cause.message : String(cause);
1201
+ logger.error `upstream control pump failed: ${message}`;
1202
+ });
1203
+ // Arm the recycle policy. The policy is a no-op when all bounds
1204
+ // are `undefined`; bounds resolution lives inside `createRecyclePolicy`.
1205
+ if (bindings.recyclePolicy !== undefined) {
1206
+ const setTimer = bindings.recyclePolicySetTimer ?? defaultSetTimer;
1207
+ const clearTimer = bindings.recyclePolicyClearTimer ?? defaultClearTimer;
1208
+ recyclePolicy = createRecyclePolicy({
1209
+ bounds: bindings.recyclePolicy,
1210
+ now,
1211
+ spawnedAt: spawnContext.spawnedAt,
1212
+ ...(bindings.readRssBytes !== undefined
1213
+ ? { readRssBytes: bindings.readRssBytes }
1214
+ : {}),
1215
+ ...(bindings.readGrantsAgeMs !== undefined
1216
+ ? { readGrantsAgeMs: bindings.readGrantsAgeMs }
1217
+ : {}),
1218
+ setTimer,
1219
+ clearTimer,
1220
+ trigger: async (reason) => {
1221
+ await recycle({ reason, origin: "policy" });
1222
+ },
1223
+ });
1224
+ }
1225
+ return {
1226
+ pid: readyInfo.childPid,
1227
+ channelId,
1228
+ credentialsSnapshot,
1229
+ };
1230
+ }
1231
+ catch (cause) {
1232
+ const message = cause instanceof Error ? cause.message : String(cause);
1233
+ // Defense-in-depth for a distinct invariant: the original spawn
1234
+ // `cause` must survive the unwind. `shutdownInternal` is designed to
1235
+ // be total and should not throw, but if it ever regresses this guard
1236
+ // logs the secondary teardown error rather than letting it replace
1237
+ // `cause` and hide the real startup failure. Mirrors the
1238
+ // recycle-failure catch, which preserves its cause the same way.
1239
+ await shutdownInternal({
1240
+ reason: `spawn failed during startup: ${message}`,
1241
+ }).catch((shutdownCause) => {
1242
+ const inner = shutdownCause instanceof Error
1243
+ ? shutdownCause.message
1244
+ : String(shutdownCause);
1245
+ logger.error `shutdown after spawn failure also threw: ${inner}`;
1246
+ });
1247
+ throw cause;
1248
+ }
1249
+ }
1250
+ /**
1251
+ * Project the active cohort's terminal broadcaster as a
1252
+ * `TerminalEventSource` the drainTimeout accumulator factory accepts.
1253
+ * Wraps the broadcaster's `source` so the iterator settles with
1254
+ * `done: true` on cohort abort -- without the abort wrap an
1255
+ * accumulator armed mid-cohort would block on the broadcaster even
1256
+ * after the supervisor has aborted the cohort.
1257
+ */
1258
+ function perCohortTerminalSource(cohortAbort, broadcaster) {
1259
+ if (cohortAbort === null)
1260
+ return null;
1261
+ if (broadcaster === null)
1262
+ return null;
1263
+ const signal = cohortAbort.signal;
1264
+ return (runId) => ({
1265
+ [Symbol.asyncIterator]() {
1266
+ if (signal.aborted) {
1267
+ return {
1268
+ next: () => Promise.resolve({ value: undefined, done: true }),
1269
+ return: (value) => Promise.resolve({ value, done: true }),
1270
+ };
1271
+ }
1272
+ const inner = broadcaster.source(runId)[Symbol.asyncIterator]();
1273
+ let onAbort = null;
1274
+ const abortPromise = new Promise((resolve) => {
1275
+ onAbort = () => resolve({ value: undefined, done: true });
1276
+ signal.addEventListener("abort", onAbort, { once: true });
1277
+ });
1278
+ function detach() {
1279
+ if (onAbort !== null) {
1280
+ signal.removeEventListener("abort", onAbort);
1281
+ onAbort = null;
1282
+ }
1283
+ }
1284
+ return {
1285
+ async next() {
1286
+ if (signal.aborted) {
1287
+ detach();
1288
+ if (typeof inner.return === "function") {
1289
+ await inner.return(undefined).catch(() => {
1290
+ /* swallowed: best-effort finalisation. */
1291
+ });
1292
+ }
1293
+ return { value: undefined, done: true };
1294
+ }
1295
+ const result = await Promise.race([inner.next(), abortPromise]);
1296
+ if (result.done === true) {
1297
+ detach();
1298
+ if (signal.aborted && typeof inner.return === "function") {
1299
+ await inner.return(undefined).catch(() => {
1300
+ /* swallowed: best-effort finalisation. */
1301
+ });
1302
+ }
1303
+ }
1304
+ return result;
1305
+ },
1306
+ async return() {
1307
+ detach();
1308
+ if (typeof inner.return === "function") {
1309
+ await inner.return(undefined).catch(() => {
1310
+ /* swallowed: best-effort finalisation. */
1311
+ });
1312
+ }
1313
+ return { value: undefined, done: true };
1314
+ },
1315
+ };
1316
+ },
1317
+ });
1318
+ }
1319
+ /**
1320
+ * Forward one dequeued inbox entry to the child as `trigger.fire`
1321
+ * and record its runId as in-flight. The runId is the messageId
1322
+ * the envelope carries (one run per trigger fire per discovery
1323
+ * Q3.1); the same value is what the dispatch loop waits on via
1324
+ * `terminalEventSource`.
1325
+ */
1326
+ async function forwardDispatchedEntry(sender, messageId, receivedAt) {
1327
+ await sender.send({
1328
+ type: "trigger.fire",
1329
+ data: {
1330
+ runId: messageId,
1331
+ messageId,
1332
+ receivedAt,
1333
+ },
1334
+ });
1335
+ inFlightRuns.add(messageId);
1336
+ return messageId;
1337
+ }
1338
+ /**
1339
+ * One iteration of the dispatch loop: dequeue the FIFO-first inbox
1340
+ * entry, forward it as a `trigger.fire`, wait for the corresponding
1341
+ * run's terminal event (or for the cohort to abort), then
1342
+ * `markConsumed`. Returns `true` if a dispatch landed (caller should
1343
+ * loop immediately) and `false` if the inbox was empty (caller
1344
+ * should await the next wake).
1345
+ */
1346
+ async function dispatchOne(sender, cohortAbort, broadcaster) {
1347
+ if (cohortAbort.signal.aborted)
1348
+ return false;
1349
+ // Subscribe to the terminal broadcaster BEFORE forwarding the
1350
+ // trigger.fire so a terminal event the child notifies between
1351
+ // forward and subscribe cannot be missed. The broadcaster fires
1352
+ // its listeners synchronously inside `notify`; with the subscribe
1353
+ // ordered first the listener buffers the event until the
1354
+ // dispatch loop's `iter.next()` consumes it.
1355
+ const beforeDequeueMs = dispatchTimingEnabled() ? performance.now() : 0;
1356
+ const dequeued = await inboxPrimitives.dequeueToProcessing(bindings.repoStore, inboxWritePrincipal, bindings.workflowRunRepoId, bindings.deploymentMailAddress);
1357
+ if (dequeued === null)
1358
+ return false;
1359
+ const envelope = dequeued.envelope;
1360
+ const runId = envelope.messageId;
1361
+ currentDispatchRunId = runId;
1362
+ emitDispatchTiming(runId, "dispatch-start", beforeDequeueMs);
1363
+ // D2 leg: the claim-check dequeue READ. `dispatch-start` is sampled
1364
+ // BEFORE the dequeue (so the roundtrip bracket includes the read);
1365
+ // the dequeue leg's own start mark is that same pre-dequeue sample
1366
+ // re-stamped under the leg channel, and its end is now (the read just
1367
+ // completed). Emitting the start retroactively here -- rather than
1368
+ // before the await -- keeps the leg keyed by the runId, which is only
1369
+ // known after the dequeue resolves.
1370
+ if (bindings.onDispatchTiming !== undefined) {
1371
+ try {
1372
+ bindings.onDispatchTiming({
1373
+ kind: "leg",
1374
+ runId,
1375
+ leg: "dequeue",
1376
+ phase: "start",
1377
+ atMs: beforeDequeueMs,
1378
+ });
1379
+ }
1380
+ catch (cause) {
1381
+ const message = cause instanceof Error ? cause.message : String(cause);
1382
+ logger.warn `onDispatchTiming leg observer threw for ${runId} (dequeue start): ${message}`;
1383
+ }
1384
+ }
1385
+ legMarkEnd(runId, "dequeue");
1386
+ const iterable = broadcaster.source(runId);
1387
+ const iter = iterable[Symbol.asyncIterator]();
1388
+ await forwardDispatchedEntry(sender, envelope.messageId, envelope.receivedAt);
1389
+ await waitForRunTerminal(iter, cohortAbort.signal);
1390
+ emitDispatchTiming(runId, "reply-produced", performance.now());
1391
+ inFlightRuns.delete(runId);
1392
+ if (cohortAbort.signal.aborted) {
1393
+ // The cohort tore down before the terminal event arrived (or
1394
+ // alongside it). Skip `markConsumed` so the recycle path's
1395
+ // drain-side replay can reclaim the processing entry.
1396
+ currentDispatchRunId = null;
1397
+ resolveMarkConsumedWaiter(runId);
1398
+ return false;
1399
+ }
1400
+ // D2 leg: `markConsumed` is paid AFTER `reply-produced` (stamped
1401
+ // above), so its growth is invisible to the 4.7 round-trip bracket --
1402
+ // the leg mark makes the out-of-window cost visible.
1403
+ legMarkStart(runId, "markconsumed");
1404
+ try {
1405
+ await inboxPrimitives.markConsumed(bindings.repoStore, inboxWritePrincipal, bindings.workflowRunRepoId, {
1406
+ address: bindings.deploymentMailAddress,
1407
+ messageId: envelope.messageId,
1408
+ runId,
1409
+ consumedAt: Date.now(),
1410
+ retentionHorizonMs: consumedRetentionMs,
1411
+ });
1412
+ }
1413
+ catch (cause) {
1414
+ const message = cause instanceof Error ? cause.message : String(cause);
1415
+ logger.error `markConsumed failed for run ${runId}: ${message}`;
1416
+ }
1417
+ legMarkEnd(runId, "markconsumed");
1418
+ resolveMarkConsumedWaiter(runId);
1419
+ // §10c forced-repack A/B (measurement-only; no-op when unwired).
1420
+ maybeRepack(runId);
1421
+ currentDispatchRunId = null;
1422
+ return true;
1423
+ }
1424
+ /**
1425
+ * Wait until the run's terminal event lands on the cohort
1426
+ * broadcaster's iterator or the cohort aborts. The caller is
1427
+ * responsible for minting the iterator before forwarding the
1428
+ * `trigger.fire` so the listener is already armed when the child's
1429
+ * upstream `terminal.event` frame arrives.
1430
+ */
1431
+ async function waitForRunTerminal(iter, abortSignal) {
1432
+ let onAbort = null;
1433
+ const abortPromise = new Promise((resolve) => {
1434
+ if (abortSignal.aborted) {
1435
+ resolve({ done: true });
1436
+ return;
1437
+ }
1438
+ onAbort = () => resolve({ done: true });
1439
+ abortSignal.addEventListener("abort", onAbort, { once: true });
1440
+ });
1441
+ try {
1442
+ while (true) {
1443
+ if (abortSignal.aborted)
1444
+ return;
1445
+ const result = await Promise.race([iter.next(), abortPromise]);
1446
+ if (result.done === true)
1447
+ return;
1448
+ // A terminal event for this runId arrived; stop waiting.
1449
+ return;
1450
+ }
1451
+ }
1452
+ finally {
1453
+ if (onAbort !== null) {
1454
+ abortSignal.removeEventListener("abort", onAbort);
1455
+ }
1456
+ if (typeof iter.return === "function") {
1457
+ await iter.return(undefined).catch(() => {
1458
+ /* swallowed: best-effort finalisation of the watcher iterator. */
1459
+ });
1460
+ }
1461
+ }
1462
+ }
1463
+ /**
1464
+ * The dispatch loop body. Runs until the cohort aborts; each
1465
+ * iteration drains one inbox entry through the FIFO claim-check
1466
+ * pipeline. The loop is restarted by `installNewChild` after a
1467
+ * recycle and torn down by `shutdownInternal` and on cohort abort.
1468
+ *
1469
+ * `replayGate` is the promise the spawn-time
1470
+ * `replayProcessingToInbox` settles on. The loop awaits it before
1471
+ * its first `dequeueToProcessing`: a fresh `mail.inbound` that
1472
+ * enqueues during the replay window must not ship ahead of an
1473
+ * orphaned `processing/` entry the replay is still moving back to
1474
+ * `inbox/`. The gate is `null` for the recycle path's restart,
1475
+ * where `triggerRecycle` already awaited its own replay before
1476
+ * calling `installNewChild`.
1477
+ */
1478
+ async function runDispatchLoop(sender, cohortAbort, broadcaster, replayGate) {
1479
+ if (replayGate !== null) {
1480
+ await replayGate;
1481
+ if (cohortAbort.signal.aborted)
1482
+ return;
1483
+ }
1484
+ while (!cohortAbort.signal.aborted) {
1485
+ let dispatched;
1486
+ try {
1487
+ dispatched = await dispatchOne(sender, cohortAbort, broadcaster);
1488
+ }
1489
+ catch (cause) {
1490
+ const message = cause instanceof Error ? cause.message : String(cause);
1491
+ logger.error `dispatch loop iteration failed: ${message}`;
1492
+ // A failure deep inside the substrate is one the operator
1493
+ // must see. The loop continues -- a transient failure should
1494
+ // not wedge the deployment -- but the loop pauses on the
1495
+ // wake so we do not busy-spin against a persistent fault.
1496
+ dispatched = false;
1497
+ }
1498
+ if (dispatched)
1499
+ continue;
1500
+ if (cohortAbort.signal.aborted)
1501
+ return;
1502
+ const wake = dispatchWake.promise;
1503
+ const abortPromise = new Promise((resolve) => {
1504
+ if (cohortAbort.signal.aborted) {
1505
+ resolve();
1506
+ return;
1507
+ }
1508
+ cohortAbort.signal.addEventListener("abort", () => resolve(), {
1509
+ once: true,
1510
+ });
1511
+ });
1512
+ await Promise.race([wake, abortPromise]);
1513
+ }
1514
+ }
1515
+ async function requestCancel(opts) {
1516
+ const result = await commitCancelRequested({
1517
+ substrate: bindings.repoStore,
1518
+ repoId: bindings.workflowRunRepoId,
1519
+ ref: bindings.workflowRunRef,
1520
+ deploymentId: bindings.deploymentId,
1521
+ runId: opts.runId,
1522
+ origin: opts.origin,
1523
+ reason: opts.reason,
1524
+ at: opts.at,
1525
+ signAsPrincipal: bindings.signAsPrincipal,
1526
+ });
1527
+ return { commitSha: result.commitSha, seq: result.seq };
1528
+ }
1529
+ async function shutdown() {
1530
+ await shutdownInternal({ reason: "shutdown requested" });
1531
+ }
1532
+ async function shutdownInternal(opts) {
1533
+ if (state.phase === "idle" || state.phase === "stopped")
1534
+ return;
1535
+ const prior = state;
1536
+ state = { phase: "stopping" };
1537
+ // shutdownInternal is designed to be TOTAL: when a child is up it must
1538
+ // always kill it and always reach `stopped`, no matter which teardown
1539
+ // step throws. Rather than depend on every step being individually
1540
+ // non-throwing (an approach that has already leaked an escape hatch),
1541
+ // the whole teardown body runs inside one `try`, and the two
1542
+ // load-bearing actions -- the child kill and the `phase = "stopped"`
1543
+ // transition -- live in the `finally`, so a throw anywhere above them
1544
+ // still runs both. This is the documented shutdown carve-out to the
1545
+ // fail-loud rule: leaking the child or wedging the supervisor in
1546
+ // `stopping` is strictly worse than logging and continuing, so the
1547
+ // steps that can throw surface at `logger.warn` and execution proceeds.
1548
+ // (`terminalCohortAbort.abort`, `rejectCohortAwaiters`, and
1549
+ // `wakeDispatch` cannot throw, and the broadcaster's `dispose` is total
1550
+ // by construction; they sit inside the `try` regardless so the
1551
+ // invariant survives if that ever changes.)
1552
+ const accumulatorsToDispose = [...drainAccumulators.values()];
1553
+ try {
1554
+ // Stop every armed drainTimeout accumulator before tearing the child
1555
+ // down. An accumulator left running would otherwise fire its
1556
+ // `setTimeout` callback (or its terminal-event watcher's settle hook)
1557
+ // against a shutdown-mid-flight supervisor. Guard each `stop` so one
1558
+ // throwing accumulator does not leave the rest armed.
1559
+ for (const accumulator of accumulatorsToDispose) {
1560
+ try {
1561
+ accumulator.stop();
1562
+ }
1563
+ catch (cause) {
1564
+ const message = cause instanceof Error ? cause.message : String(cause);
1565
+ logger.warn `drain accumulator stop threw during shutdown: ${message}`;
1566
+ }
1567
+ }
1568
+ drainAccumulators.clear();
1569
+ if (prior.phase === "starting" ||
1570
+ prior.phase === "running" ||
1571
+ prior.phase === "recycling") {
1572
+ prior.terminalCohortAbort.abort();
1573
+ // Reject every pending merge round-trip and markConsumed waiter
1574
+ // so handler closures awaiting them (including fire-and-forget
1575
+ // `handleSubstrateWriteRequest` instances) cannot outlive the
1576
+ // dying cohort. Without this, the `await new Promise` inside
1577
+ // each handler would sit forever on a resolver the dying control
1578
+ // channel will never invoke.
1579
+ rejectCohortAwaiters("shutdown");
1580
+ // Dispose the cohort broadcaster so any minted iterator settles
1581
+ // with `done: true` -- the dispatch loop's `waitForRunTerminal`
1582
+ // and any drainTimeout watcher unblock through the same shutdown
1583
+ // path the cohort abort drives.
1584
+ prior.terminalBroadcaster.dispose();
1585
+ // Wake the dispatch loop so its `dispatchWake` await settles
1586
+ // and the loop notices the cohort abort. Without the wake, the
1587
+ // loop's `Promise.race` would sit on the wake promise until
1588
+ // some other actor woke it.
1589
+ wakeDispatch();
1590
+ }
1591
+ // Await every accumulator's `disposed()` so a pending escalation
1592
+ // commit or terminal-event watcher coroutine cannot outlive the
1593
+ // supervisor and fire against torn-down bindings.
1594
+ await Promise.all(accumulatorsToDispose.map((a) => a.disposed().catch(() => {
1595
+ /* swallowed: each accumulator already logs its own failure. */
1596
+ })));
1597
+ if ((prior.phase === "running" || prior.phase === "recycling") &&
1598
+ prior.dispatchLoop !== null) {
1599
+ await prior.dispatchLoop.catch(() => {
1600
+ /* swallowed: dispatch-loop failures are surfaced by the
1601
+ loop's own logger; the shutdown path only waits for the
1602
+ loop's last iteration to settle. */
1603
+ });
1604
+ }
1605
+ if ((prior.phase === "starting" ||
1606
+ prior.phase === "running" ||
1607
+ prior.phase === "recycling") &&
1608
+ prior.replayDone !== null) {
1609
+ // Await the spawn-time replayProcessingToInbox before tearing
1610
+ // the bindings down. The replay's substrate write
1611
+ // (`processing/` -> `inbox/` rename via a tree commit) must
1612
+ // settle before the supervisor's exit; without the await the
1613
+ // substrate I/O outlives the supervisor and a subsequent boot
1614
+ // can observe a partially-applied replay.
1615
+ await prior.replayDone.catch(() => {
1616
+ /* swallowed: the replay's own catch already surfaces the
1617
+ failure to the supervisor's warn channel; the shutdown
1618
+ path only waits for the substrate write to settle. */
1619
+ });
1620
+ }
1621
+ if (recyclePolicy !== null) {
1622
+ try {
1623
+ recyclePolicy.stop();
1624
+ }
1625
+ catch (cause) {
1626
+ const message = cause instanceof Error ? cause.message : String(cause);
1627
+ logger.warn `recycle policy stop threw during shutdown: ${message}`;
1628
+ }
1629
+ recyclePolicy = null;
1630
+ }
1631
+ spawnContext = null;
1632
+ if (prior.phase === "starting" ||
1633
+ prior.phase === "running" ||
1634
+ prior.phase === "recycling") {
1635
+ if (prior.mailUnsubscribe !== null) {
1636
+ try {
1637
+ prior.mailUnsubscribe();
1638
+ }
1639
+ catch (cause) {
1640
+ const message = cause instanceof Error ? cause.message : String(cause);
1641
+ logger.warn `mail unsubscribe threw during shutdown: ${message}`;
1642
+ }
1643
+ }
1644
+ try {
1645
+ bindings.mailBus.unregisterAddress(bindings.deploymentMailAddress);
1646
+ }
1647
+ catch (cause) {
1648
+ const message = cause instanceof Error ? cause.message : String(cause);
1649
+ logger.warn `mail bus unregisterAddress threw: ${message}`;
1650
+ }
1651
+ }
1652
+ }
1653
+ finally {
1654
+ // Load-bearing: the child kill and the `stopped` transition run
1655
+ // whatever happened above, so a throwing teardown step can neither
1656
+ // leak the child nor wedge the supervisor in `stopping`. The kill is
1657
+ // itself guarded so a throw here cannot re-escape the `finally`.
1658
+ if (prior.phase === "starting" ||
1659
+ prior.phase === "running" ||
1660
+ prior.phase === "recycling") {
1661
+ try {
1662
+ prior.handle.kill();
1663
+ }
1664
+ catch (cause) {
1665
+ const message = cause instanceof Error ? cause.message : String(cause);
1666
+ logger.warn `child kill threw during shutdown: ${message}`;
1667
+ }
1668
+ await prior.handle.exited.catch(() => {
1669
+ /* swallowed: the host has already been told the deployment is
1670
+ coming down; an error surfaced from the spawner is the
1671
+ process exiting with a non-zero code, which is what the
1672
+ shutdown path expects. */
1673
+ });
1674
+ await prior.eventPump.catch(() => {
1675
+ /* swallowed for the same reason as above. */
1676
+ });
1677
+ }
1678
+ state = { phase: "stopped" };
1679
+ }
1680
+ logger.info `supervisor shutdown complete (${opts.reason})`;
1681
+ }
1682
+ async function drain(opts) {
1683
+ await drainImpl(opts, { fromRecycle: false });
1684
+ }
1685
+ /**
1686
+ * Internal drain implementation. The `fromRecycle` flag admits the
1687
+ * `recycling` phase for the recycle path's drain step (which runs
1688
+ * BEFORE `abortPriorCohort` and `kill`, against the still-live
1689
+ * controlSender). External callers leave it `false`, so a stray
1690
+ * drain that lands during the kill/respawn gap is dropped silently
1691
+ * at the public surface rather than writing into a controlSender
1692
+ * that `triggerRecycle` is about to tear down.
1693
+ *
1694
+ * The asymmetry with `deliverSignal`, which throws on `recycling`,
1695
+ * is intentional: `drain()` is documented as best-effort no-op for
1696
+ * `idle`/`stopping`/`stopped` so the host shutdown sequence can
1697
+ * call it unconditionally without sniffing the phase; tightening
1698
+ * `recycling` to a throw would break that contract for callers
1699
+ * that interleave drain and shutdown. The dropped frame surfaces
1700
+ * in operator logs only; callers that need a guaranteed-delivered
1701
+ * drain should consult the supervisor's phase first.
1702
+ */
1703
+ async function drainImpl(opts, ctx) {
1704
+ // Drain is meaningful only when a workflow-process child is up;
1705
+ // calling it from `idle`/`stopping`/`stopped` is a no-op so the
1706
+ // higher-level host shutdown sequence can call drain
1707
+ // unconditionally without sniffing the phase. The recycle path
1708
+ // calls drain via `drainImpl({}, { fromRecycle: true })` and
1709
+ // admits `recycling` because the drain step runs against a
1710
+ // still-live controlSender before the kill lands.
1711
+ if (state.phase !== "running" &&
1712
+ state.phase !== "starting" &&
1713
+ !(ctx.fromRecycle && state.phase === "recycling")) {
1714
+ return;
1715
+ }
1716
+ // Forward the `drain` control mail to the child. The child's
1717
+ // `DrainController` flips its signal on receipt; the runtime
1718
+ // body's four observation points read the change on the next
1719
+ // tick. The supervisor never blocks on the child's acknowledgement
1720
+ // -- the accumulator below is the deadline-keeper, not the round
1721
+ // trip.
1722
+ await state.controlSender.send({
1723
+ type: "drain",
1724
+ data: { deadlineMs: opts.deadlineMs },
1725
+ });
1726
+ // Arm one accumulator per in-flight run. Each accumulator's
1727
+ // `escalate` path commits a signed `CancelRequested{origin:
1728
+ // "supervisor-drain"}` against the workflow-run repo via the
1729
+ // existing `commitCancelRequested` substrate path, so the runtime
1730
+ // body's cancellation cascade tears the run down without the
1731
+ // supervisor having to thread any per-run wiring beyond what the
1732
+ // accumulator already encapsulates.
1733
+ const cohortSource = perCohortTerminalSource(state.terminalCohortAbort, state.terminalBroadcaster);
1734
+ for (const runId of inFlightRuns) {
1735
+ if (drainAccumulators.has(runId))
1736
+ continue;
1737
+ const accumulator = accumulatorFactory({
1738
+ substrate: bindings.repoStore,
1739
+ repoId: bindings.workflowRunRepoId,
1740
+ ref: bindings.workflowRunRef,
1741
+ deploymentId: bindings.deploymentId,
1742
+ runId,
1743
+ signAsPrincipal: bindings.signAsPrincipal,
1744
+ drainTimeoutMs,
1745
+ now: drainNow,
1746
+ setTimer: drainSetTimer,
1747
+ clearTimer: drainClearTimer,
1748
+ ...(cohortSource !== null ? { terminalEventSource: cohortSource } : {}),
1749
+ });
1750
+ drainAccumulators.set(runId, accumulator);
1751
+ accumulator.start();
1752
+ }
1753
+ }
1754
+ async function recycle(opts) {
1755
+ if (recycleInProgress) {
1756
+ throw new Error("supervisor: recycle already in progress");
1757
+ }
1758
+ if (state.phase !== "running") {
1759
+ throw new Error(`supervisor: recycle called in phase ${state.phase}; expected running`);
1760
+ }
1761
+ if (spawnContext === null) {
1762
+ throw new Error("supervisor: recycle called without a spawn context; spawn() must complete first");
1763
+ }
1764
+ recycleInProgress = true;
1765
+ const origin = opts.origin ?? "operator";
1766
+ const prior = state;
1767
+ const priorContext = spawnContext;
1768
+ // The cohort abort no longer fires up-front. triggerRecycle drives
1769
+ // the drain and replay steps against a LIVE cohort first, then
1770
+ // invokes `abortPriorCohort` (the callback below) between replay
1771
+ // and the kill step. Aborting up-front would starve drain
1772
+ // accumulators of live terminal events; aborting after the kill
1773
+ // would race the dispatch loop's next iteration against the
1774
+ // controlSender that's about to disappear.
1775
+ const priorDispatchLoop = prior.dispatchLoop;
1776
+ // Transition to `recycling`. Inbound mail continues to flow through
1777
+ // `enqueueInbox` unchanged; the prior dispatch loop is still alive
1778
+ // for the drain window and keeps forwarding to the dying child.
1779
+ // After triggerRecycle's `abortPriorCohort` callback fires, the
1780
+ // loop notices the abort and exits before the kill lands. The new
1781
+ // dispatch loop picks up the inbox once `installNewChild` swaps
1782
+ // the wiring.
1783
+ state = {
1784
+ phase: "recycling",
1785
+ handle: prior.handle,
1786
+ controlSender: prior.controlSender,
1787
+ channelId: prior.channelId,
1788
+ eventPump: prior.eventPump,
1789
+ onInferenceEvent: prior.onInferenceEvent,
1790
+ mailUnsubscribe: prior.mailUnsubscribe,
1791
+ credentialsSnapshot: prior.credentialsSnapshot,
1792
+ terminalCohortAbort: prior.terminalCohortAbort,
1793
+ terminalBroadcaster: prior.terminalBroadcaster,
1794
+ dispatchLoop: null,
1795
+ replayDone: null,
1796
+ };
1797
+ let attempt;
1798
+ try {
1799
+ attempt = await triggerRecycle({
1800
+ bindings,
1801
+ stepOrder: priorContext.stepOrder,
1802
+ definitionHash: priorContext.definitionHash,
1803
+ warmKeep: priorContext.warmKeep,
1804
+ onInferenceEvent: priorContext.onInferenceEvent,
1805
+ current: {
1806
+ handle: prior.handle,
1807
+ controlSender: prior.controlSender,
1808
+ channelId: prior.channelId,
1809
+ eventPump: prior.eventPump,
1810
+ },
1811
+ drain: async (deadlineMs) => {
1812
+ // The recycle path's drain step shares the drain
1813
+ // primitive but bypasses the public surface's `recycling`
1814
+ // silent-no-op so the still-live controlSender (this
1815
+ // step runs BEFORE abortPriorCohort + kill) receives the
1816
+ // frame. The public `drain()` silently no-ops on
1817
+ // `recycling` for external callers because the
1818
+ // kill/respawn gap can leave the controlSender dying.
1819
+ await drainImpl({ deadlineMs }, { fromRecycle: true });
1820
+ },
1821
+ replayProcessingToInbox: async () => {
1822
+ await inboxPrimitives.replayProcessingToInbox(bindings.repoStore, inboxWritePrincipal, bindings.workflowRunRepoId, bindings.deploymentMailAddress);
1823
+ },
1824
+ abortPriorCohort: () => {
1825
+ // Fired by triggerRecycle between drain/replay and kill.
1826
+ // The prior dispatch loop notices the abort on its next
1827
+ // wake and exits before the kill drops the child.
1828
+ prior.terminalCohortAbort.abort();
1829
+ wakeDispatch();
1830
+ },
1831
+ installNewChild: ({ wiring, credentialsSnapshot, controlIncoming, }) => {
1832
+ // Phase guard: a `shutdown()` that landed during the
1833
+ // kill/respawn gap (between `subprocessSpawner` and this
1834
+ // callback) has flipped `state.phase` to `stopping` or
1835
+ // `stopped`. The new child is now an orphan -- the
1836
+ // supervisor was supposed to be tearing down, not
1837
+ // installing a fresh cohort. Kill the new wiring's
1838
+ // handle and bail out without registering it on
1839
+ // `state`. `shutdownInternal`'s own teardown path has
1840
+ // already disposed the prior cohort; there is nothing
1841
+ // for this callback to do.
1842
+ if (state.phase !== "recycling") {
1843
+ // Kill the orphan child and release its event-channel /
1844
+ // upstream-control resources so they cannot survive as
1845
+ // unowned promises. Without this, the eventPump and
1846
+ // controlIncoming iterator would have no `state`
1847
+ // bookkeeping to drive their cleanup -- a rejection
1848
+ // inside `pumpEvents` would surface as an unhandled
1849
+ // rejection, and the upstream control iterator's
1850
+ // exit would never be observed.
1851
+ wiring.handle.kill("SIGTERM");
1852
+ void wiring.eventPump.catch((cause) => {
1853
+ const message = cause instanceof Error ? cause.message : String(cause);
1854
+ logger.warn `orphan-cohort eventPump failed during phase-guard teardown: ${message}`;
1855
+ });
1856
+ void controlIncoming.return(undefined).catch((cause) => {
1857
+ const message = cause instanceof Error ? cause.message : String(cause);
1858
+ logger.warn `orphan-cohort controlIncoming.return failed during phase-guard teardown: ${message}`;
1859
+ });
1860
+ return;
1861
+ }
1862
+ // The previous cohort was aborted inside triggerRecycle
1863
+ // by the `abortPriorCohort` callback (after drain and
1864
+ // replay, before kill) so the prior dispatch loop did not
1865
+ // race the kill/respawn gap. Stop every armed accumulator
1866
+ // (they were tracking runs that lived inside the killed
1867
+ // child); the resumed child re-discovers any survivors
1868
+ // and the next `drain()` mints fresh accumulators
1869
+ // against the new cohort.
1870
+ for (const accumulator of drainAccumulators.values()) {
1871
+ accumulator.stop();
1872
+ }
1873
+ drainAccumulators.clear();
1874
+ // Reject every pending merge round-trip and markConsumed
1875
+ // waiter registered against the dying cohort so handler
1876
+ // closures cannot survive the kill/respawn gap. The new
1877
+ // child will re-issue substrate writes through fresh
1878
+ // handlers under the new cohort's channel.
1879
+ rejectCohortAwaiters("recycle");
1880
+ // Dispose the prior cohort's broadcaster so any minted
1881
+ // iterator still held by the aborted dispatch loop or a
1882
+ // stopped accumulator settles with `done: true`. The next
1883
+ // cohort gets a fresh broadcaster wired below.
1884
+ prior.terminalBroadcaster.dispose();
1885
+ // Mint a fresh cohort abort and start a new dispatch
1886
+ // loop against the new child's controlSender.
1887
+ const newCohortAbort = new AbortController();
1888
+ const newBroadcaster = createTerminalBroadcaster();
1889
+ const newDispatchLoop = runDispatchLoop(wiring.controlSender, newCohortAbort, newBroadcaster, null);
1890
+ void newDispatchLoop.catch((cause) => {
1891
+ const message = cause instanceof Error ? cause.message : String(cause);
1892
+ logger.error `dispatch loop (post-recycle) terminated with error: ${message}`;
1893
+ });
1894
+ // Transition back to running with the new wiring; the
1895
+ // mail subscription and registration are unchanged.
1896
+ state = {
1897
+ phase: "running",
1898
+ handle: wiring.handle,
1899
+ controlSender: wiring.controlSender,
1900
+ channelId: wiring.channelId,
1901
+ eventPump: wiring.eventPump,
1902
+ onInferenceEvent: priorContext.onInferenceEvent,
1903
+ mailUnsubscribe: prior.mailUnsubscribe,
1904
+ credentialsSnapshot,
1905
+ terminalCohortAbort: newCohortAbort,
1906
+ terminalBroadcaster: newBroadcaster,
1907
+ dispatchLoop: newDispatchLoop,
1908
+ replayDone: null,
1909
+ };
1910
+ // Cache fresh spawn context with the updated spawnedAt
1911
+ // so the policy timer's uptime check resets on recycle.
1912
+ const now = bindings.recyclePolicyNow ?? defaultNow;
1913
+ spawnContext = {
1914
+ stepOrder: priorContext.stepOrder,
1915
+ definitionHash: priorContext.definitionHash,
1916
+ warmKeep: priorContext.warmKeep,
1917
+ onInferenceEvent: priorContext.onInferenceEvent,
1918
+ spawnedAt: now(),
1919
+ };
1920
+ // Re-arm the upstream control pump on the new wiring's
1921
+ // iterator. The old wiring's iterator ended when the
1922
+ // recycle path killed the predecessor handle. The new
1923
+ // pump closes over the NEW cohort's broadcaster so a
1924
+ // `terminal.event` arriving on the new iterator routes
1925
+ // to the new cohort's listeners; the prior cohort's pump
1926
+ // (still draining its own iterator) closed over the
1927
+ // prior cohort's broadcaster and is unaffected by this
1928
+ // wiring swap.
1929
+ void pumpUpstreamControl(controlIncoming, newBroadcaster).catch((cause) => {
1930
+ const message = cause instanceof Error ? cause.message : String(cause);
1931
+ logger.error `upstream control pump (post-recycle) failed: ${message}`;
1932
+ });
1933
+ // Kick the new dispatch loop so it picks up any inbox
1934
+ // entries the previous cohort's replayProcessingToInbox
1935
+ // just moved back.
1936
+ wakeDispatch();
1937
+ },
1938
+ onCrash: onChildCrash,
1939
+ // Edge-resolved once at the supervisor factory; recycle bounds
1940
+ // the respawn handshake with the same value the spawn path uses.
1941
+ readyTimeoutMs,
1942
+ ...(bindings.recyclePolicySetTimer !== undefined
1943
+ ? { setTimer: bindings.recyclePolicySetTimer }
1944
+ : {}),
1945
+ ...(bindings.recyclePolicyClearTimer !== undefined
1946
+ ? { clearTimer: bindings.recyclePolicyClearTimer }
1947
+ : {}),
1948
+ }, { origin, reason: opts.reason });
1949
+ // After the recycle, await the previous cohort's dispatch
1950
+ // loop so a teardown coroutine cannot survive past the
1951
+ // recycle's return point.
1952
+ if (priorDispatchLoop !== null) {
1953
+ await priorDispatchLoop.catch(() => {
1954
+ /* swallowed: dispatch-loop failures are surfaced by the
1955
+ loop's own logger. */
1956
+ });
1957
+ }
1958
+ }
1959
+ catch (cause) {
1960
+ // `triggerRecycle` failed after we transitioned to `recycling`.
1961
+ // Leaving the supervisor in `recycling` indefinitely would wedge
1962
+ // every subsequent operation; the only recovery would be a host-
1963
+ // level shutdown. Tear the prior cohort down through the same
1964
+ // path a real shutdown uses so the supervisor reaches a clean
1965
+ // `stopped` state, then re-throw so the operator sees the
1966
+ // recycle failure and can redeploy.
1967
+ const message = cause instanceof Error ? cause.message : String(cause);
1968
+ logger.error `recycle failed; tearing supervisor down: ${message}`;
1969
+ await shutdownInternal({
1970
+ reason: `recycle failed: ${message}`,
1971
+ }).catch((shutdownCause) => {
1972
+ const inner = shutdownCause instanceof Error
1973
+ ? shutdownCause.message
1974
+ : String(shutdownCause);
1975
+ logger.error `shutdown after recycle failure also threw: ${inner}`;
1976
+ });
1977
+ throw cause;
1978
+ }
1979
+ finally {
1980
+ recycleInProgress = false;
1981
+ }
1982
+ return attempt;
1983
+ }
1984
+ async function deliverSignal(opts) {
1985
+ // The supervisor is the single producer of `signal.deliver` control
1986
+ // IPC frames. Routing every signal delivery through the same child
1987
+ // makes the workflow-process the single writer of `runs/<runId>/events/`
1988
+ // on the sidecar side; the pack-push pipeline that propagates the
1989
+ // commit to the hub never observes a concurrent writer at the
1990
+ // same ref.
1991
+ //
1992
+ // `recycling` is rejected: during recycle, `state.controlSender`
1993
+ // still points at the dying child's sender, so a `signal.deliver`
1994
+ // either buffers behind the SIGTERM (best case) or writes into a
1995
+ // closed pipe and is silently lost (worst case). Rejecting here
1996
+ // surfaces the race to the caller so they can retry after the
1997
+ // recycle completes.
1998
+ if (state.phase !== "running" && state.phase !== "starting") {
1999
+ throw new Error(`supervisor: deliverSignal called in phase ${state.phase}; expected starting/running`);
2000
+ }
2001
+ await state.controlSender.send({
2002
+ type: "signal.deliver",
2003
+ data: {
2004
+ runId: opts.runId,
2005
+ signalName: opts.signalName,
2006
+ signalId: opts.signalId,
2007
+ payload: opts.payload,
2008
+ },
2009
+ });
2010
+ }
2011
+ async function deliverSources(opts) {
2012
+ // The supervisor is the single producer of `sources-updated` control
2013
+ // frames. `recycling` is rejected for the same reason as
2014
+ // `deliverSignal`: `state.controlSender` still points at the dying
2015
+ // child, so a frame would either buffer behind the SIGTERM or write
2016
+ // into a closed pipe and be lost. Rejecting surfaces the race so the
2017
+ // caller can retry once the recycle completes.
2018
+ if (state.phase !== "running" && state.phase !== "starting") {
2019
+ throw new Error(`supervisor: deliverSources called in phase ${state.phase}; expected starting/running`);
2020
+ }
2021
+ await state.controlSender.send({
2022
+ type: "sources-updated",
2023
+ data: {
2024
+ sources: opts.sources,
2025
+ defaultSource: opts.defaultSource,
2026
+ },
2027
+ });
2028
+ }
2029
+ function getCredentialsSnapshot() {
2030
+ if (state.phase === "starting" || state.phase === "running") {
2031
+ return state.credentialsSnapshot;
2032
+ }
2033
+ return null;
2034
+ }
2035
+ return {
2036
+ spawn,
2037
+ requestCancel,
2038
+ shutdown,
2039
+ drain,
2040
+ recycle,
2041
+ deliverSignal,
2042
+ deliverSources,
2043
+ getCredentialsSnapshot,
2044
+ };
2045
+ }
2046
+ /**
2047
+ * Iterate the control-channel receive iterator until the child's
2048
+ * `ready` frame arrives. Upstream payloads other than `ready` (e.g.
2049
+ * `recycle.request`) appear after `ready`; the supervisor's
2050
+ * `pumpUpstreamControl` consumes them off the same iterator after
2051
+ * spawn returns.
2052
+ */
2053
+ async function waitForReady(iter) {
2054
+ // Use explicit `next()` rather than `for await ... return` so the
2055
+ // generator is NOT finalized via `iter.return()` when ready lands.
2056
+ // The supervisor's upstream-control pump continues iterating the
2057
+ // same generator after `ready`, and a finalized generator would
2058
+ // immediately yield `{done: true}` to the pump and silently drop
2059
+ // the child's subsequent upstream frames (e.g. `recycle.request`).
2060
+ while (true) {
2061
+ const next = await iter.next();
2062
+ if (next.done === true) {
2063
+ throw new Error("workflow-host supervisor: control channel ended before child emitted ready");
2064
+ }
2065
+ const payload = next.value;
2066
+ if (payload.type === "ready") {
2067
+ return { childPid: payload.data.childPid };
2068
+ }
2069
+ // Drop other variants encountered before `ready`; the child is
2070
+ // not supposed to send anything else first, but the receiver
2071
+ // validated the envelope and signature, so a stray frame here is
2072
+ // a programming bug worth surfacing in the warning channel
2073
+ // rather than crashing the iterator.
2074
+ }
2075
+ }
2076
+ function defaultNow() {
2077
+ return Date.now();
2078
+ }
2079
+ /**
2080
+ * Drain the event-channel receive iterator into the host-supplied
2081
+ * sink. The function resolves when the iterator ends (child exit or
2082
+ * crash callback fired). Any thrown error is logged and surfaced
2083
+ * to the supervisor's shutdown path.
2084
+ */
2085
+ async function pumpEvents(iter, onInferenceEvent) {
2086
+ for await (const event of iter) {
2087
+ onInferenceEvent(event);
2088
+ }
2089
+ }
2090
+ /**
2091
+ * Default `deriveMailAuditRef` derivation used when no host binding
2092
+ * is configured. The reference points at an "in-process" store with
2093
+ * the messageId as the path, which keeps the supervisor's library
2094
+ * tests independent of any audit-store wiring. Production hosts
2095
+ * supply a derivation coherent with their own mail-audit surface.
2096
+ */
2097
+ function defaultInProcessMailAuditRef(messageId, _rawMessage) {
2098
+ return { store: "in-process", path: messageId };
2099
+ }
2100
+ /**
2101
+ * Derive a stable message identifier from the raw bytes the bus
2102
+ * delivered. The RFC 2822 `Message-ID` header (if present) is the
2103
+ * canonical identifier the audit log surfaces as
2104
+ * `RunStarted.consumedMessageId`; downstream consumers join inbound
2105
+ * mail to workflow-run events on this value, so the header parse must
2106
+ * win when the sender emitted one. A message that lacks a
2107
+ * `Message-ID` header falls back to a sha256 of the raw bytes so
2108
+ * runs originating from non-RFC 2822 transports still receive a
2109
+ * deterministic identifier.
2110
+ *
2111
+ * The parser walks the message until the headers/body separator
2112
+ * (`CRLF CRLF` per RFC 2822 §2.1, with the lone-`LF` variant tolerated
2113
+ * to match common in-memory senders). Header-field unfolding follows
2114
+ * RFC 2822 §2.2.3: a continuation line begins with whitespace and
2115
+ * appends to the prior line. Header-name comparison is
2116
+ * case-insensitive per RFC 2822 §1.2.2.
2117
+ */
2118
+ async function deriveMessageId(rawMessage) {
2119
+ const messageIdFromHeader = parseMessageIdHeader(rawMessage);
2120
+ if (messageIdFromHeader !== null) {
2121
+ return messageIdFromHeader;
2122
+ }
2123
+ const digest = await crypto.subtle.digest("SHA-256",
2124
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- ArrayBuffer-backed at the call site; Web Crypto's BufferSource type rejects Uint8Array<ArrayBufferLike> under TS 5.9 (microsoft/TypeScript#62240)
2125
+ rawMessage);
2126
+ return hexEncode(new Uint8Array(digest));
2127
+ }
2128
+ function parseMessageIdHeader(rawMessage) {
2129
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(rawMessage);
2130
+ // Headers end at the first blank line. RFC 2822 mandates `CRLF CRLF`
2131
+ // but tolerate `LF LF` for callers that normalize line endings.
2132
+ let headerSection = text;
2133
+ const crlfBoundary = text.indexOf("\r\n\r\n");
2134
+ const lfBoundary = text.indexOf("\n\n");
2135
+ if (crlfBoundary >= 0 && (lfBoundary < 0 || crlfBoundary < lfBoundary)) {
2136
+ headerSection = text.slice(0, crlfBoundary);
2137
+ }
2138
+ else if (lfBoundary >= 0) {
2139
+ headerSection = text.slice(0, lfBoundary);
2140
+ }
2141
+ // Unfold continuation lines (a line starting with WSP belongs to
2142
+ // the prior header field).
2143
+ const lines = headerSection.split(/\r?\n/);
2144
+ const unfolded = [];
2145
+ for (const line of lines) {
2146
+ if (line.length > 0 && (line[0] === " " || line[0] === "\t")) {
2147
+ if (unfolded.length === 0)
2148
+ continue;
2149
+ unfolded[unfolded.length - 1] += " " + line.trim();
2150
+ continue;
2151
+ }
2152
+ unfolded.push(line);
2153
+ }
2154
+ for (const line of unfolded) {
2155
+ const colon = line.indexOf(":");
2156
+ if (colon < 0)
2157
+ continue;
2158
+ const name = line.slice(0, colon).trim().toLowerCase();
2159
+ if (name !== "message-id")
2160
+ continue;
2161
+ return line.slice(colon + 1).trim();
2162
+ }
2163
+ return null;
2164
+ }
2165
+ /**
2166
+ * Project the wire shape of a `terminal.event` upstream control frame
2167
+ * into the workflow-vocabulary `TerminalRunEvent` discriminated union
2168
+ * the supervisor's downstream consumers (dispatch loop, drainTimeout
2169
+ * accumulators) reason about. The control-channel IPC validator
2170
+ * narrows `kind` and `error` upstream; the supervisor preserves that
2171
+ * narrowing here without re-validating.
2172
+ */
2173
+ function terminalEventFromPayload(data) {
2174
+ if (data.kind === "RunCompleted") {
2175
+ return { kind: "RunCompleted", seq: data.seq, at: data.at };
2176
+ }
2177
+ if (data.kind === "RunCancelled") {
2178
+ return { kind: "RunCancelled", seq: data.seq, at: data.at };
2179
+ }
2180
+ // The wire schema makes `error.message` required when `kind` is
2181
+ // `RunFailed` (see control-channel `terminal.event` validator). A
2182
+ // missing message here would mean the upstream validator was bypassed
2183
+ // or the producer is non-conforming; surface that loudly rather than
2184
+ // silently coercing to an empty string.
2185
+ if (data.error === undefined || typeof data.error.message !== "string") {
2186
+ throw new Error(`terminalEventFromPayload: RunFailed payload missing required error.message (runId=${data.runId}, seq=${String(data.seq)})`);
2187
+ }
2188
+ return {
2189
+ kind: "RunFailed",
2190
+ seq: data.seq,
2191
+ at: data.at,
2192
+ error: { message: data.error.message },
2193
+ };
2194
+ }
2195
+ /**
2196
+ * Reconstruct a runtime `OutboundMessage` from the IPC wire projection.
2197
+ * The wire shape (`OutboundMessagePayload`) carries attachment bytes
2198
+ * base64-encoded and spells every optional field with a `"?"` suffix; an
2199
+ * absent field is omitted on the wire and stays omitted on the
2200
+ * reconstructed message so `exactOptionalPropertyTypes` is honored (an
2201
+ * `undefined`-valued optional would violate it). The wire validator
2202
+ * narrows `type` to the `InterchangeType` union (see
2203
+ * `OutboundMessagePayload` in the control-channel module), so it carries
2204
+ * straight onto the message without a cast.
2205
+ */
2206
+ function outboundMessageFromPayload(payload) {
2207
+ const message = {
2208
+ to: payload.to,
2209
+ type: payload.type,
2210
+ };
2211
+ if (payload.cc !== undefined)
2212
+ message.cc = payload.cc;
2213
+ if (payload.subject !== undefined)
2214
+ message.subject = payload.subject;
2215
+ if (payload.content !== undefined)
2216
+ message.content = payload.content;
2217
+ if (payload.payload !== undefined)
2218
+ message.payload = payload.payload;
2219
+ if (payload.summary !== undefined)
2220
+ message.summary = payload.summary;
2221
+ if (payload.inReplyTo !== undefined)
2222
+ message.inReplyTo = payload.inReplyTo;
2223
+ if (payload.correlationId !== undefined) {
2224
+ message.correlationId = payload.correlationId;
2225
+ }
2226
+ if (payload.sessionId !== undefined)
2227
+ message.sessionId = payload.sessionId;
2228
+ if (payload.tenantId !== undefined)
2229
+ message.tenantId = payload.tenantId;
2230
+ if (payload.attachments !== undefined) {
2231
+ message.attachments = payload.attachments.map((a) => ({
2232
+ name: a.name,
2233
+ contentType: a.contentType,
2234
+ data: base64ToBytes(a.dataBase64),
2235
+ }));
2236
+ }
2237
+ return message;
2238
+ }
2239
+ function bytesToBase64(bytes) {
2240
+ return base64Encode(bytes);
2241
+ }
2242
+ function base64ToBytes(value) {
2243
+ return base64Decode(value);
2244
+ }