@prometheus-ai/swarm-extension 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@prometheus-ai/swarm-extension",
4
+ "version": "0.5.1",
5
+ "description": "Swarm orchestration extension for prometheus",
6
+ "homepage": "https://prometheus.trivlab.com",
7
+ "author": "Derek Rynd",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/uttamtrivedi/Prometheus.git",
12
+ "directory": "packages/swarm-extension"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/uttamtrivedi/Prometheus/issues"
16
+ },
17
+ "keywords": [
18
+ "swarm",
19
+ "orchestration",
20
+ "agent",
21
+ "extension"
22
+ ],
23
+ "main": "./src/extension.ts",
24
+ "types": "./dist/types/extension.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/types/extension.d.ts",
28
+ "import": "./src/extension.ts"
29
+ }
30
+ },
31
+ "bin": {
32
+ "prometheus-swarm": "src/cli.ts"
33
+ },
34
+ "scripts": {
35
+ "check": "biome check . && bun run check:types",
36
+ "check:types": "tsgo -p tsconfig.json --noEmit",
37
+ "lint": "biome lint .",
38
+ "fix": "biome check --write --unsafe .",
39
+ "fmt": "biome format --write ."
40
+ },
41
+ "dependencies": {
42
+ "@prometheus-ai/utils": "0.5.1"
43
+ },
44
+ "devDependencies": {
45
+ "@types/bun": "^1.3.14"
46
+ },
47
+ "peerDependencies": {
48
+ "@prometheus-ai/agent": "0.5.1"
49
+ },
50
+ "engines": {
51
+ "bun": ">=1.3.14"
52
+ },
53
+ "prometheus": {
54
+ "extensions": [
55
+ "./src/extension.ts"
56
+ ]
57
+ },
58
+ "files": [
59
+ "src",
60
+ "README.md",
61
+ "CHANGELOG.md",
62
+ "dist/types"
63
+ ]
64
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Direct pipeline runner — executes a swarm pipeline outside of the TUI.
4
+ *
5
+ * Usage: bun cli.ts <path-to-yaml>
6
+ */
7
+
8
+ import * as fs from "node:fs/promises";
9
+ import * as path from "node:path";
10
+ import { discoverAuthStorage } from "@prometheus-ai/agent";
11
+ import { ModelRegistry } from "@prometheus-ai/agent/config/model-registry";
12
+ import { Settings } from "@prometheus-ai/agent/config/settings";
13
+ import { buildDependencyGraph, buildExecutionWaves, detectCycles } from "./swarm/dag";
14
+ import { PipelineController } from "./swarm/pipeline";
15
+ import { renderSwarmProgress } from "./swarm/render";
16
+ import { parseSwarmYaml, validateSwarmDefinition } from "./swarm/schema";
17
+ import { StateTracker } from "./swarm/state";
18
+
19
+ const yamlPath = process.argv[2];
20
+ if (!yamlPath) {
21
+ console.error("Usage: prometheus-swarm <path-to-yaml>");
22
+ process.exit(1);
23
+ }
24
+
25
+ const resolvedPath = path.resolve(yamlPath);
26
+ console.log(`Reading: ${resolvedPath}`);
27
+
28
+ const content = await Bun.file(resolvedPath).text();
29
+ const def = parseSwarmYaml(content);
30
+
31
+ console.log(`Swarm: ${def.name}`);
32
+ console.log(`Mode: ${def.mode}`);
33
+ console.log(`Target count: ${def.targetCount}`);
34
+ console.log(`Agents: ${[...def.agents.keys()].join(", ")}`);
35
+
36
+ // Validate
37
+ const errors = validateSwarmDefinition(def);
38
+ if (errors.length > 0) {
39
+ console.error("Validation errors:", errors);
40
+ process.exit(1);
41
+ }
42
+
43
+ // Build DAG
44
+ const deps = buildDependencyGraph(def);
45
+ const cycles = detectCycles(deps);
46
+ if (cycles) {
47
+ console.error("Cycle detected:", cycles);
48
+ process.exit(1);
49
+ }
50
+ const waves = buildExecutionWaves(deps);
51
+ console.log(`Waves: ${waves.map((w, i) => `W${i + 1}:[${w.join(",")}]`).join(" -> ")}`);
52
+
53
+ // Resolve workspace
54
+ const workspace = path.isAbsolute(def.workspace)
55
+ ? def.workspace
56
+ : path.resolve(path.dirname(resolvedPath), def.workspace);
57
+
58
+ await fs.mkdir(workspace, { recursive: true });
59
+ console.log(`Workspace: ${workspace}`);
60
+
61
+ // Initialize
62
+ const stateTracker = new StateTracker(workspace, def.name);
63
+ await stateTracker.init([...def.agents.keys()], def.targetCount, def.mode);
64
+
65
+ // Auth + settings
66
+ const authStorage = await discoverAuthStorage();
67
+ const modelRegistry = new ModelRegistry(authStorage);
68
+ const settings = Settings.isolated();
69
+
70
+ // Progress display
71
+ let lastProgressDump = 0;
72
+ const PROGRESS_INTERVAL_MS = 5000;
73
+
74
+ // Run
75
+ console.log("\n--- Pipeline starting ---\n");
76
+
77
+ const controller = new PipelineController(def, waves, stateTracker);
78
+ const result = await controller.run({
79
+ workspace,
80
+ onProgress: () => {
81
+ const now = Date.now();
82
+ if (now - lastProgressDump > PROGRESS_INTERVAL_MS) {
83
+ lastProgressDump = now;
84
+ const lines = renderSwarmProgress(stateTracker.state);
85
+ console.log(lines.join("\n"));
86
+ console.log();
87
+ }
88
+ },
89
+ modelRegistry,
90
+ settings,
91
+ });
92
+
93
+ console.log("\n--- Pipeline finished ---\n");
94
+ console.log(`Status: ${result.status}`);
95
+ console.log(`Iterations completed: ${result.iterations}/${def.targetCount}`);
96
+ if (result.errors.length > 0) {
97
+ console.log(`Errors (${result.errors.length}):`);
98
+ for (const err of result.errors) {
99
+ console.log(` - ${err}`);
100
+ }
101
+ }
102
+ console.log(`\nState saved to: ${stateTracker.swarmDir}`);
103
+
104
+ // Final state dump
105
+ const lines = renderSwarmProgress(stateTracker.state);
106
+ console.log(lines.join("\n"));
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Swarm Extension — Multi-agent pipeline orchestration from YAML definitions.
3
+ *
4
+ * Registers:
5
+ * - /swarm run <file.yaml> — Execute a swarm pipeline
6
+ * - /swarm status — Show current pipeline status
7
+ *
8
+ * Usage: Add this extension's directory to your extensions config,
9
+ * then use /swarm in any prometheus session.
10
+ */
11
+
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import type { ExtensionAPI, ExtensionCommandContext } from "@prometheus-ai/agent";
15
+ import { formatDuration } from "@prometheus-ai/utils";
16
+ import { buildDependencyGraph, buildExecutionWaves, detectCycles } from "./swarm/dag";
17
+ import { PipelineController } from "./swarm/pipeline";
18
+ import { renderSwarmProgress } from "./swarm/render";
19
+ import { parseSwarmYaml, type SwarmDefinition, validateSwarmDefinition } from "./swarm/schema";
20
+ import { StateTracker } from "./swarm/state";
21
+
22
+ export default function swarmExtension(pi: ExtensionAPI): void {
23
+ pi.setLabel("Swarm Orchestrator");
24
+
25
+ pi.registerCommand("swarm", {
26
+ description: "Run a multi-agent swarm pipeline from YAML",
27
+ getArgumentCompletions: prefix => {
28
+ const subcommands = ["run", "status", "help"];
29
+ if (!prefix) return subcommands.map(s => ({ label: s, value: s }));
30
+ return subcommands.filter(s => s.startsWith(prefix)).map(s => ({ label: s, value: s }));
31
+ },
32
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
33
+ const parts = args.trim().split(/\s+/);
34
+ const subcommand = parts[0] ?? "help";
35
+
36
+ switch (subcommand) {
37
+ case "run": {
38
+ const yamlPath = parts[1];
39
+ if (!yamlPath) {
40
+ ctx.ui.notify("Usage: /swarm run <path/to/pipeline.yaml>", "error");
41
+ return;
42
+ }
43
+ await handleRun(yamlPath, ctx, pi);
44
+ return;
45
+ }
46
+ case "status": {
47
+ await handleStatus(parts[1], ctx);
48
+ return;
49
+ }
50
+ default:
51
+ ctx.ui.notify(
52
+ [
53
+ "Swarm — multi-agent pipeline orchestrator",
54
+ "",
55
+ " /swarm run <file.yaml> Run a pipeline",
56
+ " /swarm status [name] Show pipeline status",
57
+ " /swarm help Show this help",
58
+ ].join("\n"),
59
+ "info",
60
+ );
61
+ return;
62
+ }
63
+ },
64
+ });
65
+ }
66
+
67
+ // ============================================================================
68
+ // /swarm run
69
+ // ============================================================================
70
+
71
+ async function handleRun(yamlPath: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise<void> {
72
+ // 1. Resolve and read YAML
73
+ const resolvedPath = path.isAbsolute(yamlPath) ? yamlPath : path.resolve(ctx.cwd, yamlPath);
74
+
75
+ let content: string;
76
+ try {
77
+ content = await Bun.file(resolvedPath).text();
78
+ } catch {
79
+ ctx.ui.notify(`Cannot read file: ${resolvedPath}`, "error");
80
+ return;
81
+ }
82
+
83
+ // 2. Parse YAML
84
+ let def: SwarmDefinition;
85
+ try {
86
+ def = parseSwarmYaml(content);
87
+ } catch (err) {
88
+ ctx.ui.notify(`YAML error: ${err instanceof Error ? err.message : String(err)}`, "error");
89
+ return;
90
+ }
91
+
92
+ // 3. Validate
93
+ const validationErrors = validateSwarmDefinition(def);
94
+ if (validationErrors.length > 0) {
95
+ ctx.ui.notify(`Validation errors:\n${validationErrors.map(e => ` - ${e}`).join("\n")}`, "error");
96
+ return;
97
+ }
98
+
99
+ // 4. Build DAG
100
+ const deps = buildDependencyGraph(def);
101
+ const cycleNodes = detectCycles(deps);
102
+ if (cycleNodes) {
103
+ ctx.ui.notify(`Cycle detected in agent dependencies: [${cycleNodes.join(", ")}]`, "error");
104
+ return;
105
+ }
106
+ const waves = buildExecutionWaves(deps);
107
+
108
+ // 5. Resolve workspace (relative to YAML file location)
109
+ const workspace = path.isAbsolute(def.workspace)
110
+ ? def.workspace
111
+ : path.resolve(path.dirname(resolvedPath), def.workspace);
112
+
113
+ // Ensure workspace exists
114
+ await fs.mkdir(workspace, { recursive: true });
115
+
116
+ // 6. Initialize state tracker
117
+ const stateTracker = new StateTracker(workspace, def.name);
118
+ await stateTracker.init([...def.agents.keys()], def.targetCount, def.mode);
119
+
120
+ // 7. Log start
121
+ const agentList = [...def.agents.keys()].join(", ");
122
+ const waveDesc = waves.map((w, i) => `wave ${i + 1}: [${w.join(", ")}]`).join("; ");
123
+ pi.logger.debug("Swarm starting", {
124
+ name: def.name,
125
+ mode: def.mode,
126
+ agents: agentList,
127
+ waves: waveDesc,
128
+ workspace,
129
+ });
130
+
131
+ ctx.ui.notify(
132
+ `Starting swarm '${def.name}': ${def.agents.size} agents, ${waves.length} waves, ${def.targetCount} iteration(s)`,
133
+ "info",
134
+ );
135
+
136
+ // 8. Set up progress widget
137
+ const widgetKey = `swarm-${def.name}`;
138
+ const updateWidget = () => {
139
+ const lines = renderSwarmProgress(stateTracker.state);
140
+ ctx.ui.setWidget(widgetKey, lines);
141
+ };
142
+ updateWidget();
143
+
144
+ // 9. Run pipeline
145
+ const controller = new PipelineController(def, waves, stateTracker);
146
+
147
+ const result = await controller.run({
148
+ workspace,
149
+ onProgress: () => updateWidget(),
150
+ modelRegistry: ctx.modelRegistry,
151
+ settings: pi.pi.settings,
152
+ });
153
+
154
+ // 10. Clear widget and show summary
155
+ ctx.ui.setWidget(widgetKey, undefined);
156
+
157
+ const elapsed = stateTracker.state.completedAt
158
+ ? formatDuration(stateTracker.state.completedAt - stateTracker.state.startedAt)
159
+ : "unknown";
160
+
161
+ const summaryParts = [
162
+ `Swarm '${def.name}' ${result.status}`,
163
+ `${result.iterations}/${def.targetCount} iterations`,
164
+ `elapsed: ${elapsed}`,
165
+ ];
166
+
167
+ if (result.errors.length > 0) {
168
+ summaryParts.push(`${result.errors.length} error(s)`);
169
+ }
170
+
171
+ const summaryType = result.status === "completed" ? "info" : "error";
172
+ ctx.ui.notify(summaryParts.join(" | "), summaryType);
173
+
174
+ // Log errors
175
+ if (result.errors.length > 0) {
176
+ pi.logger.warn("Swarm completed with errors", { errors: result.errors });
177
+ }
178
+
179
+ // 11. Send summary to the conversation so the LLM knows what happened
180
+ const summaryMessage = buildSummaryMessage(def, result, stateTracker, workspace);
181
+ pi.sendMessage(
182
+ {
183
+ customType: "swarm-result",
184
+ content: [{ type: "text", text: summaryMessage }],
185
+ display: true,
186
+ details: {
187
+ swarmName: def.name,
188
+ status: result.status,
189
+ iterations: result.iterations,
190
+ errorCount: result.errors.length,
191
+ },
192
+ },
193
+ { triggerTurn: false },
194
+ );
195
+ }
196
+
197
+ // ============================================================================
198
+ // /swarm status
199
+ // ============================================================================
200
+
201
+ async function handleStatus(name: string | undefined, ctx: ExtensionCommandContext): Promise<void> {
202
+ if (!name) {
203
+ ctx.ui.notify("Usage: /swarm status <name> (reads .swarm_<name>/state/pipeline.json from cwd)", "info");
204
+ return;
205
+ }
206
+
207
+ const stateTracker = new StateTracker(ctx.cwd, name);
208
+ const state = await stateTracker.load();
209
+ if (!state) {
210
+ ctx.ui.notify(`No state found for swarm '${name}' in ${ctx.cwd}`, "error");
211
+ return;
212
+ }
213
+
214
+ const lines = renderSwarmProgress(state);
215
+ ctx.ui.notify(lines.join("\n"), "info");
216
+ }
217
+
218
+ // ============================================================================
219
+ // Helpers
220
+ // ============================================================================
221
+
222
+ function buildSummaryMessage(
223
+ def: SwarmDefinition,
224
+ result: { status: string; iterations: number; errors: string[] },
225
+ stateTracker: StateTracker,
226
+ workspace: string,
227
+ ): string {
228
+ const lines: string[] = [];
229
+ lines.push(`## Swarm Pipeline: ${def.name}`);
230
+ lines.push("");
231
+ lines.push(`- **Status**: ${result.status}`);
232
+ lines.push(`- **Mode**: ${def.mode}`);
233
+ lines.push(`- **Iterations**: ${result.iterations}/${def.targetCount}`);
234
+ lines.push(`- **Workspace**: ${workspace}`);
235
+ lines.push(`- **State dir**: ${stateTracker.swarmDir}`);
236
+ lines.push("");
237
+
238
+ lines.push("### Agent Results");
239
+ lines.push("");
240
+ for (const [name, agent] of Object.entries(stateTracker.state.agents)) {
241
+ const duration =
242
+ agent.startedAt && agent.completedAt ? formatDuration(agent.completedAt - agent.startedAt) : "n/a";
243
+ lines.push(`- **${name}**: ${agent.status} (${duration})${agent.error ? ` — ${agent.error}` : ""}`);
244
+ }
245
+
246
+ if (result.errors.length > 0) {
247
+ lines.push("");
248
+ lines.push("### Errors");
249
+ lines.push("");
250
+ for (const error of result.errors) {
251
+ lines.push(`- ${error}`);
252
+ }
253
+ }
254
+
255
+ return lines.join("\n");
256
+ }
@@ -0,0 +1,68 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type { ModelRegistry, SingleResult } from "@prometheus-ai/agent";
6
+ import * as taskExecutor from "@prometheus-ai/agent";
7
+ import { executeSwarmAgent } from "../executor";
8
+ import { StateTracker } from "../state";
9
+
10
+ const mockResult = {
11
+ index: 0,
12
+ id: "test-agent-0",
13
+ agent: "test",
14
+ agentSource: "project",
15
+ task: "test task",
16
+ exitCode: 0,
17
+ output: "ok",
18
+ stderr: "",
19
+ truncated: false,
20
+ durationMs: 100,
21
+ tokens: 0,
22
+ } as SingleResult;
23
+
24
+ let workspace: string;
25
+
26
+ beforeEach(async () => {
27
+ workspace = await fs.mkdtemp(path.join(os.tmpdir(), "swarm-test-"));
28
+ });
29
+
30
+ afterEach(async () => {
31
+ vi.restoreAllMocks();
32
+ await fs.rm(workspace, { recursive: true, force: true });
33
+ });
34
+
35
+ describe("executeSwarmAgent", () => {
36
+ it("does not pass authStorage to runSubprocess when modelRegistry is provided", async () => {
37
+ const runSubprocessSpy = vi.spyOn(taskExecutor, "runSubprocess").mockResolvedValue(mockResult);
38
+
39
+ const mockModelRegistry = {
40
+ authStorage: { discover: vi.fn() },
41
+ } as unknown as ModelRegistry;
42
+
43
+ const stateTracker = new StateTracker(workspace, "test-swarm");
44
+ await stateTracker.init(["test-agent"], 1, "parallel");
45
+
46
+ const agent = {
47
+ name: "test-agent",
48
+ role: "tester",
49
+ task: "do something",
50
+ reportsTo: [],
51
+ waitsFor: [],
52
+ };
53
+
54
+ await executeSwarmAgent(agent, 0, {
55
+ workspace,
56
+ swarmName: "test-swarm",
57
+ iteration: 0,
58
+ modelRegistry: mockModelRegistry,
59
+ stateTracker,
60
+ });
61
+
62
+ expect(runSubprocessSpy).toHaveBeenCalledTimes(1);
63
+ const passedOptions = runSubprocessSpy.mock.calls[0][0];
64
+ const { authStorage, modelRegistry } = passedOptions;
65
+ expect(authStorage).toBeUndefined();
66
+ expect(modelRegistry).toBe(mockModelRegistry);
67
+ });
68
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Directed Acyclic Graph operations for swarm agent dependencies.
3
+ *
4
+ * Builds a dependency graph from waits_for / reports_to relationships,
5
+ * detects cycles, and produces execution waves via topological sort.
6
+ */
7
+ import type { SwarmDefinition } from "./schema";
8
+
9
+ /**
10
+ * Build a dependency map: agent name → set of agents it depends on.
11
+ *
12
+ * Dependencies come from:
13
+ * 1. Explicit `waits_for` declarations
14
+ * 2. Implicit from `reports_to` (if A reports_to B, then B depends on A)
15
+ * 3. For pipeline/sequential mode with no explicit deps: chain by YAML declaration order
16
+ */
17
+ export function buildDependencyGraph(def: SwarmDefinition): Map<string, Set<string>> {
18
+ const deps = new Map<string, Set<string>>();
19
+
20
+ for (const name of def.agents.keys()) {
21
+ deps.set(name, new Set());
22
+ }
23
+
24
+ // Explicit waits_for
25
+ for (const [name, agent] of def.agents) {
26
+ for (const dep of agent.waitsFor) {
27
+ if (deps.has(dep)) {
28
+ deps.get(name)!.add(dep);
29
+ }
30
+ }
31
+ }
32
+
33
+ // reports_to implies the target waits for the reporter
34
+ for (const [name, agent] of def.agents) {
35
+ for (const target of agent.reportsTo) {
36
+ if (deps.has(target)) {
37
+ deps.get(target)!.add(name);
38
+ }
39
+ }
40
+ }
41
+
42
+ // For pipeline/sequential with no explicit deps, chain by declaration order
43
+ if ((def.mode === "pipeline" || def.mode === "sequential") && !hasExplicitDeps(deps)) {
44
+ for (let i = 1; i < def.agentOrder.length; i++) {
45
+ deps.get(def.agentOrder[i])!.add(def.agentOrder[i - 1]);
46
+ }
47
+ }
48
+
49
+ return deps;
50
+ }
51
+
52
+ function hasExplicitDeps(deps: Map<string, Set<string>>): boolean {
53
+ for (const s of deps.values()) {
54
+ if (s.size > 0) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ /**
60
+ * Detect cycles in the dependency graph.
61
+ * Returns the names of agents involved in cycles, or null if acyclic.
62
+ */
63
+ export function detectCycles(deps: Map<string, Set<string>>): string[] | null {
64
+ // Kahn's algorithm: if topological sort doesn't include all nodes, cycles exist
65
+ const inDegree = new Map<string, number>();
66
+ const forward = new Map<string, string[]>(); // dependency → its dependents
67
+
68
+ for (const [node, nodeDeps] of deps) {
69
+ inDegree.set(node, nodeDeps.size);
70
+ for (const dep of nodeDeps) {
71
+ const list = forward.get(dep) ?? [];
72
+ list.push(node);
73
+ forward.set(dep, list);
74
+ }
75
+ }
76
+
77
+ const queue: string[] = [];
78
+ for (const [node, degree] of inDegree) {
79
+ if (degree === 0) queue.push(node);
80
+ }
81
+
82
+ const sorted: string[] = [];
83
+ while (queue.length > 0) {
84
+ const node = queue.shift()!;
85
+ sorted.push(node);
86
+ for (const dependent of forward.get(node) ?? []) {
87
+ const newDegree = inDegree.get(dependent)! - 1;
88
+ inDegree.set(dependent, newDegree);
89
+ if (newDegree === 0) queue.push(dependent);
90
+ }
91
+ }
92
+
93
+ if (sorted.length < deps.size) {
94
+ return [...deps.keys()].filter(k => !sorted.includes(k));
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Build execution waves from dependency graph via topological sort.
102
+ *
103
+ * Each wave contains agents whose dependencies are all in earlier waves.
104
+ * Agents within a wave can execute in parallel.
105
+ */
106
+ export function buildExecutionWaves(deps: Map<string, Set<string>>): string[][] {
107
+ const waves: string[][] = [];
108
+ const completed = new Set<string>();
109
+ const remaining = new Set(deps.keys());
110
+
111
+ while (remaining.size > 0) {
112
+ const wave: string[] = [];
113
+
114
+ for (const node of remaining) {
115
+ const nodeDeps = deps.get(node)!;
116
+ let ready = true;
117
+ for (const dep of nodeDeps) {
118
+ if (!completed.has(dep)) {
119
+ ready = false;
120
+ break;
121
+ }
122
+ }
123
+ if (ready) {
124
+ wave.push(node);
125
+ }
126
+ }
127
+
128
+ if (wave.length === 0) {
129
+ throw new Error(
130
+ `Deadlock: agents [${[...remaining].join(", ")}] cannot make progress. This indicates a bug in cycle detection.`,
131
+ );
132
+ }
133
+
134
+ // Sort for deterministic execution order
135
+ wave.sort();
136
+
137
+ for (const node of wave) {
138
+ remaining.delete(node);
139
+ completed.add(node);
140
+ }
141
+
142
+ waves.push(wave);
143
+ }
144
+
145
+ return waves;
146
+ }