@zachwill/pi-orchestrate 0.1.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.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/examples/workers/investigator.md +41 -0
- package/examples/workers/scout.md +36 -0
- package/examples/workers/worker.md +44 -0
- package/extension/catalog.ts +372 -0
- package/extension/contract.ts +70 -0
- package/extension/delivery.ts +196 -0
- package/extension/domain.ts +335 -0
- package/extension/host.ts +107 -0
- package/extension/index.ts +176 -0
- package/extension/presentation.ts +629 -0
- package/extension/runtime.ts +1193 -0
- package/extension/scheduler.ts +66 -0
- package/extension/tools.ts +559 -0
- package/extension/worker-session.ts +526 -0
- package/package.json +46 -0
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
CANCELLATION_GRACE_MS,
|
|
5
|
+
EMPTY_WORKER_USAGE,
|
|
6
|
+
MAX_TASKS_PER_WAVE,
|
|
7
|
+
MAX_WORKER_INSTRUCTIONS_LENGTH,
|
|
8
|
+
MAX_WORKER_TITLE_LENGTH,
|
|
9
|
+
createRandomIdFactories,
|
|
10
|
+
findWorkerByName,
|
|
11
|
+
isTerminalWorkerStatus,
|
|
12
|
+
isWorkerCompleteForWave,
|
|
13
|
+
transitionWorkerStatus,
|
|
14
|
+
type OrchestrateIdFactories,
|
|
15
|
+
type OrchestrateTaskInput,
|
|
16
|
+
type WaveCompleteWorkerStatus,
|
|
17
|
+
type WaveId,
|
|
18
|
+
type WaveMode,
|
|
19
|
+
type WaveRecord,
|
|
20
|
+
type WorkerCatalog,
|
|
21
|
+
type WorkerDefinition,
|
|
22
|
+
type WorkerId,
|
|
23
|
+
type WorkerOutcome,
|
|
24
|
+
type WorkerRecord,
|
|
25
|
+
type WorkerUsage,
|
|
26
|
+
} from "./domain.js";
|
|
27
|
+
import {
|
|
28
|
+
createWorkflowScheduler,
|
|
29
|
+
type WorkflowScheduler,
|
|
30
|
+
} from "./scheduler.js";
|
|
31
|
+
import {
|
|
32
|
+
resolveWorkerModel,
|
|
33
|
+
type WorkerSessionFactory,
|
|
34
|
+
type WorkerSessionHandle,
|
|
35
|
+
} from "./worker-session.js";
|
|
36
|
+
|
|
37
|
+
export const MAX_TERMINAL_WORKER_HISTORY = 100;
|
|
38
|
+
export const MAX_COMPLETED_WAVE_HISTORY = 100;
|
|
39
|
+
/** Shutdown waits this long for interrupted bootstrap/prompt promises, then returns best-effort. */
|
|
40
|
+
export const SHUTDOWN_CLEANUP_GRACE_MS = CANCELLATION_GRACE_MS;
|
|
41
|
+
|
|
42
|
+
export interface OrchestrationContext {
|
|
43
|
+
readonly ownerSessionId: string;
|
|
44
|
+
readonly cwd: string;
|
|
45
|
+
readonly agentDir: string;
|
|
46
|
+
readonly parentSessionFile: string | undefined;
|
|
47
|
+
readonly projectTrusted: boolean;
|
|
48
|
+
readonly catalog: WorkerCatalog;
|
|
49
|
+
readonly parentModel?: Model<Api>;
|
|
50
|
+
readonly modelRegistry: ModelRegistry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AcceptedWave {
|
|
54
|
+
readonly id: WaveId;
|
|
55
|
+
readonly workerIds: readonly WorkerId[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CompletedResult {
|
|
59
|
+
readonly workerId: WorkerId;
|
|
60
|
+
readonly worker: string;
|
|
61
|
+
readonly title: string;
|
|
62
|
+
readonly status: WaveCompleteWorkerStatus;
|
|
63
|
+
readonly outcome: WorkerOutcome;
|
|
64
|
+
readonly usage: WorkerUsage;
|
|
65
|
+
readonly sessionFile: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CompletedWave {
|
|
69
|
+
readonly id: WaveId;
|
|
70
|
+
readonly ownerSessionId: string;
|
|
71
|
+
readonly mode: WaveMode;
|
|
72
|
+
readonly results: readonly CompletedResult[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RuntimeSnapshot {
|
|
76
|
+
readonly waves: readonly WaveRecord[];
|
|
77
|
+
readonly workers: readonly WorkerRecord[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type CompletionListener = (wave: CompletedWave) => void;
|
|
81
|
+
export type UnsubscribeCompletion = () => void;
|
|
82
|
+
export type StateListener = (ownerSessionId: string) => void;
|
|
83
|
+
|
|
84
|
+
export type AbortTarget =
|
|
85
|
+
| {
|
|
86
|
+
readonly workerIds: readonly WorkerId[];
|
|
87
|
+
readonly waveId?: never;
|
|
88
|
+
readonly all?: never;
|
|
89
|
+
}
|
|
90
|
+
| {
|
|
91
|
+
readonly waveId: WaveId;
|
|
92
|
+
readonly workerIds?: never;
|
|
93
|
+
readonly all?: never;
|
|
94
|
+
}
|
|
95
|
+
| {
|
|
96
|
+
readonly all: true;
|
|
97
|
+
readonly workerIds?: never;
|
|
98
|
+
readonly waveId?: never;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type DeadlineResult = "settled" | "timed-out";
|
|
102
|
+
|
|
103
|
+
export interface BestEffortDeadline {
|
|
104
|
+
wait(promise: Promise<unknown>, timeoutMs: number): Promise<DeadlineResult>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface OrchestratorRuntimeOptions {
|
|
108
|
+
readonly workerSessionFactory: WorkerSessionFactory;
|
|
109
|
+
readonly idFactories?: OrchestrateIdFactories;
|
|
110
|
+
readonly clock?: () => number;
|
|
111
|
+
readonly scheduler?: WorkflowScheduler<WorkerId>;
|
|
112
|
+
readonly bestEffortDeadline?: BestEffortDeadline;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface OrchestratorRuntime {
|
|
116
|
+
orchestrate(
|
|
117
|
+
context: OrchestrationContext,
|
|
118
|
+
tasks: readonly OrchestrateTaskInput[],
|
|
119
|
+
mode: "async",
|
|
120
|
+
signal?: AbortSignal,
|
|
121
|
+
): Promise<AcceptedWave>;
|
|
122
|
+
orchestrate(
|
|
123
|
+
context: OrchestrationContext,
|
|
124
|
+
tasks: readonly OrchestrateTaskInput[],
|
|
125
|
+
mode: "inline",
|
|
126
|
+
signal?: AbortSignal,
|
|
127
|
+
): Promise<CompletedWave>;
|
|
128
|
+
orchestrate(
|
|
129
|
+
context: OrchestrationContext,
|
|
130
|
+
tasks: readonly OrchestrateTaskInput[],
|
|
131
|
+
mode: WaveMode,
|
|
132
|
+
signal?: AbortSignal,
|
|
133
|
+
): Promise<AcceptedWave | CompletedWave>;
|
|
134
|
+
send(
|
|
135
|
+
context: OrchestrationContext,
|
|
136
|
+
workerId: WorkerId,
|
|
137
|
+
instructions: string,
|
|
138
|
+
mode: "async",
|
|
139
|
+
signal?: AbortSignal,
|
|
140
|
+
): Promise<AcceptedWave>;
|
|
141
|
+
send(
|
|
142
|
+
context: OrchestrationContext,
|
|
143
|
+
workerId: WorkerId,
|
|
144
|
+
instructions: string,
|
|
145
|
+
mode: "inline",
|
|
146
|
+
signal?: AbortSignal,
|
|
147
|
+
): Promise<CompletedWave>;
|
|
148
|
+
send(
|
|
149
|
+
context: OrchestrationContext,
|
|
150
|
+
workerId: WorkerId,
|
|
151
|
+
instructions: string,
|
|
152
|
+
mode: WaveMode,
|
|
153
|
+
signal?: AbortSignal,
|
|
154
|
+
): Promise<AcceptedWave | CompletedWave>;
|
|
155
|
+
abort(ownerSessionId: string, target: AbortTarget): Promise<void>;
|
|
156
|
+
close(ownerSessionId: string, workerId: WorkerId): Promise<void>;
|
|
157
|
+
snapshot(ownerSessionId: string): Promise<RuntimeSnapshot>;
|
|
158
|
+
subscribeCompletion(listener: CompletionListener): UnsubscribeCompletion;
|
|
159
|
+
subscribeState(listener: StateListener): () => void;
|
|
160
|
+
shutdown(): Promise<void>;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface RuntimeEntry {
|
|
164
|
+
readonly context: OrchestrationContext;
|
|
165
|
+
readonly definition: WorkerDefinition;
|
|
166
|
+
generation: number;
|
|
167
|
+
session?: WorkerSessionHandle;
|
|
168
|
+
unsubscribeUsage?: () => void;
|
|
169
|
+
unsubscribeActivity?: () => void;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface WaveWaiter {
|
|
173
|
+
readonly promise: Promise<CompletedWave>;
|
|
174
|
+
settled: boolean;
|
|
175
|
+
onSettled?: () => void;
|
|
176
|
+
resolve(wave: CompletedWave): void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const defaultBestEffortDeadline: BestEffortDeadline = {
|
|
180
|
+
wait(promise, timeoutMs) {
|
|
181
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
182
|
+
const settled = promise.then(
|
|
183
|
+
() => "settled" as const,
|
|
184
|
+
() => "settled" as const,
|
|
185
|
+
);
|
|
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
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
class DefaultOrchestratorRuntime implements OrchestratorRuntime {
|
|
196
|
+
private readonly workerSessionFactory: WorkerSessionFactory;
|
|
197
|
+
private readonly idFactories: OrchestrateIdFactories;
|
|
198
|
+
private readonly clock: () => number;
|
|
199
|
+
private readonly scheduler: WorkflowScheduler<WorkerId>;
|
|
200
|
+
private readonly bestEffortDeadline: BestEffortDeadline;
|
|
201
|
+
private readonly workers = new Map<WorkerId, WorkerRecord>();
|
|
202
|
+
private readonly waves = new Map<WaveId, WaveRecord>();
|
|
203
|
+
private readonly entries = new Map<WorkerId, RuntimeEntry>();
|
|
204
|
+
private readonly waveWaiters = new Map<WaveId, WaveWaiter>();
|
|
205
|
+
private readonly completedWaves = new Map<WaveId, CompletedWave>();
|
|
206
|
+
private readonly cancellationPromises = new Map<WorkerId, Promise<void>>();
|
|
207
|
+
private readonly cleanupOperations = new Set<Promise<void>>();
|
|
208
|
+
private readonly terminalWorkerOrder: WorkerId[] = [];
|
|
209
|
+
private readonly completedWaveOrder: WaveId[] = [];
|
|
210
|
+
private readonly completionListeners = new Set<CompletionListener>();
|
|
211
|
+
private readonly stateListeners = new Set<StateListener>();
|
|
212
|
+
private readonly disposedSessions = new WeakSet<WorkerSessionHandle>();
|
|
213
|
+
private shuttingDown = false;
|
|
214
|
+
private shutdownPromise: Promise<void> | undefined;
|
|
215
|
+
|
|
216
|
+
constructor(options: OrchestratorRuntimeOptions) {
|
|
217
|
+
this.workerSessionFactory = options.workerSessionFactory;
|
|
218
|
+
this.idFactories = options.idFactories ?? createRandomIdFactories();
|
|
219
|
+
this.clock = options.clock ?? Date.now;
|
|
220
|
+
this.scheduler = options.scheduler ?? createWorkflowScheduler<WorkerId>();
|
|
221
|
+
this.bestEffortDeadline = options.bestEffortDeadline ?? defaultBestEffortDeadline;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
orchestrate(
|
|
225
|
+
context: OrchestrationContext,
|
|
226
|
+
tasks: readonly OrchestrateTaskInput[],
|
|
227
|
+
mode: "async",
|
|
228
|
+
signal?: AbortSignal,
|
|
229
|
+
): Promise<AcceptedWave>;
|
|
230
|
+
orchestrate(
|
|
231
|
+
context: OrchestrationContext,
|
|
232
|
+
tasks: readonly OrchestrateTaskInput[],
|
|
233
|
+
mode: "inline",
|
|
234
|
+
signal?: AbortSignal,
|
|
235
|
+
): Promise<CompletedWave>;
|
|
236
|
+
orchestrate(
|
|
237
|
+
context: OrchestrationContext,
|
|
238
|
+
tasks: readonly OrchestrateTaskInput[],
|
|
239
|
+
mode: WaveMode,
|
|
240
|
+
signal?: AbortSignal,
|
|
241
|
+
): Promise<AcceptedWave | CompletedWave>;
|
|
242
|
+
async orchestrate(
|
|
243
|
+
context: OrchestrationContext,
|
|
244
|
+
tasks: readonly OrchestrateTaskInput[],
|
|
245
|
+
mode: WaveMode,
|
|
246
|
+
signal?: AbortSignal,
|
|
247
|
+
): Promise<AcceptedWave | CompletedWave> {
|
|
248
|
+
this.assertOpen();
|
|
249
|
+
throwIfAborted(signal);
|
|
250
|
+
const definitions = this.validateTasks(context, tasks, mode);
|
|
251
|
+
|
|
252
|
+
const waveId = this.idFactories.waveId();
|
|
253
|
+
const workerIds = tasks.map(() => this.idFactories.workerId());
|
|
254
|
+
this.assertFreshIds(waveId, workerIds);
|
|
255
|
+
|
|
256
|
+
const wave: WaveRecord = {
|
|
257
|
+
id: waveId,
|
|
258
|
+
ownerSessionId: context.ownerSessionId,
|
|
259
|
+
workerIds: [...workerIds],
|
|
260
|
+
mode,
|
|
261
|
+
state: "running",
|
|
262
|
+
createdAt: this.clock(),
|
|
263
|
+
};
|
|
264
|
+
const records = tasks.map<WorkerRecord>((task, index) => {
|
|
265
|
+
const definition = definitions[index];
|
|
266
|
+
const id = workerIds[index];
|
|
267
|
+
if (!definition || !id) throw new Error("Validated orchestration input became inconsistent");
|
|
268
|
+
return {
|
|
269
|
+
id,
|
|
270
|
+
worker: definition.name,
|
|
271
|
+
ownerSessionId: context.ownerSessionId,
|
|
272
|
+
waveId,
|
|
273
|
+
title: task.title,
|
|
274
|
+
instructions: task.instructions,
|
|
275
|
+
lifecycle: definition.lifecycle,
|
|
276
|
+
status: "starting",
|
|
277
|
+
usage: copyUsage(EMPTY_WORKER_USAGE),
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const waiter = makeWaveWaiter();
|
|
282
|
+
this.waves.set(waveId, wave);
|
|
283
|
+
this.waveWaiters.set(waveId, waiter);
|
|
284
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
285
|
+
const record = records[index];
|
|
286
|
+
const definition = definitions[index];
|
|
287
|
+
if (!record || !definition) throw new Error("Validated orchestration input became inconsistent");
|
|
288
|
+
this.workers.set(record.id, record);
|
|
289
|
+
this.entries.set(record.id, { context, definition, generation: 1 });
|
|
290
|
+
}
|
|
291
|
+
this.emitState(context.ownerSessionId);
|
|
292
|
+
|
|
293
|
+
for (const record of records) this.launchBootstrap(record.id, 1);
|
|
294
|
+
|
|
295
|
+
if (mode === "inline") {
|
|
296
|
+
return this.awaitInlineWave(wave, waiter, signal);
|
|
297
|
+
}
|
|
298
|
+
return freezeAcceptedWave(waveId, workerIds);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
send(
|
|
302
|
+
context: OrchestrationContext,
|
|
303
|
+
workerId: WorkerId,
|
|
304
|
+
instructions: string,
|
|
305
|
+
mode: "async",
|
|
306
|
+
signal?: AbortSignal,
|
|
307
|
+
): Promise<AcceptedWave>;
|
|
308
|
+
send(
|
|
309
|
+
context: OrchestrationContext,
|
|
310
|
+
workerId: WorkerId,
|
|
311
|
+
instructions: string,
|
|
312
|
+
mode: "inline",
|
|
313
|
+
signal?: AbortSignal,
|
|
314
|
+
): Promise<CompletedWave>;
|
|
315
|
+
send(
|
|
316
|
+
context: OrchestrationContext,
|
|
317
|
+
workerId: WorkerId,
|
|
318
|
+
instructions: string,
|
|
319
|
+
mode: WaveMode,
|
|
320
|
+
signal?: AbortSignal,
|
|
321
|
+
): Promise<AcceptedWave | CompletedWave>;
|
|
322
|
+
async send(
|
|
323
|
+
context: OrchestrationContext,
|
|
324
|
+
workerId: WorkerId,
|
|
325
|
+
instructions: string,
|
|
326
|
+
mode: WaveMode,
|
|
327
|
+
signal?: AbortSignal,
|
|
328
|
+
): Promise<AcceptedWave | CompletedWave> {
|
|
329
|
+
this.assertOpen();
|
|
330
|
+
throwIfAborted(signal);
|
|
331
|
+
validateContextOwner(context.ownerSessionId);
|
|
332
|
+
validateMode(mode);
|
|
333
|
+
validateText("instructions", instructions, MAX_WORKER_INSTRUCTIONS_LENGTH);
|
|
334
|
+
|
|
335
|
+
const current = this.ownedWorker(context.ownerSessionId, workerId);
|
|
336
|
+
if (current.lifecycle !== "reusable" || current.status !== "ready") {
|
|
337
|
+
throw new Error("worker_send requires an owned ready reusable worker");
|
|
338
|
+
}
|
|
339
|
+
const entry = this.entries.get(workerId);
|
|
340
|
+
if (!entry?.session) throw new Error("Ready reusable worker has no session handle");
|
|
341
|
+
|
|
342
|
+
const waveId = this.idFactories.waveId();
|
|
343
|
+
if (this.waves.has(waveId)) throw new Error(`Duplicate wave ID: ${waveId}`);
|
|
344
|
+
const wave: WaveRecord = {
|
|
345
|
+
id: waveId,
|
|
346
|
+
ownerSessionId: context.ownerSessionId,
|
|
347
|
+
workerIds: [workerId],
|
|
348
|
+
mode,
|
|
349
|
+
state: "running",
|
|
350
|
+
createdAt: this.clock(),
|
|
351
|
+
};
|
|
352
|
+
const running = {
|
|
353
|
+
...transitionWorkerStatus(current, "running"),
|
|
354
|
+
waveId,
|
|
355
|
+
instructions,
|
|
356
|
+
activity: undefined,
|
|
357
|
+
};
|
|
358
|
+
const waiter = makeWaveWaiter();
|
|
359
|
+
|
|
360
|
+
entry.generation += 1;
|
|
361
|
+
const generation = entry.generation;
|
|
362
|
+
this.waves.set(waveId, wave);
|
|
363
|
+
this.waveWaiters.set(waveId, waiter);
|
|
364
|
+
this.workers.set(workerId, running);
|
|
365
|
+
this.subscribeEntryObservability(workerId, entry, entry.session, generation);
|
|
366
|
+
this.emitState(context.ownerSessionId);
|
|
367
|
+
this.launchPrompt(workerId, generation, entry.session, instructions);
|
|
368
|
+
|
|
369
|
+
if (mode === "inline") {
|
|
370
|
+
return this.awaitInlineWave(wave, waiter, signal);
|
|
371
|
+
}
|
|
372
|
+
return freezeAcceptedWave(waveId, [workerId]);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async abort(ownerSessionId: string, target: AbortTarget): Promise<void> {
|
|
376
|
+
this.assertOpen();
|
|
377
|
+
validateContextOwner(ownerSessionId);
|
|
378
|
+
const targets = this.resolveAbortTargets(ownerSessionId, target);
|
|
379
|
+
await this.cancelWorkers(targets);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async close(ownerSessionId: string, workerId: WorkerId): Promise<void> {
|
|
383
|
+
this.assertOpen();
|
|
384
|
+
validateContextOwner(ownerSessionId);
|
|
385
|
+
const current = this.ownedWorker(ownerSessionId, workerId);
|
|
386
|
+
if (current.lifecycle !== "reusable" || current.status !== "ready") {
|
|
387
|
+
throw new Error("worker_close requires an owned ready reusable worker");
|
|
388
|
+
}
|
|
389
|
+
this.closeReadyWorker(current);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async snapshot(ownerSessionId: string): Promise<RuntimeSnapshot> {
|
|
393
|
+
validateContextOwner(ownerSessionId);
|
|
394
|
+
const waves = [...this.waves.values()]
|
|
395
|
+
.filter((wave) => wave.ownerSessionId === ownerSessionId)
|
|
396
|
+
.map(copyWaveRecord);
|
|
397
|
+
const workers = [...this.workers.values()]
|
|
398
|
+
.filter((worker) => worker.ownerSessionId === ownerSessionId)
|
|
399
|
+
.map(copyWorkerRecord);
|
|
400
|
+
return Object.freeze({
|
|
401
|
+
waves: Object.freeze(waves),
|
|
402
|
+
workers: Object.freeze(workers),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
subscribeCompletion(listener: CompletionListener): UnsubscribeCompletion {
|
|
407
|
+
if (typeof listener !== "function") throw new Error("Completion listener must be a function");
|
|
408
|
+
this.completionListeners.add(listener);
|
|
409
|
+
let subscribed = true;
|
|
410
|
+
return () => {
|
|
411
|
+
if (!subscribed) return;
|
|
412
|
+
subscribed = false;
|
|
413
|
+
this.completionListeners.delete(listener);
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
subscribeState(listener: StateListener): () => void {
|
|
418
|
+
if (typeof listener !== "function") throw new Error("State listener must be a function");
|
|
419
|
+
this.stateListeners.add(listener);
|
|
420
|
+
let subscribed = true;
|
|
421
|
+
return () => {
|
|
422
|
+
if (!subscribed) return;
|
|
423
|
+
subscribed = false;
|
|
424
|
+
this.stateListeners.delete(listener);
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
shutdown(): Promise<void> {
|
|
429
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
430
|
+
this.shuttingDown = true;
|
|
431
|
+
this.shutdownPromise = this.performShutdown();
|
|
432
|
+
return this.shutdownPromise;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async performShutdown(): Promise<void> {
|
|
436
|
+
try {
|
|
437
|
+
this.closeReadyWorkersForShutdown();
|
|
438
|
+
const active = [...this.workers.values()]
|
|
439
|
+
.filter((worker) => isActiveWorkerStatus(worker.status))
|
|
440
|
+
.map((worker) => worker.id);
|
|
441
|
+
await this.cancelWorkers(active);
|
|
442
|
+
} finally {
|
|
443
|
+
try {
|
|
444
|
+
await this.scheduler.close();
|
|
445
|
+
} catch {
|
|
446
|
+
// Scheduler closure is best-effort during process shutdown.
|
|
447
|
+
} finally {
|
|
448
|
+
await this.awaitTrackedCleanupBestEffort();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private validateTasks(
|
|
454
|
+
context: OrchestrationContext,
|
|
455
|
+
tasks: readonly OrchestrateTaskInput[],
|
|
456
|
+
mode: WaveMode,
|
|
457
|
+
): WorkerDefinition[] {
|
|
458
|
+
validateContextOwner(context.ownerSessionId);
|
|
459
|
+
validateMode(mode);
|
|
460
|
+
if (!Array.isArray(tasks) || tasks.length < 1 || tasks.length > MAX_TASKS_PER_WAVE) {
|
|
461
|
+
throw new Error(`orchestrate requires 1 to ${MAX_TASKS_PER_WAVE} tasks`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const definitions: WorkerDefinition[] = [];
|
|
465
|
+
for (const task of tasks) {
|
|
466
|
+
if (!task || typeof task !== "object") throw new Error("Each task must be an object");
|
|
467
|
+
validateText("worker", task.worker, MAX_WORKER_TITLE_LENGTH);
|
|
468
|
+
validateText("title", task.title, MAX_WORKER_TITLE_LENGTH);
|
|
469
|
+
validateText("instructions", task.instructions, MAX_WORKER_INSTRUCTIONS_LENGTH);
|
|
470
|
+
const definition = findWorkerByName(context.catalog, task.worker);
|
|
471
|
+
if (!definition) throw new Error(`Unknown worker: ${task.worker}`);
|
|
472
|
+
definitions.push(definition);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
for (const definition of definitions) {
|
|
476
|
+
resolveWorkerModel(definition, context.parentModel, context.modelRegistry);
|
|
477
|
+
}
|
|
478
|
+
return definitions;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private assertFreshIds(waveId: WaveId, workerIds: readonly WorkerId[]): void {
|
|
482
|
+
if (this.waves.has(waveId)) throw new Error(`Duplicate wave ID: ${waveId}`);
|
|
483
|
+
const unique = new Set<WorkerId>();
|
|
484
|
+
for (const workerId of workerIds) {
|
|
485
|
+
if (unique.has(workerId) || this.workers.has(workerId)) {
|
|
486
|
+
throw new Error(`Duplicate worker ID: ${workerId}`);
|
|
487
|
+
}
|
|
488
|
+
unique.add(workerId);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
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
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private launchPrompt(
|
|
501
|
+
workerId: WorkerId,
|
|
502
|
+
generation: number,
|
|
503
|
+
session: WorkerSessionHandle,
|
|
504
|
+
instructions: string,
|
|
505
|
+
): void {
|
|
506
|
+
this.scheduler.start(
|
|
507
|
+
workerId,
|
|
508
|
+
() => this.trackCleanup(this.executePrompt(workerId, generation, session, instructions)),
|
|
509
|
+
(error) => this.settleWorkflowDefect(workerId, generation, error),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
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);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private async bootstrap(
|
|
522
|
+
workerId: WorkerId,
|
|
523
|
+
generation: number,
|
|
524
|
+
): Promise<WorkerSessionHandle | undefined> {
|
|
525
|
+
const entry = this.entries.get(workerId);
|
|
526
|
+
if (!entry) return undefined;
|
|
527
|
+
|
|
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
|
+
}
|
|
543
|
+
|
|
544
|
+
const current = this.workers.get(workerId);
|
|
545
|
+
if (
|
|
546
|
+
this.shuttingDown ||
|
|
547
|
+
!current ||
|
|
548
|
+
current.status !== "starting" ||
|
|
549
|
+
entry.generation !== generation
|
|
550
|
+
) {
|
|
551
|
+
this.disposeSession(session);
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
entry.session = session;
|
|
557
|
+
this.subscribeEntryObservability(workerId, entry, session, generation);
|
|
558
|
+
this.workers.set(workerId, {
|
|
559
|
+
...transitionWorkerStatus(current, "running"),
|
|
560
|
+
sessionFile: session.sessionFile,
|
|
561
|
+
});
|
|
562
|
+
this.emitState(current.ownerSessionId);
|
|
563
|
+
return session;
|
|
564
|
+
} catch (error) {
|
|
565
|
+
this.disposeEntrySession(entry);
|
|
566
|
+
this.settleCreationFailure(workerId, generation, error);
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private async executePrompt(
|
|
572
|
+
workerId: WorkerId,
|
|
573
|
+
generation: number,
|
|
574
|
+
session: WorkerSessionHandle,
|
|
575
|
+
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
|
+
}
|
|
588
|
+
|
|
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);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private settleCreationFailure(
|
|
599
|
+
workerId: WorkerId,
|
|
600
|
+
generation: number,
|
|
601
|
+
error: unknown,
|
|
602
|
+
): void {
|
|
603
|
+
const current = this.workers.get(workerId);
|
|
604
|
+
const entry = this.entries.get(workerId);
|
|
605
|
+
if (!current || current.status !== "starting" || entry?.generation !== generation) return;
|
|
606
|
+
this.settleTerminalWorker(current, "failed", {
|
|
607
|
+
status: "failed",
|
|
608
|
+
message: describeError(error, "Worker session creation failed"),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private settleWorkflowDefect(
|
|
613
|
+
workerId: WorkerId,
|
|
614
|
+
generation: number,
|
|
615
|
+
error: unknown,
|
|
616
|
+
): void {
|
|
617
|
+
const current = this.workers.get(workerId);
|
|
618
|
+
const entry = this.entries.get(workerId);
|
|
619
|
+
if (!current || entry?.generation !== generation) return;
|
|
620
|
+
if (!isActiveWorkerStatus(current.status)) {
|
|
621
|
+
this.maybeCompleteWave(current.waveId);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
this.disposeEntrySession(entry);
|
|
626
|
+
this.settleTerminalWorker(current, "failed", {
|
|
627
|
+
status: "failed",
|
|
628
|
+
message: describeError(error, "Worker workflow failed"),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private settleOutcome(
|
|
633
|
+
workerId: WorkerId,
|
|
634
|
+
generation: number,
|
|
635
|
+
session: WorkerSessionHandle,
|
|
636
|
+
outcome: WorkerOutcome,
|
|
637
|
+
): void {
|
|
638
|
+
const current = this.workers.get(workerId);
|
|
639
|
+
const entry = this.entries.get(workerId);
|
|
640
|
+
if (
|
|
641
|
+
!current ||
|
|
642
|
+
current.status !== "running" ||
|
|
643
|
+
!entry ||
|
|
644
|
+
entry.generation !== generation ||
|
|
645
|
+
entry.session !== session
|
|
646
|
+
) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
let settledOutcome = outcome;
|
|
651
|
+
let status: "ready" | "completed" | "failed" | "aborted";
|
|
652
|
+
if (outcome.status === "failed" || outcome.status === "aborted") {
|
|
653
|
+
status = outcome.status;
|
|
654
|
+
} else if (current.lifecycle === "reusable" && outcome.status === "ready") {
|
|
655
|
+
status = "ready";
|
|
656
|
+
} else if (current.lifecycle === "one-shot" && outcome.status === "completed") {
|
|
657
|
+
status = "completed";
|
|
658
|
+
} else {
|
|
659
|
+
status = "failed";
|
|
660
|
+
settledOutcome = {
|
|
661
|
+
status: "failed",
|
|
662
|
+
message: `Worker session returned ${outcome.status} for a ${current.lifecycle} worker`,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
this.workers.set(workerId, {
|
|
667
|
+
...transitionWorkerStatus(current, status),
|
|
668
|
+
activity: undefined,
|
|
669
|
+
outcome: copyOutcome(settledOutcome),
|
|
670
|
+
});
|
|
671
|
+
if (status !== "ready") this.disposeEntrySession(entry);
|
|
672
|
+
const affectedOwners = this.maybeCompleteWave(current.waveId);
|
|
673
|
+
if (isTerminalWorkerStatus(status)) {
|
|
674
|
+
addAll(affectedOwners, this.rememberTerminalWorker(workerId));
|
|
675
|
+
}
|
|
676
|
+
affectedOwners.add(current.ownerSessionId);
|
|
677
|
+
this.emitStateForOwners(affectedOwners);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private settleTerminalWorker(
|
|
681
|
+
current: WorkerRecord,
|
|
682
|
+
status: "failed" | "aborted",
|
|
683
|
+
outcome: WorkerOutcome,
|
|
684
|
+
): void {
|
|
685
|
+
this.workers.set(current.id, {
|
|
686
|
+
...transitionWorkerStatus(current, status),
|
|
687
|
+
activity: undefined,
|
|
688
|
+
outcome: copyOutcome(outcome),
|
|
689
|
+
});
|
|
690
|
+
const affectedOwners = this.maybeCompleteWave(current.waveId);
|
|
691
|
+
addAll(affectedOwners, this.rememberTerminalWorker(current.id));
|
|
692
|
+
affectedOwners.add(current.ownerSessionId);
|
|
693
|
+
this.emitStateForOwners(affectedOwners);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
private maybeCompleteWave(waveId: WaveId): Set<string> {
|
|
697
|
+
const affectedOwners = new Set<string>();
|
|
698
|
+
if (this.completedWaves.has(waveId)) return affectedOwners;
|
|
699
|
+
const wave = this.waves.get(waveId);
|
|
700
|
+
if (!wave) return affectedOwners;
|
|
701
|
+
const records: WorkerRecord[] = [];
|
|
702
|
+
for (const workerId of wave.workerIds) {
|
|
703
|
+
const worker = this.workers.get(workerId);
|
|
704
|
+
if (
|
|
705
|
+
!worker ||
|
|
706
|
+
worker.waveId !== waveId ||
|
|
707
|
+
!isWorkerCompleteForWave(worker.status) ||
|
|
708
|
+
!worker.outcome
|
|
709
|
+
) {
|
|
710
|
+
return affectedOwners;
|
|
711
|
+
}
|
|
712
|
+
records.push(worker);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const completed = freezeCompletedWave(wave, records);
|
|
716
|
+
this.completedWaves.set(waveId, completed);
|
|
717
|
+
this.completedWaveOrder.push(waveId);
|
|
718
|
+
this.waves.set(waveId, { ...wave, state: "complete" });
|
|
719
|
+
this.waveWaiters.get(waveId)?.resolve(completed);
|
|
720
|
+
this.waveWaiters.delete(waveId);
|
|
721
|
+
|
|
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
|
+
return affectedOwners;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private rememberTerminalWorker(workerId: WorkerId): Set<string> {
|
|
737
|
+
if (!this.terminalWorkerOrder.includes(workerId)) {
|
|
738
|
+
this.terminalWorkerOrder.push(workerId);
|
|
739
|
+
}
|
|
740
|
+
return this.pruneHistory();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private pruneHistory(): Set<string> {
|
|
744
|
+
const affectedOwners = new Set<string>();
|
|
745
|
+
while (this.completedWaveOrder.length > MAX_COMPLETED_WAVE_HISTORY) {
|
|
746
|
+
const waveId = this.completedWaveOrder.shift();
|
|
747
|
+
if (!waveId) break;
|
|
748
|
+
this.completedWaves.delete(waveId);
|
|
749
|
+
const wave = this.waves.get(waveId);
|
|
750
|
+
if (wave) affectedOwners.add(wave.ownerSessionId);
|
|
751
|
+
this.waves.delete(waveId);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
while (this.terminalWorkerOrder.length > MAX_TERMINAL_WORKER_HISTORY) {
|
|
755
|
+
const removableIndex = this.terminalWorkerOrder.findIndex((workerId) => {
|
|
756
|
+
const worker = this.workers.get(workerId);
|
|
757
|
+
if (!worker || !isTerminalWorkerStatus(worker.status)) return true;
|
|
758
|
+
return this.waves.get(worker.waveId)?.state !== "running";
|
|
759
|
+
});
|
|
760
|
+
if (removableIndex < 0) return affectedOwners;
|
|
761
|
+
const [workerId] = this.terminalWorkerOrder.splice(removableIndex, 1);
|
|
762
|
+
if (!workerId) return affectedOwners;
|
|
763
|
+
const worker = this.workers.get(workerId);
|
|
764
|
+
if (worker) affectedOwners.add(worker.ownerSessionId);
|
|
765
|
+
this.workers.delete(workerId);
|
|
766
|
+
this.entries.delete(workerId);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return affectedOwners;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private resolveAbortTargets(ownerSessionId: string, target: AbortTarget): WorkerId[] {
|
|
773
|
+
if (!target || typeof target !== "object") throw new Error("Invalid abort target");
|
|
774
|
+
const candidate = target as {
|
|
775
|
+
workerIds?: readonly WorkerId[];
|
|
776
|
+
waveId?: WaveId;
|
|
777
|
+
all?: boolean;
|
|
778
|
+
};
|
|
779
|
+
const selected = [
|
|
780
|
+
candidate.workerIds !== undefined,
|
|
781
|
+
candidate.waveId !== undefined,
|
|
782
|
+
candidate.all !== undefined,
|
|
783
|
+
].filter(Boolean).length;
|
|
784
|
+
if (selected !== 1 || (candidate.all !== undefined && candidate.all !== true)) {
|
|
785
|
+
throw new Error("Abort target must specify exactly one of workerIds, waveId, or all: true");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (candidate.workerIds !== undefined) {
|
|
789
|
+
if (!Array.isArray(candidate.workerIds) || candidate.workerIds.length === 0) {
|
|
790
|
+
throw new Error("workerIds must contain at least one worker ID");
|
|
791
|
+
}
|
|
792
|
+
const unique = [...new Set(candidate.workerIds)];
|
|
793
|
+
for (const workerId of unique) {
|
|
794
|
+
const worker = this.ownedWorker(ownerSessionId, workerId);
|
|
795
|
+
if (worker.status === "ready") {
|
|
796
|
+
throw new Error("Ready reusable workers are not active; use worker_close");
|
|
797
|
+
}
|
|
798
|
+
if (!isActiveWorkerStatus(worker.status)) {
|
|
799
|
+
throw new Error("worker_abort requires owned active workers");
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return unique;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (candidate.waveId !== undefined) {
|
|
806
|
+
const wave = this.waves.get(candidate.waveId);
|
|
807
|
+
if (!wave || wave.ownerSessionId !== ownerSessionId) {
|
|
808
|
+
throw new Error("Wave is not owned by this session");
|
|
809
|
+
}
|
|
810
|
+
return wave.workerIds.filter((workerId) => {
|
|
811
|
+
const worker = this.workers.get(workerId);
|
|
812
|
+
return (
|
|
813
|
+
worker?.ownerSessionId === ownerSessionId &&
|
|
814
|
+
worker.waveId === wave.id &&
|
|
815
|
+
isActiveWorkerStatus(worker.status)
|
|
816
|
+
);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return [...this.workers.values()]
|
|
821
|
+
.filter(
|
|
822
|
+
(worker) =>
|
|
823
|
+
worker.ownerSessionId === ownerSessionId && isActiveWorkerStatus(worker.status),
|
|
824
|
+
)
|
|
825
|
+
.map((worker) => worker.id);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private async cancelWorkers(workerIds: readonly WorkerId[]): Promise<void> {
|
|
829
|
+
const owners = new Set<string>();
|
|
830
|
+
const cancellations: Promise<void>[] = [];
|
|
831
|
+
|
|
832
|
+
for (const workerId of workerIds) {
|
|
833
|
+
const current = this.workers.get(workerId);
|
|
834
|
+
if (!current || !isActiveWorkerStatus(current.status)) continue;
|
|
835
|
+
if (current.status !== "stopping") {
|
|
836
|
+
this.workers.set(workerId, {
|
|
837
|
+
...transitionWorkerStatus(current, "stopping"),
|
|
838
|
+
activity: undefined,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
owners.add(current.ownerSessionId);
|
|
842
|
+
cancellations.push(this.cancellationFor(workerId));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
this.emitStateForOwners(owners);
|
|
846
|
+
await Promise.all(cancellations);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
private cancellationFor(workerId: WorkerId): Promise<void> {
|
|
850
|
+
const existing = this.cancellationPromises.get(workerId);
|
|
851
|
+
if (existing) return existing;
|
|
852
|
+
|
|
853
|
+
const cancellation = Promise.resolve()
|
|
854
|
+
.then(() => this.cancelWorker(workerId))
|
|
855
|
+
.catch((error) => {
|
|
856
|
+
this.settleCancellationFailure(workerId, error);
|
|
857
|
+
});
|
|
858
|
+
this.cancellationPromises.set(workerId, cancellation);
|
|
859
|
+
void cancellation.then(() => {
|
|
860
|
+
if (this.cancellationPromises.get(workerId) === cancellation) {
|
|
861
|
+
this.cancellationPromises.delete(workerId);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
return cancellation;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private async cancelWorker(workerId: WorkerId): Promise<void> {
|
|
868
|
+
const entry = this.entries.get(workerId);
|
|
869
|
+
const session = entry?.session;
|
|
870
|
+
|
|
871
|
+
try {
|
|
872
|
+
if (session) {
|
|
873
|
+
const abortOperation = Promise.resolve()
|
|
874
|
+
.then(() => session.abort())
|
|
875
|
+
.catch(() => undefined);
|
|
876
|
+
await this.waitBestEffort(abortOperation, CANCELLATION_GRACE_MS);
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
await this.scheduler.remove(workerId);
|
|
880
|
+
} catch {
|
|
881
|
+
// Worker state still settles even if scheduler cleanup fails.
|
|
882
|
+
}
|
|
883
|
+
} finally {
|
|
884
|
+
if (entry) this.disposeEntrySession(entry);
|
|
885
|
+
const current = this.workers.get(workerId);
|
|
886
|
+
if (current?.status === "stopping") {
|
|
887
|
+
this.settleTerminalWorker(current, "aborted", { status: "aborted" });
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private settleCancellationFailure(workerId: WorkerId, error: unknown): void {
|
|
893
|
+
const current = this.workers.get(workerId);
|
|
894
|
+
if (!current || current.status !== "stopping") return;
|
|
895
|
+
const entry = this.entries.get(workerId);
|
|
896
|
+
if (entry) this.disposeEntrySession(entry);
|
|
897
|
+
this.settleTerminalWorker(current, "failed", {
|
|
898
|
+
status: "failed",
|
|
899
|
+
message: describeError(error, "Worker cancellation failed"),
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private async cancelExactWave(wave: WaveRecord): Promise<void> {
|
|
904
|
+
const active = wave.workerIds.filter((workerId) => {
|
|
905
|
+
const worker = this.workers.get(workerId);
|
|
906
|
+
return (
|
|
907
|
+
worker?.ownerSessionId === wave.ownerSessionId &&
|
|
908
|
+
worker.waveId === wave.id &&
|
|
909
|
+
isActiveWorkerStatus(worker.status)
|
|
910
|
+
);
|
|
911
|
+
});
|
|
912
|
+
await this.cancelWorkers(active);
|
|
913
|
+
this.maybeCompleteWave(wave.id);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
private awaitInlineWave(
|
|
917
|
+
wave: WaveRecord,
|
|
918
|
+
waiter: WaveWaiter,
|
|
919
|
+
signal: AbortSignal | undefined,
|
|
920
|
+
): Promise<CompletedWave> {
|
|
921
|
+
if (!signal) return waiter.promise;
|
|
922
|
+
|
|
923
|
+
return new Promise<CompletedWave>((resolve, reject) => {
|
|
924
|
+
let abortClaimed = false;
|
|
925
|
+
const removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
926
|
+
const onAbort = () => {
|
|
927
|
+
if (abortClaimed || waiter.settled) return;
|
|
928
|
+
abortClaimed = true;
|
|
929
|
+
removeAbortListener();
|
|
930
|
+
const reason = abortSignalReason(signal);
|
|
931
|
+
void this.cancelExactWave(wave).then(
|
|
932
|
+
() => reject(reason),
|
|
933
|
+
() => reject(reason),
|
|
934
|
+
);
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
waiter.onSettled = () => {
|
|
938
|
+
removeAbortListener();
|
|
939
|
+
if (!abortClaimed) waiter.promise.then(resolve, reject);
|
|
940
|
+
};
|
|
941
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
942
|
+
|
|
943
|
+
if (signal.aborted) onAbort();
|
|
944
|
+
else if (waiter.settled) waiter.onSettled();
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private closeReadyWorker(current: WorkerRecord): void {
|
|
949
|
+
const entry = this.entries.get(current.id);
|
|
950
|
+
if (entry) this.disposeEntrySession(entry);
|
|
951
|
+
this.workers.set(current.id, {
|
|
952
|
+
...transitionWorkerStatus(current, "closed"),
|
|
953
|
+
activity: undefined,
|
|
954
|
+
outcome: { status: "closed" },
|
|
955
|
+
});
|
|
956
|
+
const affectedOwners = this.maybeCompleteWave(current.waveId);
|
|
957
|
+
addAll(affectedOwners, this.rememberTerminalWorker(current.id));
|
|
958
|
+
affectedOwners.add(current.ownerSessionId);
|
|
959
|
+
this.emitStateForOwners(affectedOwners);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
private closeReadyWorkersForShutdown(): void {
|
|
963
|
+
const ready = [...this.workers.values()].filter((worker) => worker.status === "ready");
|
|
964
|
+
for (const worker of ready) this.closeReadyWorker(worker);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
private subscribeEntryObservability(
|
|
968
|
+
workerId: WorkerId,
|
|
969
|
+
entry: RuntimeEntry,
|
|
970
|
+
session: WorkerSessionHandle,
|
|
971
|
+
generation: number,
|
|
972
|
+
): void {
|
|
973
|
+
this.unsubscribeEntryObservability(entry);
|
|
974
|
+
entry.unsubscribeUsage = session.subscribeUsage((usage) => {
|
|
975
|
+
const latest = this.workers.get(workerId);
|
|
976
|
+
if (!latest || entry.session !== session || entry.generation !== generation) return;
|
|
977
|
+
if (latest.status !== "starting" && latest.status !== "running") return;
|
|
978
|
+
this.workers.set(workerId, { ...latest, usage: copyUsage(usage) });
|
|
979
|
+
this.emitState(latest.ownerSessionId);
|
|
980
|
+
});
|
|
981
|
+
entry.unsubscribeActivity = session.subscribeActivity((activity) => {
|
|
982
|
+
const latest = this.workers.get(workerId);
|
|
983
|
+
if (!latest || entry.session !== session || entry.generation !== generation) return;
|
|
984
|
+
if (latest.status !== "starting" && latest.status !== "running") return;
|
|
985
|
+
if (latest.activity === activity) return;
|
|
986
|
+
this.workers.set(workerId, { ...latest, activity });
|
|
987
|
+
this.emitState(latest.ownerSessionId);
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private emitState(ownerSessionId: string): void {
|
|
992
|
+
for (const listener of [...this.stateListeners]) {
|
|
993
|
+
try {
|
|
994
|
+
listener(ownerSessionId);
|
|
995
|
+
} catch {
|
|
996
|
+
// One subscriber cannot prevent runtime mutations or other notifications.
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private emitStateForOwners(ownerSessionIds: ReadonlySet<string>): void {
|
|
1002
|
+
for (const ownerSessionId of ownerSessionIds) this.emitState(ownerSessionId);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
private ownedWorker(ownerSessionId: string, workerId: WorkerId): WorkerRecord {
|
|
1006
|
+
const worker = this.workers.get(workerId);
|
|
1007
|
+
if (!worker || worker.ownerSessionId !== ownerSessionId) {
|
|
1008
|
+
throw new Error("Worker is not owned by this session");
|
|
1009
|
+
}
|
|
1010
|
+
return worker;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
private unsubscribeEntryObservability(entry: RuntimeEntry): void {
|
|
1014
|
+
safelyCall(entry.unsubscribeUsage);
|
|
1015
|
+
entry.unsubscribeUsage = undefined;
|
|
1016
|
+
safelyCall(entry.unsubscribeActivity);
|
|
1017
|
+
entry.unsubscribeActivity = undefined;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private disposeEntrySession(entry: RuntimeEntry): void {
|
|
1021
|
+
this.unsubscribeEntryObservability(entry);
|
|
1022
|
+
if (entry.session) this.disposeSession(entry.session);
|
|
1023
|
+
entry.session = undefined;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
private disposeSession(session: WorkerSessionHandle): void {
|
|
1027
|
+
if (this.disposedSessions.has(session)) return;
|
|
1028
|
+
this.disposedSessions.add(session);
|
|
1029
|
+
safelyCall(() => session.dispose());
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
private trackCleanup(operation: Promise<void>): Promise<void> {
|
|
1033
|
+
this.cleanupOperations.add(operation);
|
|
1034
|
+
const forget = () => this.cleanupOperations.delete(operation);
|
|
1035
|
+
void operation.then(forget, forget);
|
|
1036
|
+
return operation;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
private async awaitTrackedCleanupBestEffort(): Promise<void> {
|
|
1040
|
+
const operations = [...this.cleanupOperations];
|
|
1041
|
+
if (operations.length === 0) return;
|
|
1042
|
+
await this.waitBestEffort(
|
|
1043
|
+
Promise.allSettled(operations).then(() => undefined),
|
|
1044
|
+
SHUTDOWN_CLEANUP_GRACE_MS,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
private async waitBestEffort(promise: Promise<unknown>, timeoutMs: number): Promise<void> {
|
|
1049
|
+
try {
|
|
1050
|
+
await this.bestEffortDeadline.wait(promise, timeoutMs);
|
|
1051
|
+
} catch {
|
|
1052
|
+
// A deadline implementation cannot prevent lifecycle settlement.
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
private assertOpen(): void {
|
|
1057
|
+
if (this.shuttingDown) throw new Error("Orchestrator runtime is shutting down");
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
export function createOrchestratorRuntime(
|
|
1062
|
+
options: OrchestratorRuntimeOptions,
|
|
1063
|
+
): OrchestratorRuntime {
|
|
1064
|
+
return new DefaultOrchestratorRuntime(options);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function validateContextOwner(ownerSessionId: string): void {
|
|
1068
|
+
if (typeof ownerSessionId !== "string" || ownerSessionId.trim() === "") {
|
|
1069
|
+
throw new Error("ownerSessionId must not be blank");
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function validateMode(mode: WaveMode): void {
|
|
1074
|
+
if (mode !== "async" && mode !== "inline") throw new Error("Invalid orchestration mode");
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function validateText(name: string, value: string, maximumLength: number): void {
|
|
1078
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1079
|
+
throw new Error(`${name} must not be blank`);
|
|
1080
|
+
}
|
|
1081
|
+
if (value.length > maximumLength) {
|
|
1082
|
+
throw new Error(`${name} must be at most ${maximumLength} characters`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function describeError(error: unknown, fallback: string): string {
|
|
1087
|
+
if (error instanceof Error && error.message !== "") return error.message;
|
|
1088
|
+
if (typeof error === "string" && error !== "") return error;
|
|
1089
|
+
return fallback;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function isActiveWorkerStatus(status: WorkerRecord["status"]): boolean {
|
|
1093
|
+
return status === "starting" || status === "running" || status === "stopping";
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function safelyCall(callback: (() => void) | undefined): void {
|
|
1097
|
+
if (!callback) return;
|
|
1098
|
+
try {
|
|
1099
|
+
callback();
|
|
1100
|
+
} catch {
|
|
1101
|
+
// Session cleanup is idempotent best-effort and must not strand lifecycle state.
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
1106
|
+
if (signal?.aborted) throw abortSignalReason(signal);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function abortSignalReason(signal: AbortSignal): unknown {
|
|
1110
|
+
return signal.reason ?? new DOMException("This operation was aborted", "AbortError");
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function addAll(target: Set<string>, source: ReadonlySet<string>): void {
|
|
1114
|
+
for (const value of source) target.add(value);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function makeWaveWaiter(): WaveWaiter {
|
|
1118
|
+
let complete!: (wave: CompletedWave) => void;
|
|
1119
|
+
const waiter: WaveWaiter = {
|
|
1120
|
+
promise: new Promise<CompletedWave>((resolve) => {
|
|
1121
|
+
complete = resolve;
|
|
1122
|
+
}),
|
|
1123
|
+
settled: false,
|
|
1124
|
+
resolve(wave) {
|
|
1125
|
+
if (waiter.settled) return;
|
|
1126
|
+
waiter.settled = true;
|
|
1127
|
+
waiter.onSettled?.();
|
|
1128
|
+
complete(wave);
|
|
1129
|
+
},
|
|
1130
|
+
};
|
|
1131
|
+
return waiter;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function copyUsage(usage: WorkerUsage): WorkerUsage {
|
|
1135
|
+
return {
|
|
1136
|
+
input: usage.input,
|
|
1137
|
+
output: usage.output,
|
|
1138
|
+
cacheRead: usage.cacheRead,
|
|
1139
|
+
cacheWrite: usage.cacheWrite,
|
|
1140
|
+
cost: usage.cost,
|
|
1141
|
+
contextTokens: usage.contextTokens,
|
|
1142
|
+
turns: usage.turns,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function copyOutcome(outcome: WorkerOutcome): WorkerOutcome {
|
|
1147
|
+
return { ...outcome };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function copyWaveRecord(wave: WaveRecord): WaveRecord {
|
|
1151
|
+
return Object.freeze({
|
|
1152
|
+
...wave,
|
|
1153
|
+
workerIds: Object.freeze([...wave.workerIds]),
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function copyWorkerRecord(worker: WorkerRecord): WorkerRecord {
|
|
1158
|
+
return Object.freeze({
|
|
1159
|
+
...worker,
|
|
1160
|
+
usage: Object.freeze(copyUsage(worker.usage)),
|
|
1161
|
+
...(worker.outcome ? { outcome: Object.freeze(copyOutcome(worker.outcome)) } : {}),
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function freezeAcceptedWave(
|
|
1166
|
+
id: WaveId,
|
|
1167
|
+
workerIds: readonly WorkerId[],
|
|
1168
|
+
): AcceptedWave {
|
|
1169
|
+
return Object.freeze({ id, workerIds: Object.freeze([...workerIds]) });
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
function freezeCompletedWave(
|
|
1173
|
+
wave: WaveRecord,
|
|
1174
|
+
records: readonly WorkerRecord[],
|
|
1175
|
+
): CompletedWave {
|
|
1176
|
+
const results = records.map<CompletedResult>((record) =>
|
|
1177
|
+
Object.freeze({
|
|
1178
|
+
workerId: record.id,
|
|
1179
|
+
worker: record.worker,
|
|
1180
|
+
title: record.title,
|
|
1181
|
+
status: record.status as WaveCompleteWorkerStatus,
|
|
1182
|
+
outcome: Object.freeze(copyOutcome(record.outcome!)),
|
|
1183
|
+
usage: Object.freeze(copyUsage(record.usage)),
|
|
1184
|
+
sessionFile: record.sessionFile,
|
|
1185
|
+
}),
|
|
1186
|
+
);
|
|
1187
|
+
return Object.freeze({
|
|
1188
|
+
id: wave.id,
|
|
1189
|
+
ownerSessionId: wave.ownerSessionId,
|
|
1190
|
+
mode: wave.mode,
|
|
1191
|
+
results: Object.freeze(results),
|
|
1192
|
+
});
|
|
1193
|
+
}
|