@zachwill/pi-orchestrate 0.1.0 → 0.2.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.
@@ -1,5 +1,6 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import { Effect, Option } from "effect";
3
4
  import {
4
5
  CANCELLATION_GRACE_MS,
5
6
  EMPTY_WORKER_USAGE,
@@ -72,6 +73,31 @@ export interface CompletedWave {
72
73
  readonly results: readonly CompletedResult[];
73
74
  }
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;
99
+ }
100
+
75
101
  export interface RuntimeSnapshot {
76
102
  readonly waves: readonly WaveRecord[];
77
103
  readonly workers: readonly WorkerRecord[];
@@ -79,6 +105,8 @@ export interface RuntimeSnapshot {
79
105
 
80
106
  export type CompletionListener = (wave: CompletedWave) => void;
81
107
  export type UnsubscribeCompletion = () => void;
108
+ export type SettlementListener = (settlement: WorkerSettlement) => void;
109
+ export type UnsubscribeSettlement = () => void;
82
110
  export type StateListener = (ownerSessionId: string) => void;
83
111
 
84
112
  export type AbortTarget =
@@ -118,18 +146,21 @@ export interface OrchestratorRuntime {
118
146
  tasks: readonly OrchestrateTaskInput[],
119
147
  mode: "async",
120
148
  signal?: AbortSignal,
149
+ onSettlement?: SettlementListener,
121
150
  ): Promise<AcceptedWave>;
122
151
  orchestrate(
123
152
  context: OrchestrationContext,
124
153
  tasks: readonly OrchestrateTaskInput[],
125
154
  mode: "inline",
126
155
  signal?: AbortSignal,
156
+ onSettlement?: SettlementListener,
127
157
  ): Promise<CompletedWave>;
128
158
  orchestrate(
129
159
  context: OrchestrationContext,
130
160
  tasks: readonly OrchestrateTaskInput[],
131
161
  mode: WaveMode,
132
162
  signal?: AbortSignal,
163
+ onSettlement?: SettlementListener,
133
164
  ): Promise<AcceptedWave | CompletedWave>;
134
165
  send(
135
166
  context: OrchestrationContext,
@@ -137,6 +168,7 @@ export interface OrchestratorRuntime {
137
168
  instructions: string,
138
169
  mode: "async",
139
170
  signal?: AbortSignal,
171
+ onSettlement?: SettlementListener,
140
172
  ): Promise<AcceptedWave>;
141
173
  send(
142
174
  context: OrchestrationContext,
@@ -144,6 +176,7 @@ export interface OrchestratorRuntime {
144
176
  instructions: string,
145
177
  mode: "inline",
146
178
  signal?: AbortSignal,
179
+ onSettlement?: SettlementListener,
147
180
  ): Promise<CompletedWave>;
148
181
  send(
149
182
  context: OrchestrationContext,
@@ -151,11 +184,13 @@ export interface OrchestratorRuntime {
151
184
  instructions: string,
152
185
  mode: WaveMode,
153
186
  signal?: AbortSignal,
187
+ onSettlement?: SettlementListener,
154
188
  ): Promise<AcceptedWave | CompletedWave>;
155
189
  abort(ownerSessionId: string, target: AbortTarget): Promise<void>;
156
190
  close(ownerSessionId: string, workerId: WorkerId): Promise<void>;
157
191
  snapshot(ownerSessionId: string): Promise<RuntimeSnapshot>;
158
192
  subscribeCompletion(listener: CompletionListener): UnsubscribeCompletion;
193
+ subscribeSettlement(listener: SettlementListener): UnsubscribeSettlement;
159
194
  subscribeState(listener: StateListener): () => void;
160
195
  shutdown(): Promise<void>;
161
196
  }
@@ -167,6 +202,7 @@ interface RuntimeEntry {
167
202
  session?: WorkerSessionHandle;
168
203
  unsubscribeUsage?: () => void;
169
204
  unsubscribeActivity?: () => void;
205
+ unsubscribeMessageDirection?: () => void;
170
206
  }
171
207
 
172
208
  interface WaveWaiter {
@@ -178,17 +214,16 @@ interface WaveWaiter {
178
214
 
179
215
  const defaultBestEffortDeadline: BestEffortDeadline = {
180
216
  wait(promise, timeoutMs) {
181
- let timer: ReturnType<typeof setTimeout> | undefined;
182
- const settled = promise.then(
217
+ const settled = Effect.promise(() => promise.then(
183
218
  () => "settled" as const,
184
219
  () => "settled" as const,
220
+ ));
221
+ return Effect.runPromise(
222
+ settled.pipe(
223
+ Effect.timeoutOption(timeoutMs),
224
+ Effect.map((result) => Option.getOrElse(result, () => "timed-out" as const)),
225
+ ),
185
226
  );
186
- const timedOut = new Promise<DeadlineResult>((resolve) => {
187
- timer = setTimeout(() => resolve("timed-out"), timeoutMs);
188
- });
189
- return Promise.race([settled, timedOut]).finally(() => {
190
- if (timer !== undefined) clearTimeout(timer);
191
- });
192
227
  },
193
228
  };
194
229
 
@@ -208,7 +243,10 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
208
243
  private readonly terminalWorkerOrder: WorkerId[] = [];
209
244
  private readonly completedWaveOrder: WaveId[] = [];
210
245
  private readonly completionListeners = new Set<CompletionListener>();
246
+ private readonly settlementListeners = new Set<SettlementListener>();
247
+ private readonly waveSettlementListeners = new Map<WaveId, SettlementListener>();
211
248
  private readonly stateListeners = new Set<StateListener>();
249
+ private settlementSequence = 0;
212
250
  private readonly disposedSessions = new WeakSet<WorkerSessionHandle>();
213
251
  private shuttingDown = false;
214
252
  private shutdownPromise: Promise<void> | undefined;
@@ -226,24 +264,28 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
226
264
  tasks: readonly OrchestrateTaskInput[],
227
265
  mode: "async",
228
266
  signal?: AbortSignal,
267
+ onSettlement?: SettlementListener,
229
268
  ): Promise<AcceptedWave>;
230
269
  orchestrate(
231
270
  context: OrchestrationContext,
232
271
  tasks: readonly OrchestrateTaskInput[],
233
272
  mode: "inline",
234
273
  signal?: AbortSignal,
274
+ onSettlement?: SettlementListener,
235
275
  ): Promise<CompletedWave>;
236
276
  orchestrate(
237
277
  context: OrchestrationContext,
238
278
  tasks: readonly OrchestrateTaskInput[],
239
279
  mode: WaveMode,
240
280
  signal?: AbortSignal,
281
+ onSettlement?: SettlementListener,
241
282
  ): Promise<AcceptedWave | CompletedWave>;
242
283
  async orchestrate(
243
284
  context: OrchestrationContext,
244
285
  tasks: readonly OrchestrateTaskInput[],
245
286
  mode: WaveMode,
246
287
  signal?: AbortSignal,
288
+ onSettlement?: SettlementListener,
247
289
  ): Promise<AcceptedWave | CompletedWave> {
248
290
  this.assertOpen();
249
291
  throwIfAborted(signal);
@@ -275,12 +317,15 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
275
317
  lifecycle: definition.lifecycle,
276
318
  status: "starting",
277
319
  usage: copyUsage(EMPTY_WORKER_USAGE),
320
+ messageDirection: "to-model",
321
+ startedAt: this.clock(),
278
322
  };
279
323
  });
280
324
 
281
325
  const waiter = makeWaveWaiter();
282
326
  this.waves.set(waveId, wave);
283
327
  this.waveWaiters.set(waveId, waiter);
328
+ if (onSettlement) this.waveSettlementListeners.set(waveId, onSettlement);
284
329
  for (let index = 0; index < records.length; index += 1) {
285
330
  const record = records[index];
286
331
  const definition = definitions[index];
@@ -304,6 +349,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
304
349
  instructions: string,
305
350
  mode: "async",
306
351
  signal?: AbortSignal,
352
+ onSettlement?: SettlementListener,
307
353
  ): Promise<AcceptedWave>;
308
354
  send(
309
355
  context: OrchestrationContext,
@@ -311,6 +357,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
311
357
  instructions: string,
312
358
  mode: "inline",
313
359
  signal?: AbortSignal,
360
+ onSettlement?: SettlementListener,
314
361
  ): Promise<CompletedWave>;
315
362
  send(
316
363
  context: OrchestrationContext,
@@ -318,6 +365,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
318
365
  instructions: string,
319
366
  mode: WaveMode,
320
367
  signal?: AbortSignal,
368
+ onSettlement?: SettlementListener,
321
369
  ): Promise<AcceptedWave | CompletedWave>;
322
370
  async send(
323
371
  context: OrchestrationContext,
@@ -325,6 +373,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
325
373
  instructions: string,
326
374
  mode: WaveMode,
327
375
  signal?: AbortSignal,
376
+ onSettlement?: SettlementListener,
328
377
  ): Promise<AcceptedWave | CompletedWave> {
329
378
  this.assertOpen();
330
379
  throwIfAborted(signal);
@@ -349,11 +398,14 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
349
398
  state: "running",
350
399
  createdAt: this.clock(),
351
400
  };
352
- const running = {
401
+ const running: WorkerRecord = {
353
402
  ...transitionWorkerStatus(current, "running"),
354
403
  waveId,
355
404
  instructions,
356
405
  activity: undefined,
406
+ messageDirection: "to-model",
407
+ startedAt: this.clock(),
408
+ settledAt: undefined,
357
409
  };
358
410
  const waiter = makeWaveWaiter();
359
411
 
@@ -361,6 +413,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
361
413
  const generation = entry.generation;
362
414
  this.waves.set(waveId, wave);
363
415
  this.waveWaiters.set(waveId, waiter);
416
+ if (onSettlement) this.waveSettlementListeners.set(waveId, onSettlement);
364
417
  this.workers.set(workerId, running);
365
418
  this.subscribeEntryObservability(workerId, entry, entry.session, generation);
366
419
  this.emitState(context.ownerSessionId);
@@ -414,6 +467,17 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
414
467
  };
415
468
  }
416
469
 
470
+ subscribeSettlement(listener: SettlementListener): UnsubscribeSettlement {
471
+ if (typeof listener !== "function") throw new Error("Settlement listener must be a function");
472
+ this.settlementListeners.add(listener);
473
+ let subscribed = true;
474
+ return () => {
475
+ if (!subscribed) return;
476
+ subscribed = false;
477
+ this.settlementListeners.delete(listener);
478
+ };
479
+ }
480
+
417
481
  subscribeState(listener: StateListener): () => void {
418
482
  if (typeof listener !== "function") throw new Error("State listener must be a function");
419
483
  this.stateListeners.add(listener);
@@ -446,6 +510,10 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
446
510
  // Scheduler closure is best-effort during process shutdown.
447
511
  } finally {
448
512
  await this.awaitTrackedCleanupBestEffort();
513
+ this.waveSettlementListeners.clear();
514
+ this.completionListeners.clear();
515
+ this.settlementListeners.clear();
516
+ this.stateListeners.clear();
449
517
  }
450
518
  }
451
519
  }
@@ -490,11 +558,15 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
490
558
  }
