@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.
- package/LICENSE +176 -0
- package/README.md +287 -0
- package/dist/adapters/blob-substrate.d.ts +49 -0
- package/dist/adapters/blob-substrate.js +140 -0
- package/dist/adapters/repo-store.d.ts +39 -0
- package/dist/adapters/repo-store.js +344 -0
- package/dist/adapters/spawn-child.d.ts +74 -0
- package/dist/adapters/spawn-child.js +152 -0
- package/dist/adapters/step-invoker.d.ts +114 -0
- package/dist/adapters/step-invoker.js +360 -0
- package/dist/child/env-bootstrap.d.ts +56 -0
- package/dist/child/env-bootstrap.js +120 -0
- package/dist/child/from-process-env.d.ts +127 -0
- package/dist/child/from-process-env.js +183 -0
- package/dist/child/index.d.ts +9 -0
- package/dist/child/index.js +9 -0
- package/dist/child/outbound-mail-bridge.d.ts +36 -0
- package/dist/child/outbound-mail-bridge.js +143 -0
- package/dist/child/proxy-repo-store.d.ts +27 -0
- package/dist/child/proxy-repo-store.js +200 -0
- package/dist/child/run-child.d.ts +320 -0
- package/dist/child/run-child.js +900 -0
- package/dist/child/self-discovery.d.ts +29 -0
- package/dist/child/self-discovery.js +57 -0
- package/dist/child/substrate-write-bridge.d.ts +72 -0
- package/dist/child/substrate-write-bridge.js +188 -0
- package/dist/child/supervisor-backed-transport.d.ts +10 -0
- package/dist/child/supervisor-backed-transport.js +113 -0
- package/dist/child/warm-agent-cache.d.ts +78 -0
- package/dist/child/warm-agent-cache.js +112 -0
- package/dist/drain-controller.d.ts +37 -0
- package/dist/drain-controller.js +46 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ipc/control-channel.d.ts +336 -0
- package/dist/ipc/control-channel.js +532 -0
- package/dist/ipc/crypto.d.ts +46 -0
- package/dist/ipc/crypto.js +126 -0
- package/dist/ipc/envelope.d.ts +53 -0
- package/dist/ipc/envelope.js +88 -0
- package/dist/ipc/event-channel.d.ts +677 -0
- package/dist/ipc/event-channel.js +278 -0
- package/dist/ipc/index.d.ts +4 -0
- package/dist/ipc/index.js +143 -0
- package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
- package/dist/mail-bus/hub-transport-adapter.js +76 -0
- package/dist/mail-bus/index.d.ts +1 -0
- package/dist/mail-bus/index.js +1 -0
- package/dist/seams/index.d.ts +3 -0
- package/dist/seams/index.js +3 -0
- package/dist/seams/scheduler-adapter.d.ts +3 -0
- package/dist/seams/scheduler-adapter.js +24 -0
- package/dist/seams/scheduler.d.ts +94 -0
- package/dist/seams/scheduler.js +397 -0
- package/dist/seams/signal-channel.d.ts +74 -0
- package/dist/seams/signal-channel.js +304 -0
- package/dist/supervisor/cancel-signing.d.ts +68 -0
- package/dist/supervisor/cancel-signing.js +144 -0
- package/dist/supervisor/child-termination.d.ts +51 -0
- package/dist/supervisor/child-termination.js +76 -0
- package/dist/supervisor/credentials.d.ts +101 -0
- package/dist/supervisor/credentials.js +153 -0
- package/dist/supervisor/dispatch-attribution.d.ts +37 -0
- package/dist/supervisor/dispatch-attribution.js +114 -0
- package/dist/supervisor/drain-timeout.d.ts +127 -0
- package/dist/supervisor/drain-timeout.js +231 -0
- package/dist/supervisor/index.d.ts +7 -0
- package/dist/supervisor/index.js +6 -0
- package/dist/supervisor/recycle.d.ts +212 -0
- package/dist/supervisor/recycle.js +440 -0
- package/dist/supervisor/run-event-compaction.d.ts +34 -0
- package/dist/supervisor/run-event-compaction.js +115 -0
- package/dist/supervisor/spawn-env.d.ts +39 -0
- package/dist/supervisor/spawn-env.js +36 -0
- package/dist/supervisor/supervisor.d.ts +202 -0
- package/dist/supervisor/supervisor.js +2244 -0
- package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
- package/dist/supervisor/terminal-broadcaster.js +184 -0
- package/dist/supervisor/types.d.ts +542 -0
- package/dist/supervisor/types.js +10 -0
- 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
|
+
}
|