@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.
@@ -4,20 +4,17 @@ import { Effect, Option } from "effect";
4
4
  import {
5
5
  CANCELLATION_GRACE_MS,
6
6
  EMPTY_WORKER_USAGE,
7
- MAX_TASKS_PER_WAVE,
8
7
  MAX_WORKER_INSTRUCTIONS_LENGTH,
9
8
  MAX_WORKER_TITLE_LENGTH,
10
9
  createRandomIdFactories,
11
10
  findWorkerByName,
12
11
  isTerminalWorkerStatus,
13
- isWorkerCompleteForWave,
14
12
  transitionWorkerStatus,
15
13
  type OrchestrateIdFactories,
16
14
  type OrchestrateTaskInput,
17
- type WaveCompleteWorkerStatus,
18
- type WaveId,
19
- type WaveMode,
20
- type WaveRecord,
15
+ type RunId,
16
+ type RunMode,
17
+ type RunRecord,
21
18
  type WorkerCatalog,
22
19
  type WorkerDefinition,
23
20
  type WorkerId,
@@ -34,9 +31,18 @@ import {
34
31
  type WorkerSessionFactory,
35
32
  type WorkerSessionHandle,
36
33
  } from "./worker-session.js";
34
+ import type {
35
+ SettlementFailureStage,
36
+ WorkerSettlement,
37
+ } from "./worker-settlement.js";
38
+
39
+ export type {
40
+ SettlementFailureStage,
41
+ WorkerSettlement,
42
+ } from "./worker-settlement.js";
37
43
 
38
44
  export const MAX_TERMINAL_WORKER_HISTORY = 100;
39
- export const MAX_COMPLETED_WAVE_HISTORY = 100;
45
+ export const MAX_COMPLETED_RUN_HISTORY = 100;
40
46
  /** Shutdown waits this long for interrupted bootstrap/prompt promises, then returns best-effort. */
41
47
  export const SHUTDOWN_CLEANUP_GRACE_MS = CANCELLATION_GRACE_MS;
42
48
 
@@ -49,62 +55,41 @@ export interface OrchestrationContext {
49
55
  readonly catalog: WorkerCatalog;
50
56
  readonly parentModel?: Model<Api>;
51
57
  readonly modelRegistry: ModelRegistry;
58
+ readonly synthesisGroup?: {
59
+ readonly id: string;
60
+ readonly size: number;
61
+ };
52
62
  }
53
63
 
54
- export interface AcceptedWave {
55
- readonly id: WaveId;
56
- readonly workerIds: readonly WorkerId[];
64
+ export interface AcceptedRun {
65
+ readonly id: RunId;
66
+ readonly workerId: WorkerId;
57
67
  }
58
68
 
59
69
  export interface CompletedResult {
60
70
  readonly workerId: WorkerId;
61
71
  readonly worker: string;
62
72
  readonly title: string;
63
- readonly status: WaveCompleteWorkerStatus;
64
- readonly outcome: WorkerOutcome;
73
+ readonly status: "completed" | "ready" | "failed" | "aborted";
74
+ readonly outcome: Exclude<WorkerOutcome, { readonly status: "closed" }>;
65
75
  readonly usage: WorkerUsage;
76
+ readonly startedAt: number;
77
+ readonly settledAt: number;
66
78
  readonly sessionFile: string | undefined;
67
79
  }
68
80
 
69
- export interface CompletedWave {
70
- readonly id: WaveId;
81
+ export interface CompletedRun {
82
+ readonly id: RunId;
71
83
  readonly ownerSessionId: string;
72
- readonly mode: WaveMode;
73
- readonly results: readonly CompletedResult[];
74
- }
75
-
76
- export type SettlementFailureStage = "startup" | "prompt" | "workflow" | "cancellation";
77
-
78
- export interface WorkerSettlement {
79
- readonly eventId: string;
80
- readonly sequence: number;
81
- readonly ownerSessionId: string;
82
- readonly waveId: WaveId;
83
- readonly workerId: WorkerId;
84
- readonly generation: number;
85
- readonly mode: WaveMode;
86
- readonly worker: string;
87
- readonly title: string;
88
- readonly lifecycle: WorkerRecord["lifecycle"];
89
- readonly status: Exclude<WaveCompleteWorkerStatus, "closed">;
90
- readonly outcome: WorkerOutcome;
91
- readonly failureStage?: SettlementFailureStage;
92
- readonly usage: WorkerUsage;
93
- readonly startedAt: number;
94
- readonly settledAt: number;
95
- readonly remainingActive: number;
96
- readonly waveSize: number;
97
- readonly waveComplete: boolean;
98
- readonly sessionFile: string | undefined;
84
+ readonly mode: RunMode;
85
+ readonly result: CompletedResult;
99
86
  }
100
87
 
101
88
  export interface RuntimeSnapshot {
102
- readonly waves: readonly WaveRecord[];
89
+ readonly runs: readonly RunRecord[];
103
90
  readonly workers: readonly WorkerRecord[];
104
91
  }
105
92
 
106
- export type CompletionListener = (wave: CompletedWave) => void;
107
- export type UnsubscribeCompletion = () => void;
108
93
  export type SettlementListener = (settlement: WorkerSettlement) => void;
109
94
  export type UnsubscribeSettlement = () => void;
110
95
  export type StateListener = (ownerSessionId: string) => void;
@@ -112,18 +97,11 @@ export type StateListener = (ownerSessionId: string) => void;
112
97
  export type AbortTarget =
113
98
  | {
114
99
  readonly workerIds: readonly WorkerId[];
115
- readonly waveId?: never;
116
- readonly all?: never;
117
- }
118
- | {
119
- readonly waveId: WaveId;
120
- readonly workerIds?: never;
121
100
  readonly all?: never;
122
101
  }
123
102
  | {
124
103
  readonly all: true;
125
104
  readonly workerIds?: never;
126
- readonly waveId?: never;
127
105
  };
128
106
 
129
107
  export type DeadlineResult = "settled" | "timed-out";
@@ -143,25 +121,25 @@ export interface OrchestratorRuntimeOptions {
143
121
  export interface OrchestratorRuntime {
144
122
  orchestrate(
145
123
  context: OrchestrationContext,
146
- tasks: readonly OrchestrateTaskInput[],
124
+ task: OrchestrateTaskInput,
147
125
  mode: "async",
148
126
  signal?: AbortSignal,
149
127
  onSettlement?: SettlementListener,
150
- ): Promise<AcceptedWave>;
128
+ ): Promise<AcceptedRun>;
151
129
  orchestrate(
152
130
  context: OrchestrationContext,
153
- tasks: readonly OrchestrateTaskInput[],
131
+ task: OrchestrateTaskInput,
154
132
  mode: "inline",
155
133
  signal?: AbortSignal,
156
134
  onSettlement?: SettlementListener,
157
- ): Promise<CompletedWave>;
135
+ ): Promise<CompletedRun>;
158
136
  orchestrate(
159
137
  context: OrchestrationContext,
160
- tasks: readonly OrchestrateTaskInput[],
161
- mode: WaveMode,
138
+ task: OrchestrateTaskInput,
139
+ mode: RunMode,
162
140
  signal?: AbortSignal,
163
141
  onSettlement?: SettlementListener,
164
- ): Promise<AcceptedWave | CompletedWave>;
142
+ ): Promise<AcceptedRun | CompletedRun>;
165
143
  send(
166
144
  context: OrchestrationContext,
167
145
  workerId: WorkerId,
@@ -169,7 +147,7 @@ export interface OrchestratorRuntime {
169
147
  mode: "async",
170
148
  signal?: AbortSignal,
171
149
  onSettlement?: SettlementListener,
172
- ): Promise<AcceptedWave>;
150
+ ): Promise<AcceptedRun>;
173
151
  send(
174
152
  context: OrchestrationContext,
175
153
  workerId: WorkerId,
@@ -177,19 +155,18 @@ export interface OrchestratorRuntime {
177
155
  mode: "inline",
178
156
  signal?: AbortSignal,
179
157
  onSettlement?: SettlementListener,
180
- ): Promise<CompletedWave>;
158
+ ): Promise<CompletedRun>;
181
159
  send(
182
160
  context: OrchestrationContext,
183
161
  workerId: WorkerId,
184
162
  instructions: string,
185
- mode: WaveMode,
163
+ mode: RunMode,
186
164
  signal?: AbortSignal,
187
165
  onSettlement?: SettlementListener,
188
- ): Promise<AcceptedWave | CompletedWave>;
166
+ ): Promise<AcceptedRun | CompletedRun>;
189
167
  abort(ownerSessionId: string, target: AbortTarget): Promise<void>;
190
168
  close(ownerSessionId: string, workerId: WorkerId): Promise<void>;
191
169
  snapshot(ownerSessionId: string): Promise<RuntimeSnapshot>;
192
- subscribeCompletion(listener: CompletionListener): UnsubscribeCompletion;
193
170
  subscribeSettlement(listener: SettlementListener): UnsubscribeSettlement;
194
171
  subscribeState(listener: StateListener): () => void;
195
172
  shutdown(): Promise<void>;
@@ -205,11 +182,11 @@ interface RuntimeEntry {
205
182
  unsubscribeMessageDirection?: () => void;
206
183
  }
207
184
 
208
- interface WaveWaiter {
209
- readonly promise: Promise<CompletedWave>;
185
+ interface RunWaiter {
186
+ readonly promise: Promise<CompletedRun>;
210
187
  settled: boolean;
211
188
  onSettled?: () => void;
212
- resolve(wave: CompletedWave): void;
189
+ resolve(run: CompletedRun): void;
213
190
  }
214
191
 
215
192
  const defaultBestEffortDeadline: BestEffortDeadline = {
@@ -234,17 +211,16 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
234
211
  private readonly scheduler: WorkflowScheduler<WorkerId>;
235
212
  private readonly bestEffortDeadline: BestEffortDeadline;
236
213
  private readonly workers = new Map<WorkerId, WorkerRecord>();
237
- private readonly waves = new Map<WaveId, WaveRecord>();
214
+ private readonly runs = new Map<RunId, RunRecord>();
238
215
  private readonly entries = new Map<WorkerId, RuntimeEntry>();
239
- private readonly waveWaiters = new Map<WaveId, WaveWaiter>();
240
- private readonly completedWaves = new Map<WaveId, CompletedWave>();
216
+ private readonly runWaiters = new Map<RunId, RunWaiter>();
217
+ private readonly completedRuns = new Map<RunId, CompletedRun>();
241
218
  private readonly cancellationPromises = new Map<WorkerId, Promise<void>>();
242
219
  private readonly cleanupOperations = new Set<Promise<void>>();
243
220
  private readonly terminalWorkerOrder: WorkerId[] = [];
244
- private readonly completedWaveOrder: WaveId[] = [];
245
- private readonly completionListeners = new Set<CompletionListener>();
221
+ private readonly completedRunOrder: RunId[] = [];
246
222
  private readonly settlementListeners = new Set<SettlementListener>();
247
- private readonly waveSettlementListeners = new Map<WaveId, SettlementListener>();
223
+ private readonly runSettlementListeners = new Map<RunId, SettlementListener>();
248
224
  private readonly stateListeners = new Set<StateListener>();
249
225
  private settlementSequence = 0;
250
226
  private readonly disposedSessions = new WeakSet<WorkerSessionHandle>();
@@ -261,86 +237,81 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
261
237
 
262
238
  orchestrate(
263
239
  context: OrchestrationContext,
264
- tasks: readonly OrchestrateTaskInput[],
240
+ task: OrchestrateTaskInput,
265
241
  mode: "async",
266
242
  signal?: AbortSignal,
267
243
  onSettlement?: SettlementListener,
268
- ): Promise<AcceptedWave>;
244
+ ): Promise<AcceptedRun>;
269
245
  orchestrate(
270
246
  context: OrchestrationContext,
271
- tasks: readonly OrchestrateTaskInput[],
247
+ task: OrchestrateTaskInput,
272
248
  mode: "inline",
273
249
  signal?: AbortSignal,
274
250
  onSettlement?: SettlementListener,
275
- ): Promise<CompletedWave>;
251
+ ): Promise<CompletedRun>;
276
252
  orchestrate(
277
253
  context: OrchestrationContext,
278
- tasks: readonly OrchestrateTaskInput[],
279
- mode: WaveMode,
254
+ task: OrchestrateTaskInput,
255
+ mode: RunMode,
280
256
  signal?: AbortSignal,
281
257
  onSettlement?: SettlementListener,
282
- ): Promise<AcceptedWave | CompletedWave>;
258
+ ): Promise<AcceptedRun | CompletedRun>;
283
259
  async orchestrate(
284
260
  context: OrchestrationContext,
285
- tasks: readonly OrchestrateTaskInput[],
286
- mode: WaveMode,
261
+ task: OrchestrateTaskInput,
262
+ mode: RunMode,
287
263
  signal?: AbortSignal,
288
264
  onSettlement?: SettlementListener,
289
- ): Promise<AcceptedWave | CompletedWave> {
265
+ ): Promise<AcceptedRun | CompletedRun> {
290
266
  this.assertOpen();
291
267
  throwIfAborted(signal);
292
- const definitions = this.validateTasks(context, tasks, mode);
268
+ const definition = this.validateTask(context, task, mode);
293
269
 
294
- const waveId = this.idFactories.waveId();
295
- const workerIds = tasks.map(() => this.idFactories.workerId());
296
- this.assertFreshIds(waveId, workerIds);
270
+ const runId = this.idFactories.runId();
271
+ const workerId = this.idFactories.workerId();
272
+ this.assertFreshIds(runId, workerId);
297
273
 
298
- const wave: WaveRecord = {
299
- id: waveId,
274
+ const run: RunRecord = {
275
+ id: runId,
300
276
  ownerSessionId: context.ownerSessionId,
301
- workerIds: [...workerIds],
277
+ workerId,
302
278
  mode,
303
279
  state: "running",
304
280
  createdAt: this.clock(),
281
+ ...(context.synthesisGroup
282
+ ? {
283
+ synthesisGroupId: context.synthesisGroup.id,
284
+ synthesisGroupSize: context.synthesisGroup.size,
285
+ }
286
+ : {}),
287
+ };
288
+ const record: WorkerRecord = {
289
+ id: workerId,
290
+ worker: definition.name,
291
+ ownerSessionId: context.ownerSessionId,
292
+ runId,
293
+ title: task.title,
294
+ instructions: task.instructions,
295
+ lifecycle: definition.lifecycle,
296
+ status: "starting",
297
+ usage: copyUsage(EMPTY_WORKER_USAGE),
298
+ messageDirection: "to-model",
299
+ startedAt: this.clock(),
305
300
  };
306
- const records = tasks.map<WorkerRecord>((task, index) => {
307
- const definition = definitions[index];
308
- const id = workerIds[index];
309
- if (!definition || !id) throw new Error("Validated orchestration input became inconsistent");
310
- return {
311
- id,
312
- worker: definition.name,
313
- ownerSessionId: context.ownerSessionId,
314
- waveId,
315
- title: task.title,
316
- instructions: task.instructions,
317
- lifecycle: definition.lifecycle,
318
- status: "starting",
319
- usage: copyUsage(EMPTY_WORKER_USAGE),
320
- messageDirection: "to-model",
321
- startedAt: this.clock(),
322
- };
323
- });
324
301
 
325
- const waiter = makeWaveWaiter();
326
- this.waves.set(waveId, wave);
327
- this.waveWaiters.set(waveId, waiter);
328
- if (onSettlement) this.waveSettlementListeners.set(waveId, onSettlement);
329
- for (let index = 0; index < records.length; index += 1) {
330
- const record = records[index];
331
- const definition = definitions[index];
332
- if (!record || !definition) throw new Error("Validated orchestration input became inconsistent");
333
- this.workers.set(record.id, record);
334
- this.entries.set(record.id, { context, definition, generation: 1 });
335
- }
302
+ const waiter = makeRunWaiter();
303
+ this.runs.set(runId, run);
304
+ this.runWaiters.set(runId, waiter);
305
+ if (onSettlement) this.runSettlementListeners.set(runId, onSettlement);
306
+ this.workers.set(workerId, record);
307
+ this.entries.set(workerId, { context, definition, generation: 1 });
336
308
  this.emitState(context.ownerSessionId);
337
-
338
- for (const record of records) this.launchBootstrap(record.id, 1);
309
+ this.launchBootstrap(workerId, 1);
339
310
 
340
311
  if (mode === "inline") {
341
- return this.awaitInlineWave(wave, waiter, signal);
312
+ return this.awaitInlineRun(run, waiter, signal);
342
313
  }
343
- return freezeAcceptedWave(waveId, workerIds);
314
+ return freezeAcceptedRun(runId, workerId);
344
315
  }
345
316
 
346
317
  send(
@@ -350,7 +321,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
350
321
  mode: "async",
351
322
  signal?: AbortSignal,
352
323
  onSettlement?: SettlementListener,
353
- ): Promise<AcceptedWave>;
324
+ ): Promise<AcceptedRun>;
354
325
  send(
355
326
  context: OrchestrationContext,
356
327
  workerId: WorkerId,
@@ -358,23 +329,23 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
358
329
  mode: "inline",
359
330
  signal?: AbortSignal,
360
331
  onSettlement?: SettlementListener,
361
- ): Promise<CompletedWave>;
332
+ ): Promise<CompletedRun>;
362
333
  send(
363
334
  context: OrchestrationContext,
364
335
  workerId: WorkerId,
365
336
  instructions: string,
366
- mode: WaveMode,
337
+ mode: RunMode,
367
338
  signal?: AbortSignal,
368
339
  onSettlement?: SettlementListener,
369
- ): Promise<AcceptedWave | CompletedWave>;
340
+ ): Promise<AcceptedRun | CompletedRun>;
370
341
  async send(
371
342
  context: OrchestrationContext,
372
343
  workerId: WorkerId,
373
344
  instructions: string,
374
- mode: WaveMode,
345
+ mode: RunMode,
375
346
  signal?: AbortSignal,
376
347
  onSettlement?: SettlementListener,
377
- ): Promise<AcceptedWave | CompletedWave> {
348
+ ): Promise<AcceptedRun | CompletedRun> {
378
349
  this.assertOpen();
379
350
  throwIfAborted(signal);
380
351
  validateContextOwner(context.ownerSessionId);
@@ -388,41 +359,41 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
388
359
  const entry = this.entries.get(workerId);
389
360
  if (!entry?.session) throw new Error("Ready reusable worker has no session handle");
390
361
 
391
- const waveId = this.idFactories.waveId();
392
- if (this.waves.has(waveId)) throw new Error(`Duplicate wave ID: ${waveId}`);
393
- const wave: WaveRecord = {
394
- id: waveId,
362
+ const runId = this.idFactories.runId();
363
+ if (this.runs.has(runId)) throw new Error(`Duplicate run ID: ${runId}`);
364
+ const run: RunRecord = {
365
+ id: runId,
395
366
  ownerSessionId: context.ownerSessionId,
396
- workerIds: [workerId],
367
+ workerId,
397
368
  mode,
398
369
  state: "running",
399
370
  createdAt: this.clock(),
400
371
  };
401
372
  const running: WorkerRecord = {
402
373
  ...transitionWorkerStatus(current, "running"),
403
- waveId,
374
+ runId,
404
375
  instructions,
405
376
  activity: undefined,
406
377
  messageDirection: "to-model",
407
378
  startedAt: this.clock(),
408
379
  settledAt: undefined,
409
380
  };
410
- const waiter = makeWaveWaiter();
381
+ const waiter = makeRunWaiter();
411
382
 
412
383
  entry.generation += 1;
413
384
  const generation = entry.generation;
414
- this.waves.set(waveId, wave);
415
- this.waveWaiters.set(waveId, waiter);
416
- if (onSettlement) this.waveSettlementListeners.set(waveId, onSettlement);
385
+ this.runs.set(runId, run);
386
+ this.runWaiters.set(runId, waiter);
387
+ if (onSettlement) this.runSettlementListeners.set(runId, onSettlement);
417
388
  this.workers.set(workerId, running);
418
389
  this.subscribeEntryObservability(workerId, entry, entry.session, generation);
419
390
  this.emitState(context.ownerSessionId);
420
391
  this.launchPrompt(workerId, generation, entry.session, instructions);
421
392
 
422
393
  if (mode === "inline") {
423
- return this.awaitInlineWave(wave, waiter, signal);
394
+ return this.awaitInlineRun(run, waiter, signal);
424
395
  }
425
- return freezeAcceptedWave(waveId, [workerId]);
396
+ return freezeAcceptedRun(runId, workerId);
426
397
  }
427
398
 
428
399
  async abort(ownerSessionId: string, target: AbortTarget): Promise<void> {
@@ -444,29 +415,18 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
444
415
 
445
416
  async snapshot(ownerSessionId: string): Promise<RuntimeSnapshot> {
446
417
  validateContextOwner(ownerSessionId);
447
- const waves = [...this.waves.values()]
448
- .filter((wave) => wave.ownerSessionId === ownerSessionId)
449
- .map(copyWaveRecord);
418
+ const runs = [...this.runs.values()]
419
+ .filter((run) => run.ownerSessionId === ownerSessionId)
420
+ .map(copyRunRecord);
450
421
  const workers = [...this.workers.values()]
451
422
  .filter((worker) => worker.ownerSessionId === ownerSessionId)
452
423
  .map(copyWorkerRecord);
453
424
  return Object.freeze({
454
- waves: Object.freeze(waves),
425
+ runs: Object.freeze(runs),
455
426
  workers: Object.freeze(workers),
456
427
  });
457
428
  }
458
429
 
459
- subscribeCompletion(listener: CompletionListener): UnsubscribeCompletion {
460
- if (typeof listener !== "function") throw new Error("Completion listener must be a function");
461
- this.completionListeners.add(listener);
462
- let subscribed = true;
463
- return () => {
464
- if (!subscribed) return;
465
- subscribed = false;
466
- this.completionListeners.delete(listener);
467
- };
468
- }
469
-
470
430
  subscribeSettlement(listener: SettlementListener): UnsubscribeSettlement {
471
431
  if (typeof listener !== "function") throw new Error("Settlement listener must be a function");
472
432
  this.settlementListeners.add(listener);
@@ -510,51 +470,43 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
510
470
  // Scheduler closure is best-effort during process shutdown.
511
471
  } finally {
512
472
  await this.awaitTrackedCleanupBestEffort();
513
- this.waveSettlementListeners.clear();
514
- this.completionListeners.clear();
473
+ this.runSettlementListeners.clear();
515
474
  this.settlementListeners.clear();
516
475
  this.stateListeners.clear();
517
476
  }
518
477
  }
519
478
  }
