@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 +2 -0
- package/extensions/swarm.ts +66 -9
- package/package.json +3 -3
- package/src/cancel.ts +104 -0
- package/src/isolate.ts +33 -0
- package/src/types.ts +5 -1
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.
|
package/extensions/swarm.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
+
"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.
|
|
65
|
-
"@earendil-works/pi-tui": "^0.
|
|
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
|
-
|
|
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;
|