@zachwill/pi-orchestrate 0.8.0 → 0.9.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/extension/host.ts CHANGED
@@ -1,27 +1,89 @@
1
- import { DeliveryCoordinator } from "./delivery.js";
1
+ import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect";
2
2
  import {
3
- createOrchestratorRuntime,
4
- type OrchestratorRuntime,
3
+ Delivery,
4
+ deliveryLayer,
5
+ type DeliveryService,
6
+ } from "./delivery.js";
7
+ import {
8
+ Orchestration,
9
+ orchestrationLayer,
10
+ type AbortTarget,
11
+ type AcceptedRun,
12
+ type CompletedRun,
13
+ type OrchestrationContext,
14
+ type OrchestrationService,
15
+ SHUTDOWN_CLEANUP_GRACE_MS,
16
+ type RuntimeSnapshot,
17
+ type SettlementListener,
18
+ type UnsubscribeSettlement,
5
19
  } from "./runtime.js";
6
- import { createWorkerSessionFactory } from "./worker-session.js";
20
+ import { createChildSessionsLayer } from "./worker-session.js";
21
+ import type { OrchestrateTaskInput, RunMode } from "./domain.js";
7
22
 
8
23
  const PROCESS_HOST_KEY = Symbol.for("@zachwill/pi-orchestrate/process-host/v3");
9
24
 
25
+ type RunResult<M extends RunMode> = M extends "async"
26
+ ? AcceptedRun
27
+ : CompletedRun;
28
+
29
+ export interface OrchestratorRuntime {
30
+ orchestrate<M extends RunMode>(
31
+ context: OrchestrationContext,
32
+ task: OrchestrateTaskInput,
33
+ mode: M,
34
+ signal?: AbortSignal,
35
+ onSettlement?: SettlementListener,
36
+ ): Promise<RunResult<M>>;
37
+ sendInteractive<M extends RunMode>(
38
+ context: OrchestrationContext,
39
+ workerId: string,
40
+ instructions: string,
41
+ mode: M,
42
+ signal?: AbortSignal,
43
+ onSettlement?: SettlementListener,
44
+ ): Promise<RunResult<M>>;
45
+ abort(ownerSessionId: string, target: AbortTarget): Promise<void>;
46
+ closeInteractive(ownerSessionId: string, workerId: string): Promise<void>;
47
+ snapshot(ownerSessionId: string): Promise<RuntimeSnapshot>;
48
+ subscribeSettlement(listener: SettlementListener): UnsubscribeSettlement;
49
+ subscribeState(
50
+ ownerSessionId: string,
51
+ listener: (snapshot: RuntimeSnapshot) => void,
52
+ ): () => void;
53
+ shutdown(): Promise<void>;
54
+ }
55
+
10
56
  export interface ProcessHost {
11
57
  readonly runtime: OrchestratorRuntime;
12
- readonly delivery: DeliveryCoordinator;
58
+ readonly delivery: DeliveryService;
13
59
  }
14
60
 
15
61
  export interface ProcessHostAttachment {
16
62
  readonly host: ProcessHost;
17
63
  }
18
64
 
65
+ export interface ProcessHostDestructionOptions {
66
+ /** Process-boundary deadline capability; production uses the orchestration cleanup grace. */
67
+ readonly awaitShutdown?: (
68
+ shutdown: Promise<void>,
69
+ graceMs: number,
70
+ ) => Promise<void>;
71
+ /** Process-boundary deadline capability; production uses the orchestration cleanup grace. */
72
+ readonly awaitRootDisposal?: (
73
+ disposal: Promise<void>,
74
+ graceMs: number,
75
+ ) => Promise<void>;
76
+ }
77
+
19
78
  interface AttachmentAwareProcessHost extends ProcessHost {
20
79
  attachments?: Set<ProcessHostAttachment>;
21
80
  }
22
81
 
82
+ type ProcessHostLifecycle = "destroying" | "destroyed";
83
+
23
84
  interface OwnedProcessHost extends AttachmentAwareProcessHost {
24
- readonly unsubscribeSettlement?: () => void;
85
+ readonly effectRuntime?: ManagedRuntime.ManagedRuntime<Orchestration | Delivery, never>;
86
+ lifecycle?: ProcessHostLifecycle;
25
87
  destroyPromise?: Promise<void>;
26
88
  }