491
559
 
492
560
  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
- );
561
+ try {
562
+ this.scheduler.start(
563
+ workerId,
564
+ this.bootstrapAndPrompt(workerId, generation),
565
+ (error) => this.settleWorkflowDefect(workerId, generation, error),
566
+ );
567
+ } catch (error) {
568
+ this.settleWorkflowDefect(workerId, generation, error);
569
+ }
498
570
  }
499
571
 
500
572
  private launchPrompt(
@@ -503,51 +575,101 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
503
575
  session: WorkerSessionHandle,
504
576
  instructions: string,
505
577
  ): void {
506
- this.scheduler.start(
507
- workerId,
508
- () => this.trackCleanup(this.executePrompt(workerId, generation, session, instructions)),
509
- (error) => this.settleWorkflowDefect(workerId, generation, error),
510
- );
578
+ try {
579
+ this.scheduler.start(
580
+ workerId,
581
+ this.executePrompt(workerId, generation, session, instructions),
582
+ (error) => this.settleWorkflowDefect(workerId, generation, error),
583
+ );
584
+ } catch (error) {
585
+ this.settleWorkflowDefect(workerId, generation, error);
586
+ }
511
587
  }
512
588
 
513
- private async bootstrapAndPrompt(workerId: WorkerId, generation: number): Promise<void> {
514
- const session = await this.bootstrap(workerId, generation);
515
- if (!session) return;
516
- const current = this.workers.get(workerId);
517
- if (!current) return;
518
- await this.executePrompt(workerId, generation, session, current.instructions);
589
+ private bootstrapAndPrompt(
590
+ workerId: WorkerId,
591
+ generation: number,
592
+ ): Effect.Effect<void, never> {
593
+ const runtime = this;
594
+ return Effect.gen(function* () {
595
+ const session = yield* runtime.bootstrap(workerId, generation);
596
+ if (!session) return;
597
+ const current = runtime.workers.get(workerId);
598
+ if (!current) return;
599
+ yield* runtime.executePrompt(workerId, generation, session, current.instructions);
600
+ });
519
601
  }
520
602
 
521
- private async bootstrap(
603
+ private bootstrap(
522
604
  workerId: WorkerId,
523
605
  generation: number,
524
- ): Promise<WorkerSessionHandle | undefined> {
525
- const entry = this.entries.get(workerId);
526
- if (!entry) return undefined;
606
+ ): Effect.Effect<WorkerSessionHandle | undefined, never> {
607
+ return Effect.suspend(() => {
608
+ const entry = this.entries.get(workerId);
609
+ if (!entry) return Effect.succeed(undefined);
527
610
 
528
- let session: WorkerSessionHandle;
529
- try {
530
- session = await this.workerSessionFactory.create({
531
- cwd: entry.context.cwd,
532
- agentDir: entry.context.agentDir,
533
- parentSessionFile: entry.context.parentSessionFile,
534
- projectTrusted: entry.context.projectTrusted,
535
- definition: entry.definition,
536
- parentModel: entry.context.parentModel,
537
- modelRegistry: entry.context.modelRegistry,
538
- });
539
- } catch (error) {
540
- this.settleCreationFailure(workerId, generation, error);
541
- return undefined;
542
- }
611
+ let creation: Promise<WorkerSessionHandle>;
612
+ try {
613
+ creation = this.workerSessionFactory.create({
614
+ cwd: entry.context.cwd,
615
+ agentDir: entry.context.agentDir,
616
+ parentSessionFile: entry.context.parentSessionFile,
617
+ projectTrusted: entry.context.projectTrusted,
618
+ definition: entry.definition,
619
+ parentModel: entry.context.parentModel,
620
+ modelRegistry: entry.context.modelRegistry,
621
+ });
622
+ } catch (error) {
623
+ this.settleCreationFailure(workerId, generation, error);
624
+ return Effect.succeed(undefined);
625
+ }
543
626
 
627
+ this.trackCleanup(creation.then(() => undefined, () => undefined));
628
+ void creation.then((session) => {
629
+ if (!this.canAdoptCreatedSession(workerId, generation, entry)) {
630
+ this.disposeSession(session);
631
+ }
632
+ }, () => undefined);
633
+
634
+ return Effect.tryPromise({
635
+ try: () => creation,
636
+ catch: (error) => error,
637
+ }).pipe(
638
+ Effect.match({
639
+ onFailure: (error) => {
640
+ this.settleCreationFailure(workerId, generation, error);
641
+ return undefined;
642
+ },
643
+ onSuccess: (session) => this.adoptCreatedSession(
644
+ workerId,
645
+ generation,
646
+ entry,
647
+ session,
648
+ ),
649
+ }),
650
+ );
651
+ });
652
+ }
653
+
654
+ private canAdoptCreatedSession(
655
+ workerId: WorkerId,
656
+ generation: number,
657
+ entry: RuntimeEntry,
658
+ ): boolean {
544
659
  const current = this.workers.get(workerId);
545
- if (
546
- this.shuttingDown ||
547
- !current ||
548
- current.status !== "starting" ||
549
- entry.generation !== generation
550
- ) {
660
+ return !this.shuttingDown &&
661
+ current?.status === "starting" &&
662
+ entry.generation === generation;
663
+ }
664
+
665
+ private adoptCreatedSession(
666
+ workerId: WorkerId,
667
+ generation: number,
668
+ entry: RuntimeEntry,
669
+ session: WorkerSessionHandle,
670
+ ): WorkerSessionHandle | undefined {
671
+ const current = this.workers.get(workerId);
672
+ if (!this.canAdoptCreatedSession(workerId, generation, entry) || !current) {
551
673
  this.disposeSession(session);
552
674
  return undefined;
553
675
  }
@@ -568,31 +690,54 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
568
690
  }
569
691
  }
570
692
 
571
- private async executePrompt(
693
+ private executePrompt(
572
694
  workerId: WorkerId,
573
695
  generation: number,
574
696
  session: WorkerSessionHandle,
575
697
  instructions: string,
576
- ): Promise<void> {
577
- const before = this.workers.get(workerId);
578
- const entry = this.entries.get(workerId);
579
- if (
580
- !before ||
581
- before.status !== "running" ||
582
- !entry ||
583
- entry.generation !== generation ||
584
- entry.session !== session
585
- ) {
586
- return;
587
- }
698
+ ): Effect.Effect<void, never> {
699
+ return Effect.suspend(() => {
700
+ const before = this.workers.get(workerId);
701
+ const entry = this.entries.get(workerId);
702
+ if (
703
+ !before ||
704
+ before.status !== "running" ||
705
+ !entry ||
706
+ entry.generation !== generation ||
707
+ entry.session !== session
708
+ ) {
709
+ return Effect.void;
710
+ }
588
711
 
589
- let outcome: WorkerOutcome;
590
- try {
591
- outcome = await session.prompt(instructions);
592
- } catch (error) {
593
- outcome = { status: "failed", message: describeError(error, "Worker prompt failed") };
594
- }
595
- this.settleOutcome(workerId, generation, session, outcome);
712
+ let prompt: Promise<WorkerOutcome>;
713
+ try {
714
+ prompt = session.prompt(instructions);
715
+ } catch (error) {
716
+ this.settleOutcome(workerId, generation, session, {
717
+ status: "failed",
718
+ message: describeError(error, "Worker prompt failed"),
719
+ });
720
+ return Effect.void;
721
+ }
722
+ this.trackCleanup(prompt.then(() => undefined, () => undefined));
723
+
724
+ return Effect.tryPromise({
725
+ try: () => prompt,
726
+ catch: (error) => error,
727
+ }).pipe(
728
+ Effect.match({
729
+ onFailure: (error): WorkerOutcome => ({
730
+ status: "failed",
731
+ message: describeError(error, "Worker prompt failed"),
732
+ }),
733
+ onSuccess: (outcome) => outcome,
734
+ }),
735
+ Effect.tap((outcome) => Effect.sync(() => {
736
+ this.settleOutcome(workerId, generation, session, outcome);
737
+ })),
738
+ Effect.asVoid,
739
+ );
740
+ });
596
741
  }
597
742
 
598
743
  private settleCreationFailure(
@@ -606,7 +751,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
606
751
  this.settleTerminalWorker(current, "failed", {
607
752
  status: "failed",
608
753
  message: describeError(error, "Worker session creation failed"),
609
- });
754
+ }, "startup");
610
755
  }
611
756
 
612
757
  private settleWorkflowDefect(
@@ -626,7 +771,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
626
771
  this.settleTerminalWorker(current, "failed", {
627
772
  status: "failed",
628
773
  message: describeError(error, "Worker workflow failed"),
629
- });
774
+ }, "workflow");
630
775
  }
