@zachwill/pi-orchestrate 0.1.1 → 0.2.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.
@@ -14,6 +14,7 @@ import {
14
14
  visibleWidth,
15
15
  type Component,
16
16
  } from "@earendil-works/pi-tui";
17
+ import { Result, Schema } from "effect";
17
18
  import type { WorkerDeliveryDetails } from "./delivery.js";
18
19
  import type { WorkerOutcome, WorkerRecord, WorkerStatus, WorkerUsage } from "./domain.js";
19
20
  import type { OrchestratorRuntime, RuntimeSnapshot } from "./runtime.js";
@@ -22,33 +23,97 @@ export const ORCHESTRATION_PRESENTATION_KEY = "pi-orchestrate";
22
23
  export const MAX_RESULT_PREVIEW_LINES = 6;
23
24
  export const MAX_WIDGET_WORKERS = 8;
24
25
 
25
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
26
+ const WORKER_ANIMATIONS = {
27
+ starting: {
28
+ frames: ["⠂", "⠌", "⡑", "⢕", "⣫", "⣿", "⣫", "⢕"],
29
+ color: "muted",
30
+ },
31
+ running: {
32
+ frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
33
+ color: "accent",
34
+ },
35
+ stopping: {
36
+ frames: ["⣿", "⣶", "⣤", "⣀", "⠠", "⠐", "⠈", "⠁"],
37
+ color: "dim",
38
+ },
39
+ } as const;
40
+ const ANIMATION_CYCLE_TICKS = 40;
26
41
  const SPINNER_INTERVAL_MS = 140;
27
42
  const ACTIVE_STATUSES: ReadonlySet<WorkerStatus> = new Set(["starting", "running", "stopping"]);
28
43
 
29
44
  export type PresentationRuntime = Pick<OrchestratorRuntime, "snapshot" | "subscribeState">;
30
45
 
