@zachwill/pi-orchestrate 0.1.0 → 0.1.1

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.
@@ -72,6 +72,31 @@ export interface CompletedWave {
72
72
  readonly results: readonly CompletedResult[];
73
73
  }
74
74
 
75
+ export type SettlementFailureStage = "startup" | "prompt" | "workflow" | "cancellation";
76
+
77
+ export interface WorkerSettlement {
78
+ readonly eventId: string;
79
+ readonly sequence: number;
80
+ readonly ownerSessionId: string;
81
+ readonly waveId: WaveId;
82
+ readonly workerId: WorkerId;
83
+ readonly generation: number;
84
+ readonly mode: WaveMode;
85
+ readonly worker: string;
86
+ readonly title: string;
87
+ readonly lifecycle: WorkerRecord["lifecycle"];
88
+ readonly status: Exclude<WaveCompleteWorkerStatus, "closed">;
89
+ readonly outcome: WorkerOutcome;
90
+ readonly failureStage?: SettlementFailureStage;
91
+ readonly usage: WorkerUsage;
92
+ readonly startedAt: number;
93
+ readonly settledAt: number;
94
+ readonly remainingActive: number;
95
+ readonly waveSize: number;
96
+ readonly waveComplete: boolean;
97
+ readonly sessionFile: string | undefined;
98
+ }
99
+
75
100
  export interface RuntimeSnapshot {
76
101
  readonly waves: readonly WaveRecord[];
77
102
  readonly workers: readonly WorkerRecord[];
@@ -79,6 +104,8 @@ export interface RuntimeSnapshot {
79
104
 
80
105
  export type CompletionListener = (wave: CompletedWave) => void;
81
106
  export type UnsubscribeCompletion = () => void;
107
+ export type SettlementListener = (settlement: WorkerSettlement) => void;
108
+ export type UnsubscribeSettlement = () => void;
82
109
  export type StateListener = (ownerSessionId: string) => void;
83
110
 
84
111
  export type AbortTarget =
@@ -118,18 +145,21 @@ export interface OrchestratorRuntime {
118
145
  tasks: readonly OrchestrateTaskInput[],
119
146
  mode: "async",
120
147
  signal?: AbortSignal,
148
+ onSettlement?: SettlementListener,
121
149
  ): Promise<AcceptedWave>;
122
150
  orchestrate(
123
151
  context: OrchestrationContext,
124
152
  tasks: readonly OrchestrateTaskInput[],
125
153
  mode: "inline",
126
154
  signal?: AbortSignal,
155
+ onSettlement?: SettlementListener,
127
156
  ): Promise<CompletedWave>;
128
157
  orchestrate(
129
158
  context: OrchestrationContext,
130
159
  tasks: readonly OrchestrateTaskInput[],
131
160
  mode: WaveMode,
132
161
  signal?: AbortSignal,
162
+ onSettlement?: SettlementListener,
133
163
  ): Promise<AcceptedWave | CompletedWave>;
134
164
  send(
135
165
  context: OrchestrationContext,
@@ -137,6 +167,7 @@ export interface OrchestratorRuntime {
137
167
  instructions: string,
138
168
  mode: "async",
139
169
  signal?: AbortSignal,
170
+ onSettlement?: SettlementListener,
140
171
  ): Promise<AcceptedWave>;
141
172
  send(
142
173
  context: OrchestrationContext,
@@ -144,6 +175,7 @@ export interface OrchestratorRuntime {
144
175
  instructions: string,
145
176
  mode: "inline",
146
177
  signal?: AbortSignal,
178
+ onSettlement?: SettlementListener,
147
179
  ): Promise<CompletedWave>;
148
180
  send(
149
181
  context: OrchestrationContext,
@@ -151,11 +183,13 @@ export interface OrchestratorRuntime {
151
183
  instructions: string,
152
184
  mode: WaveMode,
153
185
  signal?: AbortSignal,
186
+ onSettlement?: SettlementListener,
154
187
  ): Promise<AcceptedWave | CompletedWave>;
155
188
  abort(ownerSessionId: string, target: AbortTarget): Promise<void>;
156
189
  close(ownerSessionId: string, workerId: WorkerId): Promise<void>;
157
190
  snapshot(ownerSessionId: string): Promise<RuntimeSnapshot>;
158
191
  subscribeCompletion(listener: CompletionListener): UnsubscribeCompletion;
192
+ subscribeSettlement(listener: SettlementListener): UnsubscribeSettlement;
159
193
  subscribeState(listener: StateListener): () => void;
160
194
  shutdown(): Promise<void>;
161
195
  }
@@ -208,7 +242,10 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
208
242
  private readonly terminalWorkerOrder: WorkerId[] = [];
209
243
  private readonly completedWaveOrder: WaveId[] = [];
210
244
  private readonly completionListeners = new Set<CompletionListener>();
245
+ private readonly settlementListeners = new Set<SettlementListener>();
246
+ private readonly waveSettlementListeners = new Map<WaveId, SettlementListener>();
211
247
  private readonly stateListeners = new Set<StateListener>();
248
+ private settlementSequence = 0;
212
249
  private readonly disposedSessions = new WeakSet<WorkerSessionHandle>();
213
250
  private shuttingDown = false;
214
251
  private shutdownPromise: Promise<void> | undefined;
@@ -226,24 +263,28 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
226
263
  tasks: readonly OrchestrateTaskInput[],
227
264
  mode: "async",
228
265
  signal?: AbortSignal,
266
+ onSettlement?: SettlementListener,
229
267
  ): Promise<AcceptedWave>;
230
268
  orchestrate(
231
269
  context: OrchestrationContext,
232
270
  tasks: readonly OrchestrateTaskInput[],
233
271
  mode: "inline",
234
272
  signal?: AbortSignal,
273
+ onSettlement?: SettlementListener,
235
274
  ): Promise<CompletedWave>;
236
275
  orchestrate(
237
276
  context: OrchestrationContext,
238
277
  tasks: readonly OrchestrateTaskInput[],
239
278
  mode: WaveMode,
240
279
  signal?: AbortSignal,
280
+ onSettlement?: SettlementListener,
241
281
  ): Promise<AcceptedWave | CompletedWave>;
242
282
  async orchestrate(
243
283
  context: OrchestrationContext,
244
284
  tasks: readonly OrchestrateTaskInput[],
245
285
  mode: WaveMode,
246
286
  signal?: AbortSignal,
287
+ onSettlement?: SettlementListener,
247
288
  ): Promise<AcceptedWave | CompletedWave> {
248
289
  this.assertOpen();
249
290
  throwIfAborted(signal);
@@ -275,12 +316,14 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
275
316
  lifecycle: definition.lifecycle,
276
317
  status: "starting",
277
318
  usage: copyUsage(EMPTY_WORKER_USAGE),
319
+ startedAt: this.clock(),
278
320
  };
279
321
  });
280
322
 
281
323
  const waiter = makeWaveWaiter();
282
324
  this.waves.set(waveId, wave);
283
325
  this.waveWaiters.set(waveId, waiter);
326
+ if (onSettlement) this.waveSettlementListeners.set(waveId, onSettlement);
284
327
  for (let index = 0; index < records.length; index += 1) {
285
328
  const record = records[index];
286
329
  const definition = definitions[index];
@@ -304,6 +347,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
304
347
  instructions: string,
305
348
  mode: "async",
306
349
  signal?: AbortSignal,
350
+ onSettlement?: SettlementListener,
307
351
  ): Promise<AcceptedWave>;
308
352
  send(
309
353
  context: OrchestrationContext,
@@ -311,6 +355,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
311
355
  instructions: string,
312
356
  mode: "inline",
313
357
  signal?: AbortSignal,
358
+ onSettlement?: SettlementListener,
314
359
  ): Promise<CompletedWave>;
315
360
  send(
316
361
  context: OrchestrationContext,
@@ -318,6 +363,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
318
363
  instructions: string,
319
364
  mode: WaveMode,
320
365
  signal?: AbortSignal,
366
+ onSettlement?: SettlementListener,
321
367
  ): Promise<AcceptedWave | CompletedWave>;
322
368
  async send(
323
369
  context: OrchestrationContext,
@@ -325,6 +371,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
325
371
  instructions: string,
326
372
  mode: WaveMode,
327
373
  signal?: AbortSignal,
374
+ onSettlement?: SettlementListener,
328
375
  ): Promise<AcceptedWave | CompletedWave> {
329
376
  this.assertOpen();
330
377
  throwIfAborted(signal);
@@ -354,6 +401,8 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
354
401
  waveId,
355
402
  instructions,
356
403
  activity: undefined,
404
+ startedAt: this.clock(),
405
+ settledAt: undefined,
357
406
  };
358
407
  const waiter = makeWaveWaiter();
359
408
 
@@ -361,6 +410,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
361
410
  const generation = entry.generation;
362
411
  this.waves.set(waveId, wave);
363
412
  this.waveWaiters.set(waveId, waiter);
413
+ if (onSettlement) this.waveSettlementListeners.set(waveId, onSettlement);
364
414
  this.workers.set(workerId, running);
365
415
  this.subscribeEntryObservability(workerId, entry, entry.session, generation);
366
416
  this.emitState(context.ownerSessionId);
@@ -414,6 +464,17 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
414
464
  };