27
89
 
@@ -37,32 +99,187 @@ export function getProcessHost(): ProcessHost | undefined {
37
99
  return processGlobal()[PROCESS_HOST_KEY];
38
100
  }
39
101
 
102
+ /** Pi-facing adapter. Every Promise operation executes one complete Orchestration Effect. */
103
+ export class ProcessHostRuntimeAdapter<R = never> implements OrchestratorRuntime {
104
+ constructor(
105
+ private readonly effectRuntime: ManagedRuntime.ManagedRuntime<Orchestration | R, never>,
106
+ private readonly orchestration: OrchestrationService,
107
+ ) {}
108
+
109
+ orchestrate(
110
+ context: OrchestrationContext,
111
+ task: OrchestrateTaskInput,
112
+ mode: "async",
113
+ signal?: AbortSignal,
114
+ onSettlement?: SettlementListener,
115
+ ): Promise<AcceptedRun>;
116
+ orchestrate(
117
+ context: OrchestrationContext,
118
+ task: OrchestrateTaskInput,
119
+ mode: "inline",
120
+ signal?: AbortSignal,
121
+ onSettlement?: SettlementListener,
122
+ ): Promise<CompletedRun>;
123
+ orchestrate(
124
+ context: OrchestrationContext,
125
+ task: OrchestrateTaskInput,
126
+ mode: RunMode,
127
+ signal?: AbortSignal,
128
+ onSettlement?: SettlementListener,
129
+ ): Promise<AcceptedRun | CompletedRun>;
130
+ orchestrate(
131
+ context: OrchestrationContext,
132
+ task: OrchestrateTaskInput,
133
+ mode: RunMode,
134
+ signal?: AbortSignal,
135
+ onSettlement?: SettlementListener,
136
+ ): Promise<AcceptedRun | CompletedRun> {
137
+ return this.run(
138
+ this.orchestration.orchestrate(context, task, mode, onSettlement),
139
+ signal,
140
+ );
141
+ }
142
+
143
+ sendInteractive(
144
+ context: OrchestrationContext,
145
+ workerId: string,
146
+ instructions: string,
147
+ mode: "async",
148
+ signal?: AbortSignal,
149
+ onSettlement?: SettlementListener,
150
+ ): Promise<AcceptedRun>;
151
+ sendInteractive(
152
+ context: OrchestrationContext,
153
+ workerId: string,
154
+ instructions: string,
155
+ mode: "inline",
156
+ signal?: AbortSignal,
157
+ onSettlement?: SettlementListener,
158
+ ): Promise<CompletedRun>;
159
+ sendInteractive(
160
+ context: OrchestrationContext,
161
+ workerId: string,
162
+ instructions: string,
163
+ mode: RunMode,
164
+ signal?: AbortSignal,
165
+ onSettlement?: SettlementListener,
166
+ ): Promise<AcceptedRun | CompletedRun>;
167
+ sendInteractive(
168
+ context: OrchestrationContext,
169
+ workerId: string,
170
+ instructions: string,
171
+ mode: RunMode,
172
+ signal?: AbortSignal,
173
+ onSettlement?: SettlementListener,
174
+ ): Promise<AcceptedRun | CompletedRun> {
175
+ return this.run(
176
+ this.orchestration.sendInteractive(
177
+ context,
178
+ workerId,
179
+ instructions,
180
+ mode,
181
+ onSettlement,
182
+ ),
183
+ signal,
184
+ );
185
+ }
186
+
187
+ abort(ownerSessionId: string, target: AbortTarget): Promise<void> {
188
+ return this.run(this.orchestration.abort(ownerSessionId, target));
189
+ }
190
+
191
+ closeInteractive(ownerSessionId: string, workerId: string): Promise<void> {
192
+ return this.run(this.orchestration.closeInteractive(ownerSessionId, workerId));
193
+ }
194
+
195
+ snapshot(ownerSessionId: string): Promise<RuntimeSnapshot> {
196
+ // Snapshot is dependency-free and remains readable from a retained host reference
197
+ // after the process root has been disposed.
198
+ return Effect.runPromise(this.orchestration.snapshot(ownerSessionId));
199
+ }
200
+
201
+ subscribeSettlement(listener: SettlementListener): UnsubscribeSettlement {
202
+ return this.orchestration.subscribeSettlement(listener);
203
+ }
204
+
205
+ subscribeState(
206
+ ownerSessionId: string,
207
+ listener: (snapshot: RuntimeSnapshot) => void,
208
+ ): () => void {
209
+ return this.orchestration.subscribeState(ownerSessionId, listener);
210
+ }
211
+
212
+ shutdown(): Promise<void> {
213
+ return this.run(this.orchestration.shutdown());
214
+ }
215
+
216
+ private async run<A, E>(
217
+ effect: Effect.Effect<A, E>,
218
+ signal?: AbortSignal,
219
+ ): Promise<A> {
220
+ if (signal?.aborted) throw abortSignalReason(signal);
221
+ if (!signal) return this.effectRuntime.runPromise(effect);
222
+
223
+ const signalInterruption = {};
224
+ const exit = await this.effectRuntime.runPromiseExit(
225
+ Effect.raceFirst(effect, abortSignalEffect(signal, signalInterruption)),
226
+ );
227
+ if (Exit.isSuccess(exit)) return exit.value;
228
+
229
+ const error = Cause.squash(exit.cause);
230
+ if (error === signalInterruption) throw abortSignalReason(signal);
231
+ throw error;
232
+ }
233
+ }
234
+
235
+ export function createProcessHostRuntimeAdapter<R>(
236
+ effectRuntime: ManagedRuntime.ManagedRuntime<Orchestration | R, never>,
237
+ ): OrchestratorRuntime {
238
+ // Orchestration acquisition is synchronous; subscriptions must remain reentrant.
239
+ const orchestration = effectRuntime.runSync(Orchestration);
240
+ return new ProcessHostRuntimeAdapter(effectRuntime, orchestration);
241
+ }
242
+
243
+ export function createProcessApplicationLayer(): Layer.Layer<Orchestration | Delivery> {
244
+ const orchestration = orchestrationLayer().pipe(
245
+ Layer.provide(createChildSessionsLayer()),
246
+ );
247
+ return deliveryLayer.pipe(Layer.provideMerge(orchestration));
248
+ }
249
+
40
250
  export function createProcessHost(): ProcessHost {
41
251
  const global = processGlobal();
42
252
  const existing = global[PROCESS_HOST_KEY];
253
+ if (existing?.lifecycle === "destroying") {
254
+ throw new Error("Cannot create a process host while the current host is being destroyed");
255
+ }
256
+ if (existing?.lifecycle === "destroyed") {
257
+ throw new Error("Cannot create a process host after the current host was destroyed");
258
+ }
43
259
  if (existing) return existing;
44
260
 
45
- const runtime = createOrchestratorRuntime({
46
- workerSessionFactory: createWorkerSessionFactory(),
47
- });
48
- const delivery = new DeliveryCoordinator();
261
+ const effectRuntime = ManagedRuntime.make(createProcessApplicationLayer());
262
+ const runtime = createProcessHostRuntimeAdapter(effectRuntime);
263
+ const delivery = effectRuntime.runSync(Delivery);
49
264
  const host: OwnedProcessHost = {
50
265
  runtime,
51
266
  delivery,
267
+ effectRuntime,
52
268
  attachments: new Set(),
53
- unsubscribeSettlement: runtime.subscribeSettlement((settlement) => {
54
- delivery.accept(settlement);
55
- }),
56
269
  };
57
270
  global[PROCESS_HOST_KEY] = host;
58
271
  return host;
59
272
  }
