@zachwill/pi-orchestrate 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -14,23 +14,23 @@ Pi packages execute with your system permissions. Review the package and worker
14
14
 
15
15
  Pi Orchestrate adds exactly five tools:
16
16
 
17
- - `orchestrate` dispatches 1–12 tasks with `orchestrate({ tasks: [{ worker, title, instructions }] })`.
18
- - `orchestration_status` inspects the trusted catalog, catalog diagnostics, waves, and worker states without exposing full task instructions.
17
+ - `orchestrate` dispatches one task with `orchestrate({ worker, title, instructions })`. Send independent tasks as sibling `orchestrate` calls in the same assistant message.
18
+ - `orchestration_status` inspects the trusted catalog, catalog diagnostics, runs, and worker states without exposing full task instructions.
19
19
  - `worker_send` sends follow-up instructions to an owned reusable worker in the `ready` state.
20
20
  - `worker_abort` stops owned active work that is no longer needed.
21
21
  - `worker_close` closes an owned reusable worker in the `ready` state.
22
22
 
23
- An `orchestrate` wave first performs atomic input, catalog, and model preflight. No worker starts if any task fails that preflight. After acceptance, worker resources start independently: a resource startup failure becomes that worker's `failed` result and does not roll back or stop its peers.
23
+ Each `orchestrate` call first performs atomic input, catalog, and model preflight before its worker starts. Sibling calls are admitted independently: one rejected call does not prevent valid siblings from starting. After acceptance, a resource startup failure becomes that worker's `failed` result and does not roll back or stop sibling calls.
24
24
 
25
- All tasks in a wave execute concurrently, including a full 12-task wave. Pi Orchestrate applies no hidden concurrency throttle. Exact unchanged worker instructions remain visible in each tool call: collapsed calls preview every message, and expanded calls show every full brief. Titles are labels, never replacements for instructions.
25
+ Pi executes sibling tool calls concurrently, so independent `orchestrate` calls start concurrently without an extension-level group limit or hidden throttle. Exact unchanged worker instructions remain visible in each tool call: collapsed calls preview the message, and expanded calls show the full brief. Titles are labels, never replacements for instructions.
26
26
 
27
- For asynchronous behavior, `orchestrate` or `worker_send` must be the sole tool call in its assistant message. Mixing either with any sibling tool call makes its wave inline and blocking. Inline work receives the parent turn's cancellation signal. Accepted async work does not retain that signal and continues independently.
27
+ A sole `orchestrate` call or a pure group of sibling `orchestrate` calls runs asynchronously. Pi accepts a pure group concurrently and yields the parent turn. Mixing `orchestrate` with another tool makes it inline and blocking. `worker_send` is asynchronous only as the sole tool call in its assistant message. Inline work receives the parent turn's cancellation signal. Accepted async work does not retain that signal and continues independently.
28
28
 
29
- A successful async call yields the parent turn. Responses enter the transcript individually as workers settle, and the final response starts the parent's synthesis turn. The bottom widget is ephemeral and shows active work only; completed, failed, aborted, and reusable ready workers disappear immediately. Inline responses accumulate in the live tool output while the call blocks.
29
+ Async worker responses enter the transcript individually as workers finish. An ungrouped response starts the parent's synthesis turn. Responses from sibling calls share one final synthesis turn. The bottom widget is ephemeral and shows active work only; completed, failed, aborted, and reusable ready workers disappear immediately. An inline call shows its single current response in the live tool output while it blocks.
30
30
 
31
31
  `orchestration_status` is for diagnostics and recovery, never a normal completion mechanism. Do not poll it for completion. If the owning session becomes inactive, its workers continue and completed results remain queued. Those results become available only when that exact owning session resumes; they are never delivered to another session.
32
32
 
33
- Worker IDs identify live worker sessions. A reusable worker keeps the same worker ID across `worker_send` follow-ups, with each follow-up result belonging to a new wave. One-shot workers finish as `completed`. Reusable workers deliver as `ready` and wait for `worker_send` or `worker_close`. Use `worker_abort` only for active work, not to close a ready worker.
33
+ Each run owns exactly one worker generation. Worker IDs identify live worker sessions. A reusable worker keeps the same worker ID across `worker_send` follow-ups, with each follow-up result belonging to a new run. One-shot workers finish as `completed`. Reusable workers deliver as `ready`, remain available for follow-up, and wait for `worker_send` or `worker_close`. Use `worker_abort` only for active work by `worker_ids` or `all`, not to close a ready worker.
34
34
 
35
35
  ## Parent orchestration contract
36
36
 
@@ -38,8 +38,8 @@ Pi Orchestrate automatically injects the authoritative orchestration contract an
38
38
 
39
39
  1. Keep trivial or tightly coupled work in the parent session.
40
40
  2. Give every delegated task a full brief: objective, paths and scope, forbidden actions, context, constraints, observable success, checks, and expected output.
