@zachwill/pi-orchestrate 0.8.0 → 0.9.2

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.
@@ -11,7 +11,6 @@ import {
11
11
  ModelRuntime,
12
12
  type ModelRegistry,
13
13
  type PromptOptions,
14
- type ResourceLoader,
15
14
  SessionManager,
16
15
  SettingsManager,
17
16
  type AgentSessionEvent,
@@ -19,7 +18,18 @@ import {
19
18
  type CreateAgentSessionResult,
20
19
  type DefaultResourceLoader,
21
20
  } from "@earendil-works/pi-coding-agent";
22
- import { Cause, Effect, Exit, Schema, Scope } from "effect";
21
+ import {
22
+ Cause,
23
+ Context,
24
+ Deferred,
25
+ Effect,
26
+ Exit,
27
+ FiberSet,
28
+ Layer,
29
+ Schema,
30
+ Scope,
31
+ SynchronizedRef,
32
+ } from "effect";
23
33
  import type {
24
34
  WorkerDefinition,
25
35
  WorkerMessageDirection,
@@ -52,7 +62,7 @@ type ResourceLoaderOptions = Omit<
52
62
  "cwd" | "agentDir" | "settingsManager"
53
63
  >;
54
64
 
55
- export interface WorkerSessionFactoryOptions {
65
+ export interface ChildSessionOptions {
56
66
  cwd: string;
57
67
  agentDir: string;
58
68
  parentSessionFile: string | undefined;
@@ -63,18 +73,18 @@ export interface WorkerSessionFactoryOptions {
63
73
  modelRegistry: ModelRegistry;
64
74
  }
65
75
 
66
- export interface WorkerSessionHandle {
67
- readonly sessionFile: string;
68
- prompt(instructions: string): Promise<WorkerOutcome>;
69
- abort(): Promise<void>;
70
- dispose(): Promise<void>;
71
- subscribeUsage(listener: (usage: WorkerUsage) => void): () => void;
72
- subscribeActivity(listener: (activity: string | undefined) => void): () => void;
73
- subscribeMessageDirection(listener: (direction: WorkerMessageDirection) => void): () => void;
76
+ export interface WorkerSessionObservation {
77
+ readonly usage: WorkerUsage;
78
+ readonly activity: string | undefined;
79
+ readonly messageDirection: WorkerMessageDirection | undefined;
74
80
  }
75
81
 
76
- export interface WorkerSessionFactory {
77
- create(options: WorkerSessionFactoryOptions): Promise<WorkerSessionHandle>;
82
+ export interface WorkerSessionHandle {
83
+ readonly sessionFile: string;
84
+ prompt(instructions: string): Effect.Effect<WorkerOutcome, never>;
85
+ abort(): Effect.Effect<void, WorkerSessionAbortError>;
86
+ dispose(): Effect.Effect<void, never>;
87
+ subscribeObservation(listener: (observation: WorkerSessionObservation) => void): () => void;
78
88
  }
79
89
 
80
90
  const WorkerModelAcquisitionOperation = Schema.Literals([
@@ -121,6 +131,54 @@ export class WorkerAgentSessionAcquisitionError extends Schema.TaggedError<Worke
121
131
  { operation: WorkerAgentSessionAcquisitionOperation, message: Schema.String, cause: Schema.Defect() },
122
132
  ) {}
123
133
 
134
+ export class WorkerSessionAcquisitionClosedError extends Schema.TaggedError<WorkerSessionAcquisitionClosedError>()(
135
+ "WorkerSession.AcquisitionClosedError",
136
+ { message: Schema.String },
137
+ ) {}
138
+
139
+ const WorkerSessionAbortOperation = Schema.Literals([
140
+ "abort-compaction",
141
+ "abort-prompt",
142
+ ]);
143
+ export type WorkerSessionAbortOperation = typeof WorkerSessionAbortOperation.Type;
144
+
145
+ const WorkerSessionAbortStage = Schema.Literals(["compaction", "prompt"]);
146
+ export type WorkerSessionAbortStage = typeof WorkerSessionAbortStage.Type;
147
+
148
+ export class WorkerSessionAbortError extends Schema.TaggedError<WorkerSessionAbortError>()(
149
+ "WorkerSession.AbortError",
150
+ {
151
+ operation: WorkerSessionAbortOperation,
152
+ stage: WorkerSessionAbortStage,
153
+ message: Schema.String,
154
+ cause: Schema.Defect(),
155
+ },
156
+ ) {}
157
+
158
+ export type WorkerSessionAcquisitionError =
159
+ | WorkerModelAcquisitionError
160
+ | WorkerResourceAcquisitionError
161
+ | WorkerAgentSessionAcquisitionError
162
+ | WorkerSessionAcquisitionClosedError;
163
+
164
+ export interface ChildSessionsService {
165
+ /**
166
+ * Acquires a child session through a process-owned producer. The adopter must
167
+ * synchronously install ownership before returning a value. Returning undefined
168
+ * rejects adoption and leaves ChildSessions responsible for disposal.
169
+ */
170
+ readonly acquire: <Adopted>(
171
+ options: ChildSessionOptions,
172
+ adopt: (session: WorkerSessionHandle) => Adopted | undefined,
173
+ ) => Effect.Effect<Adopted | undefined, WorkerSessionAcquisitionError>;
174
+ /** Closes every handoff without waiting for uncancellable Pi calls. */
175
+ readonly shutdown: () => Effect.Effect<void>;
176
+ }
177
+
178
+ export class ChildSessions extends Context.Service<ChildSessions, ChildSessionsService>()(
179
+ "@zachwill/pi-orchestrate/ChildSessions",
180
+ ) {}
181
+
124
182
  const WorkerSessionCleanupOperation = Schema.Literals([
125
183
  "unsubscribe",
126
184
  "runtime",
@@ -185,6 +243,10 @@ export interface WorkerSessionDependencies {
185
243
  createAgentSession(input: AgentSessionInput): Promise<{ session: WorkerAgentSession }>;
186
244
  createRuntime(input: RuntimeInput): OwnedWorkerRuntime;
187
245
  reportCleanupFailure(failure: WorkerSessionCleanupFailure): void;
246
+ /** Deterministic boundary before an offered session reserves adopter ownership. */
247
+ beforeAdoptionReservation(): Effect.Effect<void>;
248
+ /** Deterministic boundary for verifying reclamation admission during root closure. */
249
+ onReclamationOpenObserved(): Effect.Effect<void>;
188
250
  }
189
251
 
190
252
  const defaultDependencies: WorkerSessionDependencies = {
@@ -217,30 +279,10 @@ const defaultDependencies: WorkerSessionDependencies = {
217
279
  detail: `Operation: ${operation}`,
218
280
  });
219
281
  },
282
+ beforeAdoptionReservation: () => Effect.void,
283
+ onReclamationOpenObserved: () => Effect.void,
220
284
  };
221
285
 
222
- export function resolveWorkerModel(
223
- definition: WorkerDefinition,
224
- parentModel: Model<Api> | undefined,
225
- modelRegistry: ModelRegistry,
226
- ): Model<Api> {
227
- const configured = definition.model;
228
- if (!configured) {
229
- if (parentModel) return parentModel;
230
- throw new Error(
231
- `Worker "${definition.name}" has no configured model and no parent model is available`,
232
- );
233
- }
234
-
235
- const model = modelRegistry.find(configured.provider, configured.modelId);
236
- if (!model) {
237
- throw new Error(
238
- `Worker "${definition.name}" configured model "${configured.provider}/${configured.modelId}" was not found`,
239
- );
240
- }
241
- return model;
242
- }
243
-
244
286
  function canonicalPath(path: string): string {
245
287
  try {
246
288
  return realpathSync.native(path);
@@ -276,25 +318,18 @@ function bestEffortCleanup(
276
318
  reporter: WorkerSessionCleanupReporter,
277
319
  operation: WorkerSessionCleanupOperation,
278
320
  cleanup: () => void | Promise<void>,
321
+ afterFailure: Effect.Effect<void> = Effect.void,
279
322
  ): Effect.Effect<void> {
280
323
  return Effect.tryPromise({
281
- try: async () => cleanup(),
324
+ try: () => Promise.resolve(cleanup()),
282
325
  catch: (cause) => cause,
283
326
  }).pipe(
284
- Effect.catch((cause) => reportCleanupFailure(reporter, operation, cause)),
327
+ Effect.catch((cause) =>
328
+ reportCleanupFailure(reporter, operation, cause).pipe(Effect.andThen(afterFailure))
329
+ ),
285
330
  );
286
331
  }
287
332
 
288
- function resourceLoaderFinalizer(
289
- resourceLoader: ResourceLoader,
290
- reporter: WorkerSessionCleanupReporter,
291
- ): Effect.Effect<void> {
292
- return bestEffortCleanup(reporter, "resource-loader", () => {
293
- if (!("dispose" in resourceLoader) || typeof resourceLoader.dispose !== "function") return;
294
- resourceLoader.dispose();
295
- });
296
- }
297
-
298
333
  function describeError(error: unknown, fallback: string): string {
299
334
  if (error instanceof Error) return error.message;
300
335
  if (typeof error === "string" && error !== "") return error;
@@ -331,31 +366,67 @@ function emptyUsage(): MutableWorkerUsage {
331
366
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
332
367
  }
333
368
 
369
+ interface ActivePrompt {
370
+ readonly _tag: "Active";
371
+ readonly previousAssistant: AssistantMessage | undefined;
372
+ assistant: AssistantMessage | undefined;
373
+ abortRequested: boolean;
374
+ }
375
+
376
+ type PromptState =
377
+ | { readonly _tag: "Idle" }
378
+ | ActivePrompt
379
+ | { readonly _tag: "Disposed" };
380
+
381
+ interface PromptCompletion {
382
+ readonly failureMessage: string | undefined;
383
+ readonly message: AssistantMessage | undefined;
384
+ readonly prompt: ActivePrompt;
385
+ }
386
+
334
387
  class DefaultWorkerSessionHandle implements WorkerSessionHandle {
335
388
  readonly sessionFile: string;
336
389
  private readonly usage = emptyUsage();
337
- private readonly usageListeners = new Set<(usage: WorkerUsage) => void>();
338
- private readonly activityListeners = new Set<(activity: string | undefined) => void>();
339
- private readonly messageDirectionListeners = new Set<(direction: WorkerMessageDirection) => void>();
390
+ private readonly observationListeners = new Set<(observation: WorkerSessionObservation) => void>();
340
391
  private readonly activeToolCalls = new Map<string, string>();
341
- private disposed = false;
342
- private disposePromise: Promise<void> | undefined;
343
392
  private activity: string | undefined;
344
393
  private messageDirection: WorkerMessageDirection | undefined;
345
- private prompting = false;
346
- private abortRequested = false;
347
- private promptAssistant: AssistantMessage | undefined;
394
+ private promptState: PromptState = { _tag: "Idle" };
395
+ private disposeOperation!: Effect.Effect<void, never>;
348
396
 
349
- constructor(
397
+ private constructor(
350
398
  private readonly runtime: OwnedWorkerRuntime,
351
399
  private readonly interactive: boolean,
352
400
  sessionFile: string,
353
- private readonly scope: Scope.Closeable,
354
- private readonly cleanupReporter: WorkerSessionCleanupReporter,
355
401
  ) {
356
402
  this.sessionFile = sessionFile;
357
403
  }
358
404
 
405
+ static make(
406
+ runtime: OwnedWorkerRuntime,
407
+ interactive: boolean,
408
+ sessionFile: string,
409
+ scope: Scope.Closeable,
410
+ cleanupReporter: WorkerSessionCleanupReporter,
411
+ ): Effect.Effect<DefaultWorkerSessionHandle> {
412
+ return Effect.gen(function* () {
413
+ const handle = new DefaultWorkerSessionHandle(runtime, interactive, sessionFile);
414
+ handle.disposeOperation = yield* Effect.cached(
415
+ Effect.sync(() => handle.beginDispose()).pipe(
416
+ Effect.andThen(disposeWorkerSession(scope, cleanupReporter)),
417
+ Effect.uninterruptible,
418
+ ),
419
+ );
420
+ return handle;
421
+ });
422
+ }
423
+
424
+ private beginDispose(): void {
425
+ this.promptState = { _tag: "Disposed" };
426
+ this.activeToolCalls.clear();
427
+ this.observationListeners.clear();
428
+ }
429
+
359
430
  receiveSessionEvent(event: AgentSessionEvent): void {
360
431
  if (event.type === "message_start") {
361
432
  if (event.message.role === "assistant") this.setMessageDirection("from-model");
@@ -375,7 +446,7 @@ class DefaultWorkerSessionHandle implements WorkerSessionHandle {
375
446
  return;
376
447
  }
377
448
  if (event.type !== "turn_end" || event.message.role !== "assistant") return;
378
- this.promptAssistant = event.message;
449
+ if (this.promptState._tag === "Active") this.promptState.assistant = event.message;
379
450
  this.usage.input += event.message.usage.input ?? 0;
380
451
  this.usage.output += event.message.usage.output ?? 0;
381
452
  this.usage.cacheRead += event.message.usage.cacheRead ?? 0;
@@ -383,96 +454,130 @@ class DefaultWorkerSessionHandle implements WorkerSessionHandle {
383
454
  this.usage.cost += event.message.usage.cost?.total ?? 0;
384
455
  this.usage.contextTokens = event.message.usage.totalTokens ?? 0;
385
456
  this.usage.turns += 1;
386
- for (const listener of [...this.usageListeners]) safelyNotify(() => listener({ ...this.usage }));
457
+ this.emitObservation();
387
458
  }
388
459
 
389
460
  private setActivity(activity: string | undefined): void {
390
461
  if (this.activity === activity) return;
391
462
  this.activity = activity;
392
- for (const listener of [...this.activityListeners]) safelyNotify(() => listener(activity));
463
+ this.emitObservation();
393
464
  }
394
465
 
395
466
  private setMessageDirection(direction: WorkerMessageDirection): void {
396
467
  if (this.messageDirection === direction) return;
397
468
  this.messageDirection = direction;
398
- for (const listener of [...this.messageDirectionListeners]) safelyNotify(() => listener(direction));
469
+ this.emitObservation();
399
470
  }
400
471
 
401
- async prompt(instructions: string): Promise<WorkerOutcome> {
402
- if (this.disposed) throw new Error("Worker session has been disposed");
403
- if (this.prompting) throw new Error("Worker session is already processing a prompt");
404
- this.prompting = true;
405
- this.abortRequested = false;
406
- this.promptAssistant = undefined;
407
- this.setMessageDirection("to-model");
408
- const previousAssistant = lastAssistant(this.runtime.session.messages);
409
- let failureMessage: string | undefined;
410
- try {
411
- await this.runtime.session.prompt(instructions, {
412
- expandPromptTemplates: false,
413
- source: "extension",
414
- });
415
- } catch (error) {
416
- failureMessage = describeError(error, "Worker prompt failed");
417
- } finally {
418
- this.prompting = false;
419
- }
420
- const latestAssistant = lastAssistant(this.runtime.session.messages);
421
- const message = this.promptAssistant ?? (latestAssistant !== previousAssistant ? latestAssistant : undefined);
422
- const text = assistantText(message);
423
- const assistantPayload = text === undefined ? {} : { assistantText: text };
424
- if (this.abortRequested || message?.stopReason === "aborted") {
425
- const abortMessage = message?.errorMessage ?? failureMessage;
426
- return { status: "aborted", ...(abortMessage ? { message: abortMessage } : {}), ...assistantPayload };
427
- }
428
- if (message?.stopReason === "error") {
429
- return { status: "failed", message: message.errorMessage ?? failureMessage ?? "Worker assistant reported a failure", ...assistantPayload };
472
+ private emitObservation(): void {
473
+ const observation: WorkerSessionObservation = Object.freeze({
474
+ usage: Object.freeze({ ...this.usage }),
475
+ activity: this.activity,
476
+ messageDirection: this.messageDirection,
477
+ });
478
+ for (const listener of [...this.observationListeners]) {
479
+ safelyNotify(() => listener(observation));
430
480
  }
431
- if (failureMessage) return { status: "failed", message: failureMessage, ...assistantPayload };
432
- return { status: this.interactive ? "ready" : "completed", assistantText: text ?? "" };
433
481
  }
434
482
 
435
- async abort(): Promise<void> {
436
- if (this.prompting) this.abortRequested = true;
483
+ prompt(instructions: string): Effect.Effect<WorkerOutcome, never> {
484
+ return Effect.fn("WorkerSession.prompt")(function* (this: DefaultWorkerSessionHandle) {
485
+ const completion = yield* Effect.sync(() => this.startPrompt(instructions));
486
+ const { failureMessage, message, prompt } = yield* Effect.promise(() => completion);
487
+ const text = assistantText(message);
488
+ const assistantPayload = text === undefined ? {} : { assistantText: text };
489
+ if (prompt.abortRequested || message?.stopReason === "aborted") {
490
+ const abortMessage = message?.errorMessage ?? failureMessage;
491
+ return { status: "aborted", ...(abortMessage ? { message: abortMessage } : {}), ...assistantPayload } satisfies WorkerOutcome;
492
+ }
493
+ if (message?.stopReason === "error") {
494
+ return { status: "failed", message: message.errorMessage ?? failureMessage ?? "Worker assistant reported a failure", ...assistantPayload } satisfies WorkerOutcome;
495
+ }
496
+ if (failureMessage) return { status: "failed", message: failureMessage, ...assistantPayload } satisfies WorkerOutcome;
497
+ return { status: this.interactive ? "ready" : "completed", assistantText: text ?? "" } satisfies WorkerOutcome;
498
+ }).call(this);
499
+ }
437
500
 
438
- let firstFailure: unknown;
439
- try {
440
- this.runtime.session.abortCompaction();
441
- } catch (error) {
442
- firstFailure = error;
443
- }
501
+ private startPrompt(instructions: string): Promise<PromptCompletion> {
502
+ if (this.promptState._tag === "Disposed") throw new Error("Worker session has been disposed");
503
+ if (this.promptState._tag === "Active") throw new Error("Worker session is already processing a prompt");
504
+
505
+ const prompt: ActivePrompt = {
506
+ _tag: "Active",
507
+ previousAssistant: lastAssistant(this.runtime.session.messages),
508
+ assistant: undefined,
509
+ abortRequested: false,
510
+ };
511
+ this.promptState = prompt;
512
+ this.setMessageDirection("to-model");
444
513
 
514
+ let physicalPrompt: Promise<void>;
445
515
  try {
446
- await this.runtime.session.abort();
447
- } catch (error) {
448
- if (firstFailure === undefined) firstFailure = error;
516
+ physicalPrompt = this.runtime.session.prompt(instructions, {
517
+ expandPromptTemplates: false,
518
+ source: "extension",
519
+ });
520
+ } catch (cause) {
521
+ return Promise.resolve(this.completePrompt(
522
+ prompt,
523
+ describeError(cause, "Worker prompt failed"),
524
+ ));
449
525
  }
450
526
 
451
- if (firstFailure !== undefined) throw firstFailure;
527
+ return physicalPrompt.then(
528
+ () => this.completePrompt(prompt, undefined),
529
+ (cause) => this.completePrompt(prompt, describeError(cause, "Worker prompt failed")),
530
+ );
452
531
  }
453
532
 
454
- dispose(): Promise<void> {
455
- if (this.disposePromise) return this.disposePromise;
456
-
457
- this.disposed = true;
458
- this.activeToolCalls.clear();
459
- this.usageListeners.clear();
460
- this.activityListeners.clear();
461
- this.messageDirectionListeners.clear();
462
- this.disposePromise = Effect.runPromise(
463
- disposeWorkerSession(this.scope, this.cleanupReporter),
464
- );
465
- return this.disposePromise;
533
+ private completePrompt(
534
+ prompt: ActivePrompt,
535
+ failureMessage: string | undefined,
536
+ ): PromptCompletion {
537
+ const latestAssistant = lastAssistant(this.runtime.session.messages);
538
+ const message = prompt.assistant ??
539
+ (latestAssistant !== prompt.previousAssistant ? latestAssistant : undefined);
540
+ if (this.promptState === prompt) this.promptState = { _tag: "Idle" };
541
+ return { failureMessage, message, prompt };
466
542
  }
467
543
 
468
- subscribeUsage(listener: (usage: WorkerUsage) => void): () => void {
469
- return subscribe(this.usageListeners, listener);
544
+ abort(): Effect.Effect<void, WorkerSessionAbortError> {
545
+ return Effect.fn("WorkerSession.abort")(function* (this: DefaultWorkerSessionHandle) {
546
+ if (this.promptState._tag === "Active") this.promptState.abortRequested = true;
547
+
548
+ const compaction = yield* Effect.result(Effect.try({
549
+ try: () => this.runtime.session.abortCompaction(),
550
+ catch: (cause) => new WorkerSessionAbortError({
551
+ operation: "abort-compaction",
552
+ stage: "compaction",
553
+ message: describeError(cause, "Worker compaction abort failed"),
554
+ cause,
555
+ }),
556
+ }));
557
+ const prompt = yield* Effect.result(Effect.tryPromise({
558
+ try: () => this.runtime.session.abort(),
559
+ catch: (cause) => new WorkerSessionAbortError({
560
+ operation: "abort-prompt",
561
+ stage: "prompt",
562
+ message: describeError(cause, "Worker prompt abort failed"),
563
+ cause,
564
+ }),
565
+ }));
566
+
567
+ if (compaction._tag === "Failure") return yield* Effect.fail(compaction.failure);
568
+ if (prompt._tag === "Failure") return yield* Effect.fail(prompt.failure);
569
+ }).call(this);
470
570
  }
471
- subscribeActivity(listener: (activity: string | undefined) => void): () => void {
472
- return subscribe(this.activityListeners, listener);
571
+
572
+ dispose(): Effect.Effect<void, never> {
573
+ return this.disposeOperation;
473
574
  }
474
- subscribeMessageDirection(listener: (direction: WorkerMessageDirection) => void): () => void {
475
- return subscribe(this.messageDirectionListeners, listener);
575
+
576
+ subscribeObservation(listener: (observation: WorkerSessionObservation) => void): () => void {
577
+ // A disposed handle has no current event source. Late subscription is an
578
+ // explicit no-op rather than a listener retained forever.
579
+ if (this.promptState._tag === "Disposed") return () => {};
580
+ return subscribe(this.observationListeners, listener);
476
581
  }
477
582
  }
478
583
 
@@ -499,36 +604,30 @@ function selectedModelCoordinates(
499
604
  throw new Error(`Worker "${definition.name}" has no configured model and no parent model is available`);
500
605
  }
501
606
 
502
- function modelAcquisitionError(
503
- operation: WorkerModelAcquisitionOperation,
504
- fallback: string,
505
- ): (cause: unknown) => WorkerModelAcquisitionError {
506
- return (cause) => new WorkerModelAcquisitionError({
507
- operation,
508
- message: describeError(cause, fallback),
509
- cause,
510
- });
511
- }
512
-
513
- function resourceAcquisitionError(
514
- operation: WorkerResourceAcquisitionOperation,
515
- ): (cause: unknown) => WorkerResourceAcquisitionError {
516
- return (cause) => new WorkerResourceAcquisitionError({
517
- operation,
518
- message: describeError(cause, "Worker resource acquisition failed"),
519
- cause,
520
- });
607
+ function acquisitionErrorFactory<Operation, AcquisitionError>(
608
+ ErrorClass: new (fields: {
609
+ operation: Operation;
610
+ message: string;
611
+ cause: unknown;
612
+ }) => AcquisitionError,
613
+ defaultMessage: string,
614
+ ) {
615
+ return (operation: Operation, fallback = defaultMessage) => (cause: unknown) =>
616
+ new ErrorClass({ operation, message: describeError(cause, fallback), cause });
521
617
  }
522
618
 
523
- function agentSessionAcquisitionError(
524
- operation: WorkerAgentSessionAcquisitionOperation,
525
- ): (cause: unknown) => WorkerAgentSessionAcquisitionError {
526
- return (cause) => new WorkerAgentSessionAcquisitionError({
527
- operation,
528
- message: describeError(cause, "Worker agent session acquisition failed"),
529
- cause,
530
- });
531
- }
619
+ const modelAcquisitionError = acquisitionErrorFactory(
620
+ WorkerModelAcquisitionError,
621
+ "Worker model acquisition failed",
622
+ );
623
+ const resourceAcquisitionError = acquisitionErrorFactory(
624
+ WorkerResourceAcquisitionError,
625
+ "Worker resource acquisition failed",
626
+ );
627
+ const agentSessionAcquisitionError = acquisitionErrorFactory(
628
+ WorkerAgentSessionAcquisitionError,
629
+ "Worker agent session acquisition failed",
630
+ );
532
631
 
533
632
  const refreshModelRuntime = Effect.fn("WorkerSession.refreshModelRuntime")(function* (
534
633
  modelRuntime: ModelRuntime,
@@ -556,7 +655,7 @@ const refreshModelRuntime = Effect.fn("WorkerSession.refreshModelRuntime")(funct
556
655
  });
557
656
 
558
657
  const prepareChildModelRuntime = Effect.fn("WorkerSession.prepareChildModelRuntime")(function* (
559
- options: WorkerSessionFactoryOptions,
658
+ options: ChildSessionOptions,
560
659
  dependencies: WorkerSessionDependencies,
561
660
  ) {
562
661
  const selected = yield* Effect.try({
@@ -639,8 +738,9 @@ function contextLoaderOptions(
639
738
  const disposeWorkerSession = Effect.fn("WorkerSession.dispose")(function* (
640
739
  scope: Scope.Closeable,
641
740
  reporter: WorkerSessionCleanupReporter,
741
+ exit: Exit.Exit<void, unknown> = Exit.void,
642
742
  ) {
643
- yield* Scope.close(scope, Exit.void).pipe(
743
+ yield* Scope.close(scope, exit).pipe(
644
744
  Effect.catchCause((cause) =>
645
745
  reportCleanupFailure(reporter, "scope-close", Cause.squash(cause))
646
746
  ),
@@ -648,7 +748,7 @@ const disposeWorkerSession = Effect.fn("WorkerSession.dispose")(function* (
648
748
  });
649
749
 
650
750
  const acquireWorkerServices = Effect.fn("WorkerSession.acquireServices")(function* (
651
- options: WorkerSessionFactoryOptions,
751
+ options: ChildSessionOptions,
652
752
  dependencies: WorkerSessionDependencies,
653
753
  modelRuntime: ModelRuntime,
654
754
  ) {
@@ -692,9 +792,14 @@ const acquireWorkerServices = Effect.fn("WorkerSession.acquireServices")(functio
692
792
  }),
693
793
  catch: resourceAcquisitionError("create-services"),
694
794
  }),
695
- (services) => resourceLoaderFinalizer(
696
- services.resourceLoader,
795
+ (services) => bestEffortCleanup(
697
796
  dependencies.reportCleanupFailure,
797
+ "resource-loader",
798
+ () => {
799
+ const loader = services.resourceLoader;
800
+ if (!("dispose" in loader) || typeof loader.dispose !== "function") return;
801
+ loader.dispose();
802
+ },
698
803
  ),
699
804
  );
700
805
  });
@@ -704,29 +809,19 @@ interface AgentSessionOwnership {
704
809
  runtime: OwnedWorkerRuntime | undefined;
705
810
  }
706
811
 
707
- function rawSessionFinalizer(
708
- session: WorkerAgentSession,
709
- reporter: WorkerSessionCleanupReporter,
710
- ): Effect.Effect<void> {
711
- return bestEffortCleanup(reporter, "raw-session", () => session.dispose());
712
- }
713
-
714
812
  function agentSessionFinalizer(
715
813
  ownership: AgentSessionOwnership,
716
814
  reporter: WorkerSessionCleanupReporter,
717
815
  ): Effect.Effect<void> {
718
- const runtime = ownership.runtime;
719
- if (!runtime) return rawSessionFinalizer(ownership.session, reporter);
720
- return Effect.tryPromise({
721
- try: () => runtime.dispose(),
722
- catch: (cause) => cause,
723
- }).pipe(
724
- Effect.catch((cause) =>
725
- reportCleanupFailure(reporter, "runtime", cause).pipe(
726
- Effect.andThen(rawSessionFinalizer(ownership.session, reporter)),
727
- )
728
- ),
816
+ const disposeRawSession = bestEffortCleanup(
817
+ reporter,
818
+ "raw-session",
819
+ () => ownership.session.dispose(),
729
820
  );
821
+ const runtime = ownership.runtime;
822
+ return runtime
823
+ ? bestEffortCleanup(reporter, "runtime", () => runtime.dispose(), disposeRawSession)
824
+ : disposeRawSession;
730
825
  }
731
826
 
732
827
  const acquireAgentSession = Effect.fn("WorkerSession.acquireAgentSession")(function* (
@@ -780,7 +875,7 @@ const acquireSessionSubscription = Effect.fn("WorkerSession.acquireSubscription"
780
875
  });
781
876
 
782
877
  const createWorkerSession = Effect.fn("WorkerSession.create")(function* (
783
- options: WorkerSessionFactoryOptions,
878
+ options: ChildSessionOptions,
784
879
  dependencies: WorkerSessionDependencies,
785
880
  ) {
786
881
  const scope = yield* Scope.make("sequential");
@@ -850,7 +945,7 @@ const createWorkerSession = Effect.fn("WorkerSession.create")(function* (
850
945
  catch: agentSessionAcquisitionError("verify-durability"),
851
946
  });
852
947
 
853
- const handle = new DefaultWorkerSessionHandle(
948
+ const handle = yield* DefaultWorkerSessionHandle.make(
854
949
  runtime,
855
950
  definition.lifecycle === "interactive",
856
951
  sessionFile,
@@ -864,14 +959,10 @@ const createWorkerSession = Effect.fn("WorkerSession.create")(function* (
864
959
  return yield* acquisition.pipe(
865
960
  Effect.catchCause((cause) =>
866
961
  Effect.gen(function* () {
867
- yield* Scope.close(scope, Exit.failCause(cause)).pipe(
868
- Effect.catchCause((cleanupCause) =>
869
- reportCleanupFailure(
870
- dependencies.reportCleanupFailure,
871
- "scope-close",
872
- Cause.squash(cleanupCause),
873
- )
874
- ),
962
+ yield* disposeWorkerSession(
963
+ scope,
964
+ dependencies.reportCleanupFailure,
965
+ Exit.failCause(cause),
875
966
  );
876
967
  return yield* Effect.failCause(cause);
877
968
  })
@@ -879,9 +970,244 @@ const createWorkerSession = Effect.fn("WorkerSession.create")(function* (
879
970
  );
880
971
  });
881
972
 
882
- export function createWorkerSessionFactory(
973
+ type AcquisitionHandoffState =
974
+ | { readonly _tag: "Pending" }
975
+ | { readonly _tag: "Offered"; readonly session: WorkerSessionHandle }
976
+ | { readonly _tag: "Adopting"; readonly session: WorkerSessionHandle }
977
+ | { readonly _tag: "Adopted" }
978
+ | { readonly _tag: "Abandoned"; readonly error?: WorkerSessionAcquisitionClosedError }
979
+ | { readonly _tag: "Failed" };
980
+
981
+ interface AcquisitionHandoff {
982
+ readonly state: SynchronizedRef.SynchronizedRef<AcquisitionHandoffState>;
983
+ readonly result: Deferred.Deferred<WorkerSessionHandle, WorkerSessionAcquisitionError>;
984
+ }
985
+
986
+ type ChildSessionsState =
987
+ | { readonly _tag: "Open"; readonly handoffs: ReadonlySet<AcquisitionHandoff> }
988
+ | { readonly _tag: "Closed"; readonly error: WorkerSessionAcquisitionClosedError };
989
+
990
+ type AdoptionReservation =
991
+ | { readonly _tag: "Reserved"; readonly session: WorkerSessionHandle }
992
+ | { readonly _tag: "Closed"; readonly error: WorkerSessionAcquisitionClosedError }
993
+ | { readonly _tag: "Abandoned" };
994
+
995
+ function disposeLateSession(session: WorkerSessionHandle): Effect.Effect<void> {
996
+ return session.dispose().pipe(Effect.catchCause(() => Effect.void));
997
+ }
998
+
999
+ function admitReclamationOrJoinAfterClosure(
1000
+ fibers: FiberSet.FiberSet<void, never>,
1001
+ reclamation: Effect.Effect<void>,
1002
+ onOpenObserved: () => Effect.Effect<void>,
1003
+ ): Effect.Effect<void> {
1004
+ return Effect.suspend(() => {
1005
+ if (fibers.state._tag === "Closed") return reclamation;
1006
+ return Effect.gen(function* () {
1007
+ yield* onOpenObserved().pipe(Effect.catchCause(() => Effect.void));
1008
+ yield* FiberSet.run(fibers, reclamation, { startImmediately: true });
1009
+ if (fibers.state._tag === "Closed") {
1010
+ // FiberSet.run returns an interrupted sentinel when closure wins admission.
1011
+ // If admission won, closure interrupts the admitted fiber instead. Real
1012
+ // session disposal is cached and uninterruptible, so this fallback safely
1013
+ // joins that same disposal in both cases rather than starting cleanup twice.
1014
+ yield* reclamation;
1015
+ }
1016
+ });
1017
+ });
1018
+ }
1019
+
1020
+ export function createChildSessionsLayer(
883
1021
  overrides: Partial<WorkerSessionDependencies> = {},
884
- ): WorkerSessionFactory {
885
- const dependencies: WorkerSessionDependencies = { ...defaultDependencies, ...overrides };
886
- return { create: (options) => Effect.runPromise(createWorkerSession(options, dependencies)) };
1022
+ ): Layer.Layer<ChildSessions> {
1023
+ return Layer.effect(
1024
+ ChildSessions,
1025
+ Effect.gen(function* () {
1026
+ const dependencies: WorkerSessionDependencies = { ...defaultDependencies, ...overrides };
1027
+ const fibers = yield* FiberSet.make<void, never>();
1028
+ const serviceState = yield* SynchronizedRef.make<ChildSessionsState>({
1029
+ _tag: "Open",
1030
+ handoffs: new Set(),
1031
+ });
1032
+
1033
+ const withoutHandoff = (
1034
+ state: ChildSessionsState,
1035
+ handoff: AcquisitionHandoff,
1036
+ ): ChildSessionsState => {
1037
+ if (state._tag === "Closed" || !state.handoffs.has(handoff)) return state;
1038
+ const handoffs = new Set(state.handoffs);
1039
+ handoffs.delete(handoff);
1040
+ return { _tag: "Open", handoffs };
1041
+ };
1042
+
1043
+ const removeHandoff = (handoff: AcquisitionHandoff): Effect.Effect<void> =>
1044
+ SynchronizedRef.update(serviceState, (state) => withoutHandoff(state, handoff));
1045
+
1046
+ const runReclamation = (session: WorkerSessionHandle): Effect.Effect<void> =>
1047
+ admitReclamationOrJoinAfterClosure(
1048
+ fibers,
1049
+ disposeLateSession(session).pipe(Effect.uninterruptible),
1050
+ dependencies.onReclamationOpenObserved,
1051
+ );
1052
+
1053
+ const abandonHandoff = Effect.fn("ChildSessions.abandonHandoff")(function* (
1054
+ handoff: AcquisitionHandoff,
1055
+ ) {
1056
+ const session = yield* SynchronizedRef.modifyEffect(serviceState, (service) => {
1057
+ const nextService = withoutHandoff(service, handoff);
1058
+ return SynchronizedRef.modify(handoff.state, (state): readonly [
1059
+ { readonly session: WorkerSessionHandle | undefined },
1060
+ AcquisitionHandoffState,
1061
+ ] => {
1062
+ if (state._tag === "Pending") {
1063
+ return [{ session: undefined }, { _tag: "Abandoned" }];
1064
+ }
1065
+ if (state._tag === "Offered") {
1066
+ return [{ session: state.session }, { _tag: "Abandoned" }];
1067
+ }
1068
+ return [{ session: undefined }, state];
1069
+ }).pipe(Effect.map(({ session }) => [session, nextService] as const));
1070
+ });
1071
+ if (session) yield* runReclamation(session);
1072
+ });
1073
+
1074
+ const shutdown = Effect.fn("ChildSessions.shutdown")(() =>
1075
+ Effect.gen(function* () {
1076
+ const closed = yield* SynchronizedRef.modifyEffect(serviceState, (state) => {
1077
+ if (state._tag === "Closed") return Effect.succeed([undefined, state] as const);
1078
+ const error = new WorkerSessionAcquisitionClosedError({
1079
+ message: "Child sessions are shutting down",
1080
+ });
1081
+ return Effect.gen(function* () {
1082
+ const sessions: WorkerSessionHandle[] = [];
1083
+ for (const handoff of state.handoffs) {
1084
+ const session = yield* SynchronizedRef.modifyEffect(handoff.state, (handoffState) => {
1085
+ if (handoffState._tag === "Pending") {
1086
+ return Deferred.fail(handoff.result, error).pipe(
1087
+ Effect.as([undefined, { _tag: "Abandoned", error }] as const),
1088
+ );
1089
+ }
1090
+ if (handoffState._tag === "Offered") {
1091
+ return Effect.succeed([
1092
+ handoffState.session,
1093
+ { _tag: "Abandoned", error },
1094
+ ] as const);
1095
+ }
1096
+ // Failed already settled its Deferred. Adopting is an ownership
1097
+ // reservation whose synchronous winner must be allowed to commit.
1098
+ return Effect.succeed([undefined, handoffState] as const);
1099
+ });
1100
+ if (session) sessions.push(session);
1101
+ }
1102
+ return [{ sessions }, { _tag: "Closed", error }] as const;
1103
+ });
1104
+ });
1105
+ if (!closed) return;
1106
+ for (const session of closed.sessions) yield* runReclamation(session);
1107
+ }).pipe(Effect.uninterruptible)
1108
+ );
1109
+
1110
+ yield* Effect.addFinalizer(() => shutdown());
1111
+
1112
+ const acquire = Effect.fn("ChildSessions.acquire")(function* <Adopted>(
1113
+ options: ChildSessionOptions,
1114
+ adopt: (session: WorkerSessionHandle) => Adopted | undefined,
1115
+ ) {
1116
+ const handoff: AcquisitionHandoff = {
1117
+ state: yield* SynchronizedRef.make<AcquisitionHandoffState>({ _tag: "Pending" }),
1118
+ result: yield* Deferred.make<WorkerSessionHandle, WorkerSessionAcquisitionError>(),
1119
+ };
1120
+ return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
1121
+ const admissionError = yield* SynchronizedRef.modify(serviceState, (state) => {
1122
+ if (state._tag === "Closed") return [state.error, state] as const;
1123
+ return [undefined, {
1124
+ _tag: "Open",
1125
+ handoffs: new Set([...state.handoffs, handoff]),
1126
+ }] as const;
1127
+ });
1128
+ if (admissionError) return yield* Effect.fail(admissionError);
1129
+
1130
+ const producer = createWorkerSession(options, dependencies).pipe(
1131
+ Effect.matchCauseEffect({
1132
+ onFailure: (cause) => Effect.gen(function* () {
1133
+ const failed = yield* SynchronizedRef.modifyEffect(handoff.state, (state) =>
1134
+ state._tag === "Pending"
1135
+ ? Deferred.failCause(handoff.result, cause).pipe(
1136
+ Effect.as([true, { _tag: "Failed" }] as const),
1137
+ )
1138
+ : Effect.succeed([false, state] as const));
1139
+ if (failed) yield* removeHandoff(handoff);
1140
+ }),
1141
+ onSuccess: (session) => Effect.gen(function* () {
1142
+ const offered = yield* SynchronizedRef.modifyEffect(handoff.state, (state) =>
1143
+ state._tag === "Pending"
1144
+ ? Deferred.succeed(handoff.result, session).pipe(
1145
+ Effect.as([true, { _tag: "Offered", session }] as const),
1146
+ )
1147
+ : Effect.succeed([false, state] as const));
1148
+ if (!offered) {
1149
+ yield* removeHandoff(handoff);
1150
+ yield* runReclamation(session);
1151
+ }
1152
+ }),
1153
+ }),
1154
+ Effect.uninterruptible,
1155
+ );
1156
+ yield* FiberSet.run(fibers, producer, { startImmediately: true });
1157
+
1158
+ const session = yield* restore(
1159
+ Deferred.await(handoff.result).pipe(
1160
+ Effect.tap(() => dependencies.beforeAdoptionReservation()),
1161
+ ),
1162
+ ).pipe(Effect.onInterrupt(() => abandonHandoff(handoff)));
1163
+ const reservation = yield* SynchronizedRef.modifyEffect(serviceState, (service) => {
1164
+ if (service._tag === "Closed") {
1165
+ return Effect.succeed([
1166
+ { _tag: "Closed", error: service.error } satisfies AdoptionReservation,
1167
+ service,
1168
+ ] as const);
1169
+ }
1170
+ return SynchronizedRef.modify(
1171
+ handoff.state,
1172
+ (state): readonly [AdoptionReservation, AcquisitionHandoffState] => {
1173
+ if (state._tag === "Abandoned") {
1174
+ return state.error
1175
+ ? [{ _tag: "Closed", error: state.error }, state]
1176
+ : [{ _tag: "Abandoned" }, state];
1177
+ }
1178
+ if (state._tag !== "Offered" || state.session !== session) {
1179
+ return [{ _tag: "Abandoned" }, state];
1180
+ }
1181
+ return [
1182
+ { _tag: "Reserved", session },
1183
+ { _tag: "Adopting", session },
1184
+ ];
1185
+ },
1186
+ ).pipe(Effect.map((result) => [result, service] as const));
1187
+ });
1188
+ if (reservation._tag === "Closed") return yield* Effect.fail(reservation.error);
1189
+ if (reservation._tag === "Abandoned") {
1190
+ return yield* Effect.die(new Error("Child session acquisition handoff was abandoned"));
1191
+ }
1192
+
1193
+ // The adopter is arbitrary synchronous runtime code. The reservation
1194
+ // protects it from shutdown, but no SynchronizedRef semaphore is held.
1195
+ const adopted = yield* Effect.exit(Effect.sync(() => adopt(reservation.session)));
1196
+ const transferred = Exit.isSuccess(adopted) && adopted.value !== undefined;
1197
+ yield* SynchronizedRef.update(handoff.state, (state): AcquisitionHandoffState => {
1198
+ if (state._tag !== "Adopting" || state.session !== reservation.session) return state;
1199
+ return transferred ? { _tag: "Adopted" } : { _tag: "Abandoned" };
1200
+ });
1201
+ yield* removeHandoff(handoff);
1202
+ if (transferred) return adopted.value;
1203
+
1204
+ yield* runReclamation(reservation.session);
1205
+ if (Exit.isFailure(adopted)) return yield* Effect.failCause(adopted.cause);
1206
+ return undefined;
1207
+ }));
1208
+ });
1209
+
1210
+ return ChildSessions.of({ acquire, shutdown });
1211
+ }),
1212
+ );
887
1213
  }