@pify/swarm 0.7.7 → 0.9.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/README.md CHANGED
@@ -18,7 +18,7 @@ The catch is that "independent" is usually a small lie — the items do not depe
18
18
 
19
19
  | Parameter | Type | Notes |
20
20
  |---|---|---|
21
- | `items` | string[], 1–12 | One task per item; four run at a time, the rest queue |
21
+ | `items` | array, 1–12 | A plain string per task, or `{task, id, needs}` to declare a dependency |
22
22
  | `context` | string, optional | Prepended to every item, so shared constraints are written once |
23
23
  | `agent` | string, optional | Force one agent type for all items instead of routing |
24
24
  | `isolation` | `"worktree"`, optional | Give each item its own git worktree — use it when items write |
@@ -27,6 +27,24 @@ The catch is that "independent" is usually a small lie — the items do not depe
27
27
 
28
28
  Blocking by default: returns `N done, M error` plus a per-item report.
29
29
 
30
+ ### Dependencies: `needs`
31
+
32
+ An item can be a plain string (independent, as before) or an object that declares what it depends on:
33
+
34
+ ```json
35
+ {
36
+ "items": [
37
+ { "id": "mig", "task": "write the DB migration for the users table" },
38
+ { "id": "callers", "task": "update every caller of the old schema", "needs": ["mig"] },
39
+ { "id": "tests", "task": "run the suite and fix what broke", "needs": ["callers"] }
40
+ ]
41
+ }
42
+ ```
43
+
44
+ An item starts only once its `needs` have finished, and each upstream item's output is prepended to it as a `## Output of <id>` block — so the thing the coordinator used to forget, passing X to the step that needs it, happens by construction. Independent items still run in parallel up to the concurrency cap; a chain runs in order; a diamond joins after both branches. Ids default to `t1`, `t2`, … when you omit them.
45
+
46
+ The whole graph is checked **before anything spawns**: a cycle, a self-edge, a duplicate id, or a reference to an unknown id is rejected outright, so a bad graph costs nothing. A flat list of strings has no edges and behaves exactly as it always did.
47
+
30
48
  ### `swarm_status`
31
49
 
32
50
  | Parameter | Type | Notes |
@@ -77,7 +95,7 @@ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.
77
95
 
78
96
  ## Where this sits in the suite
79
97
 