31
- interface SafeSettlement {
32
- eventId?: string;
33
- sequence?: number;
34
- ownerSessionId: string;
35
- waveId: string;
36
- workerId: string;
37
- generation: number;
38
- mode: "async" | "inline";
39
- worker: string;
40
- title: string;
41
- lifecycle: "one-shot" | "reusable";
42
- status: "completed" | "ready" | "failed" | "aborted";
43
- outcome: Exclude<WorkerOutcome, { status: "closed" }>;
44
- usage: Partial<WorkerUsage>;
45
- startedAt?: number;
46
- settledAt?: number;
47
- remainingActive?: number;
48
- waveComplete?: boolean;
49
- sessionFile?: string;
50
- failureStage?: "startup" | "prompt" | "workflow" | "cancellation";
51
- }
46
+ const NonnegativeFinite = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0));
47
+ const NonnegativeInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
48
+ const legacyOptionalKey = <S extends Schema.Constraint>(schema: S) =>
49
+ Schema.optionalKey(Schema.UndefinedOr(schema));
50
+
51
+ const WorkerUsageSchema = Schema.Struct({
52
+ input: NonnegativeFinite,
53
+ output: NonnegativeFinite,
54
+ cacheRead: NonnegativeFinite,
55
+ cacheWrite: NonnegativeFinite,
56
+ cost: NonnegativeFinite,
57
+ contextTokens: NonnegativeFinite,
58
+ turns: NonnegativeInteger,
59
+ });
60
+
61
+ const WorkerCompletedOutcomeSchema = Schema.Struct({
62
+ status: Schema.Literal("completed"),
63
+ assistantText: Schema.String,
64
+ });
65
+ const WorkerReadyOutcomeSchema = Schema.Struct({
66
+ status: Schema.Literal("ready"),
67
+ assistantText: Schema.String,
68
+ });
69
+ const WorkerFailedOutcomeSchema = Schema.Struct({
70
+ status: Schema.Literal("failed"),
71
+ message: Schema.String,
72
+ assistantText: legacyOptionalKey(Schema.String),
73
+ });
74
+ const WorkerAbortedOutcomeSchema = Schema.Struct({
75
+ status: Schema.Literal("aborted"),
76
+ message: legacyOptionalKey(Schema.String),
77
+ assistantText: legacyOptionalKey(Schema.String),
78
+ });
79
+ const WorkerOutcomeSchema = Schema.Union([
80
+ WorkerCompletedOutcomeSchema,
81
+ WorkerReadyOutcomeSchema,
82
+ WorkerFailedOutcomeSchema,
83
+ WorkerAbortedOutcomeSchema,
84
+ ]);
85
+
86
+ const SettlementPayloadSchema = Schema.Struct({
87
+ eventId: legacyOptionalKey(Schema.String),
88
+ sequence: legacyOptionalKey(NonnegativeInteger),
89
+ ownerSessionId: Schema.String,
90
+ waveId: Schema.String,
91
+ workerId: Schema.String,
92
+ generation: NonnegativeInteger,
93
+ mode: Schema.Literals(["async", "inline"]),
94
+ worker: Schema.String,
95
+ title: Schema.String,
96
+ lifecycle: Schema.Literals(["one-shot", "reusable"]),
97
+ status: Schema.Literals(["completed", "ready", "failed", "aborted"]),
98
+ outcome: WorkerOutcomeSchema,
99
+ usage: WorkerUsageSchema,
100
+ startedAt: NonnegativeInteger,
101
+ settledAt: NonnegativeInteger,
102
+ remainingActive: legacyOptionalKey(NonnegativeInteger),
103
+ waveComplete: legacyOptionalKey(Schema.Boolean),
104
+ sessionFile: legacyOptionalKey(Schema.String),
105
+ failureStage: legacyOptionalKey(Schema.Literals(["startup", "prompt", "workflow", "cancellation"])),
106
+ }).check(Schema.makeFilter((settlement) => {
107
+ if (settlement.outcome.status !== settlement.status) return "outcome status must match settlement status";
108
+ if (settlement.settledAt < settlement.startedAt) return "settlement timestamp must not precede start timestamp";
109
+ if (settlement.failureStage !== undefined && settlement.status !== "failed" && settlement.status !== "aborted") {
110
+ return "failure stage requires a failed or aborted settlement";
111
+ }
112
+ }));
113
+
114
+ const SettlementEnvelopeSchema = Schema.Struct({ settlement: SettlementPayloadSchema });
115
+ const decodeSettlementEnvelope = Schema.decodeUnknownResult(SettlementEnvelopeSchema);
116
+ type SafeSettlement = Schema.Schema.Type<typeof SettlementPayloadSchema>;
52
117
 
53
118
  interface StatusBinding {
54
119
  readonly ownerSessionId: string;
@@ -96,7 +161,7 @@ export function formatWorkerUsage(usage: Partial<WorkerUsage> | undefined): stri
96
161
  }
97
162
 
98
163
  export function formatWorkerStatusLine(worker: WorkerRecord): string {
99
- return `${worker.worker} → ${worker.title} · ${workerStateLabel(worker)} · ${compactLiveUsage(worker.usage)}`;
164
+ return `${worker.worker} → ${worker.title} · ${workerStateLabel(worker)} · ${formatTurnMarker(worker)} · ${formatCompactNumber(numberOrZero(worker.usage?.contextTokens))} ctx`;
100
165
  }
101
166
 
