@zachwill/pi-orchestrate 0.2.1 → 0.4.0

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.
@@ -35,10 +35,17 @@ interface BoundParent {
35
35
  agentRunning: boolean;
36
36
  }
37
37
 
38
+ interface SynthesisGroupState {
39
+ expected: number;
40
+ readonly acceptedEventIds: string[];
41
+ }
42
+
38
43
  export class DeliveryCoordinator {
39
44
  private readonly boundParents = new Map<string, BoundParent>();
40
45
  private readonly pendingSettlements: WorkerSettlement[] = [];
41
46
  private readonly flushingOwners = new Set<string>();
47
+ private readonly synthesisGroups = new Map<string, SynthesisGroupState>();
48
+ private readonly finalSynthesisGroupEvents = new Set<string>();
42
49
  private highestAcceptedSequence = 0;
43
50
 
44
51
  bind(binding: ParentBinding): void {
@@ -75,12 +82,28 @@ export class DeliveryCoordinator {
75
82
 
76
83
  this.highestAcceptedSequence = settlement.sequence;
77
84
  this.pendingSettlements.push(settlement);
85
+ this.acceptSynthesisGroupSettlement(settlement);
78
86
 
79
87
  const parent = this.boundParents.get(settlement.ownerSessionId);
80
88
  if (parent) this.flush(settlement.ownerSessionId, parent.binding.generation);
81
89
  return true;
82
90
  }
83
91
 
92
+ skipSynthesisGroupMember(
93
+ ownerSessionId: string,
94
+ synthesisGroupId: string,
95
+ synthesisGroupSize: number,
96
+ ): void {
97
+ const key = synthesisGroupKey(ownerSessionId, synthesisGroupId);
98
+ const state = this.synthesisGroups.get(key) ?? {
99
+ expected: synthesisGroupSize,
100
+ acceptedEventIds: [],
101
+ };
102
+ state.expected = Math.max(0, state.expected - 1);
103
+ this.synthesisGroups.set(key, state);
104
+ this.refreshSynthesisGroupBoundary(key, state);
105
+ }
106
+
84
107
  pendingCount(ownerSessionId: string): number {
85
108
  return this.pendingSettlements.filter(
86
109
  (settlement) => settlement.ownerSessionId === ownerSessionId,
@@ -91,13 +114,11 @@ export class DeliveryCoordinator {
91
114
  this.boundParents.clear();
92
115
  this.pendingSettlements.length = 0;
93
116
  this.flushingOwners.clear();
117
+ this.synthesisGroups.clear();
118
+ this.finalSynthesisGroupEvents.clear();
94
119
  this.highestAcceptedSequence = 0;
95
120
  }
96
121
 
97
- close(): void {
98
- this.clear();
99
- }
100
-
101
122
  private matchesBinding(
102
123
  ownerSessionId: string,
103
124
  generation: ParentBindingGeneration,
@@ -127,7 +148,8 @@ export class DeliveryCoordinator {
127
148
  );
128
149
  let latestFinalIndex = -1;
129
150
  for (let index = 0; index < queued.length; index += 1) {
130
- if (queued[index]?.waveComplete) latestFinalIndex = index;
151
+ const settlement = queued[index];
152
+ if (settlement && this.isFinalBoundary(settlement)) latestFinalIndex = index;
131
153
  }
132
154
  const flushThrough = latestFinalIndex >= 0 ? latestFinalIndex : queued.length - 1;
133
155
  let flushBytesRemaining = MAX_DELIVERY_MARKDOWN_BYTES;
@@ -139,13 +161,9 @@ export class DeliveryCoordinator {
139
161
 
140
162
  const messagesRemaining = flushThrough - index + 1;
141
163
  const fairFlushBytes = Math.floor(flushBytesRemaining / messagesRemaining);
142
- const fairWaveBytes = Math.floor(
143
- MAX_DELIVERY_MARKDOWN_BYTES / Math.max(1, settlement.waveSize),
144
- );
145
164
  const byteLimit = Math.max(0, Math.min(
146
165
  MAX_WORKER_DELIVERY_MARKDOWN_BYTES,
147
166
  fairFlushBytes,
148
- fairWaveBytes,
149
167
  flushBytesRemaining,
150
168
  ));
151
169
  const triggerTurn = latestFinalIndex >= 0 && index === flushThrough;
@@ -165,6 +183,7 @@ export class DeliveryCoordinator {
165
183
  if (pendingIndex >= 0) this.pendingSettlements.splice(pendingIndex, 1);
166
184
 
167
185
  if (triggerTurn) {
186
+ this.finishSynthesisGroup(settlement);
168
187
  parent.agentRunning = true;
169
188
  return;
170
189
  }
@@ -178,17 +197,17 @@ export class DeliveryCoordinator {
178
197
  settlement: WorkerSettlement,
179
198
  byteLimit: number,
180
199
  ): WorkerDeliveryMessage {
181
- const heading = `## Worker result — ${settlement.worker}`;
200
+ const heading = `## Worker result — ${settlement.title} · ${settlement.worker}`;
182
201
  const metadata = [
183
202
  `Worker \`${settlement.workerId}\``,
184
- `wave \`${settlement.waveId}\``,
203
+ `run \`${settlement.runId}\``,
185
204
  `status \`${settlement.status}\``,
186
205
  ].join(" · ");
187
206
  const body = renderOutcome(settlement.outcome);
188
207
  const content = body.length > 0
189
- ? `${heading}\n\n### ${settlement.title}\n${metadata}\n\n${body}`
190
- : `${heading}\n\n### ${settlement.title}\n${metadata}`;
191
- const appendix = settlement.waveComplete
208
+ ? `${heading}\n\n${metadata}\n\n${body}`
209
+ : `${heading}\n\n${metadata}`;
210
+ const appendix = this.isFinalBoundary(settlement)
192
211
  ? `\n\n---\n\n${DELIVERY_PARENT_INSTRUCTIONS}`
193
212
  : "";
194
213
 
@@ -199,6 +218,45 @@ export class DeliveryCoordinator {
199
218
  details: settlement,
200
219
  });
201
220
  }
221
+
222
+ private acceptSynthesisGroupSettlement(settlement: WorkerSettlement): void {
223
+ if (!settlement.synthesisGroupId || !settlement.synthesisGroupSize) return;
224
+ const key = synthesisGroupKey(settlement.ownerSessionId, settlement.synthesisGroupId);
225
+ const state = this.synthesisGroups.get(key) ?? {
226
+ expected: settlement.synthesisGroupSize,
227
+ acceptedEventIds: [],
228
+ };
229
+ state.acceptedEventIds.push(settlement.eventId);
230
+ this.synthesisGroups.set(key, state);
231
+ this.refreshSynthesisGroupBoundary(key, state);
232
+ }
233
+
234
+ private refreshSynthesisGroupBoundary(key: string, state: SynthesisGroupState): void {
235
+ if (state.expected === 0) {
236
+ this.synthesisGroups.delete(key);
237
+ return;
238
+ }
239
+ if (state.acceptedEventIds.length !== state.expected) return;
240
+ const finalEventId = state.acceptedEventIds.at(-1);
241
+ if (finalEventId) this.finalSynthesisGroupEvents.add(finalEventId);
242
+ }
243
+
244
+ private isFinalBoundary(settlement: WorkerSettlement): boolean {
245
+ if (!settlement.synthesisGroupId) return true;
246
+ return this.finalSynthesisGroupEvents.has(settlement.eventId);
247
+ }
248
+
249
+ private finishSynthesisGroup(settlement: WorkerSettlement): void {
250
+ if (!settlement.synthesisGroupId) return;
251
+ this.synthesisGroups.delete(
252
+ synthesisGroupKey(settlement.ownerSessionId, settlement.synthesisGroupId),
253
+ );
254
+ this.finalSynthesisGroupEvents.delete(settlement.eventId);
255
+ }
256
+ }
257
+
258
+ function synthesisGroupKey(ownerSessionId: string, synthesisGroupId: string): string {
259
+ return `${ownerSessionId}\u0000${synthesisGroupId}`;
202
260
  }
203
261
 
204
262
  function capMarkdown(content: string, appendix: string, byteLimit: number): string {
@@ -238,7 +296,5 @@ function renderOutcome(outcome: WorkerSettlement["outcome"]): string {
238
296
  const reason = outcome.message ? `Aborted: ${outcome.message}` : "Aborted";
239
297
  return outcome.assistantText ? `${reason}\n\n${outcome.assistantText}` : reason;
240
298
  }
241
- case "closed":
242
- return "Closed";
243
299
  }
244
300
  }
@@ -95,17 +95,17 @@ export interface OrchestrateTaskInput {
95
95
  }
96
96
 
97
97
  declare const workerIdBrand: unique symbol;
98
- declare const waveIdBrand: unique symbol;
98
+ declare const runIdBrand: unique symbol;
99
99
 
100
100
  export type WorkerId = string & { readonly [workerIdBrand]: "WorkerId" };
101
- export type WaveId = string & { readonly [waveIdBrand]: "WaveId" };
101
+ export type RunId = string & { readonly [runIdBrand]: "RunId" };
102
102
 
103
103
  export type WorkerIdFactory = () => WorkerId;
104
- export type WaveIdFactory = () => WaveId;
104
+ export type RunIdFactory = () => RunId;
105
105
 
106
106
  export interface OrchestrateIdFactories {
107
107
  readonly workerId: WorkerIdFactory;
108
- readonly waveId: WaveIdFactory;
108
+ readonly runId: RunIdFactory;
109
109
  }
110
110
 
111
111
  export function createRandomWorkerIdFactory(
@@ -114,10 +114,10 @@ export function createRandomWorkerIdFactory(
114
114
  return () => `worker-${randomId()}` as WorkerId;
115
115
  }
116
116
 
117
- export function createRandomWaveIdFactory(
117
+ export function createRandomRunIdFactory(
118
118
  randomId: () => string = defaultRandomId,
119
- ): WaveIdFactory {
120
- return () => `wave-${randomId()}` as WaveId;
119
+ ): RunIdFactory {
120
+ return () => `run-${randomId()}` as RunId;
121
121
  }
122
122
 
123
123
  export function createRandomIdFactories(
@@ -125,7 +125,7 @@ export function createRandomIdFactories(
125
125
  ): OrchestrateIdFactories {
126
126
  return {
127
127
  workerId: createRandomWorkerIdFactory(randomId),
128
- waveId: createRandomWaveIdFactory(randomId),
128
+ runId: createRandomRunIdFactory(randomId),
129
129
  };
130
130
  }
131
131
 
@@ -134,15 +134,15 @@ export function createSequentialWorkerIdFactory(startAt = 1): WorkerIdFactory {
134
134
  return () => `worker-${next++}` as WorkerId;
135
135
  }
136
136
 
137
- export function createSequentialWaveIdFactory(startAt = 1): WaveIdFactory {
137
+ export function createSequentialRunIdFactory(startAt = 1): RunIdFactory {
138
138
  let next = startAt;
139
- return () => `wave-${next++}` as WaveId;
139
+ return () => `run-${next++}` as RunId;
140
140
  }
141
141
 
142
142
  export function createSequentialIdFactories(startAt = 1): OrchestrateIdFactories {
143
143
  return {
144
144
  workerId: createSequentialWorkerIdFactory(startAt),
145
- waveId: createSequentialWaveIdFactory(startAt),
145
+ runId: createSequentialRunIdFactory(startAt),
146
146
  };
147
147
  }
148
148
 
@@ -205,7 +205,6 @@ export type WorkerOutcome =
205
205
  | WorkerFailedOutcome
206
206
  | WorkerAbortedOutcome
207
207
  | WorkerClosedOutcome;
208
- export type TerminalWorkerOutcome = Exclude<WorkerOutcome, WorkerReadyOutcome>;
209
208
  export type WorkerStatus =
210
209
  | "starting"
211
210
  | "running"
@@ -219,13 +218,12 @@ export type TerminalWorkerStatus = Extract<
219
218
  WorkerStatus,
220
219
  "completed" | "failed" | "aborted" | "closed"
221
220
  >;
222
- export type WaveCompleteWorkerStatus = TerminalWorkerStatus | "ready";
223
221
 
224
222
  export interface WorkerRecord {
225
223
  readonly id: WorkerId;
226
224
  readonly worker: string;
227
225
  readonly ownerSessionId: string;
228
- readonly waveId: WaveId;
226
+ readonly runId: RunId;
229
227
  readonly title: string;
230
228
  readonly instructions: string;
231
229
  readonly lifecycle: WorkerLifecycle;
@@ -239,16 +237,18 @@ export interface WorkerRecord {
239
237
  readonly sessionFile?: string;
240
238
  }
241
239
 
242
- export type WaveMode = "async" | "inline";
243
- export type WaveState = "running" | "complete";
240
+ export type RunMode = "async" | "inline";
241
+ export type RunState = "running" | "complete";
244
242
 
245
- export interface WaveRecord {
246
- readonly id: WaveId;
243
+ export interface RunRecord {
244
+ readonly id: RunId;
247
245
  readonly ownerSessionId: string;
248
- readonly workerIds: readonly WorkerId[];
249
- readonly mode: WaveMode;
250
- readonly state: WaveState;
246
+ readonly workerId: WorkerId;
247
+ readonly mode: RunMode;
248
+ readonly state: RunState;
251
249
  readonly createdAt: number;
250
+ readonly synthesisGroupId?: string;
251
+ readonly synthesisGroupSize?: number;
252
252
  }
253
253
 
254
254
  export class InvalidTransitionError extends Error {
@@ -267,10 +267,6 @@ export function isTerminalWorkerStatus(status: WorkerStatus): status is Terminal
267
267
  return status === "completed" || status === "failed" || status === "aborted" || status === "closed";
268
268
  }
269
269
 
270
- export function isTerminalWorkerOutcome(outcome: WorkerOutcome): outcome is TerminalWorkerOutcome {
271
- return outcome.status !== "ready";
272
- }
273
-
274
270
  export function canTransitionWorkerStatus(
275
271
  from: WorkerStatus,
276
272
  to: WorkerStatus,
@@ -306,36 +302,6 @@ export function transitionWorkerStatus(
306
302
  return { ...worker, status, outcome: undefined };
307
303
  }
308
304
 
309
- export function isWorkerCompleteForWave(
310
- status: WorkerStatus,
311
- ): status is WaveCompleteWorkerStatus {
312
- return status === "ready" || isTerminalWorkerStatus(status);
313
- }
314
-
315
- export function getWaveWorkersInOrder(
316
- wave: WaveRecord,
317
- workersById: ReadonlyMap<WorkerId, WorkerRecord>,
318
- ): readonly WorkerRecord[] | undefined {
319
- const workers: WorkerRecord[] = [];
320
-
321
- for (const workerId of wave.workerIds) {
322
- const worker = workersById.get(workerId);
323
- if (!worker) return undefined;
324
- workers.push(worker);
325
- }
326
-
327
- return workers;
328
- }
329
-
330
- export function isWaveComplete(
331
- wave: WaveRecord,
332
- workersById: ReadonlyMap<WorkerId, WorkerRecord>,
333
- ): boolean {
334
- const workers = getWaveWorkersInOrder(wave, workersById);
335
- return workers !== undefined && workers.every((worker) => isWorkerCompleteForWave(worker.status));
336
- }
337
-
338
- export const MAX_TASKS_PER_WAVE = 12;
339
305
  export const MAX_WORKER_TITLE_LENGTH = 200;
340
306
  export const MAX_WORKER_INSTRUCTIONS_LENGTH = 100_000;
341
307
  export const CANCELLATION_GRACE_MS = 5_000;
package/extension/host.ts CHANGED
@@ -5,8 +5,7 @@ import {
5
5
  } from "./runtime.js";
6
6
  import { createWorkerSessionFactory } from "./worker-session.js";
7
7
 
8
- const PROCESS_HOST_KEY = Symbol.for("@zachwill/pi-orchestrate/process-host/v2");
9
- const LEGACY_PROCESS_HOST_KEY = Symbol.for("@zachwill/pi-orchestrate/process-host/v1");
8
+ const PROCESS_HOST_KEY = Symbol.for("@zachwill/pi-orchestrate/process-host/v3");
10
9
 
11
10
  export interface ProcessHost {
12
11
  readonly runtime: OrchestratorRuntime;
@@ -26,14 +25,8 @@ interface OwnedProcessHost extends AttachmentAwareProcessHost {
26
25
  destroyPromise?: Promise<void>;
27
26
  }
28
27
 
29
- interface LegacyProcessHost extends ProcessHost {
30
- unsubscribeCompletion?: () => void;
31
- unsubscribeSettlement?: () => void;
32
- }
33
-
34
28
  type ProcessGlobal = typeof globalThis & {
35
29
  [PROCESS_HOST_KEY]?: OwnedProcessHost;
36
- [LEGACY_PROCESS_HOST_KEY]?: LegacyProcessHost;
37
30
  };
38
31
 
39
32
  function processGlobal(): ProcessGlobal {
@@ -49,8 +42,6 @@ export function createProcessHost(): ProcessHost {
49
42
  const existing = global[PROCESS_HOST_KEY];
50
43
  if (existing) return existing;
51
44
 
52
- retireLegacyProcessHost(global);
53
-
54
45
  const runtime = createOrchestratorRuntime({
55
46
  workerSessionFactory: createWorkerSessionFactory(),
56
47
  });
@@ -114,30 +105,3 @@ export async function quitProcessHost(): Promise<void> {
114
105
  if (!host) return;
115
106
  await destroyProcessHost(host);
116
107
  }
117
-
118
- function retireLegacyProcessHost(global: ProcessGlobal): void {
119
- const legacy = global[LEGACY_PROCESS_HOST_KEY];
120
- if (!legacy) return;
121
-
122
- delete global[LEGACY_PROCESS_HOST_KEY];
123
- try {
124
- legacy.unsubscribeCompletion?.();
125
- } catch {
126
- // A stale subscription cannot prevent installation of the current host.
127
- }
128
- try {
129
- legacy.unsubscribeSettlement?.();
130
- } catch {
131
- // A stale subscription cannot prevent installation of the current host.
132
- }
133
- try {
134
- legacy.delivery.close();
135
- } catch {
136
- // Legacy delivery cleanup is best-effort during reload migration.
137
- }
138
- try {
139
- void legacy.runtime.shutdown().catch(() => undefined);
140
- } catch {
141
- // Legacy runtime shutdown is best-effort during reload migration.
142
- }
143
- }
@@ -22,14 +22,19 @@ import {
22
22
  registerOrchestrationPresentation,
23
23
  type StatusController,
24
24
  } from "./presentation.js";
25
- import { registerOrchestrationTools } from "./tools.js";
25
+ import {
26
+ registerOrchestrationTools,
27
+ type DispatchDecision,
28
+ } from "./tools.js";
26
29
 
27
30
  const DISPATCH_TOOL_NAMES: ReadonlySet<string> = new Set([
28
31
  "orchestrate",
29
32
  "worker_send",
30
33
  ]);
31
34
 
32
- type DispatchMode = "async" | "inline";
35
+ interface StoredDispatchDecision extends DispatchDecision {
36
+ readonly ownerSessionId: string;
37
+ }
33
38
 
34
39
  interface OwnerBinding {
35
40
  readonly ownerSessionId: string;
@@ -52,7 +57,7 @@ export function createOrchestrationExtension(
52
57
  const statusController =
53
58
  dependencies.createStatusController?.(host.runtime) ??
54
59
  createStatusController(host.runtime);
55
- const dispatchModes = new Map<string, DispatchMode>();
60
+ const dispatchDecisions = new Map<string, StoredDispatchDecision>();
56
61
  let hostAttachment: ProcessHostAttachment | undefined;
57
62
  let activeBinding: OwnerBinding | undefined;
58
63
  let cachedCatalog: WorkerCatalog | undefined;
@@ -69,7 +74,8 @@ export function createOrchestrationExtension(
69
74
  registerOrchestrationTools(pi, {
70
75
  runtime: host.runtime,
71
76
  getCatalog: catalogFor,
72
- getDispatchMode: (toolCallId) => dispatchModes.get(toolCallId) ?? "inline",
77
+ getDispatchDecision: (toolCallId) =>
78
+ dispatchDecisions.get(toolCallId) ?? { mode: "inline" },
73
79
  });
74
80
  registerOrchestrationPresentation(pi);
75
81
 
@@ -83,7 +89,7 @@ export function createOrchestrationExtension(
83
89
  statusController.unbind(activeBinding.ownerSessionId);
84
90
  }
85
91
 
86
- dispatchModes.clear();
92
+ dispatchDecisions.clear();
87
93
  cachedCatalog = undefined;
88
94
  const binding: OwnerBinding = {
89
95
  ownerSessionId: ctx.sessionManager.getSessionId(),
@@ -117,17 +123,39 @@ export function createOrchestrationExtension(
117
123
  const toolCalls = event.message.content.filter(
118
124
  (part) => part.type === "toolCall",
119
125
  );
120
- const mode: DispatchMode = toolCalls.length === 1 ? "async" : "inline";
126
+ const ownerSessionId = activeBinding?.ownerSessionId;
127
+ if (!ownerSessionId) return;
128
+ const groupedOrchestration =
129
+ toolCalls.length > 1 &&
130
+ toolCalls.every((toolCall) => toolCall.name === "orchestrate");
131
+ const synthesisGroup = groupedOrchestration
132
+ ? { id: `orchestrate:${toolCalls[0]?.id ?? "group"}`, size: toolCalls.length }
133
+ : undefined;
121
134
 
122
135
  for (const toolCall of toolCalls) {
123
- if (DISPATCH_TOOL_NAMES.has(toolCall.name)) {
124
- dispatchModes.set(toolCall.id, mode);
125
- }
136
+ if (!DISPATCH_TOOL_NAMES.has(toolCall.name)) continue;
137
+ const mode = groupedOrchestration || toolCalls.length === 1
138
+ ? "async"
139
+ : "inline";
140
+ dispatchDecisions.set(toolCall.id, {
141
+ mode,
142
+ ownerSessionId,
143
+ ...(toolCall.name === "orchestrate" && synthesisGroup
144
+ ? { synthesisGroup }
145
+ : {}),
146
+ });
126
147
  }
127
148
  });
128
149
 
129
150
  pi.on("tool_execution_end", (event) => {
130
- dispatchModes.delete(event.toolCallId);
151
+ const decision = dispatchDecisions.get(event.toolCallId);
152
+ dispatchDecisions.delete(event.toolCallId);
153
+ if (!event.isError || !decision?.synthesisGroup) return;
154
+ host.delivery.skipSynthesisGroupMember(
155
+ decision.ownerSessionId,
156
+ decision.synthesisGroup.id,
157
+ decision.synthesisGroup.size,
158
+ );
131
159
  });
132
160
 
133
161
  pi.on("agent_start", () => {
@@ -152,7 +180,7 @@ export function createOrchestrationExtension(
152
180
  const binding = activeBinding;
153
181
  activeBinding = undefined;
154
182
  cachedCatalog = undefined;
155
- dispatchModes.clear();
183
+ dispatchDecisions.clear();
156
184
 
157
185
  if (binding) {
158
186
  host.delivery.unbind(binding.ownerSessionId, binding.generation);