@pify/swarm 0.4.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/extensions/swarm.ts +63 -8
- package/package.json +1 -1
- package/src/cancel.ts +104 -0
- package/src/types.ts +5 -1
package/extensions/swarm.ts
CHANGED
|
@@ -25,6 +25,7 @@ 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 { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
|
|
28
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";
|
|
@@ -77,6 +78,8 @@ function loadDefs(cwd: string, agentDir: string): Map<string, AgentDef> {
|
|
|
77
78
|
export default function swarm(pi: ExtensionAPI) {
|
|
78
79
|
let defs = new Map<string, AgentDef>();
|
|
79
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();
|
|
80
83
|
let activeRun: SwarmRun | null = null;
|
|
81
84
|
let runCounter = 0;
|
|
82
85
|
let lastUiCtx: UiContext | null = null;
|
|
@@ -86,7 +89,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
86
89
|
lastUiCtx = ctx;
|
|
87
90
|
const run = activeRun;
|
|
88
91
|
const now = Date.now();
|
|
89
|
-
if (!run || (run.status
|
|
92
|
+
if (!run || (run.status !== "running" && (run.finishedAt ?? 0) < now - 15_000)) {
|
|
90
93
|
ctx.ui.setWidget("swarm", undefined);
|
|
91
94
|
return;
|
|
92
95
|
}
|
|
@@ -151,6 +154,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
151
154
|
|
|
152
155
|
async function runItem(
|
|
153
156
|
ctx: UiContext,
|
|
157
|
+
runId: string,
|
|
154
158
|
def: AgentDef,
|
|
155
159
|
item: ItemState,
|
|
156
160
|
context: string,
|
|
@@ -161,6 +165,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
161
165
|
renderWidget();
|
|
162
166
|
let session: AgentSession | null = null;
|
|
163
167
|
let unsubscribe: (() => void) | null = null;
|
|
168
|
+
let releaseLive: (() => void) | null = null;
|
|
164
169
|
try {
|
|
165
170
|
let model = ctx.model ?? null;
|
|
166
171
|
if (def.model) {
|
|
@@ -198,6 +203,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
198
203
|
}),
|
|
199
204
|
});
|
|
200
205
|
session = created.session;
|
|
206
|
+
releaseLive = live.register(runId, session);
|
|
201
207
|
|
|
202
208
|
unsubscribe = session.subscribe((event) => {
|
|
203
209
|
if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
|
|
@@ -232,6 +238,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
232
238
|
item.status = "error";
|
|
233
239
|
item.error = err instanceof Error ? err.message : String(err);
|
|
234
240
|
} finally {
|
|
241
|
+
if (releaseLive) releaseLive();
|
|
235
242
|
if (unsubscribe) {
|
|
236
243
|
try {
|
|
237
244
|
unsubscribe();
|
|
@@ -264,6 +271,9 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
264
271
|
const queue = [...run.items];
|
|
265
272
|
const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
|
|
266
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;
|
|
267
277
|
const item = queue.shift();
|
|
268
278
|
if (!item) return;
|
|
269
279
|
const def = routeItem(item.item, defs, fixed);
|
|
@@ -271,24 +281,43 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
271
281
|
if (isolate) {
|
|
272
282
|
try {
|
|
273
283
|
const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
|
|
274
|
-
await runItem(ctx, def, item, context, iso.path, mailbox);
|
|
284
|
+
await runItem(ctx, run.runId, def, item, context, iso.path, mailbox);
|
|
275
285
|
if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
|
|
276
286
|
} catch (err) {
|
|
277
287
|
item.status = "error";
|
|
278
288
|
item.error = err instanceof Error ? err.message : String(err);
|
|
279
289
|
}
|
|
280
290
|
} else {
|
|
281
|
-
await runItem(ctx, def, item, context, undefined, mailbox);
|
|
291
|
+
await runItem(ctx, run.runId, def, item, context, undefined, mailbox);
|
|
282
292
|
}
|
|
283
293
|
}
|
|
284
294
|
});
|
|
285
295
|
await Promise.all(workers);
|
|
286
|
-
run.status = "done";
|
|
296
|
+
if (run.status !== "cancelled") run.status = "done";
|
|
287
297
|
run.finishedAt = Date.now();
|
|
288
298
|
pi.appendEntry(RUN_ENTRY, run);
|
|
289
299
|
renderWidget();
|
|
290
300
|
}
|
|
291
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
|
+
|
|
292
321
|
// ── Tools ────────────────────────────────────────────────────────────
|
|
293
322
|
|
|
294
323
|
pi.registerTool({
|
|
@@ -323,7 +352,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
323
352
|
isolation?: string;
|
|
324
353
|
mailbox?: boolean;
|
|
325
354
|
},
|
|
326
|
-
|
|
355
|
+
signal,
|
|
327
356
|
_onUpdate,
|
|
328
357
|
ctx,
|
|
329
358
|
) {
|
|
@@ -359,6 +388,18 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
359
388
|
activeRun = run;
|
|
360
389
|
renderWidget(uiCtx);
|
|
361
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
|
+
|
|
362
403
|
if (run.background) {
|
|
363
404
|
void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
|
|
364
405
|
notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
|
|
@@ -371,7 +412,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
371
412
|
};
|
|
372
413
|
}
|
|
373
414
|
|
|
374
|
-
|
|
415
|
+
try {
|
|
416
|
+
await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
|
|
417
|
+
} finally {
|
|
418
|
+
if (stopListening) stopListening();
|
|
419
|
+
}
|
|
375
420
|
return {
|
|
376
421
|
content: [{ type: "text", text: buildReport(run) }],
|
|
377
422
|
details: { runId: run.runId },
|
|
@@ -389,7 +434,13 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
389
434
|
async execute(_id, params: { runId?: string }) {
|
|
390
435
|
const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
|
|
391
436
|
if (!run) throw new Error("No swarm runs this session.");
|
|
392
|
-
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);
|
|
393
444
|
return { content: [{ type: "text", text }], details: { runId: run.runId, status: run.status } };
|
|
394
445
|
},
|
|
395
446
|
});
|
|
@@ -404,7 +455,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
404
455
|
const e = entry as { type?: string; customType?: string; data?: unknown };
|
|
405
456
|
if (e.type !== "custom" || e.customType !== RUN_ENTRY || !isRecord(e.data)) continue;
|
|
406
457
|
const run = e.data as unknown as SwarmRun;
|
|
407
|
-
if (typeof run.runId === "string" && run.status
|
|
458
|
+
if (typeof run.runId === "string" && run.status !== "running") {
|
|
408
459
|
runs.set(run.runId, run);
|
|
409
460
|
const n = Number.parseInt(run.runId.slice(1), 10);
|
|
410
461
|
if (Number.isFinite(n) && n > runCounter) runCounter = n;
|
|
@@ -414,6 +465,10 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
414
465
|
});
|
|
415
466
|
|
|
416
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
|
+
}
|
|
417
472
|
if (ctx.hasUI) ctx.ui.setWidget("swarm", undefined);
|
|
418
473
|
});
|
|
419
474
|
|
package/package.json
CHANGED
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/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;
|