102
167
  export function formatFooterStatus(snapshot: RuntimeSnapshot): string | undefined {
@@ -229,7 +294,7 @@ export class WorkerStatusComponent implements Component {
229
294
  private startTimer(): void {
230
295
  if (this.timer || activeWorkers(this.snapshot).length === 0) return;
231
296
  this.timer = setInterval(() => {
232
- this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
297
+ this.frameIndex = (this.frameIndex + 1) % ANIMATION_CYCLE_TICKS;
233
298
  this.tui?.requestRender();
234
299
  }, SPINNER_INTERVAL_MS);
235
300
  const timer = this.timer;
@@ -243,13 +308,19 @@ export class WorkerStatusComponent implements Component {
243
308
  }
244
309
 
245
310
  private workerLine(worker: WorkerRecord, width: number): string {
246
- const glyph = this.theme.fg("warning", SPINNER_FRAMES[this.frameIndex] ?? SPINNER_FRAMES[0]);
247
- const turns = `${numberOrZero(worker.usage?.turns)}t`;
248
- const context = `${formatCompactNumber(numberOrZero(worker.usage?.contextTokens))} ctx`;
311
+ const animation = worker.status === "starting"
312
+ ? WORKER_ANIMATIONS.starting
313
+ : worker.status === "stopping"
314
+ ? WORKER_ANIMATIONS.stopping
315
+ : WORKER_ANIMATIONS.running;
316
+ const glyph = this.theme.fg(
317
+ animation.color,
318
+ animation.frames[this.frameIndex % animation.frames.length] ?? animation.frames[0],
319
+ );
320
+ const turns = formatTurnMarker(worker);
321
+ const context = `${formatContextTokens(numberOrZero(worker.usage?.contextTokens))} ctx`;
249
322
  const suffixFields = width >= 28 ? [turns, context] : width >= 12 ? [turns] : [];
250
- const activity = workerStateLabel(worker);
251
323
  const requiredWidth = (fields: readonly string[]) => visibleWidth(`⠋ · ${fields.join(" · ")}`) + 10;
252
- if (width >= 42 && requiredWidth([activity, ...suffixFields]) <= width) suffixFields.unshift(activity);
253
324
  const showWorker = width >= 72 && requiredWidth([worker.worker, ...suffixFields]) <= width;
254
325
  const prefix = showWorker ? `${glyph} ${this.theme.fg("muted", worker.worker)} → ` : `${glyph} `;
255
326
  const suffix = suffixFields.length ? ` · ${suffixFields.join(" · ")}` : "";
@@ -308,9 +379,19 @@ export class WorkerResultComponent implements Component {
308
379
  ? " · could not start"
309
380
  : "";
310
381
  const header = `${statusIcon(details)} ${details.worker} · ${details.title}${status}${elapsed ? ` · ${elapsed}` : ""}`;
382
+ const outcome = presentedOutcome(details);
311
383
  box.addChild(new Text(this.theme.fg(color, this.theme.bold(header)), 0, 0));
312
384
  box.addChild(new Spacer(1));
313
- box.addChild(new WidthBoundComponent(new Markdown(outcomeText(details.outcome), 0, 0, getMarkdownTheme()), this.expanded ? undefined : MAX_RESULT_PREVIEW_LINES));
385
+ if (outcome.heading) {
386
+ box.addChild(new Text(this.theme.fg("success", this.theme.bold(outcome.heading)), 0, 0));
387
+ if (outcome.body) box.addChild(new Spacer(1));
388
+ }
389
+ if (outcome.body) {
390
+ box.addChild(new WidthBoundComponent(
391
+ new Markdown(outcome.body, 0, 0, getMarkdownTheme()),
392
+ this.expanded ? undefined : MAX_RESULT_PREVIEW_LINES,
393
+ ));
394
+ }
314
395
  if (!this.expanded) {
315
396
  box.addChild(new Spacer(1));
316
397
  box.addChild(new Text(this.theme.fg("dim", keyHint("app.tools.expand", "to expand")), 0, 0));
@@ -324,80 +405,11 @@ export class WorkerResultComponent implements Component {
324
405
  }
325
406
 
326
407
  function readSettlement(value: unknown): SafeSettlement | undefined {
327
- const candidate = isRecord(value) && isRecord(value.settlement) ? value.settlement : value;
328
- if (!isRecord(candidate)) return undefined;
329
- const mode = enumField(candidate.mode, ["async", "inline"]);
330
- const lifecycle = enumField(candidate.lifecycle, ["one-shot", "reusable"]);
331
- const status = enumField(candidate.status, ["completed", "ready", "failed", "aborted"]);
332
- const outcome = readOutcome(candidate.outcome);
333
- const usage = readUsage(candidate.usage);
334
- const generation = nonnegativeInteger(candidate.generation);
335
- if (!mode || !lifecycle || !status || !outcome || outcome.status !== status || !usage || generation === undefined) return undefined;
336
- const ownerSessionId = requiredString(candidate.ownerSessionId);
337
- const waveId = requiredString(candidate.waveId);
338
- const workerId = requiredString(candidate.workerId);
339
- const worker = requiredString(candidate.worker);
340
- const title = requiredString(candidate.title);
341
- if (ownerSessionId === undefined || waveId === undefined || workerId === undefined || worker === undefined || title === undefined) return undefined;
342
- const eventId = optionalString(candidate.eventId);
343
- const sessionFile = optionalString(candidate.sessionFile);
344
- const sequence = optionalInteger(candidate.sequence);
345
- const startedAt = optionalInteger(candidate.startedAt);
346
- const settledAt = optionalInteger(candidate.settledAt);
347
- const remainingActive = optionalInteger(candidate.remainingActive);
348
- const waveComplete = optionalBoolean(candidate.waveComplete);
349
- const failureStage = optionalEnum(candidate.failureStage, ["startup", "prompt", "workflow", "cancellation"]);
350
- if (eventId === INVALID || sessionFile === INVALID || sequence === INVALID || startedAt === INVALID || settledAt === INVALID ||
351
- remainingActive === INVALID || waveComplete === INVALID || failureStage === INVALID) return undefined;
352
- if (startedAt === undefined || settledAt === undefined || settledAt < startedAt) return undefined;
353
- if (failureStage !== undefined && status !== "failed" && status !== "aborted") return undefined;
354
- return {
355
- ownerSessionId, waveId, workerId, generation, mode, worker, title, lifecycle, status, outcome, usage,
356
- ...(eventId === undefined ? {} : { eventId }),
357
- ...(sequence === undefined ? {} : { sequence }),
358
- ...(startedAt === undefined ? {} : { startedAt }),
359
- ...(settledAt === undefined ? {} : { settledAt }),
360
- ...(remainingActive === undefined ? {} : { remainingActive }),
361
- ...(waveComplete === undefined ? {} : { waveComplete }),
362
- ...(sessionFile === undefined ? {} : { sessionFile }),
363
- ...(failureStage === undefined ? {} : { failureStage }),
364
- };
408
+ const envelope = isRecord(value) && isRecord(value.settlement) ? value : { settlement: value };
409
+ const decoded = decodeSettlementEnvelope(envelope);
410
+ return Result.isSuccess(decoded) ? decoded.success.settlement : undefined;
365
411
  }
366
412
 
367
- function readOutcome(value: unknown): WorkerOutcome | undefined {
368
- if (!isRecord(value) || typeof value.status !== "string") return undefined;
369
- if ((value.status === "completed" || value.status === "ready") && typeof value.assistantText === "string") return { status: value.status, assistantText: value.assistantText };
370
- const message = optionalString(value.message);
371
- const assistantText = optionalString(value.assistantText);
372
- if (message === INVALID || assistantText === INVALID) return undefined;
373
- if (value.status === "failed" && message !== undefined) return { status: "failed", message, ...(assistantText === undefined ? {} : { assistantText }) };
374
- if (value.status === "aborted") return { status: "aborted", ...(message === undefined ? {} : { message }), ...(assistantText === undefined ? {} : { assistantText }) };
375
- return undefined;
376
- }
377
-
378
- const INVALID = Symbol("invalid");
379
- type Invalid = typeof INVALID;
380
- function requiredString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; }
381
- function optionalString(value: unknown): string | undefined | Invalid { return value === undefined ? undefined : typeof value === "string" ? value : INVALID; }
382
- function nonnegativeInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 ? value : undefined; }
383
- function optionalInteger(value: unknown): number | undefined | Invalid { return value === undefined ? undefined : nonnegativeInteger(value) ?? INVALID; }
384
- function optionalBoolean(value: unknown): boolean | undefined | Invalid { return value === undefined ? undefined : typeof value === "boolean" ? value : INVALID; }
385
- function enumField<const T extends string>(value: unknown, values: readonly T[]): T | undefined { return typeof value === "string" && values.includes(value as T) ? value as T : undefined; }
386
- function optionalEnum<const T extends string>(value: unknown, values: readonly T[]): T | undefined | Invalid { return value === undefined ? undefined : enumField(value, values) ?? INVALID; }
387
- function readUsage(value: unknown): WorkerUsage | undefined {
388
- if (!isRecord(value)) return undefined;
389
- const input = nonnegativeNumber(value.input);
390
- const output = nonnegativeNumber(value.output);
391
- const cacheRead = nonnegativeNumber(value.cacheRead);
392
- const cacheWrite = nonnegativeNumber(value.cacheWrite);
393
- const cost = nonnegativeNumber(value.cost);
394
- const contextTokens = nonnegativeNumber(value.contextTokens);
395
- const turns = nonnegativeInteger(value.turns);
396
- if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined || cost === undefined || contextTokens === undefined || turns === undefined) return undefined;
397
- return { input, output, cacheRead, cacheWrite, cost, contextTokens, turns };
398
- }
399
- function nonnegativeNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; }
400
-
401
413
  function statusHeading(result: SafeSettlement): string {
402
414
  if (result.status === "failed") return result.failureStage === "startup" ? "could not start" : "failed";
403
415
  if (result.status === "aborted") return "aborted";
@@ -418,6 +430,24 @@ function outcomeText(outcome: WorkerOutcome): string {
418
430
  return "Worker session closed.";
419
431
  }
420
432
 
433
+ function presentedOutcome(result: SafeSettlement): { heading?: string; body: string } {
434
+ const body = outcomeText(result.outcome);
435
+ if (result.status !== "completed" && result.status !== "ready") return { body };
436
+
437
+ const lines = body.split("\n");
438
+ const headingIndex = lines.findIndex((line) => line.trim() !== "");
439
+ if (headingIndex < 0 || !/^#{1,6}\s+(?:completed|complete|done)\s*#*\s*$/i.test(lines[headingIndex]!)) {
440
+ return { body };
441
+ }
442
+
443
+ lines.splice(headingIndex, 1);
444
+ while (lines[headingIndex]?.trim() === "") lines.splice(headingIndex, 1);
445
+ return {
446
+ heading: result.status === "ready" ? "✓ Response complete" : "✓ Completed",
447
+ body: lines.join("\n").trimEnd(),
448
+ };
449
+ }
450
+
421
451
  function settlementMetadata(result: SafeSettlement): string[] {
422
452
  return [
423
453
  `worker ID ${result.workerId} · wave ID ${result.waveId}`,
@@ -446,8 +476,9 @@ function workerStateLabel(worker: Pick<WorkerRecord, "status" | "activity">): st
446
476
  return TOOL_ACTIVITY[worker.activity] ?? worker.activity;
447
477
  }
448
478
 
449
- function compactLiveUsage(usage: Partial<WorkerUsage> | undefined): string {
450
- return `${numberOrZero(usage?.turns)}t · ${formatCompactNumber(numberOrZero(usage?.contextTokens))} ctx`;
479
+ function formatTurnMarker(worker: Pick<WorkerRecord, "messageDirection" | "usage">): string {
480
+ const direction = worker.messageDirection === "from-model" ? "↓" : "↑";
481
+ return `${numberOrZero(worker.usage?.turns)}${direction}`;
451
482
  }
452
483
  function elapsedBetween(start?: number, end?: number): string | undefined {
453
484
  return start !== undefined && end !== undefined && end >= start ? formatElapsed(end - start) : undefined;
@@ -462,6 +493,10 @@ function firstNonEmptyLines(text: string, limit: number): string[] {
462
493
  return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, limit);
463
494
  }
464
495
  function numberOrZero(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; }
496
+ function formatContextTokens(value: number): string {
497
+ if (value < 1_000) return String(Math.round(value));
498
+ return `${Math.round(value / 1_000)}k`;
499
+ }
465
500
  function formatCompactNumber(value: number): string {
466
501
  if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`;
467
502
  if (value >= 1_000) return `${trimDecimal(value / 1_000)}k`;
@@ -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,
@@ -201,6 +202,7 @@ interface RuntimeEntry {
201
202
  session?: WorkerSessionHandle;
202
203
  unsubscribeUsage?: () => void;
203
204
  unsubscribeActivity?: () => void;
205
+ unsubscribeMessageDirection?: () => void;
204
206
  }
205
207
 
206
208
  interface WaveWaiter {
@@ -212,17 +214,16 @@ interface WaveWaiter {
212
214
 
213
215
  const defaultBestEffortDeadline: BestEffortDeadline = {
214
216
  wait(promise, timeoutMs) {
215
- let timer: ReturnType<typeof setTimeout> | undefined;
216
- const settled = promise.then(
217
+ const settled = Effect.promise(() => promise.then(
217
218
  () => "settled" as const,
218
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
+ ),
219
226
  );
220
- const timedOut = new Promise<DeadlineResult>((resolve) => {
221
- timer = setTimeout(() => resolve("timed-out"), timeoutMs);
222
- });
223
- return Promise.race([settled, timedOut]).finally(() => {
224
- if (timer !== undefined) clearTimeout(timer);
225
- });
226
227
  },
227
228
  };
228
229
 
@@ -316,6 +317,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
316
317
  lifecycle: definition.lifecycle,
317
318
  status: "starting",
318
319
  usage: copyUsage(EMPTY_WORKER_USAGE),
320
+ messageDirection: "to-model",
319
321
  startedAt: this.clock(),
320
322
  };
321
323
  });
@@ -396,11 +398,12 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
396
398
  state: "running",
397
399
  createdAt: this.clock(),
398
400
  };
399
- const running = {
401
+ const running: WorkerRecord = {
400
402
  ...transitionWorkerStatus(current, "running"),
401
403
  waveId,
402
404
  instructions,
403
405
  activity: undefined,
406
+ messageDirection: "to-model",
404
407
  startedAt: this.clock(),
405
408
  settledAt: undefined,
406
409
  };
@@ -558,7 +561,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
558
561
  try {
559
562
  this.scheduler.start(
560
563
  workerId,
561
- () => this.trackCleanup(this.bootstrapAndPrompt(workerId, generation)),
564
+ this.bootstrapAndPrompt(workerId, generation),
562
565
  (error) => this.settleWorkflowDefect(workerId, generation, error),
563
566
  );
564
567
  } catch (error) {
@@ -575,7 +578,7 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
575
578
  try {
576
579
  this.scheduler.start(
577
580
  workerId,
578
- () => this.trackCleanup(this.executePrompt(workerId, generation, session, instructions)),
581
+ this.executePrompt(workerId, generation, session, instructions),
579
582
  (error) => this.settleWorkflowDefect(workerId, generation, error),
580
583
  );
581
584
  } catch (error) {
@@ -583,44 +586,90 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
583
586
  }
584
587
  }
585
588
 
586
- private async bootstrapAndPrompt(workerId: WorkerId, generation: number): Promise<void> {
587
- const session = await this.bootstrap(workerId, generation);
588
- if (!session) return;
589
- const current = this.workers.get(workerId);
590
- if (!current) return;
591
- 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
+ });
592
601
  }
593
602
 
594
- private async bootstrap(
603
+ private bootstrap(
595
604
  workerId: WorkerId,
596
605
  generation: number,
597
- ): Promise<WorkerSessionHandle | undefined> {
598
- const entry = this.entries.get(workerId);
599
- 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);
600
610
 
601
- let session: WorkerSessionHandle;
602
- try {
603
- session = await this.workerSessionFactory.create({
604
- cwd: entry.context.cwd,
605
- agentDir: entry.context.agentDir,
606
- parentSessionFile: entry.context.parentSessionFile,
607
- projectTrusted: entry.context.projectTrusted,
608
- definition: entry.definition,
609
- parentModel: entry.context.parentModel,
610
- modelRegistry: entry.context.modelRegistry,
611
- });
612
- } catch (error) {
613
- this.settleCreationFailure(workerId, generation, error);
614
- return undefined;
615
- }
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
+ }
616
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 {
617
659
  const current = this.workers.get(workerId);
618
- if (
619
- this.shuttingDown ||
620
- !current ||
621
- current.status !== "starting" ||
622
- entry.generation !== generation
623
- ) {
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) {
624
673
  this.disposeSession(session);
625
674
  return undefined;
626
675
  }
@@ -641,31 +690,54 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
641
690
  }
642
691
  }
643
692
 
644
- private async executePrompt(
693
+ private executePrompt(
645
694
  workerId: WorkerId,
646
695
  generation: number,
647
696
  session: WorkerSessionHandle,
648
697
  instructions: string,
649
- ): Promise<void> {
650
- const before = this.workers.get(workerId);
651
- const entry = this.entries.get(workerId);
652
- if (
653
- !before ||
654
- before.status !== "running" ||
655
- !entry ||
656
- entry.generation !== generation ||
657
- entry.session !== session
658
- ) {
659
- return;
660
- }
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
+ }
661
711
 
662
- let outcome: WorkerOutcome;
663
- try {
664
- outcome = await session.prompt(instructions);
665
- } catch (error) {
666
- outcome = { status: "failed", message: describeError(error, "Worker prompt failed") };
667
- }
668
- 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
+ });
669
741
  }
670
742
 
671
743
  private settleCreationFailure(
@@ -1137,6 +1209,14 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
1137
1209
  this.workers.set(workerId, { ...latest, activity });
1138
1210
  this.emitState(latest.ownerSessionId);
1139
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
+ });
1140
1220
  }
1141
1221
 
1142
1222
  private emitState(ownerSessionId: string): void {
@@ -1166,6 +1246,8 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
1166
1246
  entry.unsubscribeUsage = undefined;
1167
1247
  safelyCall(entry.unsubscribeActivity);
1168
1248
  entry.unsubscribeActivity = undefined;
1249
+ safelyCall(entry.unsubscribeMessageDirection);
1250
+ entry.unsubscribeMessageDirection = undefined;
1169
1251
  }
1170
1252
 
1171
1253
  private disposeEntrySession(entry: RuntimeEntry): void {
@@ -1177,7 +1259,12 @@ class DefaultOrchestratorRuntime implements OrchestratorRuntime {
1177
1259
  private disposeSession(session: WorkerSessionHandle): void {
1178
1260
  if (this.disposedSessions.has(session)) return;
1179
1261
  this.disposedSessions.add(session);
1180
- 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
+ }
1181
1268
  }
1182
1269
 
1183
1270
  private trackCleanup(operation: Promise<void>): Promise<void> {
@@ -1258,7 +1345,8 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
1258
1345
  }
1259
1346
 
1260
1347
  function abortSignalReason(signal: AbortSignal): unknown {
1261
- return signal.reason ?? new DOMException("This operation was aborted", "AbortError");
1348
+ if ("reason" in signal) return signal.reason;
1349
+ return new DOMException("This operation was aborted", "AbortError");
1262
1350
  }
1263
1351
 
1264
1352
  function addAll(target: Set<string>, source: ReadonlySet<string>): void {