@intx/workflow-host 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +287 -0
  3. package/dist/adapters/blob-substrate.d.ts +49 -0
  4. package/dist/adapters/blob-substrate.js +140 -0
  5. package/dist/adapters/repo-store.d.ts +39 -0
  6. package/dist/adapters/repo-store.js +344 -0
  7. package/dist/adapters/spawn-child.d.ts +74 -0
  8. package/dist/adapters/spawn-child.js +152 -0
  9. package/dist/adapters/step-invoker.d.ts +114 -0
  10. package/dist/adapters/step-invoker.js +360 -0
  11. package/dist/child/env-bootstrap.d.ts +56 -0
  12. package/dist/child/env-bootstrap.js +120 -0
  13. package/dist/child/from-process-env.d.ts +127 -0
  14. package/dist/child/from-process-env.js +183 -0
  15. package/dist/child/index.d.ts +9 -0
  16. package/dist/child/index.js +9 -0
  17. package/dist/child/outbound-mail-bridge.d.ts +36 -0
  18. package/dist/child/outbound-mail-bridge.js +143 -0
  19. package/dist/child/proxy-repo-store.d.ts +27 -0
  20. package/dist/child/proxy-repo-store.js +200 -0
  21. package/dist/child/run-child.d.ts +320 -0
  22. package/dist/child/run-child.js +900 -0
  23. package/dist/child/self-discovery.d.ts +29 -0
  24. package/dist/child/self-discovery.js +57 -0
  25. package/dist/child/substrate-write-bridge.d.ts +72 -0
  26. package/dist/child/substrate-write-bridge.js +188 -0
  27. package/dist/child/supervisor-backed-transport.d.ts +10 -0
  28. package/dist/child/supervisor-backed-transport.js +113 -0
  29. package/dist/child/warm-agent-cache.d.ts +78 -0
  30. package/dist/child/warm-agent-cache.js +112 -0
  31. package/dist/drain-controller.d.ts +37 -0
  32. package/dist/drain-controller.js +46 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/ipc/control-channel.d.ts +336 -0
  36. package/dist/ipc/control-channel.js +532 -0
  37. package/dist/ipc/crypto.d.ts +46 -0
  38. package/dist/ipc/crypto.js +126 -0
  39. package/dist/ipc/envelope.d.ts +53 -0
  40. package/dist/ipc/envelope.js +88 -0
  41. package/dist/ipc/event-channel.d.ts +677 -0
  42. package/dist/ipc/event-channel.js +278 -0
  43. package/dist/ipc/index.d.ts +4 -0
  44. package/dist/ipc/index.js +143 -0
  45. package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
  46. package/dist/mail-bus/hub-transport-adapter.js +76 -0
  47. package/dist/mail-bus/index.d.ts +1 -0
  48. package/dist/mail-bus/index.js +1 -0
  49. package/dist/seams/index.d.ts +3 -0
  50. package/dist/seams/index.js +3 -0
  51. package/dist/seams/scheduler-adapter.d.ts +3 -0
  52. package/dist/seams/scheduler-adapter.js +24 -0
  53. package/dist/seams/scheduler.d.ts +94 -0
  54. package/dist/seams/scheduler.js +397 -0
  55. package/dist/seams/signal-channel.d.ts +74 -0
  56. package/dist/seams/signal-channel.js +304 -0
  57. package/dist/supervisor/cancel-signing.d.ts +68 -0
  58. package/dist/supervisor/cancel-signing.js +144 -0
  59. package/dist/supervisor/child-termination.d.ts +51 -0
  60. package/dist/supervisor/child-termination.js +76 -0
  61. package/dist/supervisor/credentials.d.ts +101 -0
  62. package/dist/supervisor/credentials.js +153 -0
  63. package/dist/supervisor/dispatch-attribution.d.ts +37 -0
  64. package/dist/supervisor/dispatch-attribution.js +114 -0
  65. package/dist/supervisor/drain-timeout.d.ts +127 -0
  66. package/dist/supervisor/drain-timeout.js +231 -0
  67. package/dist/supervisor/index.d.ts +7 -0
  68. package/dist/supervisor/index.js +6 -0
  69. package/dist/supervisor/recycle.d.ts +212 -0
  70. package/dist/supervisor/recycle.js +440 -0
  71. package/dist/supervisor/run-event-compaction.d.ts +34 -0
  72. package/dist/supervisor/run-event-compaction.js +115 -0
  73. package/dist/supervisor/spawn-env.d.ts +39 -0
  74. package/dist/supervisor/spawn-env.js +36 -0
  75. package/dist/supervisor/supervisor.d.ts +202 -0
  76. package/dist/supervisor/supervisor.js +2244 -0
  77. package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
  78. package/dist/supervisor/terminal-broadcaster.js +184 -0
  79. package/dist/supervisor/types.d.ts +542 -0
  80. package/dist/supervisor/types.js +10 -0
  81. package/package.json +35 -0