631
776
 
632
777
  private settleOutcome(
@@ -663,17 +808,27 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
663
808
  };
664
809
  }
665
810
 
811
+ const settledAt = this.clock();
666
812
  this.workers.set(workerId, {
667
813
  ...transitionWorkerStatus(current, status),
668
814
  activity: undefined,
669
815
  outcome: copyOutcome(settledOutcome),
816
+ settledAt,
670
817
  });
671
818
  if (status !== "ready") this.disposeEntrySession(entry);
672
819
  const affectedOwners = this.maybeCompleteWave(current.waveId);
820
+ affectedOwners.add(current.ownerSessionId);
821
+ this.emitSettlement(
822
+ workerId,
823
+ generation,
824
+ settledAt,
825
+ status === "failed" ? "prompt" : undefined,
826
+ );
673
827
  if (isTerminalWorkerStatus(status)) {
674
828
  addAll(affectedOwners, this.rememberTerminalWorker(workerId));
829
+ } else {
830
+ addAll(affectedOwners, this.pruneHistory());
675
831
  }
676
- affectedOwners.add(current.ownerSessionId);
677
832
  this.emitStateForOwners(affectedOwners);
678
833
  }
679
834
 
@@ -681,18 +836,91 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
681
836
  current: WorkerRecord,
682
837
  status: "failed" | "aborted",
