@pify/swarm 0.8.0 → 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
 
@@ -42,6 +42,8 @@ import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "
42
42
  import { parseAgentFile } from "../src/frontmatter.ts";
43
43
  import { buildReport, buildStatusLine } from "../src/report.ts";
44
44
  import { routeItem } from "../src/routing.ts";
45
+ import { normalizeItems } from "../src/graph.ts";
46
+ import { runGraph } from "../src/schedule.ts";
45
47
  import { buildWidgetLines } from "../src/widget.ts";
46
48
  import {
47
49
  DEFAULT_CONCURRENCY,
@@ -328,7 +330,11 @@ export default function swarm(pi: ExtensionAPI) {
328
330
  }
329
331
  }
330
332
 
331
- /** 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
+ */
332
338
  async function executeRun(
333
339
  ctx: UiContext,
334
340
  run: SwarmRun,
@@ -339,25 +345,33 @@ export default function swarm(pi: ExtensionAPI) {
339
345
  ): Promise<void> {
340
346
  // One shared log per run; only created when the caller asked for it.
341
347
  const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
342
- const queue = [...run.items];
343
- const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
344
- for (;;) {
345
- // A cancelled run stops taking new items; the ones already in flight
346
- // were aborted by cancelRun.
347
- if (run.status === "cancelled") return;
348
- const item = queue.shift();
349
- 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) => {
350
366
  const def = routeItem(item.item, defs, fixed);
351
367
  item.agent = def.name;
368
+ const itemContext = contextFor(upstream);
352
369
  if (isolate) {
353
370
  try {
354
371
  const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
355
- await runItem(ctx, run.runId, def, item, context, iso.path, mailbox);
356
- // Remove the worktree when the item changed nothing the cleanup
357
- // the README promised but the code never performed (removeIfUnchanged
358
- // was imported and never called, leaking a worktree + branch per
359
- // read-only item). Kept when there is work to merge, and only then
360
- // 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.
361
375
  const removed = removeIfUnchanged(ctx.cwd, iso);
362
376
  if (item.result !== null) {
363
377
  item.result = `${item.result}\n\n${removed ? CLEAN_WORKTREE_NOTE : isolationNote(iso)}`;
@@ -367,11 +381,12 @@ export default function swarm(pi: ExtensionAPI) {
367
381
  item.error = err instanceof Error ? err.message : String(err);
368
382
  }
369
383
  } else {
370
- await runItem(ctx, run.runId, def, item, context, undefined, mailbox);
384
+ await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
371
385
  }
372
- }
373
- });
374
- await Promise.all(workers);
386
+ },
387
+ () => run.status === "cancelled",
388
+ );
389
+
375
390
  if (run.status !== "cancelled") run.status = "done";
376
391
  run.finishedAt = Date.now();
377
392
  pi.appendEntry(RUN_ENTRY, run);
@@ -411,9 +426,24 @@ export default function swarm(pi: ExtensionAPI) {
411
426
  "Write each item as a self-contained brief — children see nothing else. For MUTATING items set " +
412
427
  "isolation=worktree: each item gets its own git worktree and branch; reports say how to merge. " +
413
428
  "mailbox=true adds swarm_post/swarm_inbox so agents can warn each other about shared files and " +
414
- "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.",
415
433
  parameters: Type.Object({
416
- 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
+ ),
417
447
  context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
418
448
  agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
419
449
  isolation: Type.Optional(Type.String({ description: "Set to worktree to give each item its own git worktree (for mutating items)" })),
@@ -425,7 +455,7 @@ export default function swarm(pi: ExtensionAPI) {
425
455
  async execute(
426
456
  _id,
427
457
  params: {
428
- items: string[];
458
+ items: Array<string | { task: string; id?: string; needs?: string[] }>;
429
459
  context?: string;
430
460
  agent?: string;
431
461
  background?: boolean;
@@ -437,8 +467,11 @@ export default function swarm(pi: ExtensionAPI) {
437
467
  ctx,
438
468
  ) {
439
469
  const uiCtx = ctx as UiContext;
440
- const items = params.items.map((s) => s.trim()).filter(Boolean);
441
- 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.");
442
475
  if (params.agent && !defs.has(params.agent.toLowerCase())) {
443
476
  throw new Error(`Unknown agent type "${params.agent}". Available: ${[...defs.keys()].sort().join(", ")}`);
444
477
  }
@@ -453,9 +486,11 @@ export default function swarm(pi: ExtensionAPI) {
453
486
  status: "running",
454
487
  startedAt: Date.now(),
455
488
  finishedAt: null,
456
- items: items.map((item, index) => ({
489
+ items: nodes.map((node, index) => ({
457
490
  index,
458
- item,
491
+ id: node.id,
492
+ item: node.task,
493
+ needs: node.needs,
459
494
  agent: params.agent?.toLowerCase() ?? "?",
460
495
  status: "queued",
461
496
  turns: 0,
@@ -506,7 +541,7 @@ export default function swarm(pi: ExtensionAPI) {
506
541
  });
507
542
  return {
508
543
  content: [
509
- { 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}".` },
510
545
  ],
511
546
  details: { runId: run.runId },
512
547
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.8.0",
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,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;