60
273
 
61
274
  export function attachProcessHost(host: ProcessHost): ProcessHostAttachment {
275
+ const ownedHost = host as OwnedProcessHost;
276
+ if (ownedHost.lifecycle === "destroying" || ownedHost.lifecycle === "destroyed") {
277
+ throw new Error(`Cannot attach to a ${ownedHost.lifecycle} process host`);
278
+ }
279
+
62
280
  const attachment: ProcessHostAttachment = { host };
63
- const attachmentAwareHost = host as AttachmentAwareProcessHost;
64
- attachmentAwareHost.attachments ??= new Set();
65
- attachmentAwareHost.attachments.add(attachment);
281
+ ownedHost.attachments ??= new Set();
282
+ ownedHost.attachments.add(attachment);
66
283
  return attachment;
67
284
  }
68
285
 
@@ -77,27 +294,64 @@ export function detachProcessHost(
77
294
  return attachments.size === 0;
78
295
  }
79
296
 
80
- export async function destroyProcessHost(host: ProcessHost): Promise<void> {
297
+ export function destroyProcessHost(
298
+ host: ProcessHost,
299
+ options: ProcessHostDestructionOptions = {},
300
+ ): Promise<void> {
81
301
  const ownedHost = host as OwnedProcessHost;
82
- if ((ownedHost.attachments?.size ?? 0) > 0) return;
83
- if (ownedHost.destroyPromise) {
84
- await ownedHost.destroyPromise;
85
- return;
86
- }
302
+ if (ownedHost.destroyPromise) return ownedHost.destroyPromise;
303
+ if ((ownedHost.attachments?.size ?? 0) > 0) return Promise.resolve();
304
+
305
+ ownedHost.lifecycle = "destroying";
306
+ let resolveDestruction!: () => void;
307
+ let rejectDestruction!: (error: unknown) => void;
308
+ const destroyPromise = new Promise<void>((resolve, reject) => {
309
+ resolveDestruction = resolve;
310
+ rejectDestruction = reject;
311
+ });
312
+ // Publish the exact shared result before shutdown can synchronously reenter destruction.
313
+ ownedHost.destroyPromise = destroyPromise;
87
314
 
88
- ownedHost.destroyPromise = (async () => {
315
+ let shutdown: Promise<void>;
316
+ try {
317
+ // shutdown() closes Orchestration admission before returning its bounded teardown Promise.
318
+ shutdown = ownedHost.runtime.shutdown();
319
+ } catch (error) {
320
+ shutdown = Promise.reject(error);
321
+ }
322
+ const awaitShutdown =
323
+ options.awaitShutdown ?? awaitPromiseWithinCleanupGrace;
324
+ const awaitRootDisposal =
325
+ options.awaitRootDisposal ?? awaitPromiseWithinCleanupGrace;
326
+ const teardown = (async () => {
89
327
  try {
90
- await ownedHost.runtime.shutdown();
328
+ // A timed-out shutdown keeps physical best-effort finalizers alive. Observe
329
+ // either late outcome while proceeding to the separately bounded root disposal.
330
+ void shutdown.catch(() => {});
331
+ await awaitShutdown(shutdown, SHUTDOWN_CLEANUP_GRACE_MS);
91
332
  } finally {
92
- ownedHost.delivery.clear();
93
- ownedHost.unsubscribeSettlement?.();
94
- const global = processGlobal();
95
- if (global[PROCESS_HOST_KEY] === ownedHost) {
96
- delete global[PROCESS_HOST_KEY];
333
+ try {
334
+ const disposal = ownedHost.effectRuntime?.dispose();
335
+ if (disposal) {
336
+ // ManagedRuntime disposal keeps ownership of uninterruptible finalizers after timeout.
337
+ // Observe its eventual rejection while host destruction proceeds best-effort.
338
+ void disposal.catch(() => {});
339
+ await awaitRootDisposal(disposal, SHUTDOWN_CLEANUP_GRACE_MS);
340
+ }
341
+ } finally {
342
+ // After either deadline the host is logically destroyed and detached even
343
+ // though abandoned physical finalizers may still settle. A replacement may
344
+ // overlap only that cleanup; identity guards keep stale completion harmless.
345
+ ownedHost.lifecycle = "destroyed";
346
+ const global = processGlobal();
347
+ if (global[PROCESS_HOST_KEY] === ownedHost) {
348
+ delete global[PROCESS_HOST_KEY];
349
+ }
97
350
  }
98
351
  }
99
352
  })();
100
- await ownedHost.destroyPromise;
353
+ void teardown.then(resolveDestruction, rejectDestruction);
354
+ return destroyPromise;
101
355
  }
102
356
 
103
357
  export async function quitProcessHost(): Promise<void> {
@@ -105,3 +359,35 @@ export async function quitProcessHost(): Promise<void> {
105
359
  if (!host) return;
106
360
  await destroyProcessHost(host);
107
361
  }
362
+
363
+ function awaitPromiseWithinCleanupGrace(
364
+ promise: Promise<void>,
365
+ graceMs: number,
366
+ ): Promise<void> {
367
+ return Effect.runPromise(
368
+ Effect.promise(() => promise).pipe(
369
+ Effect.timeoutOption(graceMs),
370
+ Effect.asVoid,
371
+ ),
372
+ );
373
+ }
374
+
375
+ function abortSignalEffect(
376
+ signal: AbortSignal,
377
+ interruption: object,
378
+ ): Effect.Effect<never, object> {
379
+ return Effect.callback((resume) => {
380
+ const onAbort = () => resume(Effect.fail(interruption));
381
+ signal.addEventListener("abort", onAbort, { once: true });
382
+ if (signal.aborted) {
383
+ signal.removeEventListener("abort", onAbort);
384
+ onAbort();
385
+ }
386
+ return Effect.sync(() => signal.removeEventListener("abort", onAbort));
387
+ });
388
+ }
389
+
390
+ function abortSignalReason(signal: AbortSignal): unknown {
391
+ if ("reason" in signal) return signal.reason;
392
+ return new DOMException("This operation was aborted", "AbortError");
393
+ }
@@ -52,42 +52,40 @@ export function createOrchestrationExtension(
52
52
  dependencies: OrchestrationExtensionDependencies = {},
53
53
  ): ExtensionFactory {
54
54
  return (pi) => {
55
- const host = dependencies.getHost?.() ?? createProcessHost();
56
55
  const discoverCatalog = dependencies.discoverCatalog ?? discoverWorkerCatalog;
57
- const statusController =
58
- dependencies.createStatusController?.(host.runtime) ??
59
- createStatusController(host.runtime);
60
56
  const dispatchDecisions = new Map<string, StoredDispatchDecision>();
57
+ let host: ProcessHost | undefined;
61
58
  let hostAttachment: ProcessHostAttachment | undefined;
59
+ let statusController: StatusController | undefined;
62
60
  let activeBinding: OwnerBinding | undefined;
63
61
  let cachedCatalog: WorkerCatalog | undefined;
64
62
 
65
- const catalogFor = (ctx: ExtensionContext): WorkerCatalog => {
66
- if (cachedCatalog) return cachedCatalog;
67
- cachedCatalog = discoverCatalog({
63
+ const discoverCatalogFor = (ctx: ExtensionContext): WorkerCatalog =>
64
+ discoverCatalog({
68
65
  cwd: ctx.cwd,
69
66
  projectTrusted: ctx.isProjectTrusted(),
70
67
  });
68
+
69
+ const catalogFor = (ctx: ExtensionContext): WorkerCatalog => {
70
+ if (cachedCatalog) return cachedCatalog;
71
+ cachedCatalog = discoverCatalogFor(ctx);
71
72
  return cachedCatalog;
72
73
  };
73
74
 
74
- registerOrchestrationTools(pi, {
75
- runtime: host.runtime,
76
- getCatalog: catalogFor,
77
- getDispatchDecision: (toolCallId) =>
78
- dispatchDecisions.get(toolCallId) ?? { mode: "inline" },
79
- });
80
75
  registerOrchestrationPresentation(pi);
81
76
 
82
77
  pi.on("session_start", (_event, ctx) => {
78
+ host ??= dependencies.getHost?.() ?? createProcessHost();
79
+ statusController ??=
80
+ dependencies.createStatusController?.(host.runtime) ??
81
+ createStatusController(host.runtime);
82
+ registerOrchestrationTools(pi, {
83
+ runtime: host.runtime,
84
+ getCatalog: catalogFor,
85
+ getDispatchDecision: (toolCallId) =>
86
+ dispatchDecisions.get(toolCallId) ?? { mode: "inline" },
87
+ });
83
88
  hostAttachment ??= attachProcessHost(host);
84
- if (activeBinding) {
85
- host.delivery.unbind(
86
- activeBinding.ownerSessionId,
87
- activeBinding.generation,
88
- );
89
- statusController.unbind(activeBinding.ownerSessionId);
90
- }
91
89
 
92
90
  dispatchDecisions.clear();
93
91
  cachedCatalog = undefined;
@@ -106,10 +104,7 @@ export function createOrchestrationExtension(
106
104
  });
107
105
 
108
106
  pi.on("before_agent_start", (event, ctx) => {
109
- cachedCatalog = discoverCatalog({
110
- cwd: ctx.cwd,
111
- projectTrusted: ctx.isProjectTrusted(),
112
- });
107
+ cachedCatalog = discoverCatalogFor(ctx);
113
108
  return {
114
109
  systemPrompt: appendOrchestratorContract(
115
110
  event.systemPrompt,
@@ -125,6 +120,8 @@ export function createOrchestrationExtension(
125
120
  );
126
121
  const ownerSessionId = activeBinding?.ownerSessionId;
127
122
  if (!ownerSessionId) return;
123
+ // Sole dispatches and homogeneous orchestrate waves detach; mixed tools stay with the
124
+ // current parent turn, while a wave shares one boundary for one later synthesis turn.
128
125
  const isOrchestrateGroup =
129
126
  toolCalls.length > 1 &&
130
127
  toolCalls.every((toolCall) => toolCall.name === "orchestrate");
@@ -151,7 +148,7 @@ export function createOrchestrationExtension(
151
148
  const decision = dispatchDecisions.get(event.toolCallId);
152
149
  dispatchDecisions.delete(event.toolCallId);
153
150
  if (!event.isError || !decision?.synthesisGroup) return;
154
- host.delivery.skipSynthesisGroupMember(
151
+ host?.delivery.skipSynthesisGroupMember(
155
152
  decision.ownerSessionId,
156
153
  decision.synthesisGroup.id,
157
154
  decision.synthesisGroup.size,
@@ -161,7 +158,7 @@ export function createOrchestrationExtension(
161
158
  pi.on("agent_start", () => {
162
159
  const binding = activeBinding;
163
160
  if (!binding) return;
164
- host.delivery.markAgentStarted(
161
+ host?.delivery.markAgentStarted(
165
162
  binding.ownerSessionId,
166
163
  binding.generation,
167
164
  );
@@ -170,7 +167,7 @@ export function createOrchestrationExtension(
170
167
  pi.on("agent_settled", () => {
171
168
  const binding = activeBinding;
172
169
  if (!binding) return;
173
- host.delivery.markAgentSettled(
170
+ host?.delivery.markAgentSettled(
174
171
  binding.ownerSessionId,
175
172
  binding.generation,
176
173
  );
@@ -182,17 +179,17 @@ export function createOrchestrationExtension(
182
179
  cachedCatalog = undefined;
183
180
  dispatchDecisions.clear();
184
181
 
185
- if (binding) {
182
+ if (binding && host) {
186
183
  host.delivery.unbind(binding.ownerSessionId, binding.generation);
187
184
  }
188
- statusController.dispose();
185
+ statusController?.dispose();
189
186
 
190
187
  const attachment = hostAttachment;
191
188
  hostAttachment = undefined;
192
- const wasLastAttachment = attachment
189
+ const wasLastAttachment = host && attachment
193
190
  ? detachProcessHost(host, attachment)
194
191
  : false;
195
- if (event.reason === "quit" && wasLastAttachment) {
192
+ if (event.reason === "quit" && wasLastAttachment && host) {
196
193
  await (dependencies.destroyHost ?? destroyProcessHost)(host);
197
194
  }
198
195
  });