@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,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
|
+
}
|