@pify/swarm 0.3.0 → 0.5.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
@@ -39,3 +39,5 @@ Without it, parallel agents cannot see each other, so two of them cheerfully fix
39
39
  ## License
40
40
 
41
41
  MIT © [Pify maintainers](https://github.com/pifydev)
42
+
43
+ **Isolated runs clean up after themselves** (v0.4): a worktree whose child changed nothing is removed along with its branch — otherwise a read-only step left one of each behind, per run. Anything uncommitted, or any commit the child made, is kept and reported.
@@ -25,7 +25,8 @@ import { Text } from "@earendil-works/pi-tui";
25
25
  import { Type } from "typebox";
26
26
 
27
27
  import { BUILTIN_AGENTS } from "../src/builtin.ts";
28
- import { createIsolationWorktree, isolationNote } from "../src/isolate.ts";
28
+ import { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
29
+ import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
29
30
  import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
30
31
  import { parseAgentFile } from "../src/frontmatter.ts";
31
32
  import { buildReport, buildStatusLine } from "../src/report.ts";
@@ -43,6 +44,8 @@ import { readFileSync, readdirSync } from "node:fs";
43
44
  import { basename, join } from "node:path";
44
45
 
45
46
  const RUN_ENTRY = "swarm-run";
47
+ const CLEAN_WORKTREE_NOTE =
48
+ "Ran isolated in a temporary worktree; it changed nothing, so the worktree was removed.";
46
49
 
47
50
  type UiContext = ExtensionContext;
48
51
 
@@ -75,6 +78,8 @@ function loadDefs(cwd: string, agentDir: string): Map<string, AgentDef> {
75
78
  export default function swarm(pi: ExtensionAPI) {
76
79
  let defs = new Map<string, AgentDef>();
77
80
  const runs = new Map<string, SwarmRun>();
81
+ /** Live child sessions per run, so a stop actually reaches the children. */
82
+ const live = new LiveChildren();
78
83
  let activeRun: SwarmRun | null = null;
79
84
  let runCounter = 0;
80
85
  let lastUiCtx: UiContext | null = null;
@@ -84,7 +89,7 @@ export default function swarm(pi: ExtensionAPI) {
84
89
  lastUiCtx = ctx;
85
90
  const run = activeRun;
86
91
  const now = Date.now();
87
- if (!run || (run.status === "done" && (run.finishedAt ?? 0) < now - 15_000)) {
92
+ if (!run || (run.status !== "running" && (run.finishedAt ?? 0) < now - 15_000)) {
88
93
  ctx.ui.setWidget("swarm", undefined);
89
94
  return;
90
95
  }
@@ -149,6 +154,7 @@ export default function swarm(pi: ExtensionAPI) {
149
154
 
150
155
  async function runItem(
151
156
  ctx: UiContext,
157
+ runId: string,
152
158
  def: AgentDef,
153
159
  item: ItemState,
154
160
  context: string,
@@ -159,6 +165,7 @@ export default function swarm(pi: ExtensionAPI) {
159
165
  renderWidget();
160
166
  let session: AgentSession | null = null;
161
167
  let unsubscribe: (() => void) | null = null;
168
+ let releaseLive: (() => void) | null = null;
162
169
  try {
163
170
  let model = ctx.model ?? null;
164
171
  if (def.model) {
@@ -196,6 +203,7 @@ export default function swarm(pi: ExtensionAPI) {
196
203
  }),
197
204
  });
198
205
  session = created.session;
206
+ releaseLive = live.register(runId, session);
199
207
 
200
208
  unsubscribe = session.subscribe((event) => {
201
209
  if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
@@ -230,6 +238,7 @@ export default function swarm(pi: ExtensionAPI) {
230
238
  item.status = "error";
231
239
  item.error = err instanceof Error ? err.message : String(err);
232
240
  } finally {
241
+ if (releaseLive) releaseLive();
233
242
  if (unsubscribe) {
234
243
  try {
235
244
  unsubscribe();
@@ -262,6 +271,9 @@ export default function swarm(pi: ExtensionAPI) {
262
271
  const queue = [...run.items];
263
272
  const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
264
273
  for (;;) {
274
+ // A cancelled run stops taking new items; the ones already in flight
275
+ // were aborted by cancelRun.
276
+ if (run.status === "cancelled") return;
265
277
  const item = queue.shift();
266
278
  if (!item) return;
267
279
  const def = routeItem(item.item, defs, fixed);
@@ -269,24 +281,43 @@ export default function swarm(pi: ExtensionAPI) {
269
281
  if (isolate) {
270
282
  try {
271
283
  const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
272
- await runItem(ctx, def, item, context, iso.path, mailbox);
284
+ await runItem(ctx, run.runId, def, item, context, iso.path, mailbox);
273
285
  if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
274
286
  } catch (err) {
275
287
  item.status = "error";
276
288
  item.error = err instanceof Error ? err.message : String(err);
277
289
  }
278
290
  } else {
279
- await runItem(ctx, def, item, context, undefined, mailbox);
291
+ await runItem(ctx, run.runId, def, item, context, undefined, mailbox);
280
292
  }
281
293
  }
282
294
  });
283
295
  await Promise.all(workers);
284
- run.status = "done";
296
+ if (run.status !== "cancelled") run.status = "done";
285
297
  run.finishedAt = Date.now();
286
298
  pi.appendEntry(RUN_ENTRY, run);
287
299
  renderWidget();
288
300
  }
289
301
 
302
+ /**
303
+ * Stop a run and every child it started. Both meanings of "stop" — the
304
+ * user's abort and session teardown — come through here.
305
+ */
306
+ function cancelRun(run: SwarmRun, reason: CancelReason): void {
307
+ const stopped = live.abortRun(run.runId);
308
+ if (run.status === "running") {
309
+ run.status = "cancelled";
310
+ run.finishedAt = Date.now();
311
+ }
312
+ for (const item of run.items) {
313
+ if (item.status === "running" || item.status === "queued") {
314
+ item.status = "aborted";
315
+ item.error = cancelNote(reason, stopped);
316
+ }
317
+ }
318
+ renderWidget();
319
+ }
320
+
290
321
  // ── Tools ────────────────────────────────────────────────────────────
291
322
 
292
323
  pi.registerTool({
@@ -321,7 +352,7 @@ export default function swarm(pi: ExtensionAPI) {
321
352
  isolation?: string;
322
353
  mailbox?: boolean;
323
354
  },
324
- _signal,
355
+ signal,
325
356
  _onUpdate,
326
357
  ctx,
327
358
  ) {
@@ -357,6 +388,18 @@ export default function swarm(pi: ExtensionAPI) {
357
388
  activeRun = run;
358
389
  renderWidget(uiCtx);
359
390
 
391
+ // Esc must reach the children. A background run outlives this tool call
392
+ // by design, so its signal is not its cancel button.
393
+ let stopListening: (() => void) | null = null;
394
+ if (signal && !run.background) {
395
+ const onAbort = () => cancelRun(run, "user-abort");
396
+ if (signal.aborted) onAbort();
397
+ else {
398
+ signal.addEventListener("abort", onAbort, { once: true });
399
+ stopListening = () => signal.removeEventListener("abort", onAbort);
400
+ }
401
+ }
402
+
360
403
  if (run.background) {
361
404
  void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
362
405
  notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
@@ -369,7 +412,11 @@ export default function swarm(pi: ExtensionAPI) {
369
412
  };
370
413
  }
371
414
 
372
- await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
415
+ try {
416
+ await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
417
+ } finally {
418
+ if (stopListening) stopListening();
419
+ }
373
420
  return {
374
421
  content: [{ type: "text", text: buildReport(run) }],
375
422
  details: { runId: run.runId },
@@ -387,7 +434,13 @@ export default function swarm(pi: ExtensionAPI) {
387
434
  async execute(_id, params: { runId?: string }) {
388
435
  const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
389
436
  if (!run) throw new Error("No swarm runs this session.");
390
- const text = run.status === "done" ? buildReport(run) : buildStatusLine(run);
437
+ const text =
438
+ run.status === "cancelled"
439
+ ? "This run was cancelled before it finished. Below is what the items that did complete produced.\n" +
440
+ buildReport(run)
441
+ : run.status === "done"
442
+ ? buildReport(run)
443
+ : buildStatusLine(run);
391
444
  return { content: [{ type: "text", text }], details: { runId: run.runId, status: run.status } };
392
445
  },
393
446
  });
@@ -402,7 +455,7 @@ export default function swarm(pi: ExtensionAPI) {
402
455
  const e = entry as { type?: string; customType?: string; data?: unknown };
403
456
  if (e.type !== "custom" || e.customType !== RUN_ENTRY || !isRecord(e.data)) continue;
404
457
  const run = e.data as unknown as SwarmRun;
405
- if (typeof run.runId === "string" && run.status === "done") {
458
+ if (typeof run.runId === "string" && run.status !== "running") {
406
459
  runs.set(run.runId, run);
407
460
  const n = Number.parseInt(run.runId.slice(1), 10);
408
461
  if (Number.isFinite(n) && n > runCounter) runCounter = n;
@@ -412,6 +465,10 @@ export default function swarm(pi: ExtensionAPI) {
412
465
  });
413
466
 
414
467
  pi.on("session_shutdown", async (_event, ctx) => {
468
+ // A run cannot outlive the session that owns it.
469
+ for (const run of runs.values()) {
470
+ if (run.status === "running") cancelRun(run, "session-switch");
471
+ }
415
472
  if (ctx.hasUI) ctx.ui.setWidget("swarm", undefined);
416
473
  });
417
474
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -61,8 +61,8 @@
61
61
  }
62
62
  },
63
63
  "devDependencies": {
64
- "@earendil-works/pi-coding-agent": "^0.84.4",
65
- "@earendil-works/pi-tui": "^0.84.4",
64
+ "@earendil-works/pi-coding-agent": "^0.85.1",
65
+ "@earendil-works/pi-tui": "^0.85.1",
66
66
  "@types/node": "^22.10.2",
67
67
  "typebox": "^1.1.38",
68
68
  "typescript": "^5.7.2"
package/src/cancel.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Stopping means stopping the children too.
3
+ *
4
+ * A run in this package is not one process: it is a tree of child agent
5
+ * sessions, each with its own provider connection. The tool that started them
6
+ * is handed an AbortSignal and the extension is told when the session goes
7
+ * away — and until now neither reached the children. Pressing Esc, or
8
+ * switching sessions with a run in flight, marked a record "aborted" while the
9
+ * children kept talking to the provider on the user's money, writing into a
10
+ * conversation nobody was reading.
11
+ *
12
+ * So every live child registers here, and the two places that mean "stop"
13
+ * abort all of them. (The rule is FradSer-adjacent prior art: @zhushanwen's
14
+ * subagent-workflow terminates running runs on session switch or shutdown
15
+ * rather than letting them outlive the session that owns them.)
16
+ */
17
+
18
+ /** The part of a child agent session this module needs. */
19
+ export interface Abortable {
20
+ abort(): unknown;
21
+ }
22
+
23
+ export type CancelReason = "user-abort" | "session-switch" | "timeout";
24
+
25
+ /**
26
+ * Live child sessions, grouped by the run that owns them. Registration
27
+ * returns its own release, so a child that finishes normally leaves no trace
28
+ * and cannot be aborted twice.
29
+ */
30
+ export class LiveChildren {
31
+ private byRun = new Map<string, Set<Abortable>>();
32
+
33
+ register(runId: string, child: Abortable): () => void {
34
+ let set = this.byRun.get(runId);
35
+ if (!set) {
36
+ set = new Set();
37
+ this.byRun.set(runId, set);
38
+ }
39
+ set.add(child);
40
+ return () => {
41
+ const current = this.byRun.get(runId);
42
+ if (!current) return;
43
+ current.delete(child);
44
+ if (current.size === 0) this.byRun.delete(runId);
45
+ };
46
+ }
47
+
48
+ /** How many children of this run are still live. */
49
+ count(runId: string): number {
50
+ return this.byRun.get(runId)?.size ?? 0;
51
+ }
52
+
53
+ /** Total live children across every run. */
54
+ total(): number {
55
+ let sum = 0;
56
+ for (const set of this.byRun.values()) sum += set.size;
57
+ return sum;
58
+ }
59
+
60
+ /**
61
+ * Abort every live child of one run and return how many were stopped. A
62
+ * child that throws from abort() is still counted and still dropped: the
63
+ * point is that nothing is left holding a connection, and one stubborn
64
+ * child must not spare the others.
65
+ */
66
+ abortRun(runId: string): number {
67
+ const set = this.byRun.get(runId);
68
+ if (!set) return 0;
69
+ let stopped = 0;
70
+ for (const child of [...set]) {
71
+ try {
72
+ const result = child.abort();
73
+ // abort() is async in pi; a rejection here is not ours to surface.
74
+ void Promise.resolve(result).catch(() => {});
75
+ } catch {
76
+ // already gone
77
+ }
78
+ stopped++;
79
+ }
80
+ this.byRun.delete(runId);
81
+ return stopped;
82
+ }
83
+
84
+ /** Abort every live child of every run. */
85
+ abortAll(): number {
86
+ let stopped = 0;
87
+ for (const runId of [...this.byRun.keys()]) stopped += this.abortRun(runId);
88
+ return stopped;
89
+ }
90
+ }
91
+
92
+ /** One line for the run log, naming who stopped it and what that cost. */
93
+ export function cancelNote(reason: CancelReason, stopped: number): string {
94
+ const children =
95
+ stopped === 0 ? "no child agents were running" : `${stopped} child agent${stopped === 1 ? "" : "s"} stopped`;
96
+ switch (reason) {
97
+ case "user-abort":
98
+ return `Cancelled by the user — ${children}. Work already finished is kept; the run itself did not complete.`;
99
+ case "session-switch":
100
+ return `The session went away, so the run was terminated — ${children}. Tokens already spent are not recoverable; start a new run for a result.`;
101
+ case "timeout":
102
+ return `The run exceeded its time limit — ${children}.`;
103
+ }
104
+ }
package/src/isolate.ts CHANGED
@@ -79,3 +79,36 @@ export function isolationNote(isolation: Isolation): string {
79
79
  `or inspect: cd "${isolation.path}" && git log --stat`,
80
80
  ].join("\n");
81
81
  }
82
+
83
+ /**
84
+ * Remove a worktree the child left untouched. An isolated run that changed
85
+ * nothing is the common case — a review, a search, a question — and keeping
86
+ * its worktree means a directory and a branch per run accumulate under
87
+ * ~/.worktrees for as long as the machine runs. A worktree with any change,
88
+ * staged or not, committed or not, is kept: that is someone's work.
89
+ *
90
+ * Returns true when it was removed. Never throws: failing to clean up must
91
+ * not fail the run that already succeeded.
92
+ */
93
+ export function removeIfUnchanged(cwd: string, isolation: Isolation): boolean {
94
+ try {
95
+ // Uncommitted work, tracked or not.
96
+ if (git(isolation.path, ["status", "--porcelain"]).trim()) return false;
97
+ // Commits made inside the worktree: the branch moved off the commit it
98
+ // was cut from. (A fresh agent/<slug> branch has no upstream, so asking
99
+ // git for "ahead of upstream" would throw here rather than answer.)
100
+ const head = git(isolation.path, ["rev-parse", "HEAD"]).trim();
101
+ const base = git(cwd, ["rev-parse", "HEAD"]).trim();
102
+ if (!head || head !== base) return false;
103
+ } catch {
104
+ // A worktree we cannot inspect is one we must not delete.
105
+ return false;
106
+ }
107
+ try {
108
+ git(cwd, ["worktree", "remove", "--force", isolation.path]);
109
+ git(cwd, ["branch", "-D", isolation.branch]);
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
package/src/types.ts CHANGED
@@ -61,7 +61,11 @@ export interface ItemState {
61
61
  error: string | null;
62
62
  }
63
63
 
64
- export type RunStatus = "running" | "done";
64
+ /**
65
+ * "cancelled" is its own outcome, not a completion: someone stopped the run,
66
+ * and calling it done would report results nobody produced.
67
+ */
68
+ export type RunStatus = "running" | "done" | "cancelled";
65
69
 
66
70
  export interface SwarmRun {
67
71
  runId: string;