@g3un/pi-orchestra 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import type { AgentResultStatus, AgentRun } from "../core/subagent.ts";
4
- import type { OrchestraApi, WaitRunResult } from "../core/orchestra.ts";
3
+ import type { AgentResultStatus, AgentRun, AgentRunResult } from "../core/subagent.ts";
4
+ import type { OrchestraApi } from "../core/orchestra.ts";
5
5
  import type { AgentStore } from "../core/store.ts";
6
6
  import type { WorkflowRun, WorkflowStageOutput, WorkflowStageRun, WorkflowStageSpec } from "../core/workflow.ts";
7
7
  import { WORKGROUP_STRATEGY_VALUES, type WorkgroupMember, type WorkgroupStrategy } from "../core/workgroup.ts";
@@ -15,7 +15,6 @@ import {
15
15
  isTerminalAgentState,
16
16
  normalizeEntityName,
17
17
  requireWorkflow,
18
- resolveWaitTimeoutMs,
19
18
  } from "../utils.ts";
20
19
  import {
21
20
  createWorkgroupTool,
@@ -41,17 +40,10 @@ export type WorkflowInput =
41
40
  | {
42
41
  action: "cancel";
43
42
  id: string;
44
- }
45
- | {
46
- action: "wait";
47
- id: string;
48
- /** Defaults to 10 minutes. Use null to wait indefinitely. */
49
- timeoutMs?: number | null;
50
43
  };
51
44
 
52
45
  export interface WorkflowOutput {
53
46
  workflow?: WorkflowRun;
54
- timedOut?: boolean;
55
47
  message: string;
56
48
  }
57
49
 
@@ -65,6 +57,11 @@ export interface WorkflowToolDeps {
65
57
  store: AgentStore;
66
58
  }
67
59
 
60
+ export interface WorkflowPiToolOptions {
61
+ onWorkflowInput?: (ctx: ExtensionContext, input: WorkflowInput) => void;
62
+ onWorkflowOutput?: (ctx: ExtensionContext, output: WorkflowOutput) => void;
63
+ }
64
+
68
65
  const WorkflowStageParams = Type.Object(
69
66
  {
70
67
  name: Type.String({
@@ -87,8 +84,8 @@ const WorkflowStageParams = Type.Object(
87
84
  );
88
85
 
89
86
  const WorkflowActionParams = Type.String({
90
- enum: ["start", "status", "cancel", "wait"],
91
- description: "start launches; status inspects; cancel closes active runs; wait awaits terminal workflow state.",
87
+ enum: ["start", "status", "cancel"],
88
+ description: "start launches; status inspects progress or results; cancel closes active runs.",
92
89
  });
93
90
 
94
91
  const WorkflowToolParams = Type.Object(
@@ -101,7 +98,7 @@ const WorkflowToolParams = Type.Object(
101
98
  ),
102
99
  id: Type.Optional(
103
100
  Type.String({
104
- description: "Required for status/cancel/wait. Workflow id/name.",
101
+ description: "Required for status/cancel. Workflow id/name.",
105
102
  }),
106
103
  ),
107
104
  goal: Type.Optional(
@@ -115,19 +112,6 @@ const WorkflowToolParams = Type.Object(
115
112
  minItems: 1,
116
113
  }),
117
114
  ),
118
- timeoutMs: Type.Optional(
119
- Type.Union(
120
- [
121
- Type.Number({
122
- exclusiveMinimum: 0,
123
- }),
124
- Type.Null(),
125
- ],
126
- {
127
- description: "Optional for action=wait. Positive ms; default 10 min; null waits indefinitely.",
128
- },
129
- ),
130
- ),
131
115
  },
132
116
  { additionalProperties: false },
133
117
  );
@@ -156,24 +140,9 @@ export function createWorkflowTool({ orchestra, store }: WorkflowToolDeps): Work
156
140
  }
157
141
 
158
142
  const workflow = findWorkflow(store, input.id);
159
- if (!workflow) {
160
- return input.action === "wait"
161
- ? { timedOut: false, message: formatWorkflowNotFound(input.id) }
162
- : { message: formatWorkflowNotFound(input.id) };
163
- }
143
+ if (!workflow) return { message: formatWorkflowNotFound(input.id) };
164
144
 
165
- if (input.action === "status") {
166
- return { workflow, message: formatWorkflowMessage(workflow) };
167
- }
168
-
169
- if (input.action === "wait") {
170
- const result = await waitWorkflow(store, workflow.id, input.timeoutMs);
171
- return {
172
- workflow: result.workflow,
173
- timedOut: result.timedOut,
174
- message: formatWaitWorkflowMessage(result.workflow, result.timedOut, input.id),
175
- };
176
- }
145
+ if (input.action === "status") return { workflow, message: formatWorkflowMessage(workflow) };
177
146
 
178
147
  const closedWorkflow = await closeWorkflow(orchestra, store, workflow);
179
148
  return { workflow: closedWorkflow, message: formatWorkflowMessage(closedWorkflow, "Workflow cancelled.") };
@@ -181,24 +150,29 @@ export function createWorkflowTool({ orchestra, store }: WorkflowToolDeps): Work
181
150
  };
182
151
  }