415
465
  }
416
466
 
467
+ subscribeSettlement(listener: SettlementListener): UnsubscribeSettlement {
468
+ if (typeof listener !== "function") throw new Error("Settlement listener must be a function");
469
+ this.settlementListeners.add(listener);
470
+ let subscribed = true;
471
+ return () => {
472
+ if (!subscribed) return;
473
+ subscribed = false;
474
+ this.settlementListeners.delete(listener);
475
+ };
476
+ }
477
+
417
478
  subscribeState(listener: StateListener): () => void {
418
479
  if (typeof listener !== "function") throw new Error("State listener must be a function");
419
480
  this.stateListeners.add(listener);
@@ -446,6 +507,10 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
446
507
  // Scheduler closure is best-effort during process shutdown.
447
508
  } finally {
448
509
  await this.awaitTrackedCleanupBestEffort();
510
+ this.waveSettlementListeners.clear();
511
+ this.completionListeners.clear();
512
+ this.settlementListeners.clear();
513
+ this.stateListeners.clear();
449
514
  }
450
515
  }
451
516
  }
@@ -490,11 +555,15 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
490
555
  }
491
556
 
492
557
  private launchBootstrap(workerId: WorkerId, generation: number): void {
493
- this.scheduler.start(
494
- workerId,
495
- () => this.trackCleanup(this.bootstrapAndPrompt(workerId, generation)),
496
- (error) => this.settleWorkflowDefect(workerId, generation, error),
497
- );
558
+ try {
559
+ this.scheduler.start(
560
+ workerId,
561
+ () => this.trackCleanup(this.bootstrapAndPrompt(workerId, generation)),
562
+ (error) => this.settleWorkflowDefect(workerId, generation, error),
563
+ );
564
+ } catch (error) {
565
+ this.settleWorkflowDefect(workerId, generation, error);
566
+ }
498
567
  }