41
- 3. Dispatch every known independent task in one `orchestrate` wave, up to 12 tasks.
42
- 4. Make an async `orchestrate` or `worker_send` call the sole tool call in its assistant message, then yield after acceptance.
41
+ 3. Dispatch every known independent task with a sibling `orchestrate` call in the same assistant message.
42
+ 4. Keep an async `orchestrate` call or pure sibling group separate from other tools, then yield after acceptance. Make `worker_send` the sole tool call when it should run asynchronously.
43
43
  5. Review delivered evidence and changes, resolve conflicts, integrate deliberately, and run the relevant verification.
44
44
  6. Deliver the final answer from the parent session.
45
45
 
@@ -13,7 +13,11 @@ import type {
13
13
  WorkerDefinition,
14
14
  WorkerSourceKind,
15
15
  } from "./domain.js";
16
- import { isSupportedToolName, SUPPORTED_TOOL_NAMES } from "./domain.js";
16
+ import {
17
+ createWorkerCatalog,
18
+ isSupportedToolName,
19
+ SUPPORTED_TOOL_NAMES,
20
+ } from "./domain.js";
17
21
 
18
22
  const MAX_WORKER_BYTES = 64 * 1024;
19
23
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
@@ -127,27 +131,34 @@ function isMissingPath(error: unknown): boolean {
127
131
  return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
128
132
  }
129
133
 