683
838
  outcome: WorkerOutcome,
839
+ failureStage?: SettlementFailureStage,
684
840
  ): void {
841
+ const settledAt = this.clock();
685
842
  this.workers.set(current.id, {
686
843
  ...transitionWorkerStatus(current, status),
687
844
  activity: undefined,
688
845
  outcome: copyOutcome(outcome),
846
+ settledAt,
689
847
  });
690
848
  const affectedOwners = this.maybeCompleteWave(current.waveId);
691
- addAll(affectedOwners, this.rememberTerminalWorker(current.id));
692
849
  affectedOwners.add(current.ownerSessionId);
850
+ const generation = this.entries.get(current.id)?.generation;
851
+ if (generation !== undefined) {
852
+ this.emitSettlement(current.id, generation, settledAt, failureStage);
853
+ }
854
+ addAll(affectedOwners, this.rememberTerminalWorker(current.id));
693
855
  this.emitStateForOwners(affectedOwners);
694
856
  }
695
857
 
858
+ private emitSettlement(
859
+ workerId: WorkerId,
860
+ generation: number,
861
+ settledAt: number,
862
+ failureStage?: SettlementFailureStage,
863
+ ): void {
864
+ 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
+ }
878
+
879
+ const sequence = ++this.settlementSequence;
880
+ const settlement: WorkerSettlement = Object.freeze({
881
+ eventId: `${sequence}:${wave.id}:${workerId}:${generation}`,
882
+ sequence,
883
+ ownerSessionId: worker.ownerSessionId,
884
+ waveId: wave.id,
885
+ workerId,
886
+ generation,
887
+ mode: wave.mode,
888
+ worker: worker.worker,
889
+ title: worker.title,
890
+ lifecycle: worker.lifecycle,
891
+ status: worker.status,
892
+ outcome: Object.freeze(copyOutcome(worker.outcome)),
893
+ ...(failureStage ? { failureStage } : {}),
894
+ usage: Object.freeze(copyUsage(worker.usage)),
895
+ startedAt: worker.startedAt,
896
+ settledAt,
897
+ remainingActive,
898
+ waveSize: wave.workerIds.length,
899
+ waveComplete,
900
+ sessionFile: worker.sessionFile,
901
+ });
902
+
903
+ const localListener = this.waveSettlementListeners.get(wave.id);
904
+ if (waveComplete) this.waveSettlementListeners.delete(wave.id);
905
+ notifySettlementListener(localListener, settlement);
906
+ for (const listener of [...this.settlementListeners]) {
907
+ notifySettlementListener(listener, settlement);
908
+ }
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
+ }
923
+
696
924
  private maybeCompleteWave(waveId: WaveId): Set<string> {
697
925
  const affectedOwners = new Set<string>();
698
926
  if (this.completedWaves.has(waveId)) return affectedOwners;
@@ -719,17 +947,6 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
719
947
  this.waveWaiters.get(waveId)?.resolve(completed);
720
948
  this.waveWaiters.delete(waveId);
721
949
 
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
950
  return affectedOwners;
734
951
  }