499
568
 
500
569
  private launchPrompt(
@@ -503,11 +572,15 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
503
572
  session: WorkerSessionHandle,
504
573
  instructions: string,
505
574
  ): void {
506
- this.scheduler.start(
507
- workerId,
508
- () => this.trackCleanup(this.executePrompt(workerId, generation, session, instructions)),
509
- (error) => this.settleWorkflowDefect(workerId, generation, error),
510
- );
575
+ try {
576
+ this.scheduler.start(
577
+ workerId,
578
+ () => this.trackCleanup(this.executePrompt(workerId, generation, session, instructions)),
579
+ (error) => this.settleWorkflowDefect(workerId, generation, error),
580
+ );
581
+ } catch (error) {
582
+ this.settleWorkflowDefect(workerId, generation, error);
583
+ }
511
584
  }
512
585
 
513
586
  private async bootstrapAndPrompt(workerId: WorkerId, generation: number): Promise<void> {
@@ -606,7 +679,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
606
679
  this.settleTerminalWorker(current, "failed", {
607
680
  status: "failed",
608
681
  message: describeError(error, "Worker session creation failed"),
609
- });
682
+ }, "startup");
610
683
  }
611
684
 
612
685
  private settleWorkflowDefect(
@@ -626,7 +699,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
626
699
  this.settleTerminalWorker(current, "failed", {
627
700
  status: "failed",
628
701
  message: describeError(error, "Worker workflow failed"),
629
- });
702
+ }, "workflow");
630
703
  }