520
479
 
521
- private validateTasks(
480
+ private validateTask(
522
481
  context: OrchestrationContext,
523
- tasks: readonly OrchestrateTaskInput[],
524
- mode: WaveMode,
525
- ): WorkerDefinition[] {
482
+ task: OrchestrateTaskInput,
483
+ mode: RunMode,
484
+ ): WorkerDefinition {
526
485
  validateContextOwner(context.ownerSessionId);
527
486
  validateMode(mode);
528
- if (!Array.isArray(tasks) || tasks.length < 1 || tasks.length > MAX_TASKS_PER_WAVE) {
529
- throw new Error(`orchestrate requires 1 to ${MAX_TASKS_PER_WAVE} tasks`);
487
+ if (!task || typeof task !== "object" || Array.isArray(task)) {
488
+ throw new Error("orchestrate requires one task object");
530
489
  }
531
-
532
- const definitions: WorkerDefinition[] = [];
533
- for (const task of tasks) {
534
- if (!task || typeof task !== "object") throw new Error("Each task must be an object");
535
- validateText("worker", task.worker, MAX_WORKER_TITLE_LENGTH);
536
- validateText("title", task.title, MAX_WORKER_TITLE_LENGTH);
537
- validateText("instructions", task.instructions, MAX_WORKER_INSTRUCTIONS_LENGTH);
538
- const definition = findWorkerByName(context.catalog, task.worker);
539
- if (!definition) throw new Error(`Unknown worker: ${task.worker}`);
540
- definitions.push(definition);
490
+ if (context.synthesisGroup) {
491
+ validateText("synthesis group ID", context.synthesisGroup.id, MAX_WORKER_TITLE_LENGTH);
492
+ if (mode !== "async") throw new Error("Sibling synthesis requires an async task");
493
+ if (!Number.isSafeInteger(context.synthesisGroup.size) || context.synthesisGroup.size < 2) {
494
+ throw new Error("Synthesis group size must be an integer of at least 2");
495
+ }
541
496
  }
542
497
 
543
- for (const definition of definitions) {
544
- resolveWorkerModel(definition, context.parentModel, context.modelRegistry);
545
- }
546
- return definitions;
498
+ validateText("worker", task.worker, MAX_WORKER_TITLE_LENGTH);
499
+ validateText("title", task.title, MAX_WORKER_TITLE_LENGTH);
500
+ validateText("instructions", task.instructions, MAX_WORKER_INSTRUCTIONS_LENGTH);
501
+ const definition = findWorkerByName(context.catalog, task.worker);
502
+ if (!definition) throw new Error(`Unknown worker: ${task.worker}`);
503
+ resolveWorkerModel(definition, context.parentModel, context.modelRegistry);
504
+ return definition;
547
505
  }
548
506
 
549
- private assertFreshIds(waveId: WaveId, workerIds: readonly WorkerId[]): void {
550
- if (this.waves.has(waveId)) throw new Error(`Duplicate wave ID: ${waveId}`);
551
- const unique = new Set<WorkerId>();
552
- for (const workerId of workerIds) {
553
- if (unique.has(workerId) || this.workers.has(workerId)) {
554
- throw new Error(`Duplicate worker ID: ${workerId}`);
555
- }
556
- unique.add(workerId);
557
- }
507
+ private assertFreshIds(runId: RunId, workerId: WorkerId): void {
508
+ if (this.runs.has(runId)) throw new Error(`Duplicate run ID: ${runId}`);
509
+ if (this.workers.has(workerId)) throw new Error(`Duplicate worker ID: ${workerId}`);
558
510
  }
559
511
 
560
512
  private launchBootstrap(workerId: WorkerId, generation: number): void {
@@ -763,7 +715,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
763
715
  const entry = this.entries.get(workerId);
764
716
  if (!current || entry?.generation !== generation) return;
765
717
  if (!isActiveWorkerStatus(current.status)) {
766
- this.maybeCompleteWave(current.waveId);
718
+ this.maybeCompleteRun(current.runId);
767
719
  return;
768
720
  }
769
721
 
@@ -816,8 +768,8 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
816
768
  settledAt,
817
769
  });
818
770
  if (status !== "ready") this.disposeEntrySession(entry);
819
- const affectedOwners = this.maybeCompleteWave(current.waveId);
820
- affectedOwners.add(current.ownerSessionId);
771
+ this.maybeCompleteRun(current.runId);
772
+ const affectedOwners = new Set([current.ownerSessionId]);
821
773
  this.emitSettlement(
822
774
  workerId,
823
775
  generation,
@@ -845,8 +797,8 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
845
797
  outcome: copyOutcome(outcome),
846
798
  settledAt,
847
799
  });
848
- const affectedOwners = this.maybeCompleteWave(current.waveId);
849
- affectedOwners.add(current.ownerSessionId);
800
+ this.maybeCompleteRun(current.runId);
801
+ const affectedOwners = new Set([current.ownerSessionId]);
850
802
  const generation = this.entries.get(current.id)?.generation;
851
803
  if (generation !== undefined) {
852
804
  this.emitSettlement(current.id, generation, settledAt, failureStage);
@@ -862,29 +814,19 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
862
814
  failureStage?: SettlementFailureStage,
863
815
  ): void {
864
816
  const worker = this.workers.get(workerId);
865
- const wave = worker ? this.waves.get(worker.waveId) : undefined;
866
- if (!worker || !wave || !worker.outcome || !isWorkerCompleteForWave(worker.status)) return;
867
- if (worker.status === "closed") return;
868
-
869
- let remainingActive = 0;
870
- let waveComplete = true;
871
- for (const waveWorkerId of wave.workerIds) {
872
- const waveWorker = this.workers.get(waveWorkerId);
873
- if (!waveWorker || waveWorker.waveId !== wave.id || !isWorkerCompleteForWave(waveWorker.status)) {
874
- waveComplete = false;
875
- if (waveWorker && isActiveWorkerStatus(waveWorker.status)) remainingActive += 1;
876
- }
877
- }
817
+ const run = worker ? this.runs.get(worker.runId) : undefined;
818
+ if (!worker || !run || !worker.outcome || !isSettledWorkerStatus(worker.status)) return;
819
+ if (worker.outcome.status === "closed") return;
878
820
 
879
821
  const sequence = ++this.settlementSequence;
880
822
  const settlement: WorkerSettlement = Object.freeze({
881
- eventId: `${sequence}:${wave.id}:${workerId}:${generation}`,
823
+ eventId: `${sequence}:${run.id}:${workerId}:${generation}`,
882
824
  sequence,
883
825
  ownerSessionId: worker.ownerSessionId,
884
- waveId: wave.id,
826
+ runId: run.id,
885
827
  workerId,
886
828
  generation,
887
- mode: wave.mode,
829
+ mode: run.mode,
888
830
  worker: worker.worker,
889
831
  title: worker.title,
890
832
  lifecycle: worker.lifecycle,
@@ -894,60 +836,38 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
894
836
  usage: Object.freeze(copyUsage(worker.usage)),
895
837
  startedAt: worker.startedAt,
896
838
  settledAt,
897
- remainingActive,
898
- waveSize: wave.workerIds.length,
899
- waveComplete,
900
- sessionFile: worker.sessionFile,
839
+ ...(run.synthesisGroupId && run.synthesisGroupSize
840
+ ? {
841
+ synthesisGroupId: run.synthesisGroupId,
842
+ synthesisGroupSize: run.synthesisGroupSize,
843
+ }
844
+ : {}),
845
+ ...(worker.sessionFile !== undefined ? { sessionFile: worker.sessionFile } : {}),
901
846
  });
902
847
 
903
- const localListener = this.waveSettlementListeners.get(wave.id);
904
- if (waveComplete) this.waveSettlementListeners.delete(wave.id);
848
+ const localListener = this.runSettlementListeners.get(run.id);
849
+ this.runSettlementListeners.delete(run.id);
905
850
  notifySettlementListener(localListener, settlement);
906
851
  for (const listener of [...this.settlementListeners]) {
907
852
  notifySettlementListener(listener, settlement);
908
853
  }
909
- if (waveComplete) this.emitCompletedWave(wave.id);
910
- }
911
-
912
- private emitCompletedWave(waveId: WaveId): void {
913
- const completed = this.completedWaves.get(waveId);
914
- if (!completed || completed.mode !== "async") return;
915
- for (const listener of [...this.completionListeners]) {
916
- try {
917
- listener(completed);
918
- } catch {
919
- // One subscriber cannot prevent other subscribers from receiving completion.
920
- }
921
- }
922
854
  }
923
855
 
924
- private maybeCompleteWave(waveId: WaveId): Set<string> {
925
- const affectedOwners = new Set<string>();
926
- if (this.completedWaves.has(waveId)) return affectedOwners;
927
- const wave = this.waves.get(waveId);
928
- if (!wave) return affectedOwners;
929
- const records: WorkerRecord[] = [];
930
- for (const workerId of wave.workerIds) {
931
- const worker = this.workers.get(workerId);
932
- if (
933
- !worker ||
934
- worker.waveId !== waveId ||
935
- !isWorkerCompleteForWave(worker.status) ||
936
- !worker.outcome
937
- ) {
938
- return affectedOwners;
939
- }
940
- records.push(worker);
856
+ private maybeCompleteRun(runId: RunId): void {
857
+ if (this.completedRuns.has(runId)) return;
858
+ const run = this.runs.get(runId);
859
+ if (!run) return;
860
+ const worker = this.workers.get(run.workerId);
861
+ if (!worker || worker.runId !== runId || !worker.outcome || isActiveWorkerStatus(worker.status)) {
862
+ return;
941
863
  }
942
864
 
943
- const completed = freezeCompletedWave(wave, records);
944
- this.completedWaves.set(waveId, completed);
945
- this.completedWaveOrder.push(waveId);
946
- this.waves.set(waveId, { ...wave, state: "complete" });
947
- this.waveWaiters.get(waveId)?.resolve(completed);
948
- this.waveWaiters.delete(waveId);
949
-
950
- return affectedOwners;
865
+ const completed = freezeCompletedRun(run, worker);
866
+ this.completedRuns.set(runId, completed);
867
+ this.completedRunOrder.push(runId);
868
+ this.runs.set(runId, { ...run, state: "complete" });
869
+ this.runWaiters.get(runId)?.resolve(completed);
870
+ this.runWaiters.delete(runId);
951
871
  }
952
872
 
953
873
  private rememberTerminalWorker(workerId: WorkerId): Set<string> {
@@ -959,20 +879,20 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
959
879
 
960
880
  private pruneHistory(): Set<string> {
961
881
  const affectedOwners = new Set<string>();
962
- while (this.completedWaveOrder.length > MAX_COMPLETED_WAVE_HISTORY) {
963
- const waveId = this.completedWaveOrder.shift();
964
- if (!waveId) break;
965
- this.completedWaves.delete(waveId);
966
- const wave = this.waves.get(waveId);
967
- if (wave) affectedOwners.add(wave.ownerSessionId);
968
- this.waves.delete(waveId);
882
+ while (this.completedRunOrder.length > MAX_COMPLETED_RUN_HISTORY) {
883
+ const runId = this.completedRunOrder.shift();
884
+ if (!runId) break;
885
+ this.completedRuns.delete(runId);
886
+ const run = this.runs.get(runId);
887
+ if (run) affectedOwners.add(run.ownerSessionId);
888
+ this.runs.delete(runId);
969
889
  }
970
890
 
971
891
  while (this.terminalWorkerOrder.length > MAX_TERMINAL_WORKER_HISTORY) {
972
892
  const removableIndex = this.terminalWorkerOrder.findIndex((workerId) => {
973
893
  const worker = this.workers.get(workerId);
974
894
  if (!worker || !isTerminalWorkerStatus(worker.status)) return true;
975
- return this.waves.get(worker.waveId)?.state !== "running";
895
+ return this.runs.get(worker.runId)?.state !== "running";
976
896
  });
977
897
  if (removableIndex < 0) return affectedOwners;
978
898
  const [workerId] = this.terminalWorkerOrder.splice(removableIndex, 1);
@@ -990,16 +910,14 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
990
910
  if (!target || typeof target !== "object") throw new Error("Invalid abort target");
991
911
  const candidate = target as {
992
912
  workerIds?: readonly WorkerId[];
993
- waveId?: WaveId;
994
913
  all?: boolean;
995
914
  };
996
915
  const selected = [
997
916
  candidate.workerIds !== undefined,
998
- candidate.waveId !== undefined,
999
917
  candidate.all !== undefined,
1000
918
  ].filter(Boolean).length;
1001
919
  if (selected !== 1 || (candidate.all !== undefined && candidate.all !== true)) {
1002
- throw new Error("Abort target must specify exactly one of workerIds, waveId, or all: true");
920
+ throw new Error("Abort target must specify exactly one of workerIds or all: true");
1003
921
  }
1004
922
 
1005
923
  if (candidate.workerIds !== undefined) {
@@ -1019,21 +937,6 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
1019
937
  return unique;
1020
938
  }
1021
939
 
1022
- if (candidate.waveId !== undefined) {
1023
- const wave = this.waves.get(candidate.waveId);
1024
- if (!wave || wave.ownerSessionId !== ownerSessionId) {
1025
- throw new Error("Wave is not owned by this session");
1026
- }
1027
- return wave.workerIds.filter((workerId) => {
1028
- const worker = this.workers.get(workerId);
1029
- return (
1030
- worker?.ownerSessionId === ownerSessionId &&
1031
- worker.waveId === wave.id &&
1032
- isActiveWorkerStatus(worker.status)
1033
- );
1034
- });
1035
- }
1036
-
1037
940
  return [...this.workers.values()]
1038
941
  .filter(
1039
942
  (worker) =>
@@ -1122,27 +1025,25 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
1122
1025
  }, "cancellation");
1123
1026
  }
1124
1027
 
1125
- private async cancelExactWave(wave: WaveRecord): Promise<void> {
1126
- const active = wave.workerIds.filter((workerId) => {
1127
- const worker = this.workers.get(workerId);
1128
- return (
1129
- worker?.ownerSessionId === wave.ownerSessionId &&
1130
- worker.waveId === wave.id &&
1028
+ private async cancelExactRun(run: RunRecord): Promise<void> {
1029
+ const worker = this.workers.get(run.workerId);
1030
+ const active = worker?.ownerSessionId === run.ownerSessionId &&
1031
+ worker.runId === run.id &&
1131
1032
  isActiveWorkerStatus(worker.status)
1132
- );
1133
- });
1033
+ ? [worker.id]
1034
+ : [];
1134
1035
  await this.cancelWorkers(active);
1135
- this.maybeCompleteWave(wave.id);
1036
+ this.maybeCompleteRun(run.id);
1136
1037
  }
1137
1038
 
1138
- private awaitInlineWave(
1139
- wave: WaveRecord,
1140
- waiter: WaveWaiter,
1039
+ private awaitInlineRun(
1040
+ run: RunRecord,
1041
+ waiter: RunWaiter,
1141
1042
  signal: AbortSignal | undefined,
1142
- ): Promise<CompletedWave> {
1043
+ ): Promise<CompletedRun> {
1143
1044
  if (!signal) return waiter.promise;
1144
1045
 
1145
- return new Promise<CompletedWave>((resolve, reject) => {
1046
+ return new Promise<CompletedRun>((resolve, reject) => {
1146
1047
  let abortClaimed = false;
1147
1048
  const removeAbortListener = () => signal.removeEventListener("abort", onAbort);
1148
1049
  const onAbort = () => {
@@ -1150,7 +1051,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
1150
1051
  abortClaimed = true;
1151
1052
  removeAbortListener();
1152
1053
  const reason = abortSignalReason(signal);
1153
- void this.cancelExactWave(wave).then(
1054
+ void this.cancelExactRun(run).then(
1154
1055
  () => reject(reason),
1155
1056
  () => reject(reason),
1156
1057
  );
@@ -1176,8 +1077,8 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
1176
1077
  outcome: { status: "closed" },
1177
1078
  settledAt: this.clock(),
1178
1079
  });
1179
- const affectedOwners = this.maybeCompleteWave(current.waveId);
1180
- addAll(affectedOwners, this.rememberTerminalWorker(current.id));
1080
+ this.maybeCompleteRun(current.runId);
1081
+ const affectedOwners = this.rememberTerminalWorker(current.id);
1181
1082
  affectedOwners.add(current.ownerSessionId);
1182
1083
  this.emitStateForOwners(affectedOwners);
1183
1084
  }
@@ -1308,7 +1209,7 @@ function validateContextOwner(ownerSessionId: string): void {
1308
1209
  }
1309
1210
  }
1310
1211
 
1311
- function validateMode(mode: WaveMode): void {
1212
+ function validateMode(mode: RunMode): void {
1312
1213
  if (mode !== "async" && mode !== "inline") throw new Error("Invalid orchestration mode");
1313
1214
  }
1314
1215
 
@@ -1331,6 +1232,12 @@ function isActiveWorkerStatus(status: WorkerRecord["status"]): boolean {
1331
1232
  return status === "starting" || status === "running" || status === "stopping";
1332
1233
  }
1333
1234
 
1235
+ function isSettledWorkerStatus(
1236
+ status: WorkerRecord["status"],
1237
+ ): status is CompletedResult["status"] {
1238
+ return status === "completed" || status === "ready" || status === "failed" || status === "aborted";
1239
+ }
1240
+
1334
1241
  function safelyCall(callback: (() => void) | undefined): void {
1335
1242
  if (!callback) return;
1336
1243
  try {
@@ -1345,7 +1252,8 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
1345
1252
  }
1346
1253
 
1347
1254
  function abortSignalReason(signal: AbortSignal): unknown {
1348
- return signal.reason ?? new DOMException("This operation was aborted", "AbortError");
1255
+ if ("reason" in signal) return signal.reason;
1256
+ return new DOMException("This operation was aborted", "AbortError");
1349
1257
  }
1350
1258
 
1351
1259
  function addAll(target: Set<string>, source: ReadonlySet<string>): void {
@@ -1364,18 +1272,18 @@ function notifySettlementListener(
1364
1272
  }
1365
1273
  }
1366
1274
 
1367
- function makeWaveWaiter(): WaveWaiter {
1368
- let complete!: (wave: CompletedWave) => void;
1369
- const waiter: WaveWaiter = {
1370
- promise: new Promise<CompletedWave>((resolve) => {
1275
+ function makeRunWaiter(): RunWaiter {
1276
+ let complete!: (run: CompletedRun) => void;
1277
+ const waiter: RunWaiter = {
1278
+ promise: new Promise<CompletedRun>((resolve) => {
1371
1279
  complete = resolve;
1372
1280
  }),
1373
1281
  settled: false,
1374
- resolve(wave) {
1282
+ resolve(run) {
1375
1283
  if (waiter.settled) return;
1376
1284
  waiter.settled = true;
1377
1285
  waiter.onSettled?.();
1378
- complete(wave);
1286
+ complete(run);
1379
1287
  },
1380
1288
  };
1381
1289
  return waiter;
@@ -1393,15 +1301,12 @@ function copyUsage(usage: WorkerUsage): WorkerUsage {
1393
1301
  };
1394
1302
  }
1395
1303
 
1396
- function copyOutcome(outcome: WorkerOutcome): WorkerOutcome {
1304
+ function copyOutcome<Outcome extends WorkerOutcome>(outcome: Outcome): Outcome {
1397
1305
  return { ...outcome };
1398
1306
  }
1399
1307
 
1400
- function copyWaveRecord(wave: WaveRecord): WaveRecord {
1401
- return Object.freeze({
1402
- ...wave,
1403
- workerIds: Object.freeze([...wave.workerIds]),
1404
- });
1308
+ function copyRunRecord(run: RunRecord): RunRecord {
1309
+ return Object.freeze({ ...run });
1405
1310
  }
1406
1311
 
1407
1312
  function copyWorkerRecord(worker: WorkerRecord): WorkerRecord {
@@ -1412,32 +1317,36 @@ function copyWorkerRecord(worker: WorkerRecord): WorkerRecord {
1412
1317
  });
1413
1318
  }
1414
1319
 
1415
- function freezeAcceptedWave(
1416
- id: WaveId,
1417
- workerIds: readonly WorkerId[],
1418
- ): AcceptedWave {
1419
- return Object.freeze({ id, workerIds: Object.freeze([...workerIds]) });
1320
+ function freezeAcceptedRun(id: RunId, workerId: WorkerId): AcceptedRun {
1321
+ return Object.freeze({ id, workerId });
1420
1322
  }
1421
1323
 
1422
- function freezeCompletedWave(
1423
- wave: WaveRecord,
1424
- records: readonly WorkerRecord[],
1425
- ): CompletedWave {
1426
- const results = records.map<CompletedResult>((record) =>
1427
- Object.freeze({
1428
- workerId: record.id,
1429
- worker: record.worker,
1430
- title: record.title,
1431
- status: record.status as WaveCompleteWorkerStatus,
1432
- outcome: Object.freeze(copyOutcome(record.outcome!)),
1433
- usage: Object.freeze(copyUsage(record.usage)),
1434
- sessionFile: record.sessionFile,
1435
- }),
1436
- );
1324
+ function freezeCompletedRun(
1325
+ run: RunRecord,
1326
+ record: WorkerRecord,
1327
+ ): CompletedRun {
1328
+ const outcome = record.outcome;
1329
+ if (!outcome || outcome.status === "closed") {
1330
+ throw new Error("Completed run requires a worker response outcome");
1331
+ }
1332
+ if (record.settledAt === undefined) {
1333
+ throw new Error("Completed run requires a settlement timestamp");
1334
+ }
1335
+ const result: CompletedResult = Object.freeze({
1336
+ workerId: record.id,
1337
+ worker: record.worker,
1338
+ title: record.title,
1339
+ status: record.status as CompletedResult["status"],
1340
+ outcome: Object.freeze(copyOutcome(outcome)),
1341
+ usage: Object.freeze(copyUsage(record.usage)),
1342
+ startedAt: record.startedAt,
1343
+ settledAt: record.settledAt,
1344
+ sessionFile: record.sessionFile,
1345
+ });
1437
1346
  return Object.freeze({
1438
- id: wave.id,
1439
- ownerSessionId: wave.ownerSessionId,
1440
- mode: wave.mode,
1441
- results: Object.freeze(results),
1347
+ id: run.id,
1348
+ ownerSessionId: run.ownerSessionId,
1349
+ mode: run.mode,
1350
+ result,
1442
1351
  });
1443
1352
  }