735
952
 
@@ -884,7 +1101,12 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
884
1101
  if (entry) this.disposeEntrySession(entry);
885
1102
  const current = this.workers.get(workerId);
886
1103
  if (current?.status === "stopping") {
887
- this.settleTerminalWorker(current, "aborted", { status: "aborted" });
1104
+ this.settleTerminalWorker(
1105
+ current,
1106
+ "aborted",
1107
+ { status: "aborted" },
1108
+ "cancellation",
1109
+ );
888
1110
  }
889
1111
  }
890
1112
  }
@@ -897,7 +1119,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
897
1119
  this.settleTerminalWorker(current, "failed", {
898
1120
  status: "failed",
899
1121
  message: describeError(error, "Worker cancellation failed"),
900
- });
1122
+ }, "cancellation");
901
1123
  }
902
1124
 
903
1125
  private async cancelExactWave(wave: WaveRecord): Promise<void> {
@@ -952,6 +1174,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
952
1174
  ...transitionWorkerStatus(current, "closed"),
953
1175
  activity: undefined,
954
1176
  outcome: { status: "closed" },
1177
+ settledAt: this.clock(),
955
1178
  });
956
1179
  const affectedOwners = this.maybeCompleteWave(current.waveId);
957
1180
  addAll(affectedOwners, this.rememberTerminalWorker(current.id));