631
704
 
632
705
  private settleOutcome(
@@ -663,17 +736,27 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
663
736
  };
664
737
  }
665
738
 
739
+ const settledAt = this.clock();
666
740
  this.workers.set(workerId, {
667
741
  ...transitionWorkerStatus(current, status),
668
742
  activity: undefined,
669
743
  outcome: copyOutcome(settledOutcome),
744
+ settledAt,
670
745
  });
671
746
  if (status !== "ready") this.disposeEntrySession(entry);
672
747
  const affectedOwners = this.maybeCompleteWave(current.waveId);
748
+ affectedOwners.add(current.ownerSessionId);
749
+ this.emitSettlement(
750
+ workerId,
751
+ generation,
752
+ settledAt,
753
+ status === "failed" ? "prompt" : undefined,
754
+ );
673
755
  if (isTerminalWorkerStatus(status)) {
674
756
  addAll(affectedOwners, this.rememberTerminalWorker(workerId));
757
+ } else {
758
+ addAll(affectedOwners, this.pruneHistory());
675
759
  }
676
- affectedOwners.add(current.ownerSessionId);
677
760
  this.emitStateForOwners(affectedOwners);
678
761
  }
679
762
 
@@ -681,18 +764,91 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
681
764
  current: WorkerRecord,
682
765
  status: "failed" | "aborted",
683
766
  outcome: WorkerOutcome,
767
+ failureStage?: SettlementFailureStage,
684
768
  ): void {
769
+ const settledAt = this.clock();
685
770
  this.workers.set(current.id, {
686
771
  ...transitionWorkerStatus(current, status),
687
772
  activity: undefined,
688
773
  outcome: copyOutcome(outcome),
774
+ settledAt,
689
775
  });
690
776
  const affectedOwners = this.maybeCompleteWave(current.waveId);
691
- addAll(affectedOwners, this.rememberTerminalWorker(current.id));
692
777
  affectedOwners.add(current.ownerSessionId);
778
+ const generation = this.entries.get(current.id)?.generation;
779
+ if (generation !== undefined) {
780
+ this.emitSettlement(current.id, generation, settledAt, failureStage);
781
+ }
782
+ addAll(affectedOwners, this.rememberTerminalWorker(current.id));
693
783
  this.emitStateForOwners(affectedOwners);
694
784
  }
695
785
 
