@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,29 @@
1
+ import type { RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
2
+ import { type RunState, type WorkflowEvent } from "@intx/workflow";
3
+ import type { RepoStore as RuntimeRepoStore } from "@intx/workflow";
4
+ /**
5
+ * Per-run discovery entry. The runtime body re-applies `seedEvents`
6
+ * via its `resumeFromEvents` path and resumes the run from
7
+ * `resumedState`. The caller hands both into `runtimeRun`.
8
+ */
9
+ export interface DiscoveredRun {
10
+ runId: string;
11
+ seedEvents: readonly WorkflowEvent[];
12
+ resumedState: RunState;
13
+ }
14
+ export interface DiscoverRunsOpts {
15
+ /** Workflow-run substrate repo store. */
16
+ substrate: SubstrateRepoStore;
17
+ /** Workflow-run repo identity. */
18
+ repoId: RepoId;
19
+ /** Runtime-env `RepoStore` adapter the runs are read through. */
20
+ runtimeRepoStore: RuntimeRepoStore;
21
+ }
22
+ /**
23
+ * Enumerate `runs/<runId>/` subdirectories and return one
24
+ * `DiscoveredRun` entry per run whose log does not already end with a
25
+ * terminal event. Runs that already terminated are skipped because
26
+ * resume against a terminal log would still settle without progress
27
+ * but would generate spurious "resume seed" reads for no benefit.
28
+ */
29
+ export declare function discoverInFlightRuns(opts: DiscoverRunsOpts): Promise<readonly DiscoveredRun[]>;
@@ -0,0 +1,57 @@
1
+ // Self-discovery: enumerate in-flight runs from the workflow-run repo
2
+ // at startup and surface their event logs so the child can resume
3
+ // each one in place.
4
+ //
5
+ // The supervisor does not pass an explicit list of runs; the
6
+ // workflow-run repo's `runs/<runId>/` subtree is the authoritative
7
+ // ledger. A run is in-flight if its log lacks a terminal event
8
+ // (`RunCompleted`, `RunFailed`, `RunCancelled`). Resume seeds the
9
+ // runtime body via the seed-events path on `runtimeRun`.
10
+ //
11
+ // Working-tree-read pattern: the substrate's `getRepoDir(repoId)` is a
12
+ // pure path computation; sibling production adapters
13
+ // (`adapters/repo-store.ts`, `adapters/blob-substrate.ts`,
14
+ // `adapters/spawn-child.ts`) read working-tree contents directly via
15
+ // `node:fs/promises`. Self-discovery follows the same path so a
16
+ // startup scan does not need to consult the git object database.
17
+ import { isTerminalRunPhase, resumeFromLog, } from "@intx/workflow";
18
+ const RUNS_PREFIX = "runs";
19
+ /**
20
+ * Enumerate `runs/<runId>/` subdirectories and return one
21
+ * `DiscoveredRun` entry per run whose log does not already end with a
22
+ * terminal event. Runs that already terminated are skipped because
23
+ * resume against a terminal log would still settle without progress
24
+ * but would generate spurious "resume seed" reads for no benefit.
25
+ */
26
+ export async function discoverInFlightRuns(opts) {
27
+ const fs = await import("node:fs/promises");
28
+ const path = await import("node:path");
29
+ const dir = opts.substrate.getRepoDir(opts.repoId);
30
+ const runsDir = path.join(dir, RUNS_PREFIX);
31
+ let runDirs;
32
+ try {
33
+ runDirs = await fs.readdir(runsDir);
34
+ }
35
+ catch (cause) {
36
+ if (isErrnoNotFound(cause))
37
+ return [];
38
+ throw cause;
39
+ }
40
+ const out = [];
41
+ for (const runId of runDirs) {
42
+ const events = await opts.runtimeRepoStore.read(runId);
43
+ if (events.length === 0)
44
+ continue;
45
+ const resumed = resumeFromLog(runId, events);
46
+ if (isTerminalRunPhase(resumed.phase))
47
+ continue;
48
+ out.push({ runId, seedEvents: events, resumedState: resumed });
49
+ }
50
+ return out;
51
+ }
52
+ function isErrnoNotFound(cause) {
53
+ if (cause === null || typeof cause !== "object")
54
+ return false;
55
+ const code = cause.code;
56
+ return code === "ENOENT";
57
+ }
@@ -0,0 +1,72 @@
1
+ import type { ControlChannelSender, ControlPayload } from "../ipc/control-channel.js";
2
+ /**
3
+ * Arguments the bridge takes per `writeTreePreservingPrefix` call. The
4
+ * shape mirrors the substrate's `WriteTreePreservingPrefixArgs` plus
5
+ * the repoId/ref the supervisor needs to route the write to the right
6
+ * underlying substrate.
7
+ */
8
+ export interface SubstrateWriteRequest {
9
+ repoId: {
10
+ kind: string;
11
+ id: string;
12
+ };
13
+ ref: string;
14
+ preservePrefix: string;
15
+ message: string;
16
+ /**
17
+ * Caller's merge closure. The bridge invokes it locally inside the
18
+ * supervisor-driven merge round-trip; the closure receives the
19
+ * existing prefix entries the supervisor decoded from
20
+ * `substrate.merge.request`'s payload and returns the prospective
21
+ * tree the bridge encodes back into `substrate.merge.response`.
22
+ */
23
+ merge: (existing: ReadonlyMap<string, Uint8Array>) => Promise<Record<string, string | Uint8Array>>;
24
+ }
25
+ /**
26
+ * Bridge surface the child's substrate proxy reaches into. `submit`
27
+ * sends a `substrate.write.request` upstream and resolves once the
28
+ * supervisor's matching `substrate.write.response` lands. The
29
+ * `handleMergeRequest` and `handleWriteResponse` hooks are the
30
+ * receiver-side entry points the child's control loop invokes when
31
+ * the corresponding downstream frames arrive.
32
+ *
33
+ * `cancelAll` is the cleanup hook the control loop invokes on any
34
+ * exit path so a pending write does not leak an awaiter when the
35
+ * supervisor has torn the IPC down.
36
+ */
37
+ export interface ChildSubstrateWriteBridge {
38
+ submit(req: SubstrateWriteRequest): Promise<{
39
+ commitSha: string;
40
+ }>;
41
+ handleMergeRequest(data: Extract<ControlPayload, {
42
+ type: "substrate.merge.request";
43
+ }>["data"]): void;
44
+ handleWriteResponse(data: Extract<ControlPayload, {
45
+ type: "substrate.write.response";
46
+ }>["data"]): void;
47
+ cancelAll(reason: string): void;
48
+ readonly pendingCount: number;
49
+ }
50
+ export interface CreateChildSubstrateWriteBridgeOpts {
51
+ upstreamSender: ControlChannelSender;
52
+ /**
53
+ * Optional `requestId` allocator. Production wires a per-instance
54
+ * monotonic counter plus a random suffix; tests inject a
55
+ * deterministic factory so the upstream frame's `requestId` is
56
+ * predictable.
57
+ */
58
+ allocateRequestId?: () => string;
59
+ }
60
+ /**
61
+ * Construct the child-side substrate-write bridge. Pending writes
62
+ * live in a map keyed by `requestId`; the bridge resolves the awaiter
63
+ * when the supervisor's matching `substrate.write.response` lands.
64
+ *
65
+ * The supervisor may emit zero or more `substrate.merge.request`
66
+ * frames per pending write (the supervisor's merge callback may run
67
+ * once per attempt; the substrate retries on conflict). The bridge
68
+ * resolves each merge request synchronously through the pending
69
+ * entry's `merge` closure and emits `substrate.merge.response`. The
70
+ * pending entry stays alive until the terminal write response lands.
71
+ */
72
+ export declare function createChildSubstrateWriteBridge(opts: CreateChildSubstrateWriteBridgeOpts): ChildSubstrateWriteBridge;
@@ -0,0 +1,188 @@
1
+ // Child-side substrate-write bridge.
2
+ //
3
+ // The workflow-process child holds no write authority over the
4
+ // workflow-run repo's ref. The hub linearizes writes at the ref tip;
5
+ // a child that opened its own substrate would race the supervisor's
6
+ // inbox / processing / consumed writes at the same ref. The supervisor
7
+ // owns the write contract; the child proxies its
8
+ // `writeTreePreservingPrefix` calls over the control IPC into the
9
+ // supervisor's substrate.
10
+ //
11
+ // Lifecycle of one proxied write:
12
+ //
13
+ // 1. The child's proxy `RepoStore.writeTreePreservingPrefix` mints a
14
+ // `requestId`, registers a pending entry holding the caller's
15
+ // original merge closure plus resolve/reject hooks, and emits
16
+ // `substrate.write.request` upstream.
17
+ // 2. The supervisor receives the request and invokes its own wrapped
18
+ // `writeTreePreservingPrefix` against the supervisor's repoStore.
19
+ // Inside the supervisor's merge callback, the supervisor sends
20
+ // `substrate.merge.request` back to the child carrying the
21
+ // existing prefix entries.
22
+ // 3. The bridge resolves the existing entries through the pending
23
+ // entry's merge closure, encodes the resulting tree as
24
+ // base64-coded files, and replies with `substrate.merge.response`.
25
+ // 4. The supervisor's merge callback returns the decoded files; the
26
+ // substrate commits the prospective tree under the per-repo lock.
27
+ // 5. The supervisor sends `substrate.write.response` with the
28
+ // resulting `commitSha` (or the structured failure). The bridge
29
+ // resolves / rejects the pending awaiter; the child's substrate
30
+ // proxy returns the result to its caller.
31
+ //
32
+ // The bridge does NOT serialize the merge closure: the closure lives
33
+ // in the child's address space, so the merge invocation always runs
34
+ // here. The IPC carries the bytes the closure consumes and the bytes
35
+ // the closure produces, both base64-encoded.
36
+ import { getLogger } from "@intx/log";
37
+ import { base64Decode, base64Encode } from "@intx/types";
38
+ const logger = getLogger(["workflow-host", "child", "substrate-write-bridge"]);
39
+ /**
40
+ * Construct the child-side substrate-write bridge. Pending writes
41
+ * live in a map keyed by `requestId`; the bridge resolves the awaiter
42
+ * when the supervisor's matching `substrate.write.response` lands.
43
+ *
44
+ * The supervisor may emit zero or more `substrate.merge.request`
45
+ * frames per pending write (the supervisor's merge callback may run
46
+ * once per attempt; the substrate retries on conflict). The bridge
47
+ * resolves each merge request synchronously through the pending
48
+ * entry's `merge` closure and emits `substrate.merge.response`. The
49
+ * pending entry stays alive until the terminal write response lands.
50
+ */
51
+ export function createChildSubstrateWriteBridge(opts) {
52
+ const pending = new Map();
53
+ const allocate = opts.allocateRequestId ?? defaultRequestIdAllocator();
54
+ return {
55
+ get pendingCount() {
56
+ return pending.size;
57
+ },
58
+ async submit(req) {
59
+ const requestId = allocate();
60
+ const resultPromise = new Promise((resolve, reject) => {
61
+ pending.set(requestId, { req, resolve, reject });
62
+ });
63
+ try {
64
+ await opts.upstreamSender.send({
65
+ type: "substrate.write.request",
66
+ data: {
67
+ requestId,
68
+ repoId: { kind: req.repoId.kind, id: req.repoId.id },
69
+ ref: req.ref,
70
+ preservePrefix: req.preservePrefix,
71
+ message: req.message,
72
+ },
73
+ });
74
+ }
75
+ catch (cause) {
76
+ pending.delete(requestId);
77
+ const message = cause instanceof Error ? cause.message : String(cause);
78
+ throw new Error(`workflow-child substrate write: upstream send failed for requestId ${requestId}: ${message}`, { cause });
79
+ }
80
+ return resultPromise;
81
+ },
82
+ handleMergeRequest(data) {
83
+ const entry = pending.get(data.requestId);
84
+ if (entry === undefined) {
85
+ logger.warn `substrate.merge.request landed with no pending entry; requestId=${data.requestId} dropped`;
86
+ // Reply with a structured failure so the supervisor's merge
87
+ // callback can short-circuit rather than wedge waiting on a
88
+ // response that will never come.
89
+ void opts.upstreamSender
90
+ .send({
91
+ type: "substrate.merge.response",
92
+ data: {
93
+ requestId: data.requestId,
94
+ result: {
95
+ ok: false,
96
+ reason: `workflow-child substrate write: no pending entry for requestId ${data.requestId}`,
97
+ },
98
+ },
99
+ })
100
+ .catch((cause) => {
101
+ const msg = cause instanceof Error ? cause.message : String(cause);
102
+ logger.error `substrate.merge.response upstream send (no-pending) failed: ${msg}`;
103
+ });
104
+ return;
105
+ }
106
+ void (async () => {
107
+ try {
108
+ const existing = decodeMergeRequest(data.existing);
109
+ const merged = await entry.req.merge(existing);
110
+ const files = encodeFiles(merged);
111
+ await opts.upstreamSender.send({
112
+ type: "substrate.merge.response",
113
+ data: {
114
+ requestId: data.requestId,
115
+ result: { ok: true, files },
116
+ },
117
+ });
118
+ }
119
+ catch (cause) {
120
+ const reason = cause instanceof Error ? cause.message : String(cause);
121
+ try {
122
+ await opts.upstreamSender.send({
123
+ type: "substrate.merge.response",
124
+ data: {
125
+ requestId: data.requestId,
126
+ result: { ok: false, reason },
127
+ },
128
+ });
129
+ }
130
+ catch (sendCause) {
131
+ const msg = sendCause instanceof Error
132
+ ? sendCause.message
133
+ : String(sendCause);
134
+ logger.error `substrate.merge.response upstream send (failure path) failed: ${msg}`;
135
+ }
136
+ }
137
+ })();
138
+ },
139
+ handleWriteResponse(data) {
140
+ const entry = pending.get(data.requestId);
141
+ if (entry === undefined) {
142
+ logger.warn `substrate.write.response landed with no pending entry; requestId=${data.requestId} dropped`;
143
+ return;
144
+ }
145
+ pending.delete(data.requestId);
146
+ if (data.result.ok) {
147
+ entry.resolve({ commitSha: data.result.commitSha });
148
+ return;
149
+ }
150
+ entry.reject(new Error(`workflow-child substrate write (requestId=${data.requestId}) rejected by supervisor: ${data.result.reason}`));
151
+ },
152
+ cancelAll(reason) {
153
+ for (const [requestId, entry] of pending) {
154
+ entry.reject(new Error(`workflow-child substrate write (requestId=${requestId}) cancelled: ${reason}`));
155
+ }
156
+ pending.clear();
157
+ },
158
+ };
159
+ }
160
+ function decodeMergeRequest(existing) {
161
+ const out = new Map();
162
+ for (const entry of existing) {
163
+ out.set(entry.path, base64ToBytes(entry.contentBase64));
164
+ }
165
+ return out;
166
+ }
167
+ function encodeFiles(files) {
168
+ const out = [];
169
+ for (const [path, content] of Object.entries(files)) {
170
+ const bytes = typeof content === "string" ? new TextEncoder().encode(content) : content;
171
+ out.push({ path, contentBase64: bytesToBase64(bytes) });
172
+ }
173
+ return out;
174
+ }
175
+ function bytesToBase64(bytes) {
176
+ return base64Encode(bytes);
177
+ }
178
+ function base64ToBytes(value) {
179
+ return base64Decode(value);
180
+ }
181
+ function defaultRequestIdAllocator() {
182
+ let counter = 0;
183
+ return () => {
184
+ counter += 1;
185
+ const rand = Math.random().toString(36).slice(2, 10);
186
+ return `sw-${String(counter)}-${rand}`;
187
+ };
188
+ }
@@ -0,0 +1,10 @@
1
+ import type { MessageTransport } from "@intx/types/runtime";
2
+ import type { ChildOutboundMailBridge } from "./outbound-mail-bridge.js";
3
+ /**
4
+ * Construct a `MessageTransport` whose outbound side routes through the
5
+ * supervisor (via `bridge`) and whose inbound side is inert. `address`
6
+ * is the agent's mail address; the supervisor signs the outbound mail as
7
+ * this address through the host transport, so it must be the address the
8
+ * host registered the agent's `CryptoProvider` against.
9
+ */
10
+ export declare function createSupervisorBackedTransport(bridge: ChildOutboundMailBridge, address: string): MessageTransport;
@@ -0,0 +1,113 @@
1
+ // Supervisor-backed `MessageTransport` for a unified-host step agent
2
+ // (OUTBOUND half of mailbox ownership, §3a).
3
+ //
4
+ // Under the unified host the supervisor is the sole mail owner: it holds
5
+ // the durable inbox and the host transport against which the agent's
6
+ // address is registered with its signing key. The step agent therefore
7
+ // does NOT subscribe its own transport for inbound mail (the supervisor
8
+ // delivers inputs via the step path) and does NOT hold a signing key to
9
+ // send outbound mail. Its mail tools are backed by this transport:
10
+ //
11
+ // - INBOUND is a no-op. `watch` returns a no-op unsubscribe and never
12
+ // fires; the supervisor delivers the agent's input as the step
13
+ // input, not through the agent's own mailbox. The IMAP read surface
14
+ // (`search`, `fetchFull`, `fetchHeaders`, ...) throws: the agent
15
+ // owns no mailbox in the unified host, so a read against one is a
16
+ // programming error, surfaced loudly rather than returning a
17
+ // silently-empty result that would hide the missing inbound surface.
18
+ // - OUTBOUND (`send` / `append`) routes through the supervisor over
19
+ // the control IPC via the outbound-mail bridge. The supervisor
20
+ // performs the actual signed send through the host transport, so the
21
+ // outbound mail carries the agent's signature with full parity to
22
+ // the in-process path. The agent never holds the key.
23
+ /**
24
+ * Construct a `MessageTransport` whose outbound side routes through the
25
+ * supervisor (via `bridge`) and whose inbound side is inert. `address`
26
+ * is the agent's mail address; the supervisor signs the outbound mail as
27
+ * this address through the host transport, so it must be the address the
28
+ * host registered the agent's `CryptoProvider` against.
29
+ */
30
+ export function createSupervisorBackedTransport(bridge, address) {
31
+ function inboundUnsupported(method) {
32
+ throw new Error(`supervisor-backed transport: ${method} is not supported for unified-host step agent ${address}; the supervisor owns the mailbox and delivers inbound mail as the step input`);
33
+ }
34
+ return {
35
+ async send(message, _signal) {
36
+ return bridge.submit(address, message);
37
+ },
38
+ async append(_mailbox, _message, _flags, _signal) {
39
+ // `append` writes into a mailbox the agent owns; in the unified
40
+ // host the agent owns none. The mail tools do not append (they
41
+ // `send`), so a reachable `append` is a programming error.
42
+ return inboundUnsupported("append");
43
+ },
44
+ async listMailboxes(_signal) {
45
+ return inboundUnsupported("listMailboxes");
46
+ },
47
+ async createMailbox(_name, _signal) {
48
+ return inboundUnsupported("createMailbox");
49
+ },
50
+ async deleteMailbox(_name, _signal) {
51
+ return inboundUnsupported("deleteMailbox");
52
+ },
53
+ async getMailboxStatus(_name, _signal) {
54
+ return inboundUnsupported("getMailboxStatus");
55
+ },
56
+ async search(_mailbox, _query, _signal) {
57
+ return inboundUnsupported("search");
58
+ },
59
+ async thread(_mailbox, _algorithm, _query, _signal) {
60
+ return inboundUnsupported("thread");
61
+ },
62
+ async fetchHeaders(_ref, _signal) {
63
+ return inboundUnsupported("fetchHeaders");
64
+ },
65
+ async fetchStructure(_ref, _signal) {
66
+ return inboundUnsupported("fetchStructure");
67
+ },
68
+ async fetchPart(_ref, _partPath, _signal) {
69
+ return inboundUnsupported("fetchPart");
70
+ },
71
+ async fetchFull(_ref, _signal) {
72
+ return inboundUnsupported("fetchFull");
73
+ },
74
+ async setFlags(_ref, _flags, _signal) {
75
+ return inboundUnsupported("setFlags");
76
+ },
77
+ async clearFlags(_ref, _flags, _signal) {
78
+ return inboundUnsupported("clearFlags");
79
+ },
80
+ async move(_ref, _toMailbox, _signal) {
81
+ return inboundUnsupported("move");
82
+ },
83
+ async copy(_ref, _toMailbox, _signal) {
84
+ return inboundUnsupported("copy");
85
+ },
86
+ async expunge(_mailbox, _signal) {
87
+ return inboundUnsupported("expunge");
88
+ },
89
+ watch(_mailbox, _callback) {
90
+ // Inbound delivery is a no-op: the supervisor delivers the agent's
91
+ // input as the step input, not through the agent's mailbox. The
92
+ // watch never fires; return a no-op unsubscribe so a mail tool that
93
+ // installs a watch (mail_wait) does not throw at install time but
94
+ // also never observes a spurious event.
95
+ return () => undefined;
96
+ },
97
+ async sync(_mailbox, _knownState, _signal) {
98
+ return inboundUnsupported("sync");
99
+ },
100
+ async createList(_address, _name, _signal) {
101
+ return inboundUnsupported("createList");
102
+ },
103
+ async listMembers(_address, _signal) {
104
+ return inboundUnsupported("listMembers");
105
+ },
106
+ async subscribe(_listAddress, _subscriberAddress, _signal) {
107
+ return inboundUnsupported("subscribe");
108
+ },
109
+ async unsubscribe(_listAddress, _subscriberAddress, _signal) {
110
+ return inboundUnsupported("unsubscribe");
111
+ },
112
+ };
113
+ }
@@ -0,0 +1,78 @@
1
+ import type { Agent } from "@intx/agent";
2
+ import type { InferenceEvent, InferenceSource } from "@intx/types/runtime";
3
+ /**
4
+ * Mutable per-entry event sink the warm agent's stream forwarder reads
5
+ * before forwarding each event. The step-invoker swaps `current` to the
6
+ * active step's `onEvent` before every `agent.send`, so events from the
7
+ * agent's single lifetime stream reach whichever run is in flight. A
8
+ * `null` `current` drops events (no run is driving the agent), which is
9
+ * the correct behaviour in the gap between sends.
10
+ */
11
+ export interface WarmEventSinkRef {
12
+ current: ((event: InferenceEvent) => void) | null;
13
+ }
14
+ /**
15
+ * Per-address warm-agent cache. Keyed by the step's stable identity (the
16
+ * single step's id), so a long-lived agent resolves to the same entry on
17
+ * every inbound message. The cache is single-writer from the run-loop's
18
+ * perspective: the step-invoker builds-or-reuses inside one step
19
+ * invocation, and the run-loop evicts at teardown.
20
+ */
21
+ export interface WarmAgentCache {
22
+ /**
23
+ * Return the warm agent cached for `key`, or `null` when none is
24
+ * built yet (the lazy first-message path). The caller builds the
25
+ * agent and calls `store` on a miss.
26
+ */
27
+ acquire(key: string): Agent | null;
28
+ /**
29
+ * Cache a freshly-built warm agent under `key`. The `eventSinkRef` is
30
+ * the mutable sink the agent's stream forwarder reads; `eventForward`
31
+ * is the forwarder loop's settle promise. Throws if an entry already
32
+ * exists for `key` -- a double-build is a step-invoker bug, not a
33
+ * silent overwrite that would leak the prior agent's LSP subprocess.
34
+ */
35
+ store(key: string, agent: Agent, eventSinkRef: WarmEventSinkRef, eventForward: Promise<void>): void;
36
+ /**
37
+ * Point the warm agent's stream forwarder at the active step's event
38
+ * sink before its `agent.send`. Throws when no entry exists for
39
+ * `key` -- the step-invoker must `store` before it rewrites the sink.
40
+ */
41
+ setEventSink(key: string, onEvent: (event: InferenceEvent) => void): void;
42
+ /**
43
+ * Clear the active event sink for `key` after a step's `agent.send`
44
+ * settles, so a stray event between messages is dropped rather than
45
+ * delivered to a torn-down per-run channel. A missing entry is a
46
+ * no-op: the agent may already have been evicted.
47
+ */
48
+ clearEventSink(key: string): void;
49
+ /**
50
+ * Apply a rotated inference-source list to every retained warm agent in
51
+ * place, via `Agent.setSources`. A single-step warm cache holds 0 or 1
52
+ * entry, so this rotates the one built agent -- or is a no-op when none
53
+ * is built yet (the pre-first-build window). The swap mutates the
54
+ * agent's shared active-source object in place and takes effect on the
55
+ * reactor's next inference call; the single-threaded control loop means
56
+ * there is no torn read against a concurrent send. `setSources` validates
57
+ * the list and throws on an invalid source (or a closed agent, if a
58
+ * rotation races eviction), so a bad rotation surfaces rather than being
59
+ * swallowed.
60
+ */
61
+ applySources(sources: InferenceSource[], defaultSource: string): void;
62
+ /**
63
+ * Tear down every cached warm agent: run the wrapped `agent.close()`
64
+ * (disposing plugins and killing the LSP subprocess) and drain the
65
+ * stream forwarder. Idempotent -- a second call after the cache is
66
+ * empty is a no-op, so the run-loop can evict on both the shutdown
67
+ * frame and the exit-path `finally` without double-closing. Resolves
68
+ * once every agent is closed and every forwarder has drained, so no
69
+ * LSP subprocess outlives the call.
70
+ */
71
+ evictAll(reason: string): Promise<void>;
72
+ }
73
+ /**
74
+ * Construct an empty warm-agent cache. The run-loop builds one per
75
+ * spawn when the deployment is a warm candidate and threads it into the
76
+ * step-invoker; multi-step deployments construct none.
77
+ */
78
+ export declare function createWarmAgentCache(): WarmAgentCache;
@@ -0,0 +1,112 @@
1
+ // Warm-agent cache for the workflow-process child (design §3b).
2
+ //
3
+ // A long-lived single-step agent is built once -- tools materialized,
4
+ // plugins instantiated, the LSP subprocess spawned -- and reused across
5
+ // every inbound message. Re-materializing tools and re-spawning the LSP
6
+ // per message is the instantiate-send-teardown cost the warm cache
7
+ // removes; keeping the agent alive across messages is also what
8
+ // preserves in-memory conversation continuity (durability across child
9
+ // respawns lands in §3c, a later sub-step).
10
+ //
11
+ // Ownership and lifetime. The cache lives in the child's address space,
12
+ // owned by the run-loop (`run-child.ts`), NOT the supervisor. The
13
+ // step-invoker consults it on every step invocation: a cache hit reuses
14
+ // the warm agent, a miss builds and stores one lazily. The cached agent
15
+ // is torn down -- the wrapped `agent.close()` runs, disposing plugins
16
+ // and killing the LSP subprocess -- only at the run-loop's eviction
17
+ // points (child shutdown, deployment undeploy, recycle, post-drain
18
+ // teardown), never between messages. On recycle the child process dies,
19
+ // killing the LSP grandchild regardless; the respawned child starts with
20
+ // an empty cache and re-warms lazily.
21
+ //
22
+ // Per-message event sink. The agent's `stream()` is consumed once, for
23
+ // the agent's whole life, by a single forwarder owned by the entry. The
24
+ // per-step `onEvent` sink the runtime threads in differs per message
25
+ // (it carries the run id in its error-log path), so the forwarder routes
26
+ // through a mutable reference the step-invoker rewrites before each
27
+ // `agent.send`. The forwarder loop ends only when the agent closes at an
28
+ // eviction point.
29
+ //
30
+ // Warm-keep is gated explicitly: the cache is constructed only when the
31
+ // deploy projection marks the deployment a warm candidate (the
32
+ // single-step launched agent). Multi-step deployments pass no cache and
33
+ // keep instantiate-send-teardown per step. The decision is never a
34
+ // silent default -- a multi-step agent is never warm-kept.
35
+ import { getLogger } from "@intx/log";
36
+ const logger = getLogger(["workflow-host", "child", "warm-agent-cache"]);
37
+ /**
38
+ * Construct an empty warm-agent cache. The run-loop builds one per
39
+ * spawn when the deployment is a warm candidate and threads it into the
40
+ * step-invoker; multi-step deployments construct none.
41
+ */
42
+ export function createWarmAgentCache() {
43
+ const entries = new Map();
44
+ function acquire(key) {
45
+ const entry = entries.get(key);
46
+ return entry === undefined ? null : entry.agent;
47
+ }
48
+ function store(key, agent, eventSinkRef, eventForward) {
49
+ if (entries.has(key)) {
50
+ throw new Error(`warm-agent cache: an entry already exists for ${key}; the step-invoker must reuse the cached agent rather than rebuild it`);
51
+ }
52
+ entries.set(key, { agent, eventSinkRef, eventForward });
53
+ }
54
+ function setEventSink(key, onEvent) {
55
+ const entry = entries.get(key);
56
+ if (entry === undefined) {
57
+ throw new Error(`warm-agent cache: setEventSink for ${key} with no cached entry; the step-invoker must store the warm agent before wiring its per-message event sink`);
58
+ }
59
+ entry.eventSinkRef.current = onEvent;
60
+ }
61
+ function clearEventSink(key) {
62
+ const entry = entries.get(key);
63
+ if (entry === undefined)
64
+ return;
65
+ entry.eventSinkRef.current = null;
66
+ }
67
+ function applySources(sources, defaultSource) {
68
+ for (const entry of entries.values()) {
69
+ entry.agent.setSources(sources, defaultSource);
70
+ }
71
+ }
72
+ async function evictAll(reason) {
73
+ if (entries.size === 0)
74
+ return;
75
+ const toEvict = [...entries.values()];
76
+ entries.clear();
77
+ for (const entry of toEvict) {
78
+ // Clear the sink first so any event emitted during the agent's
79
+ // shutdown window is dropped rather than delivered to a per-run
80
+ // channel the run-loop is tearing down.
81
+ entry.eventSinkRef.current = null;
82
+ try {
83
+ // The wrapped close (see `createToolBearingAgentFactory`) runs
84
+ // the agent's own close and then the plugin + tool-bundle
85
+ // disposers, killing the LSP subprocess. A close failure must
86
+ // surface, not be swallowed -- a leaked LSP subprocess is
87
+ // exactly the failure warm-keep risks -- so it propagates after
88
+ // we have drained what we can.
89
+ await entry.agent.close();
90
+ }
91
+ catch (cause) {
92
+ const message = cause instanceof Error ? cause.message : String(cause);
93
+ logger.error `warm-agent eviction (${reason}): agent.close failed: ${message}`;
94
+ throw cause instanceof Error ? cause : new Error(message);
95
+ }
96
+ finally {
97
+ // `agent.close()` terminates the stream iterator, so the
98
+ // forwarder loop has ended (or is about to). Await it so no
99
+ // forwarder outlives the eviction.
100
+ await entry.eventForward;
101
+ }
102
+ }
103
+ }
104
+ return {
105
+ acquire,
106
+ store,
107
+ setEventSink,
108
+ clearEventSink,
109
+ applySources,
110
+ evictAll,
111
+ };
112
+ }