183
152
 
184
- export function defineWorkflowPiTool(resolveTool: (ctx: ExtensionContext) => WorkflowTool) {
153
+ export function defineWorkflowPiTool(
154
+ resolveTool: (ctx: ExtensionContext) => WorkflowTool,
155
+ options: WorkflowPiToolOptions = {},
156
+ ) {
185
157
  return defineTool({
186
158
  name: "workflow",
187
159
  label: "Workflow",
188
160
  description: "Run linear workgroup stages with automatic restricted stage leaders.",
189
- promptSnippet: "Launch a multi-stage workflow, then use workflow wait/status for progress and final output.",
161
+ promptSnippet: "Launch a multi-stage workflow; completion is delivered automatically as a pi-orchestra event.",
190
162
  promptGuidelines: [
191
163
  "Use workflow for ordered multi-stage work; not branching/DAG plans.",
192
164
  "Each stage gets its own bus and automatic leader; previous outputs feed the next stage.",
193
165
  "Use compete when one worker success is enough; use synthesize when findings must be combined.",
194
- "Use status for progress, wait for terminal success/blocked/failed/closed.",
166
+ "Use workflow status for progress; workflow.finished events deliver terminal success/blocked/failed/closed results.",
195
167
  ],
196
168
  parameters: WorkflowToolParams,
197
169
  executionMode: "sequential",
198
170
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
199
- const output = await resolveTool(ctx).execute(
200
- withDefaultModels(toWorkflowInput(params as RawWorkflowParams), ctx),
201
- );
171
+ const input = withDefaultModels(toWorkflowInput(params as RawWorkflowParams), ctx);
172
+ options.onWorkflowInput?.(ctx, input);
173
+
174
+ const output = await resolveTool(ctx).execute(input);
175
+ if (output.workflow) options.onWorkflowOutput?.(ctx, output);
202
176
 
203
177
  return {
204
178
  content: [{ type: "text", text: output.message }],
@@ -262,7 +236,13 @@ async function runStage(workflowId: string, stageIndex: number, deps: WorkflowRu
262
236
  workerRunIds: workgroupOutput.runs.map((run) => run.id),
263
237
  });
264
238
 
265
- const settledWorkgroup = await settleWorkgroupRuns(deps.orchestra, bus.id, stage.strategy);
239
+ const settledWorkgroup = await settleWorkgroupRuns(
240
+ deps.orchestra,
241
+ deps.store,
242
+ bus.id,
243
+ workgroupOutput.runs.map((run) => run.id),
244
+ stage.strategy,
245
+ );
266
246
  if (isWorkflowClosed(deps.store, workflowId)) return;
267
247
 
268
248
  if (stage.strategy === "compete" && !settledWorkgroup.winner) {
@@ -278,7 +258,7 @@ async function runStageLeader(
278
258
  workflowId: string,
279
259
  stageIndex: number,
280
260
  busId: string,
281
- workerResults: WaitRunResult[],
261
+ workerResults: AgentRunResult[],
282
262
  deps: WorkflowRunnerDeps,
283
263
  ): Promise<void> {
284
264
  const workflow = requireWorkflow(deps.store, workflowId);
@@ -300,10 +280,9 @@ async function runStageLeader(
300
280
  leaderRunId: leaderRun.id,
301
281
  });
302
282
 
303
- const leaderSettled = await deps.orchestra.waitBusSettled(busId, { timeoutMs: null });
283
+ const latestLeaderRun = await terminalRunEvent(deps.store, leaderRun.id);
304
284
  if (isWorkflowClosed(deps.store, workflowId)) return;
305
285
 
306
- const latestLeaderRun = leaderSettled.runs.find((run) => run.id === leaderRun.id) ?? leaderRun;
307
286
  const output = buildStageOutput(latestLeaderRun, leaderRun.id, workerResults);
308
287
  finishStage(deps.store, requireWorkflow(deps.store, workflowId), stageIndex, output);
309
288
  }
@@ -381,7 +360,6 @@ function toWorkflowInput(params: RawWorkflowParams): WorkflowInput {
381
360
  }
382
361
 
383
362
  if (!params.id) throw new Error(`workflow action=${params.action} requires id.`);
384
- if (params.action === "wait") return { action: "wait", id: params.id, timeoutMs: params.timeoutMs };
385
363
  return { action: params.action, id: params.id };
386
364
  }
387
365
 
@@ -489,7 +467,7 @@ function buildStageWorkerGoal(workflow: WorkflowRun, stageIndex: number): string
489
467
  return parts.join("\n");
490
468
  }
491
469
 
492
- function buildLeaderTask(workflow: WorkflowRun, stageIndex: number, workerResults: WaitRunResult[]): string {
470
+ function buildLeaderTask(workflow: WorkflowRun, stageIndex: number, workerResults: AgentRunResult[]): string {
493
471
  const stage = workflow.stages[stageIndex];
494
472
  const strategyInstructions =
495
473
  stage.strategy === "compete"
@@ -536,12 +514,12 @@ function formatPreviousStageOutput(stageName: string, output: WorkflowStageOutpu
536
514
  return [`<stage_output name="${stageName}">`, formatStageOutputForPrompt(output), "</stage_output>"].join("\n");
537
515
  }
538
516
 
539
- function formatWorkerResults(workerResults: WaitRunResult[]): string {
517
+ function formatWorkerResults(workerResults: AgentRunResult[]): string {
540
518
  if (workerResults.length === 0) return "None.";
541
519
  return workerResults.map(formatWorkerResult).join("\n\n");
542
520
  }
543
521
 
544
- function formatWorkerResult(result: WaitRunResult): string {
522
+ function formatWorkerResult(result: AgentRunResult): string {
545
523
  const lines = [
546
524
  `<worker_result run_id="${result.runId}" name="${result.name}" profile="${result.profile}" state="${result.state}">`,
547
525
  ];
@@ -565,7 +543,7 @@ function formatJsonData(data: unknown): string {
565
543
  return JSON.stringify(data, null, 2) ?? String(data);
566
544
  }
567
545
 
568
- function buildCompeteNoWinnerOutput(workerResults: WaitRunResult[]): WorkflowStageOutput {
546
+ function buildCompeteNoWinnerOutput(workerResults: AgentRunResult[]): WorkflowStageOutput {
569
547
  const status = workerResults.some((worker) => worker.result?.status === "blocked") ? "blocked" : "failed";
570
548
  const counts = countWorkerResultStatuses(workerResults);
571
549
  const workflowRunResults = workerResults.map(toWorkflowRunResult);
@@ -577,7 +555,7 @@ function buildCompeteNoWinnerOutput(workerResults: WaitRunResult[]): WorkflowSta
577
555
  };
578
556
  }
579
557
 
580
- function countWorkerResultStatuses(workerResults: WaitRunResult[]): Record<AgentResultStatus, number> {
558
+ function countWorkerResultStatuses(workerResults: AgentRunResult[]): Record<AgentResultStatus, number> {
581
559
  const counts: Record<AgentResultStatus, number> = { success: 0, blocked: 0, failed: 0 };
582
560
  for (const worker of workerResults) {
583
561
  if (worker.result) counts[worker.result.status]++;
@@ -588,7 +566,7 @@ function countWorkerResultStatuses(workerResults: WaitRunResult[]): Record<Agent
588
566
  function buildStageOutput(
589
567
  leaderRun: AgentRun,
590
568
  leaderRunId: string,
591
- workerResults: WaitRunResult[],
569
+ workerResults: AgentRunResult[],
592
570
  ): WorkflowStageOutput {
593
571
  if (!leaderRun.result) {
594
572
  return {
@@ -609,7 +587,7 @@ function buildStageOutput(
609
587
  return output;
610
588
  }
611
589
 
612
- function toWorkflowRunResult(result: WaitRunResult) {
590
+ function toWorkflowRunResult(result: AgentRunResult) {
613
591
  const output = {
614
592
  runId: result.runId,
615
593
  name: result.name,
@@ -625,46 +603,21 @@ function formatStageOutput(output: WorkflowStageOutput): string {
625
603
  return parts.join("\n");
626
604
  }
627
605
 
628
- function waitWorkflow(
629
- store: AgentStore,
630
- workflowId: string,
631
- timeoutMs: number | null | undefined,
632
- ): Promise<{ workflow: WorkflowRun; timedOut: boolean }> {
633
- const resolvedTimeoutMs = resolveWaitTimeoutMs("workflow wait", timeoutMs);
634
- const initialWorkflow = requireWorkflow(store, workflowId);
635
- if (isTerminalAgentState(initialWorkflow.state)) {
636
- return Promise.resolve({ workflow: initialWorkflow, timedOut: false });
637
- }
606
+ function terminalRunEvent(store: AgentStore, runId: string): Promise<AgentRun> {
607
+ const initialRun = store.getRun(runId);
608
+ if (initialRun && isTerminalAgentState(initialRun.state)) return Promise.resolve(initialRun);
638
609
 
639
610
  return new Promise((resolve) => {
640
- let settled = false;
641
- let latestWorkflow = initialWorkflow;
642
- let timeout: ReturnType<typeof setTimeout> | undefined;
643
-
644
- const cleanup = () => {
645
- if (timeout) clearTimeout(timeout);
646
- unsubscribe();
647
- };
648
-
649
- const unsubscribe = store.subscribeWorkflow(workflowId, (workflow) => {
650
- if (settled) return;
651
- latestWorkflow = workflow;
652
- if (!isTerminalAgentState(workflow.state)) return;
653
-
654
- settled = true;
655
- cleanup();
656
- resolve({ workflow, timedOut: false });
657
- });
658
-
659
- if (resolvedTimeoutMs !== null) {
660
- timeout = setTimeout(() => {
661
- if (settled) return;
662
-
663
- settled = true;
664
- cleanup();
665
- resolve({ workflow: latestWorkflow, timedOut: true });
666
- }, resolvedTimeoutMs);
667
- }
611
+ let unsubscribe: () => void = () => undefined;
612
+ unsubscribe = store.subscribeRuns(
613
+ (run) => {
614
+ if (!isTerminalAgentState(run.state)) return;
615
+
616
+ unsubscribe();
617
+ resolve(run);
618
+ },
619
+ (run) => run.id === runId,
620
+ );
668
621
  });
669
622
  }
670
623
 
@@ -672,14 +625,6 @@ function formatWorkflowNotFound(id: string): string {
672
625
  return `Workflow ${id} not found.`;
673
626
  }
674
627
 
675
- function formatWaitWorkflowMessage(workflow: WorkflowRun | undefined, timedOut: boolean, requestedId?: string): string {
676
- if (!workflow) return formatWorkflowNotFound(requestedId ?? "");
677
- const label = requestedId === workflow.id ? workflow.id : formatNamedEntityLabel(workflow);
678
- const prefix = timedOut ? "Timed out waiting for" : "Workflow reached terminal state:";
679
- const result = workflow.result ? ` result=${workflow.result.status}` : "";
680
- return `${prefix} ${label}; state=${workflow.state}${result}.`;
681
- }
682
-
683
628
  function formatWorkflowMessage(
684
629
  workflow: WorkflowRun,
685
630
  headline = `Workflow ${formatNamedEntityLabel(workflow)} is ${workflow.state}.`,
@@ -695,12 +640,11 @@ function formatWorkflowMessage(
695
640
  }
696
641
 
697
642
  type RawWorkflowParams = {
698
- action: "start" | "status" | "cancel" | "wait";
643
+ action: "start" | "status" | "cancel";
699
644
  name?: string;
700
645
  id?: string;
701
646
  goal?: string;
702
647
  stages?: RawWorkflowStageParams[];
703
- timeoutMs?: number | null;
704
648
  };
705
649
 
706
650
  type RawWorkflowStageParams = {
@@ -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