@g3un/pi-orchestra 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,19 @@
1
1
  import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import type { AgentProfile, AgentRun } from "../core/subagent.ts";
3
+ import type { AgentProfile, AgentResultStatus, AgentRun, AgentRunResult } from "../core/subagent.ts";
4
4
  import type { Bus } from "../core/bus.ts";
5
- import type { AgentResultStatus } from "../core/subagent.ts";
6
- import type { OrchestraApi, WaitRunResult } from "../core/orchestra.ts";
5
+ import type { OrchestraApi } from "../core/orchestra.ts";
6
+ import type { AgentStore } from "../core/store.ts";
7
7
  import { WORKGROUP_STRATEGY_VALUES, type WorkgroupMember, type WorkgroupStrategy } from "../core/workgroup.ts";
8
- import { closeAgentRuns, formatError, formatNamedEntityLabel, normalizeEntityName, slugify } from "../utils.ts";
8
+ import {
9
+ closeAgentRuns,
10
+ formatError,
11
+ formatNamedEntityLabel,
12
+ isTerminalAgentState,
13
+ normalizeEntityName,
14
+ slugify,
15
+ toAgentRunResult,
16
+ } from "../utils.ts";
9
17
  import {
10
18
  AgentProfileParams,
11
19
  spawnSubagent,
@@ -33,10 +41,10 @@ export interface WorkgroupSettlement {
33
41
  strategy: WorkgroupStrategy;
34
42
  status: AgentResultStatus;
35
43
  /** Results that should be consumed by downstream orchestration. For compete, this is the winning result when present. */
36
- workerResults: WaitRunResult[];
44
+ workerResults: AgentRunResult[];
37
45
  /** Every terminal result observed while settling this workgroup. */
38
- completedResults: WaitRunResult[];
39
- winner?: WaitRunResult;
46
+ completedResults: AgentRunResult[];
47
+ winner?: AgentRunResult;
40
48
  pendingRunIds: string[];
41
49
  }
42
50
 
@@ -45,8 +53,25 @@ export interface WorkgroupTool {
45
53
  execute(input: WorkgroupInput): Promise<WorkgroupOutput>;
46
54
  }
47
55
 
56
+ export interface WorkgroupLaunchEvent {
57
+ input: WorkgroupInput;
58
+ bus: Bus;
59
+ }
60
+
61
+ export interface WorkgroupLaunchedEvent {
62
+ input: WorkgroupInput;
63
+ output: WorkgroupOutput;
64
+ }
65
+
66
+ export interface WorkgroupLaunchFailedEvent extends WorkgroupLaunchEvent {
67
+ error: unknown;
68
+ }
69
+
48
70
  export interface WorkgroupToolDeps {
49
71
  orchestra: OrchestraApi;
72
+ onWorkgroupLaunching?: (event: WorkgroupLaunchEvent) => void;
73
+ onWorkgroupLaunched?: (event: WorkgroupLaunchedEvent) => void;
74
+ onWorkgroupLaunchFailed?: (event: WorkgroupLaunchFailedEvent) => void;
50
75
  }
51
76
 
52
77
  export const WorkgroupMemberParams = Type.Object(
@@ -100,7 +125,12 @@ interface SpawnFailure {
100
125
  error: unknown;
101
126
  }
102
127
 
103
- export function createWorkgroupTool({ orchestra }: WorkgroupToolDeps): WorkgroupTool {
128
+ export function createWorkgroupTool({
129
+ orchestra,
130
+ onWorkgroupLaunching,
131
+ onWorkgroupLaunched,
132
+ onWorkgroupLaunchFailed,
133
+ }: WorkgroupToolDeps): WorkgroupTool {
104
134
  return {
105
135
  name: "workgroup",
106
136
 
@@ -110,51 +140,52 @@ export function createWorkgroupTool({ orchestra }: WorkgroupToolDeps): Workgroup
110
140
  const bus = orchestra.getBus(input.busId);
111
141
  if (!bus) throw new Error(`Bus ${input.busId} not found.`);
112
142
 
113
- const preparedInput: PreparedWorkgroupInput = {
114
- ...input,
115
- members: prepareMembers(input.members, orchestra.listRuns()),
116
- };
117
- const spawnResults = await Promise.allSettled(
118
- preparedInput.members.map(async (member): Promise<SpawnSuccess> => {
119
- const run = await spawnSubagent(orchestra, toSubagentSpawnInput(preparedInput, member, bus.id));
120
- return { member, run };
121
- }),
122
- );
123
-
124
- const successes = collectSpawnSuccesses(spawnResults);
125
- const failures = collectSpawnFailures(preparedInput.members, spawnResults);
126
- if (failures.length > 0) {
127
- const cleanupResults = await Promise.allSettled(
128
- successes.map((success) => orchestra.closeAgent(success.run.id)),
143
+ onWorkgroupLaunching?.({ input, bus });
144
+ try {
145
+ const preparedInput: PreparedWorkgroupInput = {
146
+ ...input,
147
+ members: prepareMembers(input.members, orchestra.listRuns()),
148
+ };
149
+ const spawnResults = await Promise.allSettled(
150
+ preparedInput.members.map(async (member): Promise<SpawnSuccess> => {
151
+ const run = await spawnSubagent(orchestra, toSubagentSpawnInput(preparedInput, member, bus.id));
152
+ return { member, run };
153
+ }),
129
154
  );
130
- throw new Error(formatLaunchFailure(failures, successes, cleanupResults));
131
- }
132
155
 
133
- const runs = successes.map((success) => success.run);
134
- return {
135
- bus,
136
- runs,
137
- message: formatWorkgroupMessage(bus, preparedInput, runs),
138
- };
156
+ const successes = collectSpawnSuccesses(spawnResults);
157
+ const failures = collectSpawnFailures(preparedInput.members, spawnResults);
158
+ if (failures.length > 0) {
159
+ const cleanupResults = await Promise.allSettled(
160
+ successes.map((success) => orchestra.closeAgent(success.run.id)),
161
+ );
162
+ throw new Error(formatLaunchFailure(failures, successes, cleanupResults));
163
+ }
164
+
165
+ const runs = successes.map((success) => success.run);
166
+ const output = {
167
+ bus,
168
+ runs,
169
+ message: formatWorkgroupMessage(bus, preparedInput, runs),
170
+ };
171
+ onWorkgroupLaunched?.({ input, output });
172
+ return output;
173
+ } catch (error) {
174
+ onWorkgroupLaunchFailed?.({ input, bus, error });
175
+ throw error;
176
+ }
139
177
  },
140
178
  };
141
179
  }
142
180
 
143
181
  export async function settleWorkgroupRuns(
144
182
  orchestra: OrchestraApi,
183
+ store: AgentStore,
145
184
  busId: string,
185
+ runIds: string[],
146
186
  strategy: WorkgroupStrategy,
147
187
  ): Promise<WorkgroupSettlement> {
148
- if (strategy === "compete") return await settleCompeteWorkgroupRuns(orchestra, busId);
149
-
150
- const settled = await orchestra.waitBusSettled(busId, { timeoutMs: null });
151
- return {
152
- strategy,
153
- status: resolveWorkgroupStatus(settled.runResults),
154
- workerResults: settled.runResults,
155
- completedResults: settled.runResults,
156
- pendingRunIds: settled.pendingRunIds,
157
- };
188
+ return await new WorkgroupSettlementCollector(orchestra, store, busId, runIds, strategy).settle();
158
189
  }
159
190
 
160
191
  export function defineWorkgroupPiTool(resolveTool: (ctx: ExtensionContext) => WorkgroupTool) {
@@ -162,11 +193,11 @@ export function defineWorkgroupPiTool(resolveTool: (ctx: ExtensionContext) => Wo
162
193
  name: "workgroup",
163
194
  label: "Workgroup",
164
195
  description: "Spawn multiple subagents onto an existing bus; you lead and collect results.",
165
- promptSnippet: "Spawn a main-led workgroup on an existing bus, then collect results with bus wait actions.",
196
+ promptSnippet: "Spawn a main-led workgroup on an existing bus; member finish events are delivered automatically.",
166
197
  promptGuidelines: [
167
198
  "Create a bus first; workgroup only spawns members.",
168
- "Use compete when one successful member is enough; use wait_next, then close losers and summarize.",
169
- "Use synthesize when members provide complementary findings to combine; wait_settled usually fits.",
199
+ "Use workgroup compete when one successful member is enough; close losers after a success event if appropriate.",
200
+ "Use workgroup synthesize when members provide complementary findings; react to member finish events as they arrive.",
170
201
  "publish_bus is peer-reference context, not a leader-request channel.",
171
202
  ],
172
203
  parameters: WorkgroupToolParams,
@@ -183,39 +214,123 @@ export function defineWorkgroupPiTool(resolveTool: (ctx: ExtensionContext) => Wo
183
214
  });
184
215
  }
185
216
 
186
- async function settleCompeteWorkgroupRuns(orchestra: OrchestraApi, busId: string): Promise<WorkgroupSettlement> {
187
- const completedResults: WaitRunResult[] = [];
188
- const excludeRunIds: string[] = [];
217
+ class WorkgroupSettlementCollector {
218
+ private readonly runIds: Set<string>;
219
+ private readonly completedRunIds = new Set<string>();
220
+ private readonly completedResults: AgentRunResult[] = [];
221
+
222
+ constructor(
223
+ private readonly orchestra: OrchestraApi,
224
+ private readonly store: AgentStore,
225
+ private readonly busId: string,
226
+ runIds: string[],
227
+ private readonly strategy: WorkgroupStrategy,
228
+ ) {
229
+ this.runIds = new Set(runIds);
230
+ }
189
231
 
190
- for (;;) {
191
- const nextRun = await orchestra.waitNextRun(busId, { excludeRunIds, timeoutMs: null });
192
- if (!nextRun.runResult) {
193
- return {
194
- strategy: "compete",
195
- status: resolveWorkgroupStatus(completedResults),
196
- workerResults: completedResults,
197
- completedResults,
198
- pendingRunIds: nextRun.pendingRunIds,
232
+ settle(): Promise<WorkgroupSettlement> {
233
+ return new Promise((resolve) => {
234
+ let settled = false;
235
+ let unsubscribe: () => void = () => undefined;
236
+
237
+ const finish = (settlement: WorkgroupSettlement) => {
238
+ if (settled) return;
239
+
240
+ settled = true;
241
+ unsubscribe();
242
+ resolve(settlement);
199
243
  };
200
- }
201
244
 
202
- completedResults.push(nextRun.runResult);
203
- excludeRunIds.push(nextRun.runResult.runId);
204
- if (nextRun.runResult.result?.status === "success") {
205
- await closeAgentRuns(orchestra, nextRun.pendingRunIds);
206
- return {
207
- strategy: "compete",
208
- status: "success",
209
- workerResults: [nextRun.runResult],
210
- completedResults,
211
- winner: nextRun.runResult,
212
- pendingRunIds: [],
245
+ const finishWithWinner = (winner: AgentRunResult) => {
246
+ if (settled) return;
247
+
248
+ settled = true;
249
+ unsubscribe();
250
+ void closeAgentRuns(this.orchestra, this.getPendingRunIds()).finally(() => {
251
+ resolve({
252
+ strategy: this.strategy,
253
+ status: "success",
254
+ workerResults: [winner],
255
+ completedResults: this.completedResults,
256
+ winner,
257
+ pendingRunIds: [],
258
+ });
259
+ });
260
+ };
261
+
262
+ const finishFromCurrentState = () => {
263
+ this.captureTerminalRuns();
264
+
265
+ const winner = this.strategy === "compete" ? this.completedResults.find(isSuccessfulRunResult) : undefined;
266
+ if (winner) {
267
+ finishWithWinner(winner);
268
+ return;
269
+ }
270
+
271
+ if (!this.isSettled()) return;
272
+
273
+ finish({
274
+ strategy: this.strategy,
275
+ status: resolveWorkgroupStatus(this.completedResults),
276
+ workerResults: this.completedResults,
277
+ completedResults: this.completedResults,
278
+ pendingRunIds: this.getPendingRunIds(),
279
+ });
213
280
  };
281
+
282
+ const observeRun = (run: AgentRun) => {
283
+ if (settled || !this.runIds.has(run.id) || !isTerminalAgentState(run.state)) return;
284
+ this.recordTerminalRun(run);
285
+ finishFromCurrentState();
286
+ };
287
+
288
+ unsubscribe = this.store.subscribeRuns(observeRun, (run) => run.busId === this.busId && this.runIds.has(run.id));
289
+ finishFromCurrentState();
290
+
291
+ if (!settled && this.runIds.size === 0) {
292
+ finish({
293
+ strategy: this.strategy,
294
+ status: "failed",
295
+ workerResults: [],
296
+ completedResults: [],
297
+ pendingRunIds: [],
298
+ });
299
+ }
300
+ });
301
+ }
302
+
303
+ private captureTerminalRuns(): void {
304
+ for (const runId of this.runIds) {
305
+ const run = this.store.getRun(runId);
306
+ if (run) this.recordTerminalRun(run);
214
307
  }
215
308
  }
309
+
310
+ private recordTerminalRun(run: AgentRun): void {
311
+ if (!isTerminalAgentState(run.state) || this.completedRunIds.has(run.id)) return;
312
+
313
+ this.completedRunIds.add(run.id);
314
+ this.completedResults.push(toAgentRunResult(run));
315
+ }
316
+
317
+ private isSettled(): boolean {
318
+ return [...this.runIds].every((runId) => {
319
+ const run = this.store.getRun(runId);
320
+ return run !== undefined && isTerminalAgentState(run.state);
321
+ });
322
+ }
323
+
324
+ private getPendingRunIds(): string[] {
325
+ return [...this.runIds].filter((runId) => this.store.getRun(runId)?.state === "idle");
326
+ }
327
+ }
328
+
329
+ function isSuccessfulRunResult(result: AgentRunResult): boolean {
330
+ return result.result?.status === "success";
216
331
  }
217
332
 
218
- function resolveWorkgroupStatus(results: WaitRunResult[]): AgentResultStatus {
333
+ function resolveWorkgroupStatus(results: AgentRunResult[]): AgentResultStatus {
219
334
  const statuses = results.map((result) => result.result?.status);
220
335
  if (statuses.includes("success")) return "success";
221
336
  if (statuses.includes("blocked")) return "blocked";
@@ -404,7 +519,7 @@ function formatWorkgroupMessage(bus: Bus, input: PreparedWorkgroupInput, runs: A
404
519
  "Runs:",
405
520
  ...runs.map((run) => `- ${formatNamedEntityLabel(run)}: ${run.state}`),
406
521
  "",
407
- "Use bus action=wait_next to handle member results as they finish, or bus action=wait_settled for full fan-in.",
522
+ "Pi-orchestra will deliver workgroup.member_finished events as members finish.",
408
523
  ].join("\n");
409
524
  }
410
525
 
package/src/utils.ts CHANGED
@@ -1,10 +1,8 @@
1
- import type { OrchestraApi, WaitRunResult } from "./core/orchestra.ts";
2
- import type { AgentRun, AgentState } from "./core/subagent.ts";
1
+ import type { OrchestraApi } from "./core/orchestra.ts";
2
+ import type { AgentRun, AgentRunResult, AgentState } from "./core/subagent.ts";
3
3
  import type { AgentStore } from "./core/store.ts";
4
4
  import type { WorkflowRun } from "./core/workflow.ts";
5
5
 
6
- export const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
7
-
8
6
  export interface NamedEntity {
9
7
  id: string;
10
8
  name: string;
@@ -52,14 +50,6 @@ export function formatNamedEntityLabel(entity: NamedEntity): string {
52
50
  return entity.name === entity.id ? entity.id : `${entity.name} (${entity.id})`;
53
51
  }
54
52
 
55
- export function resolveWaitTimeoutMs(label: string, timeoutMs: number | null | undefined): number | null {
56
- const resolvedTimeoutMs = timeoutMs === undefined ? DEFAULT_WAIT_TIMEOUT_MS : timeoutMs;
57
- if (resolvedTimeoutMs !== null && (!Number.isFinite(resolvedTimeoutMs) || resolvedTimeoutMs <= 0)) {
58
- throw new Error(`${label} timeoutMs must be positive, or null to wait indefinitely.`);
59
- }
60
- return resolvedTimeoutMs;
61
- }
62
-
63
53
  export function findWorkflow(store: AgentStore, id: string): WorkflowRun | undefined {
64
54
  return store.getWorkflow(id) ?? store.listWorkflows().find((workflow) => workflow.name === id);
65
55
  }
@@ -85,8 +75,8 @@ export function formatError(error: unknown): string {
85
75
  return error instanceof Error ? error.message : String(error);
86
76
  }
87
77
 
88
- export function toWaitRunResult(run: AgentRun): WaitRunResult {
89
- const runResult: WaitRunResult = {
78
+ export function toAgentRunResult(run: AgentRun): AgentRunResult {
79
+ const runResult: AgentRunResult = {
90
80
  runId: run.id,
91
81
  name: run.name,
92
82
  profile: run.profile,