@@ -986,6 +1209,14 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
986
1209
  this.workers.set(workerId, { ...latest, activity });
987
1210
  this.emitState(latest.ownerSessionId);
988
1211
  });
1212
+ entry.unsubscribeMessageDirection = session.subscribeMessageDirection((messageDirection) => {
1213
+ const latest = this.workers.get(workerId);
1214
+ if (!latest || entry.session !== session || entry.generation !== generation) return;
1215
+ if (latest.status !== "starting" && latest.status !== "running") return;
1216
+ if (latest.messageDirection === messageDirection) return;
1217
+ this.workers.set(workerId, { ...latest, messageDirection });
1218
+ this.emitState(latest.ownerSessionId);
1219
+ });
989
1220
  }
990
1221
 
991
1222
  private emitState(ownerSessionId: string): void {
@@ -1015,6 +1246,8 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
1015
1246
  entry.unsubscribeUsage = undefined;
1016
1247
  safelyCall(entry.unsubscribeActivity);
1017
1248
  entry.unsubscribeActivity = undefined;
1249
+ safelyCall(entry.unsubscribeMessageDirection);
1250
+ entry.unsubscribeMessageDirection = undefined;
1018
1251
  }
1019
1252
 
1020
1253
  private disposeEntrySession(entry: RuntimeEntry): void {
@@ -1026,7 +1259,12 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
1026
1259
  private disposeSession(session: WorkerSessionHandle): void {
1027
1260
  if (this.disposedSessions.has(session)) return;
1028
1261
  this.disposedSessions.add(session);
1029
- safelyCall(() => session.dispose());
1262
+
1263
+ try {
1264
+ this.trackCleanup(session.dispose());
1265
+ } catch {
1266
+ // Cleanup is best-effort and cannot leave lifecycle state unsettled.
1267
+ }
1030
1268
  }
1031
1269
 
1032
1270
  private trackCleanup(operation: Promise<void>): Promise<void> {
@@ -1114,6 +1352,18 @@ function addAll(target: Set<string>, source: ReadonlySet<string>): void {
1114
1352
  for (const value of source) target.add(value);
1115
1353
  }
1116
1354
 
1355
+ function notifySettlementListener(
1356
+ listener: SettlementListener | undefined,
1357
+ settlement: WorkerSettlement,
1358
+ ): void {
1359
+ if (!listener) return;
1360
+ try {
1361
+ listener(settlement);
1362
+ } catch {
1363
+ // One observer cannot prevent settlement or other observers from being notified.
1364
+ }
1365
+ }
1366
+
1117
1367
  function makeWaveWaiter(): WaveWaiter {
1118
1368
  let complete!: (wave: CompletedWave) => void;
1119
1369
  const waiter: WaveWaiter = {