130
- interface UnexpectedPath {
134
+ interface IssuePath {
131
135
  readonly path: readonly PropertyKey[];
136
+ readonly issue: SchemaIssue.Issue;
132
137
  }
133
138
 
134
- function collectUnexpectedPaths(
139
+ function collectIssuePaths(
135
140
  issue: SchemaIssue.Issue,
136
141
  parentPath: readonly PropertyKey[] = [],
137
- ): UnexpectedPath[] {
142
+ ): IssuePath[] {
138
143
  switch (issue._tag) {
139
144
  case "Pointer":
140
- return collectUnexpectedPaths(issue.issue, [...parentPath, ...issue.path]);
145
+ return collectIssuePaths(issue.issue, [...parentPath, ...issue.path]);
141
146
  case "Composite":
147
+ return issue.issues.flatMap((child) => collectIssuePaths(child, parentPath));
142
148
  case "AnyOf":
143
- return issue.issues.flatMap((child) => collectUnexpectedPaths(child, parentPath));
149
+ return issue.issues.length === 0
150
+ ? [{ path: parentPath, issue }]
151
+ : issue.issues.flatMap((child) => collectIssuePaths(child, parentPath));
144
152
  case "Encoding":
145
153
  case "Filter":
146
- return collectUnexpectedPaths(issue.issue, parentPath);
154
+ return collectIssuePaths(issue.issue, parentPath);
155
+ case "InvalidType":
156
+ case "InvalidValue":
157
+ case "MissingKey":
147
158
  case "UnexpectedKey":
148
- return [{ path: parentPath }];
149
- default:
150
- return [];
159
+ case "Forbidden":
160
+ case "OneOf":
161
+ return [{ path: parentPath, issue }];
151
162
  }
152
163
  }
153
164
 
@@ -165,8 +176,9 @@ function listItems(value: unknown): readonly unknown[] {
165
176
  return Array.isArray(value) ? value : [];
166
177
  }
167
178
 
168
- function schemaDiagnostic(error: Schema.SchemaError, frontmatter: unknown): string {
169
- const unexpected = collectUnexpectedPaths(error.issue);
179
+ function schemaDiagnostic(issue: SchemaIssue.Issue, frontmatter: unknown): string {
180
+ const issuePaths = collectIssuePaths(issue);
181
+ const unexpected = issuePaths.filter(({ issue }) => issue._tag === "UnexpectedKey");
170
182
  const frontmatterFields = unexpected
171
183
  .filter(({ path }) => path.length === 1 && typeof path[0] === "string")
172
184
  .map(({ path }) => String(path[0]))
@@ -196,8 +208,9 @@ function schemaDiagnostic(error: Schema.SchemaError, frontmatter: unknown): stri
196
208
  "compaction",
197
209
  "lifecycle",
198
210
  ];
199
- const message = error.message;
200
- const field = orderedFields.find((candidate) => message.includes(`["${candidate}"]`));
211
+ const field = orderedFields.find((candidate) =>
212
+ issuePaths.some(({ path }) => path[0] === candidate)
213
+ );
201
214
  const value = field === undefined ? undefined : fieldValue(frontmatter, field);
202
215
 
203
216
  if (field === "name" || field === "description") {
@@ -234,13 +247,21 @@ function schemaDiagnostic(error: Schema.SchemaError, frontmatter: unknown): stri
234
247
  if (compactionFields.length > 0) {
235
248
  return `unknown compaction field${compactionFields.length === 1 ? "" : "s"}: ${compactionFields.join(", ")}`;
236
249
  }
237
- if (message.includes('["enabled"]')) {
250
+ if (issuePaths.some(({ path }) => path[0] === "compaction" && path[1] === "enabled")) {
238
251
  return "frontmatter field 'compaction.enabled' must be a boolean";
239
252
  }
240
- if (message.includes('["reserveTokens"]')) {
253
+ if (
254
+ issuePaths.some(({ path }) =>
255
+ path[0] === "compaction" && path[1] === "reserveTokens"
256
+ )
257
+ ) {
241
258
  return "frontmatter field 'compaction.reserveTokens' must be a non-negative integer";
242
259
  }
243
- if (message.includes('["keepRecentTokens"]')) {
260
+ if (
261
+ issuePaths.some(({ path }) =>
262
+ path[0] === "compaction" && path[1] === "keepRecentTokens"
263
+ )
264
+ ) {
244
265
  return "frontmatter field 'compaction.keepRecentTokens' must be a non-negative integer";
245
266
  }
246
267
  return "frontmatter field 'compaction' must be a mapping";
@@ -266,7 +287,7 @@ function parseWorker(
266
287
  const { frontmatter, body } = parsed;
267
288
  const decoded = decodeWorkerFrontmatter(frontmatter);
268
289
  if (Result.isFailure(decoded)) {
269
- throw new Error(schemaDiagnostic(decoded.failure, frontmatter));
290
+ throw new Error(schemaDiagnostic(decoded.failure.issue, frontmatter));
270
291
  }
271
292
 
272
293
  const worker = decoded.success;
@@ -381,10 +402,7 @@ export function createWorkerCatalogDiscovery(fileSystem: CatalogFileSystem) {
381
402
  diagnostics.push(...discovered.diagnostics);
382
403
  }
383
404
 
384
- const workers = [...workersByName.values()].sort((left, right) =>
385
- compareText(left.name, right.name),
386
- );
387
- return { workers, diagnostics };
405
+ return createWorkerCatalog([...workersByName.values()], diagnostics);
388
406
  };
389
407
  }
390
408
 
@@ -30,12 +30,12 @@ function buildContract(catalog: WorkerCatalog): string {
30
30
  You are the parent orchestrator and own the task end to end.
31
31
 
32
32
  - Keep trivial or tightly coupled work in the parent. Use as many useful workers as independent scopes justify.
33
- - Delegate every known independent scope in one concurrent \`orchestrate\` wave with \`{ tasks: [{ worker, title, instructions }] }\`. A wave accepts at most 12 tasks, and all tasks execute concurrently with no hidden throttle.
33
+ - Delegate each independent scope with its own \`orchestrate\` call using \`{ worker, title, instructions }\`. Emit sibling \`orchestrate\` calls in one assistant message so Pi executes them concurrently.
34
34
  - Give every worker a full brief: objective; paths/scope; forbidden actions; context; constraints; observable success; checks; expected output.
35
- - Input, catalog, and model preflight is atomic before any worker starts. Resource startup failures become per-worker failed results and do not roll back peers.
36
- - For asynchronous behavior, \`orchestrate\` or \`worker_send\` must be the sole tool call in its assistant message. Mixing either with any sibling tool call makes its wave inline and blocking.
35
+ - Input, catalog, and model preflight is atomic per call before that worker starts. Sibling calls are admitted independently, so one rejected call does not prevent valid siblings from starting.
36
+ - A sole \`orchestrate\` call or a pure group of sibling \`orchestrate\` calls runs asynchronously. Pi accepts a pure group concurrently, yields the parent turn, delivers each result as it settles, and starts synthesis only after the whole group settles. Mixing \`orchestrate\` with another tool makes it inline and blocking. \`worker_send\` is asynchronous only as the sole tool call in its assistant message.
37
37
  - Exact worker instructions remain visible in the tool call and can be expanded; titles are labels, not substitutes for complete messages.
38
- - After an accepted async wave, yield the parent turn. Worker responses arrive individually as each worker settles, and the final response starts parent synthesis. Do not duplicate delegated work, poll \`orchestration_status\`, or use it as a normal completion mechanism.
38
+ - After an accepted async run, yield the parent turn. Worker responses arrive individually as each worker settles, and the final response starts parent synthesis. Do not duplicate delegated work, poll \`orchestration_status\`, or use it as a normal completion mechanism.
39
39
  - The active-work widget shows only workers currently starting, running, or stopping. Inline worker responses appear progressively in live tool output.
40
40
  - The parent synthesizes worker results, reviews their evidence and changes, resolves conflicts, integrates the final result, and runs the relevant verification before declaring completion.
41
41
  - Prefer one-shot workers. Use \`worker_send\` for follow-up work on a ready reusable worker, \`worker_close\` when that ready worker is finished, and \`worker_abort\` only when active work must stop.
@@ -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
- }