@@ -0,0 +1,440 @@
1
+ // =============================================================
2
+ // RECYCLE -- workflow-process supervisor library code
3
+ // =============================================================
4
+ //
5
+ // Recycle is the supervisor's "same deploy tree, fresh process" path.
6
+ // It tears the existing workflow-process child down and stands a new
7
+ // one up against the SAME deploy tree (same `workflow.json`, same
8
+ // per-step credential repos). It is STRICTLY ORTHOGONAL TO REDEPLOY:
9
+ //
10
+ // - Recycle = same deploy tree, fresh process.
11
+ // - Redeploy = new deploy tree.
12
+ //
13
+ // Recycle does not refetch the deploy tree, does not consult an
14
+ // updated workflow definition, does not re-resolve agents. If a
15
+ // deploy-tree change is needed the host runs redeploy, which is a
16
+ // different code path with different authorization and a different
17
+ // rollback shape. The recycle module must never grow a "maybe also
18
+ // refetch the deploy tree" mode -- that would erase the orthogonality
19
+ // and let a recycle silently turn into a redeploy.
20
+ //
21
+ // Six-step sequence (locked):
22
+ //
23
+ // 1. `drain` -- send the existing drain control mail. Wait for
24
+ // in-flight runs to drain per each step's `drainBehavior`.
25
+ // `drainTimeout` escalation applies normally; the drain-timeout
26
+ // accumulator and its `CancelRequested{origin: "supervisor-drain"}`
27
+ // commit path are unchanged from the standalone-drain case.
28
+ // 2. `kill` -- terminate the workflow-process child cleanly. SIGTERM
29
+ // first; if the child does not exit within the kill-timeout, the
30
+ // handle's `kill(SIGKILL)` lands the hard stop. The supervisor's
31
+ // injected subprocess spawner returns the child-handle API used
32
+ // here; the recycle path does not reach into Node primitives.
33
+ // 3. `respawn` -- mint a new 16-byte hex channelId (the same shape
34
+ // the initial spawn uses, per `generateChannelId`), generate a
35
+ // fresh 32-byte HMAC key, re-read per-step credentials via the
36
+ // injected `RepoStore`, and spawn a new Bun child via the same
37
+ // subprocess-spawner binding. The new IPC anchors flow through
38
+ // spawn-time env exactly as the initial spawn's anchors did.
39
+ // 4. `self-discover` -- the new child runs its existing self-
40
+ // discovery on spawn. The recycle path does not coordinate this
41
+ // step; it is the child's responsibility.
42
+ // 5. `resume` -- self-discovery resumes any in-flight runs from the
43
+ // workflow-run log. The runtime body's seed-events path re-arms
44
+ // timers, pending awaits, and uncancelled children.
45
+ // 6. Buffered mail is the supervisor's FIFO inbox claim-check queue
46
+ // (the new child's dispatch loop picks up entries that arrived
47
+ // during the kill/respawn gap once it starts). The recycle path
48
+ // does NOT drain an in-memory mail buffer; every inbound message
49
+ // enqueues into the substrate-backed inbox regardless of phase.
50
+ //
51
+ // Mail-address ownership across the gap: the supervisor holds the
52
+ // mail-bus registration across the recycle via the injected mail-bus
53
+ // binding. No re-register, no unregister. Inbound mail during the
54
+ // gap commits to the substrate-backed inbox; the new child's dispatch
55
+ // loop dequeues in arrival order (the envelope's `receivedAt` prefix
56
+ // preserves FIFO discipline across the gap).
57
+ //
58
+ // Before the kill lands the recycle path calls
59
+ // `ctx.replayProcessingToInbox()` so any in-flight `processing/`
60
+ // entries that the dying cohort's dispatch loop did not reach
61
+ // `markConsumed` for get moved back to `inbox/` under their original
62
+ // `<receivedAt>-<messageId>` keys. Without this step the dying child
63
+ // would leave an orphaned processing entry that no live dispatch
64
+ // loop owns.
65
+ //
66
+ // Three trigger origins funnel through `triggerRecycle(reason, ctx)`:
67
+ //
68
+ // - Operator command -- the host receives a `recycle` request via
69
+ // its caller-facing API and routes it to the supervisor's
70
+ // `recycle()` method, which delegates here.
71
+ // - Supervisor policy -- a periodic check (every ~minute) consults
72
+ // configurable bounds (max-uptime, max-rss, grants-staleness;
73
+ // defaults unlimited). On a threshold trip the policy calls
74
+ // `triggerRecycle` with a reason tagged with the tripped bound.
75
+ // - Workflow-process self-initiated -- the child sends a
76
+ // `recycle.request` payload over control IPC. The supervisor's
77
+ // upstream control-channel reader recognises the variant and
78
+ // funnels it here.
79
+ //
80
+ // All three origins land in the same code path. The reason string is
81
+ // the only origin-specific data the path carries forward.
82
+ import { getLogger } from "@intx/log";
83
+ import { generateKeyPair } from "@intx/crypto";
84
+ import { createControlChannelSender, generateChannelId, generateHmacKey, receiveControlChannel, receiveEventChannel, } from "../ipc/index.js";
85
+ import { assembleCredentialsSnapshot, } from "./credentials.js";
86
+ import { buildChildSpawnEnv } from "./spawn-env.js";
87
+ import { DEFAULT_KILL_TIMEOUT_MS, DEFAULT_READY_TIMEOUT_MS, defaultClearTimer, defaultSetTimer, killChildHandle, waitDeadline, } from "./child-termination.js";
88
+ const logger = getLogger(["workflow-host", "supervisor", "recycle"]);
89
+ /**
90
+ * Bound on the supervisor's mail buffer across the kill/respawn gap.
91
+ * A real workflow's inbound rate is well below this; saturation
92
+ * indicates either an upstream stuck on the deployment or a recycle
93
+ * stuck partway through. Either case is one the operator must see.
94
+ */
95
+ export const MAX_BUFFERED_MAIL = 256;
96
+ /**
97
+ * Default supervisor-policy check interval. The policy thread wakes
98
+ * roughly every minute, evaluates the configured bounds against the
99
+ * live child, and triggers a recycle if any threshold has been
100
+ * crossed. Operator-overridable via the supervisor's policy bindings.
101
+ */
102
+ export const DEFAULT_POLICY_INTERVAL_MS = 60_000;
103
+ /**
104
+ * Run the six-step recycle sequence. The function returns once the
105
+ * new child has emitted `ready` and the supervisor has drained its
106
+ * buffered mail into it; the supervisor installs the new wiring via
107
+ * `ctx.installNewChild` before that point.
108
+ */
109
+ export async function triggerRecycle(ctx, opts) {
110
+ const drainDeadlineMs = ctx.drainDeadlineMs ?? 60_000;
111
+ const killTimeoutMs = ctx.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
112
+ const previousChannelId = ctx.current.channelId;
113
+ logger.info `recycle ${opts.origin} requested: ${opts.reason} (previousChannelId=${previousChannelId})`;
114
+ // Step 1: drain. The supervisor's drain primitive is the same one
115
+ // the standalone-drain path uses; the recycle path does not
116
+ // reimplement the drain control mail or the drainTimeout
117
+ // accumulator. The `drainBehavior` of each in-flight step decides
118
+ // whether it aborts or continues; `drainTimeout` escalation lands
119
+ // through the existing supervisor-drain origin.
120
+ await ctx.drain(drainDeadlineMs);
121
+ // After drain settles and BEFORE the kill lands, replay any
122
+ // in-flight `processing/` entries back to `inbox/`. Without this
123
+ // replay, a child whose run was mid-dispatch when drain expired
124
+ // would leave its `processing/` entry orphaned -- the dispatch
125
+ // loop for the dying cohort already aborted on cohort teardown
126
+ // and will not reach its `markConsumed` step. The new child's
127
+ // dispatch loop dequeues the recovered entries in arrival order
128
+ // (the envelope's `receivedAt` prefix preserves FIFO discipline).
129
+ try {
130
+ await ctx.replayProcessingToInbox();
131
+ }
132
+ catch (cause) {
133
+ const message = cause instanceof Error ? cause.message : String(cause);
134
+ logger.warn `recycle: replayProcessingToInbox before kill failed: ${message}`;
135
+ }
136
+ // Abort the prior cohort here -- after drain and replay have run
137
+ // against a live cohort, before the kill drops the child. Aborting
138
+ // up front (in the supervisor wrapper) would starve drain
139
+ // accumulators of live terminal events and force every recycle to
140
+ // pay the full drainTimeout budget. Aborting after the kill would
141
+ // race the dispatch loop's next iteration against the
142
+ // controlSender that is about to disappear.
143
+ ctx.abortPriorCohort();
144
+ // Step 2: kill. SIGTERM first; if the child does not exit within
145
+ // `killTimeoutMs`, the handle's hard kill lands. The injected
146
+ // spawner's `SubprocessHandle.kill()` is the surface the recycle
147
+ // path touches; the spawner owns the Node primitives.
148
+ await killChildHandle(ctx.current.handle, killTimeoutMs, {
149
+ logger,
150
+ ...(ctx.setTimer !== undefined ? { setTimer: ctx.setTimer } : {}),
151
+ ...(ctx.clearTimer !== undefined ? { clearTimer: ctx.clearTimer } : {}),
152
+ });
153
+ // Step 3: respawn. Fresh channelId, fresh HMAC key, fresh Ed25519
154
+ // IPC keypair. Per-step credentials are re-read so a grants update
155
+ // that landed since the original spawn is reflected in the new
156
+ // child's snapshot. The deploy tree (`workflow.json`, agents,
157
+ // workflow-asset repo) is UNCHANGED.
158
+ const channelId = generateChannelId();
159
+ const hmacKey = generateHmacKey();
160
+ const ipcKeypair = await (ctx.bindings.ipcKeyPairFactory ?? generateKeyPair)();
161
+ const env = buildChildSpawnEnv({
162
+ substrateEnv: ctx.bindings.substrateEnv,
163
+ dynamicSpawnEnv: ctx.bindings.dynamicSpawnEnv,
164
+ channelId,
165
+ hmacKey,
166
+ hostPublicKey: ipcKeypair.publicKey,
167
+ deploymentId: ctx.bindings.deploymentId,
168
+ deploymentMailAddress: ctx.bindings.deploymentMailAddress,
169
+ stepCount: ctx.bindings.stepCount,
170
+ definitionHash: ctx.definitionHash,
171
+ warmKeep: ctx.warmKeep,
172
+ });
173
+ const handle = ctx.bindings.subprocessSpawner({
174
+ binaryPath: ctx.bindings.binaryPath,
175
+ env,
176
+ });
177
+ const controlSender = createControlChannelSender({
178
+ privateKeySeed: ipcKeypair.privateKey,
179
+ channelId,
180
+ writer: handle.controlWriter,
181
+ });
182
+ const controlIncoming = receiveControlChannel({
183
+ publicKey: { bootstrapFromReady: true },
184
+ channelId,
185
+ reader: handle.controlReader,
186
+ onCrash: ctx.onCrash,
187
+ });
188
+ const readyPromise = waitForReady(controlIncoming);
189
+ // Attach a benign handler at creation, before the fold below consumes
190
+ // the rejection: `readyPromise` is created here but not raced until
191
+ // after `assembleCredentialsSnapshot` awaits. A child that exits during
192
+ // that window rejects `readyPromise` with no handler yet attached -- an
193
+ // unhandled rejection across the await boundary. Mirrors the spawn
194
+ // path's identical guard. Attaching `.catch` here and `.then` at the
195
+ // race is fine; both observe the same settled value.
196
+ void readyPromise.catch(() => undefined);
197
+ const eventIter = receiveEventChannel({
198
+ hmacKey,
199
+ channelId,
200
+ reader: handle.eventReader,
201
+ onCrash: ctx.onCrash,
202
+ });
203
+ const eventPump = pumpEvents(eventIter, ctx.onInferenceEvent);
204
+ // The child handshake below is deadline-bounded and needs these timer
205
+ // bindings; the pre-handshake credentials-read reap needs them too, so
206
+ // derive them before that read.
207
+ const setTimer = ctx.setTimer ?? defaultSetTimer;
208
+ const clearTimer = ctx.clearTimer ?? defaultClearTimer;
209
+ // Re-read per-step credentials. A grants update that landed during
210
+ // the previous child's lifetime is picked up here -- the recycle
211
+ // doubles as the supervisor's grant-refresh path. The deploy tree
212
+ // is not consulted; this read is against the `agent-state` repos
213
+ // alone, whose contents are independent of `workflow.json`.
214
+ //
215
+ // This is a substrate read that can reject -- a grants file that
216
+ // became malformed is precisely the recycle's grant-refresh path. The
217
+ // new child is already spawned and wired but not yet installed on
218
+ // `state`, so the supervisor's recycle-failure teardown (which reaps
219
+ // the PRIOR cohort) cannot see it; reap it here on failure or it
220
+ // leaks. The spawn path routes this same throw through its teardown
221
+ // owner. The try wraps only the awaited read: the handle and pumps it
222
+ // reaps are all constructed above, so the reap always has live
223
+ // handles.
224
+ let credentialsSnapshot;
225
+ try {
226
+ credentialsSnapshot = await assembleCredentialsSnapshot({
227
+ repoStore: ctx.bindings.repoStore,
228
+ principal: ctx.bindings.readPrincipal,
229
+ stepOrder: ctx.stepOrder,
230
+ deploymentId: ctx.bindings.deploymentId,
231
+ deriveStepAddress: ctx.bindings.deriveStepAddress,
232
+ ...(ctx.bindings.deriveStepRepoId !== undefined
233
+ ? { deriveStepRepoId: ctx.bindings.deriveStepRepoId }
234
+ : {}),
235
+ });
236
+ }
237
+ catch (cause) {
238
+ await reapUnreadyChild(handle, eventPump, controlIncoming, {
239
+ killTimeoutMs,
240
+ setTimer,
241
+ clearTimer,
242
+ phase: "credentials read failure",
243
+ });
244
+ throw cause;
245
+ }
246
+ // Steps 4 + 5: self-discover + resume. These run inside the child
247
+ // before it emits `ready`; the supervisor waits, but bounded -- a child
248
+ // that neither readies nor exits must not park the recycle (and thus
249
+ // the supervisor) in `recycling` forever. Bound the handshake exactly
250
+ // as the spawn path bounds its own: fold ready/failed into values, race
251
+ // a resolve-only deadline, clear the timer on every path.
252
+ //
253
+ // NOTE: `assembleCredentialsSnapshot` above is a substrate read that
254
+ // sits OUTSIDE this deadline; a wedged substrate is a supervisor-side
255
+ // fault bounded at its own layer, not by overloading this handshake
256
+ // timer. The respawn is deadline-bounded on the child handshake, not on
257
+ // that read.
258
+ const readyTimeoutMs = ctx.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
259
+ const readyOutcome = readyPromise.then((info) => ({ kind: "ready", info }), (err) => ({ kind: "failed", err }));
260
+ const readyDeadline = waitDeadline(setTimer, readyTimeoutMs);
261
+ const readyRace = await Promise.race([
262
+ readyOutcome,
263
+ readyDeadline.promise.then(() => ({ kind: "timeout" })),
264
+ ]);
265
+ clearTimer(readyDeadline.handle);
266
+ if (readyRace.kind !== "ready") {
267
+ // The new child was never installed on `state`, so the supervisor's
268
+ // recycle-failure teardown (which reaps the PRIOR cohort) would leak
269
+ // it. Reap it here. `killChildHandle` on an already-dead handle is a
270
+ // cheap no-op, so the `failed` path (a control-channel end does not
271
+ // guarantee the process died, since the event channel is separate)
272
+ // is reaped too, not just the timeout.
273
+ await reapUnreadyChild(handle, eventPump, controlIncoming, {
274
+ killTimeoutMs,
275
+ setTimer,
276
+ clearTimer,
277
+ phase: "handshake failure",
278
+ });
279
+ if (readyRace.kind === "timeout") {
280
+ throw new Error(`workflow-host supervisor recycle: child did not emit ready within ${String(readyTimeoutMs)}ms; killed`);
281
+ }
282
+ throw readyRace.err;
283
+ }
284
+ const readyInfo = readyRace.info;
285
+ logger.info `recycle ${opts.origin}: child ready (pid=${String(readyInfo.childPid)}, newChannelId=${channelId})`;
286
+ const newWiring = {
287
+ handle,
288
+ controlSender,
289
+ channelId,
290
+ eventPump,
291
+ };
292
+ ctx.installNewChild({
293
+ wiring: newWiring,
294
+ credentialsSnapshot,
295
+ controlIncoming,
296
+ });
297
+ // Step 6: the FIFO inbox claim-check queue holds any mail that
298
+ // arrived during the kill/respawn gap (every inbound message
299
+ // enqueues into the substrate-backed inbox regardless of phase).
300
+ // The new dispatch loop that `installNewChild` started dequeues
301
+ // those entries in arrival order; the recycle path does not need
302
+ // an in-memory drain step.
303
+ return {
304
+ origin: opts.origin,
305
+ reason: opts.reason,
306
+ newChannelId: channelId,
307
+ previousChannelId,
308
+ };
309
+ }
310
+ /**
311
+ * Reap a respawned child that was spawned and wired but never installed
312
+ * on `state`. Such a child is invisible to the supervisor's
313
+ * recycle-failure teardown -- that path reaps the PRIOR cohort
314
+ * (`state.handle` during `recycling`) -- so a respawn that fails after
315
+ * the spawn must reap the new child here or it leaks its OS process and
316
+ * both IPC channels.
317
+ *
318
+ * Kill FIRST, then finalize the pumps: process death drives EOF on both
319
+ * channels, which unparks `waitForReady`'s in-flight `iter.next()`
320
+ * (letting `controlIncoming.return` complete) and ends `pumpEvents`.
321
+ * Awaiting either finalizer before the kill would hang behind the
322
+ * still-open channels. `killChildHandle` on an already-dead handle is a
323
+ * cheap no-op, so a child that died on its own -- not just one killed on
324
+ * timeout -- is reaped safely too.
325
+ */
326
+ async function reapUnreadyChild(handle, eventPump, controlIncoming, deps) {
327
+ await killChildHandle(handle, deps.killTimeoutMs, {
328
+ logger,
329
+ setTimer: deps.setTimer,
330
+ clearTimer: deps.clearTimer,
331
+ });
332
+ void eventPump.catch((cause) => {
333
+ const message = cause instanceof Error ? cause.message : String(cause);
334
+ logger.warn `recycle: reaped child eventPump failed after ${deps.phase}: ${message}`;
335
+ });
336
+ void controlIncoming.return(undefined).catch((cause) => {
337
+ const message = cause instanceof Error ? cause.message : String(cause);
338
+ logger.warn `recycle: reaped child controlIncoming.return failed after ${deps.phase}: ${message}`;
339
+ });
340
+ }
341
+ /**
342
+ * Iterate the new child's control-receive iterator until the `ready`
343
+ * frame lands. Identical shape to the supervisor's spawn-time helper;
344
+ * factored here so the recycle path does not depend on the
345
+ * supervisor's private function.
346
+ */
347
+ async function waitForReady(iter) {
348
+ // Explicit `next()` instead of `for await ... return` so the
349
+ // generator is not finalized when ready lands. The supervisor's
350
+ // upstream-control pump continues iterating the same generator
351
+ // after the recycle path returns; finalizing it here would silently
352
+ // drop any subsequent child-initiated upstream frames.
353
+ while (true) {
354
+ const next = await iter.next();
355
+ if (next.done === true) {
356
+ throw new Error("workflow-host supervisor recycle: control channel ended before child emitted ready");
357
+ }
358
+ const payload = next.value;
359
+ if (payload.type === "ready") {
360
+ return { childPid: payload.data.childPid };
361
+ }
362
+ // Other upstream control payloads encountered before `ready` are
363
+ // dropped silently; the receiver iterator already verified them
364
+ // and any later upstream traffic flows through the supervisor's
365
+ // pump once recycle returns.
366
+ }
367
+ }
368
+ async function pumpEvents(iter, onInferenceEvent) {
369
+ for await (const event of iter) {
370
+ onInferenceEvent(event);
371
+ }
372
+ }
373
+ /**
374
+ * Start the supervisor-policy periodic recycle check. Returns a
375
+ * handle the supervisor calls `stop()` on at shutdown. The policy is
376
+ * single-trigger per tick: even if multiple bounds are tripped on the
377
+ * same tick, exactly one `trigger` invocation lands with a reason
378
+ * naming the first tripped bound.
379
+ */
380
+ export function createRecyclePolicy(opts) {
381
+ const intervalMs = opts.intervalMs ?? DEFAULT_POLICY_INTERVAL_MS;
382
+ let stopped = false;
383
+ let timerHandle = null;
384
+ async function tick() {
385
+ if (stopped)
386
+ return;
387
+ const reason = evaluateBounds(opts);
388
+ if (reason !== null) {
389
+ try {
390
+ await opts.trigger(reason);
391
+ }
392
+ catch (cause) {
393
+ const message = cause instanceof Error ? cause.message : String(cause);
394
+ logger.error `recycle policy trigger failed: ${message}`;
395
+ }
396
+ }
397
+ }
398
+ function arm() {
399
+ if (stopped)
400
+ return;
401
+ timerHandle = opts.setTimer(() => {
402
+ void tick().finally(() => arm());
403
+ }, intervalMs);
404
+ }
405
+ arm();
406
+ return {
407
+ stop() {
408
+ if (stopped)
409
+ return;
410
+ stopped = true;
411
+ if (timerHandle !== null)
412
+ opts.clearTimer(timerHandle);
413
+ timerHandle = null;
414
+ },
415
+ tick,
416
+ };
417
+ }
418
+ function evaluateBounds(opts) {
419
+ if (opts.bounds.maxUptimeMs !== undefined) {
420
+ const uptimeMs = opts.now() - opts.spawnedAt;
421
+ if (uptimeMs >= opts.bounds.maxUptimeMs) {
422
+ return `max-uptime: uptime ${String(uptimeMs)}ms >= threshold ${String(opts.bounds.maxUptimeMs)}ms`;
423
+ }
424
+ }
425
+ if (opts.bounds.maxRssBytes !== undefined &&
426
+ opts.readRssBytes !== undefined) {
427
+ const rss = opts.readRssBytes();
428
+ if (rss !== undefined && rss >= opts.bounds.maxRssBytes) {
429
+ return `max-rss: rss ${String(rss)} bytes >= threshold ${String(opts.bounds.maxRssBytes)} bytes`;
430
+ }
431
+ }
432
+ if (opts.bounds.maxGrantsAgeMs !== undefined &&
433
+ opts.readGrantsAgeMs !== undefined) {
434
+ const ageMs = opts.readGrantsAgeMs();
435
+ if (ageMs !== undefined && ageMs >= opts.bounds.maxGrantsAgeMs) {
436
+ return `grants-staleness: age ${String(ageMs)}ms >= threshold ${String(opts.bounds.maxGrantsAgeMs)}ms`;
437
+ }
438
+ }
439
+ return null;
440
+ }
@@ -0,0 +1,34 @@
1
+ import type { RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
2
+ export type CompactRunEventsOpts = {
3
+ /** Substrate handle the supervisor writes through. */
4
+ substrate: SubstrateRepoStore;
5
+ /** Workflow-run repo for this deployment. */
6
+ repoId: RepoId;
7
+ /** Events ref the workflow-run repo writes to. */
8
+ ref: string;
9
+ /** Deployment id used to construct the supervisor principal. */
10
+ deploymentId: string;
11
+ /** Run to seal. */
12
+ runId: string;
13
+ };
14
+ /**
15
+ * Fold a terminated run's per-event `events/<seq>.json` blobs into one
16
+ * combined `events.jsonl`, dropping the per-event files. This shrinks the
17
+ * repo's file count -- and so every per-commit cost that scales with it --
18
+ * without losing any event.
19
+ *
20
+ * Idempotent and terminal-only: a run already sealed (no `events/` subtree)
21
+ * or one whose latest event is not terminal is left untouched, so the call
22
+ * is safe to repeat. The live caller fires it once per run, right after the
23
+ * run terminates; a bounded recovery sweep that would re-fire it to seal a
24
+ * run whose fold a crash interrupted is not yet implemented.
25
+ *
26
+ * The combined file is the verbatim byte concatenation of the per-event
27
+ * blobs in seq order (`encodeCombinedEventLog`), the exact shape the
28
+ * workflow-run kind handler's compaction validation requires. It is written
29
+ * as a sibling of `events/`, so returning it from the merge while omitting
30
+ * the per-event files lets the substrate's prefix clear drop them.
31
+ */
32
+ export declare function compactRunEvents(opts: CompactRunEventsOpts): Promise<{
33
+ compacted: boolean;
34
+ }>;
@@ -0,0 +1,115 @@
1
+ // Run-event compaction path for the per-deployment supervisor.
2
+ //
3
+ // Once a workflow run terminates, the supervisor folds that run's
4
+ // per-event `events/<seq>.json` blobs into one combined `events.jsonl`
5
+ // and drops the per-event files. This shrinks the workflow-run repo's
6
+ // file count -- and every per-commit cost that scales with it --
7
+ // without losing any event. The fold writes under the substrate's
8
+ // per-repo lock as the `supervisor` principal, whose `deploymentId`
9
+ // the workflow-run kind handler checks against `repoId.id`.
10
+ import { WORKFLOW_RUN_EVENTS_FILE, encodeCombinedEventLog, } from "@intx/hub-sessions/substrate";
11
+ import { SUPERVISOR_PRINCIPAL_KIND } from "./cancel-signing.js";
12
+ const RUNS_PREFIX = "runs";
13
+ const EVENTS_DIR = "events";
14
+ const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
15
+ const TERMINAL_EVENT_TYPES = new Set([
16
+ "RunCompleted",
17
+ "RunFailed",
18
+ "RunCancelled",
19
+ ]);
20
+ /**
21
+ * Fold a terminated run's per-event `events/<seq>.json` blobs into one
22
+ * combined `events.jsonl`, dropping the per-event files. This shrinks the
23
+ * repo's file count -- and so every per-commit cost that scales with it --
24
+ * without losing any event.
25
+ *
26
+ * Idempotent and terminal-only: a run already sealed (no `events/` subtree)
27
+ * or one whose latest event is not terminal is left untouched, so the call
28
+ * is safe to repeat. The live caller fires it once per run, right after the
29
+ * run terminates; a bounded recovery sweep that would re-fire it to seal a
30
+ * run whose fold a crash interrupted is not yet implemented.
31
+ *
32
+ * The combined file is the verbatim byte concatenation of the per-event
33
+ * blobs in seq order (`encodeCombinedEventLog`), the exact shape the
34
+ * workflow-run kind handler's compaction validation requires. It is written
35
+ * as a sibling of `events/`, so returning it from the merge while omitting
36
+ * the per-event files lets the substrate's prefix clear drop them.
37
+ */
38
+ export async function compactRunEvents(opts) {
39
+ const fs = await import("node:fs/promises");
40
+ const path = await import("node:path");
41
+ const dir = opts.substrate.getRepoDir(opts.repoId);
42
+ const eventsDir = path.join(dir, RUNS_PREFIX, opts.runId, EVENTS_DIR);
43
+ // Cheap pre-check off the working tree to skip an empty commit when there
44
+ // is nothing to seal (already combined, or not yet terminal). The merge
45
+ // re-reads the prefix under the per-repo lock, so the seal stays
46
+ // consistent if another writer raced in between.
47
+ let filenames;
48
+ try {
49
+ filenames = await fs.readdir(eventsDir);
50
+ }
51
+ catch (cause) {
52
+ if (isErrnoNotFound(cause))
53
+ return { compacted: false };
54
+ throw cause;
55
+ }
56
+ const seqs = [];
57
+ for (const name of filenames) {
58
+ const match = EVENT_FILENAME_RE.exec(name);
59
+ if (match === null || match[1] === undefined)
60
+ continue;
61
+ seqs.push(Number.parseInt(match[1], 10));
62
+ }
63
+ if (seqs.length === 0)
64
+ return { compacted: false };
65
+ const lastRaw = await fs.readFile(path.join(eventsDir, `${String(Math.max(...seqs))}.json`), "utf8");
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(lastRaw);
69
+ }
70
+ catch {
71
+ return { compacted: false };
72
+ }
73
+ if (typeof parsed !== "object" || parsed === null || !("type" in parsed)) {
74
+ return { compacted: false };
75
+ }
76
+ const lastType = parsed.type;
77
+ if (typeof lastType !== "string" || !TERMINAL_EVENT_TYPES.has(lastType)) {
78
+ return { compacted: false };
79
+ }
80
+ const prefix = `${RUNS_PREFIX}/${opts.runId}/${EVENTS_DIR}/`;
81
+ const combinedPath = `${RUNS_PREFIX}/${opts.runId}/${WORKFLOW_RUN_EVENTS_FILE}`;
82
+ const principal = {
83
+ kind: SUPERVISOR_PRINCIPAL_KIND,
84
+ deploymentId: opts.deploymentId,
85
+ };
86
+ let sealed = false;
87
+ await opts.substrate.writeTreePreservingPrefix(principal, opts.repoId, opts.ref, {
88
+ preservePrefix: prefix,
89
+ merge: async (existing) => {
90
+ const entries = [];
91
+ for (const [filepath, bytes] of existing) {
92
+ const name = filepath.slice(prefix.length);
93
+ const match = EVENT_FILENAME_RE.exec(name);
94
+ if (match === null || match[1] === undefined) {
95
+ throw new Error(`supervisor run-event-compaction: unexpected non-event file ${filepath} under run ${opts.runId}; refusing to compact`);
96
+ }
97
+ entries.push({ seq: Number.parseInt(match[1], 10), bytes });
98
+ }
99
+ if (entries.length === 0)
100
+ return {};
101
+ entries.sort((a, b) => a.seq - b.seq);
102
+ sealed = true;
103
+ return {
104
+ [combinedPath]: encodeCombinedEventLog(entries.map((e) => e.bytes)),
105
+ };
106
+ },
107
+ message: `compact run ${opts.runId} events`,
108
+ });
109
+ return { compacted: sealed };
110
+ }
111
+ function isErrnoNotFound(cause) {
112
+ if (cause === null || typeof cause !== "object")
113
+ return false;
114
+ return cause.code === "ENOENT";
115
+ }
@@ -0,0 +1,39 @@
1
+ export interface ChildSpawnEnvParts {
2
+ /**
3
+ * The deployment's stable substrate env (DATA_DIR, the adapter manifest,
4
+ * and so on), frozen for the deployment's lifetime. Layered UNDER the
5
+ * dynamic env fragment (so a host revision wins) and the per-spawn anchors
6
+ * (so a required key is never shadowed).
7
+ */
8
+ substrateEnv: Record<string, string>;
9
+ /**
10
+ * Host-supplied dynamic env fragment, recomputed for every spawn and
11
+ * respawn. Its keys layer OVER `substrateEnv` (so a value the host revised
12
+ * between spawns wins) and UNDER the required anchors. Returns `{}` when
13
+ * the host has no dynamic entries.
14
+ */
15
+ dynamicSpawnEnv: () => Record<string, string>;
16
+ /** Supervisor-minted IPC channel id for this spawn. */
17
+ channelId: string;
18
+ /** Shared HMAC key for the event channel, minted for this spawn. */
19
+ hmacKey: Uint8Array;
20
+ /** Supervisor's Ed25519 public key for this spawn's control channel. */
21
+ hostPublicKey: Uint8Array;
22
+ /** Deployment identity the supervisor manages. */
23
+ deploymentId: string;
24
+ /** Mail address the deployment registered on the bus. */
25
+ deploymentMailAddress: string;
26
+ /** Step count of the deployed workflow (`stepOrder.length`). */
27
+ stepCount: number;
28
+ /** Content hash of the deployed workflow definition. */
29
+ definitionHash: string;
30
+ /** Whether this deployment's agent is warm-kept across messages. */
31
+ warmKeep: boolean;
32
+ }
33
+ /**
34
+ * Build the spawn-time env for a workflow-process child. The single
35
+ * producer of what `parseSpawnTimeEnv` consumes; the initial spawn and
36
+ * every recycle respawn both call it, so neither can drift from the
37
+ * required-key contract.
38
+ */
39
+ export declare function buildChildSpawnEnv(parts: ChildSpawnEnvParts): Record<string, string>;
@@ -0,0 +1,36 @@
1
+ // Supervisor-side producer of the workflow-process child's spawn-time env.
2
+ //
3
+ // Both the initial spawn and every recycle respawn route the env through
4
+ // this single builder, so the required-key contract the child-side parser
5
+ // (`parseSpawnTimeEnv`) enforces has exactly one producer. A divergence
6
+ // between the two paths -- the recycle env once omitted `STEP_COUNT` and
7
+ // broke every recycle -- is no longer expressible: the required keys are
8
+ // built as an exactly-typed record, so omitting one is a compile error.
9
+ import { hexEncode } from "@intx/types";
10
+ /**
11
+ * Build the spawn-time env for a workflow-process child. The single
12
+ * producer of what `parseSpawnTimeEnv` consumes; the initial spawn and
13
+ * every recycle respawn both call it, so neither can drift from the
14
+ * required-key contract.
15
+ */
16
+ export function buildChildSpawnEnv(parts) {
17
+ // Exactly-typed so omitting a required key fails the type-check rather
18
+ // than surfacing as a child env-parse abort in production.
19
+ const required = {
20
+ IPC_CHANNEL_ID: parts.channelId,
21
+ IPC_HMAC_KEY: hexEncode(parts.hmacKey),
22
+ HOST_PUBKEY: hexEncode(parts.hostPublicKey),
23
+ DEPLOYMENT_ID: parts.deploymentId,
24
+ DEFINITION_HASH: parts.definitionHash,
25
+ MAILBOX_ADDRESS: parts.deploymentMailAddress,
26
+ STEP_COUNT: String(parts.stepCount),
27
+ };
28
+ return {
29
+ ...parts.substrateEnv,
30
+ // Host-revised entries win over the frozen substrate env; the required
31
+ // anchors below still win over everything.
32
+ ...parts.dynamicSpawnEnv(),
33
+ ...required,
34
+ WARM_KEEP: parts.warmKeep ? "true" : "false",
35
+ };
36
+ }