80
- [`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many independent items at once. [`@pify/workflow`](https://github.com/pifydev/workflow) is deterministic scripted orchestration for when the steps genuinely depend on each other. Pick the smallest one that fits.
98
+ [`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many items at once — independent, or wired together with a declarative `needs` graph (fan-out, chains, joins). [`@pify/workflow`](https://github.com/pifydev/workflow) is for when orchestration needs real control flow loops, conditionals, retries, fan-out computed at run time — that a static graph can't express. Pick the smallest one that fits.
81
99
 
82
100
  ## License
83
101
 
@@ -26,6 +26,7 @@ import { Type } from "typebox";
26
26
 
27
27
  import { BUILTIN_AGENTS } from "../src/builtin.ts";
28
28
  import { withUiLock } from "../src/ui-lock.ts";
29
+ import { LoopGuard } from "../src/loop-guard.ts";
29
30
  import {
30
31
  consentQuestion,
31
32
  decideConsent,
@@ -41,6 +42,8 @@ import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "
41
42
  import { parseAgentFile } from "../src/frontmatter.ts";
42
43
  import { buildReport, buildStatusLine } from "../src/report.ts";
43
44
  import { routeItem } from "../src/routing.ts";
45
+ import { normalizeItems } from "../src/graph.ts";
46
+ import { runGraph } from "../src/schedule.ts";
44
47
  import { buildWidgetLines } from "../src/widget.ts";
45
48
  import {
46
49
  DEFAULT_CONCURRENCY,
@@ -183,6 +186,7 @@ export default function swarm(pi: ExtensionAPI) {
183
186
  let session: AgentSession | null = null;
184
187
  let unsubscribe: (() => void) | null = null;
185
188
  let releaseLive: (() => void) | null = null;
189
+ let stallReason: string | null = null;
186
190
  try {
187
191
  let model = ctx.model ?? null;
188
192
  if (def.model) {
@@ -230,11 +234,37 @@ export default function swarm(pi: ExtensionAPI) {
230
234
  session = created.session;
231
235
  releaseLive = live.register(runId, session);
232
236
 
237
+ const guard = new LoopGuard();
233
238
  unsubscribe = session.subscribe((event) => {
234
- if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
239
+ const message = (
240
+ event as {
241
+ message?: {
242
+ role?: string;
243
+ usage?: { totalTokens?: number };
244
+ content?: Array<{ type?: string; text?: string }>;
245
+ };
246
+ }
247
+ ).message;
248
+ if (event.type === "message_end" && message?.role === "assistant") {
235
249
  item.turns++;
236
- const usage = (event as { message?: { usage?: { totalTokens?: number } } }).message?.usage;
250
+ const usage = message.usage;
237
251
  if (usage && typeof usage.totalTokens === "number") item.tokens += usage.totalTokens;
252
+
253
+ // Stop an item that is spinning — restating itself without acting —
254
+ // rather than letting it run to the turn cap. See loop-guard.ts.
255
+ if (!stallReason && Array.isArray(message.content)) {
256
+ const usedTool = message.content.some((c) => c.type === "toolCall");
257
+ const turnText = message.content
258
+ .filter((c) => c.type === "text" && typeof c.text === "string")
259
+ .map((c) => c.text)
260
+ .join("\n");
261
+ const verdict = guard.observe({ text: turnText, usedTool });
262
+ if (verdict.stalled) {
263
+ stallReason = verdict.reason ?? "no progress";
264
+ void session?.abort().catch(() => {});
265
+ }
266
+ }
267
+
238
268
  renderWidget();
239
269
  if (item.turns >= def.maxTurns) void session?.abort().catch(() => {});
240
270
  }
@@ -255,9 +285,18 @@ export default function swarm(pi: ExtensionAPI) {
255
285
  .join("\n")
256
286
  .trim();
257
287
 
258
- item.result = text || null;
259
- item.status =
260
- last?.stopReason === "aborted" ? "aborted" : last?.stopReason === "error" ? "error" : "done";
288
+ // An item the loop guard stopped gave up rather than concluded — mark it
289
+ // so the aggregated report does not read it as a finished answer.
290
+ item.result = stallReason
291
+ ? `${text ? `${text}\n\n` : ""}[stopped: no progress — the agent ${stallReason}]`
292
+ : text || null;
293
+ item.status = stallReason
294
+ ? "aborted"
295
+ : last?.stopReason === "aborted"
296
+ ? "aborted"
297
+ : last?.stopReason === "error"
298
+ ? "error"
299
+ : "done";
261
300
  if (item.status === "error") item.error = text || "child session error";
262
301
  // A child that stopped cleanly and said nothing has not answered — the
263
302
  // fix subagent already carries and this executor never received. Left
@@ -291,7 +330,11 @@ export default function swarm(pi: ExtensionAPI) {
291
330
  }
292
331
  }
293
332
 
294
- /** Pool executor: at most DEFAULT_CONCURRENCY items in flight. */
333
+ /**
334
+ * Readiness scheduler: run each item as soon as its `needs` are done, up to
335
+ * DEFAULT_CONCURRENCY at once. A flat run (no needs anywhere) makes every
336
+ * item ready immediately, so this is identical to the old parallel pool.
337
+ */
295
338
  async function executeRun(
296
339
  ctx: UiContext,
297
340
  run: SwarmRun,
@@ -302,25 +345,33 @@ export default function swarm(pi: ExtensionAPI) {
302
345
  ): Promise<void> {
303
346
  // One shared log per run; only created when the caller asked for it.
304
347
  const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
305
- const queue = [...run.items];
306
- const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
307
- for (;;) {
308
- // A cancelled run stops taking new items; the ones already in flight
309
- // were aborted by cancelRun.
310
- if (run.status === "cancelled") return;
311
- const item = queue.shift();
312
- if (!item) return;
348
+
349
+ // A dependent structurally receives each upstream's output the thing a
350
+ // hand-sequenced coordinator forgets. Prepended to the shared preamble.
351
+ const contextFor = (upstream: ItemState[]): string => {
352
+ if (upstream.length === 0) return context;
353
+ const blocks = upstream.map((up) => {
354
+ const body = up.result ?? (up.error ? `(failed: ${up.error})` : "(no output)");
355
+ return `## Output of ${up.id}\n${body}`;
356
+ });
357
+ return [context, ...blocks].filter((s) => s && s.trim()).join("\n\n");
358
+ };
359
+
360
+ // Run each item once its needs finish, up to the concurrency cap; a flat
361
+ // run (no needs) has everything ready at once, exactly like the old pool.
362
+ await runGraph(
363
+ run.items,
364
+ DEFAULT_CONCURRENCY,
365
+ async (item, upstream) => {
313
366
  const def = routeItem(item.item, defs, fixed);
314
367
  item.agent = def.name;
368
+ const itemContext = contextFor(upstream);
315
369
  if (isolate) {
316
370
  try {
317
371
  const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
318
- await runItem(ctx, run.runId, def, item, context, iso.path, mailbox);
319
- // Remove the worktree when the item changed nothing the cleanup
320
- // the README promised but the code never performed (removeIfUnchanged
321
- // was imported and never called, leaking a worktree + branch per
322
- // read-only item). Kept when there is work to merge, and only then
323
- // is the merge note worth showing.
372
+ await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
373
+ // Remove the worktree when the item changed nothing (the leak
374
+ // removeIfUnchanged fixes); keep it when there is work to merge.
324
375
  const removed = removeIfUnchanged(ctx.cwd, iso);
325
376
  if (item.result !== null) {
326
377
  item.result = `${item.result}\n\n${removed ? CLEAN_WORKTREE_NOTE : isolationNote(iso)}`;
@@ -330,11 +381,12 @@ export default function swarm(pi: ExtensionAPI) {
330
381
  item.error = err instanceof Error ? err.message : String(err);
331
382
  }
332
383
  } else {
333
- await runItem(ctx, run.runId, def, item, context, undefined, mailbox);
384
+ await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
334
385
  }
335
- }
336
- });
337
- await Promise.all(workers);
386
+ },
387
+ () => run.status === "cancelled",
388
+ );
389
+
338
390
  if (run.status !== "cancelled") run.status = "done";
339
391
  run.finishedAt = Date.now();
340
392
  pi.appendEntry(RUN_ENTRY, run);
@@ -374,9 +426,24 @@ export default function swarm(pi: ExtensionAPI) {
374
426
  "Write each item as a self-contained brief — children see nothing else. For MUTATING items set " +
375
427
  "isolation=worktree: each item gets its own git worktree and branch; reports say how to merge. " +
376
428
  "mailbox=true adds swarm_post/swarm_inbox so agents can warn each other about shared files and " +
377
- "conventions instead of silently conflicting.",
429
+ "conventions instead of silently conflicting. " +
430
+ "An item can be a plain string (independent) OR an object {task, id, needs:[ids]} to declare a " +
431
+ "dependency: a needed item's output is prepended to the dependent automatically, and the dependent " +
432
+ "starts only once its needs finish. A cycle, a self-edge, or an unknown id is rejected before anything runs.",
378
433
  parameters: Type.Object({
379
- items: Type.Array(Type.String(), { minItems: 1, maxItems: MAX_ITEMS }),
434
+ items: Type.Array(
435
+ Type.Union([
436
+ Type.String({ description: "A self-contained task brief (independent item)" }),
437
+ Type.Object({
438
+ task: Type.String({ description: "A self-contained task brief" }),
439
+ id: Type.Optional(Type.String({ description: "Stable id other items can reference in needs (default t1, t2, …)" })),
440
+ needs: Type.Optional(
441
+ Type.Array(Type.String(), { description: "Ids of items that must finish first; their output is prepended to this item" }),
442
+ ),
443
+ }),
444
+ ]),
445
+ { minItems: 1, maxItems: MAX_ITEMS },
446
+ ),
380
447
  context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
381
448
  agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
382
449
  isolation: Type.Optional(Type.String({ description: "Set to worktree to give each item its own git worktree (for mutating items)" })),
@@ -388,7 +455,7 @@ export default function swarm(pi: ExtensionAPI) {
388
455
  async execute(
389
456
  _id,
390
457
  params: {
391
- items: string[];
458
+ items: Array<string | { task: string; id?: string; needs?: string[] }>;
392
459
  context?: string;
393
460
  agent?: string;
394
461
  background?: boolean;
@@ -400,8 +467,11 @@ export default function swarm(pi: ExtensionAPI) {
400
467
  ctx,
401
468
  ) {
402
469
  const uiCtx = ctx as UiContext;
403
- const items = params.items.map((s) => s.trim()).filter(Boolean);
404
- if (items.length === 0) throw new Error("swarm_run requires at least one non-empty item.");
470
+ // Normalize strings/objects into graph nodes and reject a bad graph
471
+ // (cycle, self-edge, unknown or duplicate id) BEFORE spawning anything.
472
+ const { nodes, error } = normalizeItems(params.items ?? []);
473
+ if (error) throw new Error(`swarm_run: ${error}`);
474
+ if (nodes.length === 0) throw new Error("swarm_run requires at least one non-empty item.");
405
475
  if (params.agent && !defs.has(params.agent.toLowerCase())) {
406
476
  throw new Error(`Unknown agent type "${params.agent}". Available: ${[...defs.keys()].sort().join(", ")}`);
407
477
  }
@@ -416,9 +486,11 @@ export default function swarm(pi: ExtensionAPI) {
416
486
  status: "running",
417
487
  startedAt: Date.now(),
418
488
  finishedAt: null,
419
- items: items.map((item, index) => ({
489
+ items: nodes.map((node, index) => ({
420
490
  index,
421
- item,
491
+ id: node.id,
492
+ item: node.task,
493
+ needs: node.needs,
422
494
  agent: params.agent?.toLowerCase() ?? "?",
423
495
  status: "queued",
424
496
  turns: 0,
@@ -469,7 +541,7 @@ export default function swarm(pi: ExtensionAPI) {
469
541
  });
470
542
  return {
471
543
  content: [
472
- { type: "text", text: `Swarm ${run.runId} started (${items.length} items). Poll swarm_status runId="${run.runId}".` },
544
+ { type: "text", text: `Swarm ${run.runId} started (${run.items.length} items). Poll swarm_status runId="${run.runId}".` },
473
545
  ],
474
546
  details: { runId: run.runId },
475
547
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.7.7",
3
+ "version": "0.9.0",
4
4
  "description": "Coordinate multiple pi agents in parallel: swarm_run fan-out with per-item auto-routing, concurrency queue, aggregated reports",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/graph.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Turning a list of items — some depending on others — into a runnable graph.
3
+ *
4
+ * swarm's flat mode fans N independent items out in parallel. But real work is
5
+ * rarely that flat: "write the migration, THEN update the callers, THEN run the
6
+ * tests" is a chain, and "gather these three, then summarize" is a join. Left
7
+ * to the coordinator to sequence by hand, the dependency is a thing it can
8
+ * forget — and the classic failure is forgetting to pass the upstream's output
9
+ * to the downstream item at all.
10
+ *
11
+ * So an item may declare `needs: [id, …]`. This module is the pure half: it
12
+ * normalizes items (string or object) into nodes with stable ids, and rejects a
13
+ * bad graph — a self-edge, a reference to an unknown id, a duplicate id, or a
14
+ * cycle — BEFORE anything is spawned, because a bad graph should cost nothing.
15
+ * The scheduler (in the extension) then runs each item as soon as its needs are
16
+ * done and prepends every upstream's output to it.
17
+ *
18
+ * A flat list (no `needs` anywhere) validates trivially and every node is ready
19
+ * at once — identical to the old parallel fan-out. Zero dependencies.
20
+ */
21
+
22
+ export interface RawItem {
23
+ task: string;
24
+ id?: string;
25
+ needs?: string[];
26
+ }
27
+
28
+ export interface GraphNode {
29
+ id: string;
30
+ task: string;
31
+ needs: string[];
32
+ }
33
+
34
+ export interface NormalizeResult {
35
+ nodes: GraphNode[];
36
+ error: string | null;
37
+ }
38
+
39
+ /** Accept a plain string or an object; give every item a stable id and clean needs. */
40
+ export function normalizeItems(items: Array<string | RawItem>): NormalizeResult {
41
+ const nodes: GraphNode[] = [];
42
+ for (let i = 0; i < items.length; i++) {
43
+ const raw = items[i]!;
44
+ const isString = typeof raw === "string";
45
+ const task = (isString ? raw : raw.task ?? "").trim();
46
+ if (!task) return { nodes: [], error: `item ${i + 1} has no task text` };
47
+ const id = !isString && typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : `t${i + 1}`;
48
+ const needs =
49
+ !isString && Array.isArray(raw.needs)
50
+ ? raw.needs.filter((n): n is string => typeof n === "string" && n.trim() !== "").map((n) => n.trim())
51
+ : [];
52
+ nodes.push({ id, task, needs });
53
+ }
54
+ const error = validateGraph(nodes);
55
+ return { nodes, error };
56
+ }
57
+
58
+ /** Reject a self-edge, an unknown reference, a duplicate id, or a cycle. */
59
+ export function validateGraph(nodes: GraphNode[]): string | null {
60
+ const ids = new Set<string>();
61
+ for (const n of nodes) {
62
+ if (ids.has(n.id)) return `duplicate item id "${n.id}"`;
63
+ ids.add(n.id);
64
+ }
65
+ for (const n of nodes) {
66
+ for (const dep of n.needs) {
67
+ if (dep === n.id) return `item "${n.id}" cannot depend on itself`;
68
+ if (!ids.has(dep)) return `item "${n.id}" needs unknown item "${dep}"`;
69
+ }
70
+ }
71
+ const byId = new Map(nodes.map((n) => [n.id, n]));
72
+ const state = new Map<string, 0 | 1 | 2>(); // 0 unseen, 1 on stack, 2 done
73
+ const hasCycle = (id: string): boolean => {
74
+ const s = state.get(id) ?? 0;
75
+ if (s === 1) return true;
76
+ if (s === 2) return false;
77
+ state.set(id, 1);
78
+ for (const dep of byId.get(id)!.needs) if (hasCycle(dep)) return true;
79
+ state.set(id, 2);
80
+ return false;
81
+ };
82
+ for (const n of nodes) if (hasCycle(n.id)) return `dependency cycle involving "${n.id}"`;
83
+ return null;
84
+ }
85
+
86
+ /** Does any node declare a dependency? If not, the run is a plain flat fan-out. */
87
+ export function hasEdges(nodes: GraphNode[]): boolean {
88
+ return nodes.some((n) => n.needs.length > 0);
89
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Stop a child that has stopped making progress.
3
+ *
4
+ * A turn cap bounds what a runaway child can cost; it does not notice the
5
+ * characteristic autonomous-agent failure, which is cheaper per turn and just
6
+ * as stuck: restating the same plan every turn without calling a tool, or
7
+ * oscillating between two states forever. pi's own loop will happily let that
8
+ * run to the cap.
9
+ *
10
+ * So fingerprint each assistant turn and watch for two shapes. A turn that
11
+ * called a tool is progress and clears the history — the guard only fires on
12
+ * turns that did nothing but talk. `repeat` identical tool-free turns is a
13
+ * child spinning in place; an A-B-A-B… run of `cycle` cycles is one bouncing
14
+ * between two dead ends. Both are advisory signals the caller acts on (a
15
+ * spawned child is unattended, so acting means aborting it).
16
+ *
17
+ * Zero dependencies — node:crypto for the hash. Deterministic and pure given
18
+ * the sequence of turns, so it is unit-testable without a live child.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+
22
+ export interface LoopGuardConfig {
23
+ /** Consecutive identical tool-free turns before flagging a stall (min 2, default 3). */
24
+ repeat?: number;
25
+ /** Repeats of a two-turn A-B cycle before flagging (min 2, default 3). */
26
+ cycle?: number;
27
+ }
28
+
29
+ export interface Turn {
30
+ /** The visible assistant text of the turn. */
31
+ text: string;
32
+ /** Did the turn invoke at least one tool? A tool call is progress. */
33
+ usedTool: boolean;
34
+ }
35
+
36
+ export interface LoopVerdict {
37
+ stalled: boolean;
38
+ reason?: string;
39
+ }
40
+
41
+ /** Fold away cosmetic differences so "the same thought" hashes the same. */
42
+ function fingerprint(text: string): string {
43
+ const norm = text.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
44
+ return createHash("sha256").update(norm).digest("hex");
45
+ }
46
+
47
+ export class LoopGuard {
48
+ private readonly repeat: number;
49
+ private readonly cycle: number;
50
+ /** Recent tool-free fingerprints; a tool call clears this. */
51
+ private readonly recent: string[] = [];
52
+ private static readonly MAX = 16;
53
+
54
+ constructor(cfg: LoopGuardConfig = {}) {
55
+ this.repeat = Math.max(2, cfg.repeat ?? 3);
56
+ this.cycle = Math.max(2, cfg.cycle ?? 3);
57
+ }
58
+
59
+ observe(turn: Turn): LoopVerdict {
60
+ // A tool call is forward motion: forget the stall history entirely.
61
+ if (turn.usedTool) {
62
+ this.recent.length = 0;
63
+ return { stalled: false };
64
+ }
65
+ // A silent turn (no text, no tool) is not evidence of a loop by itself.
66
+ if (turn.text.trim() === "") return { stalled: false };
67
+
68
+ const fp = fingerprint(turn.text);
69
+ this.recent.push(fp);
70
+ if (this.recent.length > LoopGuard.MAX) this.recent.shift();
71
+
72
+ // Spinning in place: the last `repeat` tool-free turns are identical.
73
+ if (this.recent.length >= this.repeat && this.recent.slice(-this.repeat).every((f) => f === fp)) {
74
+ return { stalled: true, reason: `repeated the same output for ${this.repeat} turns without acting` };
75
+ }
76
+
77
+ // Oscillating: the last 2×cycle turns are a strict A-B-A-B… alternation.
78
+ const need = 2 * this.cycle;
79
+ if (this.recent.length >= need) {
80
+ const tail = this.recent.slice(-need);
81
+ const a = tail[0]!;
82
+ const b = tail[1]!;
83
+ if (a !== b && tail.every((f, i) => f === (i % 2 === 0 ? a : b))) {
84
+ return { stalled: true, reason: `oscillated between two states for ${this.cycle} cycles without acting` };
85
+ }
86
+ }
87
+
88
+ return { stalled: false };
89
+ }
90
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The readiness scheduler, pure and testable.
3
+ *
4
+ * Given nodes that may depend on one another, run each one as soon as its
5
+ * `needs` have finished, never more than `concurrency` at a time. The caller's
6
+ * `run(node, upstream)` does the actual work and is handed the finished
7
+ * upstream nodes (in `needs` order) so it can thread their output into the
8
+ * dependent. A node whose run rejects still counts as done, so a failure
9
+ * unblocks its dependents rather than wedging the whole graph. `cancelled()` is
10
+ * polled between launches to stop taking on new work.
11
+ *
12
+ * A flat set (no `needs`) makes every node ready at once, so this collapses to
13
+ * a plain concurrency-capped fan-out. Zero dependencies; the graph itself must
14
+ * already be validated (see graph.ts) — this assumes no cycle.
15
+ */
16
+
17
+ export interface Schedulable {
18
+ id: string;
19
+ needs: string[];
20
+ }
21
+
22
+ export async function runGraph<T extends Schedulable>(
23
+ nodes: readonly T[],
24
+ concurrency: number,
25
+ run: (node: T, upstream: T[]) => void | Promise<void>,
26
+ cancelled: () => boolean = () => false,
27
+ ): Promise<void> {
28
+ const byId = new Map(nodes.map((n) => [n.id, n]));
29
+ const done = new Set<string>();
30
+ const started = new Set<string>();
31
+ const inFlight = new Map<string, Promise<void>>();
32
+ const cap = Math.max(1, concurrency);
33
+
34
+ const isReady = (n: T) => !started.has(n.id) && n.needs.every((d) => done.has(d));
35
+
36
+ while (!cancelled()) {
37
+ for (const n of nodes) {
38
+ if (inFlight.size >= cap) break;
39
+ if (!isReady(n)) continue;
40
+ started.add(n.id);
41
+ const upstream = n.needs.map((id) => byId.get(id)).filter((x): x is T => x !== undefined);
42
+ const p = Promise.resolve()
43
+ .then(() => run(n, upstream))
44
+ .catch(() => {
45
+ // The caller records its own failure; here a rejected node must still
46
+ // settle so dependents unblock instead of the graph hanging.
47
+ })
48
+ .then(() => {
49
+ done.add(n.id);
50
+ inFlight.delete(n.id);
51
+ });
52
+ inFlight.set(n.id, p);
53
+ }
54
+ if (inFlight.size === 0) break; // nothing running and nothing newly ready
55
+ await Promise.race(inFlight.values());
56
+ }
57
+ // A cancel can leave work in flight; let it settle so the caller's record is
58
+ // complete rather than half-written.
59
+ await Promise.all(inFlight.values());
60
+ }
package/src/types.ts CHANGED
@@ -52,7 +52,11 @@ export type ItemStatus = "queued" | "running" | "done" | "error" | "aborted";
52
52
 
53
53
  export interface ItemState {
54
54
  index: number;
55
+ /** Stable id used for dependency references, e.g. "t1" or a caller-given id. */
56
+ id: string;
55
57
  item: string;
58
+ /** Ids of items that must finish before this one starts (empty = flat). */
59
+ needs: string[];
56
60
  agent: string;
57
61
  status: ItemStatus;
58
62
  turns: number;