786
+ private emitSettlement(
787
+ workerId: WorkerId,
788
+ generation: number,
789
+ settledAt: number,
790
+ failureStage?: SettlementFailureStage,
791
+ ): void {
792
+ const worker = this.workers.get(workerId);
793
+ const wave = worker ? this.waves.get(worker.waveId) : undefined;
794
+ if (!worker || !wave || !worker.outcome || !isWorkerCompleteForWave(worker.status)) return;
795
+ if (worker.status === "closed") return;
796
+
797
+ let remainingActive = 0;
798
+ let waveComplete = true;
799
+ for (const waveWorkerId of wave.workerIds) {
800
+ const waveWorker = this.workers.get(waveWorkerId);
801
+ if (!waveWorker || waveWorker.waveId !== wave.id || !isWorkerCompleteForWave(waveWorker.status)) {
802
+ waveComplete = false;
803
+ if (waveWorker && isActiveWorkerStatus(waveWorker.status)) remainingActive += 1;
804
+ }
805
+ }
806
+
807
+ const sequence = ++this.settlementSequence;
808
+ const settlement: WorkerSettlement = Object.freeze({
809
+ eventId: `${sequence}:${wave.id}:${workerId}:${generation}`,
810
+ sequence,
811
+ ownerSessionId: worker.ownerSessionId,
812
+ waveId: wave.id,
813
+ workerId,
814
+ generation,
815
+ mode: wave.mode,
816
+ worker: worker.worker,
817
+ title: worker.title,
818
+ lifecycle: worker.lifecycle,
819
+ status: worker.status,
820
+ outcome: Object.freeze(copyOutcome(worker.outcome)),
821
+ ...(failureStage ? { failureStage } : {}),
822
+ usage: Object.freeze(copyUsage(worker.usage)),
823
+ startedAt: worker.startedAt,
824
+ settledAt,
825
+ remainingActive,
826
+ waveSize: wave.workerIds.length,
827
+ waveComplete,
828
+ sessionFile: worker.sessionFile,
829
+ });
830
+
831
+ const localListener = this.waveSettlementListeners.get(wave.id);
832
+ if (waveComplete) this.waveSettlementListeners.delete(wave.id);
833
+ notifySettlementListener(localListener, settlement);
834
+ for (const listener of [...this.settlementListeners]) {
835
+ notifySettlementListener(listener, settlement);
836
+ }
837
+ if (waveComplete) this.emitCompletedWave(wave.id);
838
+ }
839
+
840
+ private emitCompletedWave(waveId: WaveId): void {
841
+ const completed = this.completedWaves.get(waveId);
842
+ if (!completed || completed.mode !== "async") return;
843
+ for (const listener of [...this.completionListeners]) {
844
+ try {
845
+ listener(completed);
846
+ } catch {
847
+ // One subscriber cannot prevent other subscribers from receiving completion.
848
+ }
849
+ }
850
+ }
851
+
696
852
  private maybeCompleteWave(waveId: WaveId): Set<string> {
697
853
  const affectedOwners = new Set<string>();
698
854
  if (this.completedWaves.has(waveId)) return affectedOwners;
@@ -719,17 +875,6 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
719
875
  this.waveWaiters.get(waveId)?.resolve(completed);
720
876
  this.waveWaiters.delete(waveId);
721
877
 
722
- if (wave.mode === "async") {
723
- for (const listener of [...this.completionListeners]) {
724
- try {
725
- listener(completed);
726
- } catch {
727
- // One subscriber cannot prevent other subscribers from receiving completion.
728
- }
729
- }
730
- }
731
-
732
- addAll(affectedOwners, this.pruneHistory());
733
878
  return affectedOwners;
734
879
  }
735
880
 
@@ -884,7 +1029,12 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
884
1029
  if (entry) this.disposeEntrySession(entry);
885
1030
  const current = this.workers.get(workerId);
886
1031
  if (current?.status === "stopping") {
887
- this.settleTerminalWorker(current, "aborted", { status: "aborted" });
1032
+ this.settleTerminalWorker(
1033
+ current,
1034
+ "aborted",
1035
+ { status: "aborted" },
1036
+ "cancellation",
1037
+ );
888
1038
  }
889
1039
  }
890
1040
  }
@@ -897,7 +1047,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
897
1047
  this.settleTerminalWorker(current, "failed", {
898
1048
  status: "failed",
899
1049
  message: describeError(error, "Worker cancellation failed"),
900
- });
1050
+ }, "cancellation");
901
1051
  }
902
1052
 
903
1053
  private async cancelExactWave(wave: WaveRecord): Promise<void> {
@@ -952,6 +1102,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
952
1102
  ...transitionWorkerStatus(current, "closed"),
953
1103
  activity: undefined,
954
1104
  outcome: { status: "closed" },
1105
+ settledAt: this.clock(),
955
1106
  });
956
1107
  const affectedOwners = this.maybeCompleteWave(current.waveId);
957
1108
  addAll(affectedOwners, this.rememberTerminalWorker(current.id));
@@ -1114,6 +1265,18 @@ function addAll(target: Set<string>, source: ReadonlySet<string>): void {
1114
1265
  for (const value of source) target.add(value);
1115
1266
  }
1116
1267
 
1268
+ function notifySettlementListener(
1269
+ listener: SettlementListener | undefined,
1270
+ settlement: WorkerSettlement,
1271
+ ): void {
1272
+ if (!listener) return;
1273
+ try {
1274
+ listener(settlement);
1275
+ } catch {
1276
+ // One observer cannot prevent settlement or other observers from being notified.
1277
+ }
1278
+ }
1279
+
1117
1280
  function makeWaveWaiter(): WaveWaiter {
1118
1281
  let complete!: (wave: CompletedWave) => void;
1119
1282
  const waiter: WaveWaiter = {