@pify/swarm 0.10.0 → 0.11.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 +7 -4
- package/extensions/swarm.ts +154 -28
- package/package.json +1 -1
- package/skills/swarm/SKILL.md +21 -7
- package/src/builtin.ts +12 -2
- package/src/frontmatter.ts +16 -5
- package/src/gate.ts +128 -39
- package/src/isolate.ts +67 -7
- package/src/pending.ts +34 -10
- package/src/repair-policy.ts +28 -0
- package/src/repair.ts +4 -4
- package/src/report.ts +6 -1
- package/src/wait.ts +48 -0
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ Blocking by default: returns `N succeeded, M failed` plus a per-item report.
|
|
|
33
33
|
|
|
34
34
|
### Gates and outcomes
|
|
35
35
|
|
|
36
|
-
A gate asks the shell, not a model. Each item's gate runs in the tree that item worked in — its own worktree under `isolation: "worktree"` — after the child finishes, so it judges what you would merge. A failing gate sends the child back once with the command, the verdict and the output, then re-runs; `gateRepairs: 0` turns that off. A read-only agent is never asked to repair.
|
|
36
|
+
A gate asks the shell, not a model. Each item's gate runs in the tree that item worked in — its own worktree under `isolation: "worktree"` — after the child finishes, so it judges what you would merge. It runs asynchronously: pi keeps rendering, Esc still lands, and the other children's streams are still read while a two-minute suite runs; at the deadline the whole process tree is killed, not just the shell that started it, so a timed-out suite does not run on holding the pipes open. A failing gate sends the child back once with the command, the verdict and the output, then re-runs; `gateRepairs: 0` turns that off. That repair brief is the whole prompt the child gets — it quotes the original task under `== Original task ==` and says to fix the cause and stop, not to do the task again — and the child rejoins the run's mailbox. A read-only agent is never asked to repair, and neither is a child that ended its report with `OUTCOME: blocked`: the wall it named is outside its reach, and a failing gate does not move it. The gate still runs once so the report says what it proved. While a repair is in flight the item shows as running, not finished.
|
|
37
37
|
|
|
38
38
|
The verdict can say more than pass/fail: `success`, `failure`, `result_missing` (exited 0 but never showed the evidence `gateExpect` asked for — a runner that matched no tests), `timeout`, or `no_attestation` (never ran at all — a typo, a missing runner; not a verdict on the work, and never repaired). If other items were changing the same directory while a gate ran, the report says the verdict is true of the tree, not of that item alone — which is what `isolation` is for.
|
|
39
39
|
|
|
@@ -66,8 +66,9 @@ The whole graph is checked **before anything spawns**: a cycle, a self-edge, a d
|
|
|
66
66
|
| Parameter | Type | Notes |
|
|
67
67
|
|---|---|---|
|
|
68
68
|
| `runId` | string, optional | Defaults to the most recent run |
|
|
69
|
+
| `wait` | number, optional | Seconds to hold the call for the run to finish, 0–120 (default 0) |
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
A "not ready" answer while the run is in flight (per-item progress is on the widget and in `/swarm`), and the full report once the run finishes. Completed runs survive `/reload`. `wait` is for the headless case (`pi -p`), where nothing is delivered after the turn ends: one call that waits returns the report in one turn instead of several; the wait ends early on Esc.
|
|
71
72
|
|
|
72
73
|
### `swarm_post` / `swarm_inbox`
|
|
73
74
|
|
|
@@ -98,17 +99,19 @@ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.
|
|
|
98
99
|
## Behaviour
|
|
99
100
|
|
|
100
101
|
- **Independence by design.** Items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns`.
|
|
101
|
-
- **Stopping stops the children.** Pressing Esc,
|
|
102
|
+
- **Stopping stops the children.** Pressing Esc stops a foreground run, `/swarm stop [runId]` stops a background one (its tool call returned long ago, so Esc has nothing to reach), and switching away from the session stops both — in every case every live child is aborted rather than left talking to the provider on your money. A cancelled run keeps that verdict — it is never reported as done — and `swarm_status` shows what the items that did finish produced, with each stopped item saying who stopped it.
|
|
102
103
|
- **Isolated runs clean up after themselves.** With `isolation: "worktree"`, a worktree whose child changed nothing is removed along with its branch; otherwise a read-only step left one of each behind on every run. Anything uncommitted, and any commit the child made, is kept and reported.
|
|
103
104
|
|
|
104
105
|
## A background run comes back to you
|
|
105
106
|
|
|
106
|
-
`swarm_status` on a run still in flight used to say "still running", which left the model one option: ask again. The aggregated report is **delivered** into the conversation when the run finishes — measured, not assumed: `test/live/delivery-wire.mjs` drives a real background swarm through pi, holds the session open the way an interactive one naturally stays open, and reads the report out of pi's own provider payload (3/3; the run finished and the report arrived unasked). One caveat the measurement taught: delivery is a property of sessions that outlive their runs — interactive sessions do, `pi -p` does not. Asking early returns a structured result carrying `retryable`, the elapsed time and `pollRequired
|
|
107
|
+
`swarm_status` on a run still in flight used to say "still running", which left the model one option: ask again. The aggregated report is **delivered** into the conversation when the run finishes — measured, not assumed: `test/live/delivery-wire.mjs` drives a real background swarm through pi, holds the session open the way an interactive one naturally stays open, and reads the report out of pi's own provider payload (3/3; the run finished and the report arrived unasked). One caveat the measurement taught: delivery is a property of sessions that outlive their runs — interactive sessions do, `pi -p` does not. Asking early returns a structured result carrying `retryable`, the elapsed time and `pollRequired` (`false` interactively; `true` under `pi -p`, where the text tells the model to collect within the turn or use `wait`) — a normal answer rather than an error, because a tool error over a condition only time resolves invites the model's retry machinery into a loop.
|
|
107
108
|
|
|
108
109
|
## Command
|
|
109
110
|
|
|
110
111
|
`/swarm` — runs in this session, and the agent types available for routing.
|
|
111
112
|
|
|
113
|
+
`/swarm stop [runId]` — cancel a live run (default: the active one) and abort its children. The way to stop a background run; a foreground run stops on Esc.
|
|
114
|
+
|
|
112
115
|
## Where this sits in the suite
|
|
113
116
|
|
|
114
117
|
[`@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.
|
package/extensions/swarm.ts
CHANGED
|
@@ -52,9 +52,18 @@ import {
|
|
|
52
52
|
} from "../src/mailbox.ts";
|
|
53
53
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
54
54
|
import { buildReport, buildStatusLine, earlyFailureNotice } from "../src/report.ts";
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
normalizeGate,
|
|
57
|
+
runGate,
|
|
58
|
+
sharedWith,
|
|
59
|
+
type GateContract,
|
|
60
|
+
type GateSibling,
|
|
61
|
+
type GateVerdict,
|
|
62
|
+
} from "../src/gate.ts";
|
|
56
63
|
import { runGateCycle } from "../src/repair.ts";
|
|
64
|
+
import { repairAllowed } from "../src/repair-policy.ts";
|
|
57
65
|
import { deriveOutcome, parseDeclaredOutcome, stripDeclaration } from "../src/outcome.ts";
|
|
66
|
+
import { waitUntil } from "../src/wait.ts";
|
|
58
67
|
import { routeItem } from "../src/routing.ts";
|
|
59
68
|
import { normalizeItems } from "../src/graph.ts";
|
|
60
69
|
import { runGraph } from "../src/schedule.ts";
|
|
@@ -72,6 +81,8 @@ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "no
|
|
|
72
81
|
import { basename, join } from "node:path";
|
|
73
82
|
|
|
74
83
|
const RUN_ENTRY = "swarm-run";
|
|
84
|
+
/** Longest a swarm_status call may hold on to a running run, in seconds. */
|
|
85
|
+
const MAX_STATUS_WAIT_S = 120;
|
|
75
86
|
const CLEAN_WORKTREE_NOTE =
|
|
76
87
|
"Ran isolated in a temporary worktree; it changed nothing, so the worktree was removed.";
|
|
77
88
|
|
|
@@ -196,6 +207,12 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
196
207
|
workDir?: string,
|
|
197
208
|
mailbox?: string,
|
|
198
209
|
): Promise<void> {
|
|
210
|
+
// A stop can land before this item has a session to abort — the scheduler
|
|
211
|
+
// launched it, the loader is still reloading — and cancelRun has already
|
|
212
|
+
// written its record. Starting anyway would overwrite that with "running"
|
|
213
|
+
// and leave a child no stop can reach.
|
|
214
|
+
const cancelled = (): boolean => runs.get(runId)?.status === "cancelled";
|
|
215
|
+
if (cancelled()) return;
|
|
199
216
|
item.status = "running";
|
|
200
217
|
renderWidget();
|
|
201
218
|
let session: AgentSession | null = null;
|
|
@@ -233,7 +250,17 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
233
250
|
appendSystemPrompt: [
|
|
234
251
|
...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
|
|
235
252
|
def.systemPrompt,
|
|
236
|
-
|
|
253
|
+
// The same contract subagent's children get, including the OUTCOME
|
|
254
|
+
// line: the report tallies `blocked`, and skip-on-upstream-failure
|
|
255
|
+
// keys off the outcome, so a child that is never told the protocol
|
|
256
|
+
// can never be anything but succeeded or failed.
|
|
257
|
+
"You are one agent in a swarm, handling exactly one item. Your final assistant message is the deliverable — " +
|
|
258
|
+
"make it complete and self-contained; the swarm cannot reply to it. Close by stating each requirement of your " +
|
|
259
|
+
"item and the concrete evidence it is met (the command you ran and what it showed); mark anything you could " +
|
|
260
|
+
"not verify as unverified rather than done. Finishing your turn is not the same as finishing the item: if you " +
|
|
261
|
+
"could not do it, end the report with a line reading exactly `OUTCOME: blocked` (a decision, access or " +
|
|
262
|
+
"information you do not have) or `OUTCOME: failed` (you tried and it does not work), so the swarm does not " +
|
|
263
|
+
"have to infer it from your prose. Say nothing if it went fine.",
|
|
237
264
|
...(mailbox ? [mailboxPrompt(item.agent + "-" + item.index)] : []),
|
|
238
265
|
],
|
|
239
266
|
});
|
|
@@ -251,6 +278,9 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
251
278
|
resourceLoader: loader,
|
|
252
279
|
});
|
|
253
280
|
session = created.session;
|
|
281
|
+
// Same window, other side: a stop during session creation found nothing
|
|
282
|
+
// registered. Do not prompt a child of a run that is already over.
|
|
283
|
+
if (cancelled()) return;
|
|
254
284
|
releaseLive = live.register(runId, session);
|
|
255
285
|
|
|
256
286
|
const guard = new LoopGuard();
|
|
@@ -349,15 +379,6 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
349
379
|
}
|
|
350
380
|
}
|
|
351
381
|
|
|
352
|
-
/**
|
|
353
|
-
* Readiness scheduler: run each item as soon as its `needs` are done, up to
|
|
354
|
-
* DEFAULT_CONCURRENCY at once. A flat run (no needs anywhere) makes every
|
|
355
|
-
* item ready immediately, so this is identical to the old parallel pool.
|
|
356
|
-
*/
|
|
357
|
-
/** An agent that can write is one that can fix what a gate complained about. */
|
|
358
|
-
const canWrite = (def: AgentDef) =>
|
|
359
|
-
def.tools.some((t) => t === "edit" || t === "write" || t === "bash" || t === "powershell");
|
|
360
|
-
|
|
361
382
|
/**
|
|
362
383
|
* Settle the two facts the status alone cannot give: what the item's task came
|
|
363
384
|
* to, and how well that is known. Settled once — re-running it after the
|
|
@@ -393,29 +414,70 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
393
414
|
def: AgentDef,
|
|
394
415
|
opts: RunOptions,
|
|
395
416
|
): Promise<void> {
|
|
396
|
-
|
|
417
|
+
// A cancelled run has nothing left to prove; a gate is a test suite, and
|
|
418
|
+
// spending one on work nobody is waiting for is the stop not stopping.
|
|
419
|
+
if (!opts.gate || item.status !== "done" || run.status === "cancelled") return;
|
|
397
420
|
const subject = item.workDir ?? ctx.cwd;
|
|
398
|
-
|
|
421
|
+
// sharedWith() reads an undefined workDir as "the subject's directory", so
|
|
422
|
+
// an item that ran in place — no worktree of its own — would count as
|
|
423
|
+
// sharing every isolated sibling's worktree, and every isolated item's
|
|
424
|
+
// pass came out attributed to the tree. Name the directory each item was
|
|
425
|
+
// actually in.
|
|
426
|
+
const dirOf = (i: ItemState): string => i.workDir ?? ctx.cwd;
|
|
427
|
+
const self: GateSibling = { id: item.index, label: item.id, status: item.status, workDir: dirOf(item) };
|
|
399
428
|
const siblings: GateSibling[] = run.items
|
|
400
429
|
.filter((i) => i.index !== item.index)
|
|
401
|
-
.map((i) => ({ id: i.index, label: i.id, status: i.status, workDir: i
|
|
430
|
+
.map((i) => ({ id: i.index, label: i.id, status: i.status, workDir: dirOf(i) }));
|
|
431
|
+
// Read the child's declaration now, while it is still in the result:
|
|
432
|
+
// settleItem strips it, and a blocked child is not sent to fix a gate.
|
|
433
|
+
const canRepair = repairAllowed(def, item.result);
|
|
402
434
|
try {
|
|
435
|
+
// The gate that actually ran, whatever the cycle reports. After a repair
|
|
436
|
+
// the cycle re-runs the gate; on a cancelled run that is up to the whole
|
|
437
|
+
// deadline spent proving nothing anyone will read — but a check that
|
|
438
|
+
// DID run and failed before the stop landed is a real verdict, and
|
|
439
|
+
// answering no_attestation for it would file a failed gate as
|
|
440
|
+
// "proved nothing", which is the case the outcome model exists to stop.
|
|
441
|
+
let last: (GateVerdict & { output: string }) | null = null;
|
|
442
|
+
let repaired = false;
|
|
403
443
|
const { record, verification } = await runGateCycle(item.item, opts.gate, subject, {
|
|
404
|
-
runGate,
|
|
405
|
-
|
|
444
|
+
runGate: async (contract, cwd) => {
|
|
445
|
+
if (run.status === "cancelled" && last) return last;
|
|
446
|
+
last = await runGate(contract, cwd);
|
|
447
|
+
return last;
|
|
448
|
+
},
|
|
449
|
+
canRepair,
|
|
406
450
|
maxAttempts: opts.gateRepairs ?? 1,
|
|
407
451
|
sharedWith: sharedWith(self, subject, siblings),
|
|
408
452
|
repair: async (prompt) => {
|
|
409
|
-
|
|
453
|
+
if (run.status === "cancelled") return;
|
|
454
|
+
repaired = true;
|
|
455
|
+
// The brief IS the item. Handed over as `context` it arrived as "fix
|
|
456
|
+
// exactly this" followed by the whole original task under `Your
|
|
457
|
+
// item:`, which reads as an invitation to do the task again. The
|
|
458
|
+
// brief already quotes the task; nothing else is sent.
|
|
459
|
+
//
|
|
460
|
+
// The repair is the same child type over the same tree, in the same
|
|
461
|
+
// mailbox — the siblings' facts still apply — and its report
|
|
410
462
|
// replaces the stale one, which described a tree that has changed.
|
|
411
|
-
const fix: ItemState = { ...item, result: null, error: null, status: "queued", turns: 0 };
|
|
412
|
-
|
|
463
|
+
const fix: ItemState = { ...item, item: prompt, result: null, error: null, status: "queued", turns: 0, tokens: 0 };
|
|
464
|
+
// The widget draws `item`, not `fix`; without this the row sat as a
|
|
465
|
+
// finished ✓ for the whole repair.
|
|
466
|
+
item.status = "running";
|
|
467
|
+
renderWidget();
|
|
468
|
+
await runItem(ctx, run.runId, def, fix, "", item.workDir, opts.mailbox);
|
|
413
469
|
item.turns += fix.turns;
|
|
414
470
|
item.tokens += fix.tokens;
|
|
471
|
+
// cancelRun may have marked the item aborted meanwhile; that verdict
|
|
472
|
+
// and its note stand, and the stale result is not swapped under it.
|
|
473
|
+
if (item.status !== "running") return;
|
|
474
|
+
item.status = "done";
|
|
415
475
|
if (fix.status === "done" && fix.result?.trim()) item.result = fix.result;
|
|
416
476
|
},
|
|
417
477
|
});
|
|
418
|
-
|
|
478
|
+
// The cycle counts a repair pass it asked for; one the stop refused is
|
|
479
|
+
// not a repair, and the record must not say the tree was fixed.
|
|
480
|
+
item.gate = repaired ? record : { ...record, repairs: undefined };
|
|
419
481
|
item.verification = verification;
|
|
420
482
|
} catch (err) {
|
|
421
483
|
// A gate that throws proved nothing; say so rather than losing the
|
|
@@ -437,6 +499,8 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
437
499
|
useMailbox?: boolean;
|
|
438
500
|
gate?: GateContract;
|
|
439
501
|
gateRepairs?: number;
|
|
502
|
+
/** The run's mailbox dir once executeRun has made one, so a repair child joins the same log. */
|
|
503
|
+
mailbox?: string;
|
|
440
504
|
onUpstreamFailure?: UpstreamFailurePolicy;
|
|
441
505
|
/** Called once, for the first item that settles badly while others run. */
|
|
442
506
|
onEarlyFailure?: (item: ItemState) => void;
|
|
@@ -450,6 +514,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
450
514
|
const mailbox = useMailbox
|
|
451
515
|
? mailboxDir(getAgentDir(), mailboxKey(run.runId, run.startedAt))
|
|
452
516
|
: undefined;
|
|
517
|
+
const gateOpts: RunOptions = { ...opts, mailbox };
|
|
453
518
|
|
|
454
519
|
// A dependent structurally receives each upstream's output — the thing a
|
|
455
520
|
// hand-sequenced coordinator forgets. Prepended to the shared preamble.
|
|
@@ -501,7 +566,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
501
566
|
const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
|
|
502
567
|
item.workDir = iso.path;
|
|
503
568
|
await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
|
|
504
|
-
await gateItem(ctx, run, item, def,
|
|
569
|
+
await gateItem(ctx, run, item, def, gateOpts);
|
|
505
570
|
// Remove the worktree when the item changed nothing (the leak
|
|
506
571
|
// removeIfUnchanged fixes); keep it when there is work to merge.
|
|
507
572
|
const removed = removeIfUnchanged(ctx.cwd, iso);
|
|
@@ -514,7 +579,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
514
579
|
}
|
|
515
580
|
} else {
|
|
516
581
|
await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
|
|
517
|
-
await gateItem(ctx, run, item, def,
|
|
582
|
+
await gateItem(ctx, run, item, def, gateOpts);
|
|
518
583
|
}
|
|
519
584
|
settle(item);
|
|
520
585
|
},
|
|
@@ -745,6 +810,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
745
810
|
};
|
|
746
811
|
void executeRun(uiCtx, run, options)
|
|
747
812
|
.then(() => {
|
|
813
|
+
// A run the user just stopped is not one that "finished", and the
|
|
814
|
+
// model is not woken to fold in a report of work that was
|
|
815
|
+
// cancelled out from under it. The stop already said what it
|
|
816
|
+
// stopped; what the items produced is in swarm_status.
|
|
817
|
+
if (run.status === "cancelled") return;
|
|
748
818
|
notify(uiCtx, `swarm ${run.runId} finished`, "info");
|
|
749
819
|
// The report goes to the agent, not only to the screen — otherwise
|
|
750
820
|
// asking again was its only way to find out.
|
|
@@ -768,7 +838,14 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
768
838
|
});
|
|
769
839
|
return {
|
|
770
840
|
content: [
|
|
771
|
-
{
|
|
841
|
+
{
|
|
842
|
+
type: "text",
|
|
843
|
+
text:
|
|
844
|
+
`Swarm ${run.runId} started (${run.items.length} items) in the background. Its report is delivered to you ` +
|
|
845
|
+
`when it finishes, and the first hard failure interrupts you early — do not poll. ` +
|
|
846
|
+
`swarm_status runId="${run.runId}" shows progress if you need it early. ` +
|
|
847
|
+
`The user can stop it with /swarm stop ${run.runId} (Esc does not reach a background run).`,
|
|
848
|
+
},
|
|
772
849
|
],
|
|
773
850
|
details: { runId: run.runId },
|
|
774
851
|
};
|
|
@@ -790,22 +867,47 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
790
867
|
name: "swarm_status",
|
|
791
868
|
label: "Swarm status",
|
|
792
869
|
promptSnippet: "Progress of a running swarm",
|
|
793
|
-
description:
|
|
870
|
+
description:
|
|
871
|
+
"Progress of a swarm run (default: the latest). Returns the full report when finished. " +
|
|
872
|
+
"wait=N (seconds, up to 120) holds this call until the run finishes or N seconds pass, so a headless " +
|
|
873
|
+
"session can collect the report in one call instead of asking repeatedly.",
|
|
794
874
|
parameters: Type.Object({
|
|
795
875
|
runId: Type.Optional(Type.String()),
|
|
876
|
+
wait: Type.Optional(
|
|
877
|
+
Type.Number({
|
|
878
|
+
description: "Seconds to wait for the run to finish before answering, 0-120 (default 0)",
|
|
879
|
+
minimum: 0,
|
|
880
|
+
maximum: MAX_STATUS_WAIT_S,
|
|
881
|
+
}),
|
|
882
|
+
),
|
|
796
883
|
}),
|
|
797
|
-
async execute(_id, params: { runId?: string }) {
|
|
884
|
+
async execute(_id, params: { runId?: string; wait?: number }, signal, _onUpdate, ctx) {
|
|
798
885
|
const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
|
|
799
886
|
if (!run) throw new Error("No swarm runs this session.");
|
|
887
|
+
// Bounded and abortable: the wait is the caller's turn, and Esc must end
|
|
888
|
+
// it the way it ends anything else the tool call is doing.
|
|
889
|
+
const waitMs = Math.max(0, Math.min(MAX_STATUS_WAIT_S, params.wait ?? 0)) * 1000;
|
|
890
|
+
if (run.status === "running" && waitMs > 0) {
|
|
891
|
+
await waitUntil(() => run.status !== "running", waitMs, 250, signal);
|
|
892
|
+
}
|
|
800
893
|
if (run.status === "running") {
|
|
894
|
+
const interactive = (ctx as { hasUI?: boolean }).hasUI !== false;
|
|
801
895
|
const pending = pendingResult({
|
|
802
896
|
id: run.runId,
|
|
803
897
|
kind: "running",
|
|
804
898
|
startedAt: run.startedAt,
|
|
805
899
|
now: Date.now(),
|
|
806
900
|
collectWith: "swarm_status",
|
|
901
|
+
// A headless `pi -p` run ends with this turn: "it will be delivered"
|
|
902
|
+
// is a promise nothing can keep there, so the text says to collect.
|
|
903
|
+
interactive,
|
|
807
904
|
});
|
|
808
|
-
|
|
905
|
+
// Headless is where repeated calls actually happen; one call that
|
|
906
|
+
// waits is the same answer for one turn instead of several.
|
|
907
|
+
const text = interactive
|
|
908
|
+
? pending.text
|
|
909
|
+
: `${pending.text}\nPass wait=${MAX_STATUS_WAIT_S} (seconds) to swarm_status to hold one call until it finishes instead of asking again.`;
|
|
910
|
+
return { content: [{ type: "text", text }], details: pending.details as never };
|
|
809
911
|
}
|
|
810
912
|
const text =
|
|
811
913
|
run.status === "cancelled"
|
|
@@ -890,9 +992,33 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
890
992
|
});
|
|
891
993
|
|
|
892
994
|
pi.registerCommand("swarm", {
|
|
893
|
-
description: "Show swarm runs and
|
|
894
|
-
handler: async (
|
|
995
|
+
description: "Show swarm runs and agent types; /swarm stop [runId] cancels a live run",
|
|
996
|
+
handler: async (args, ctx) => {
|
|
895
997
|
if (!ctx.hasUI) return;
|
|
998
|
+
const words = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
999
|
+
if (words[0] === "stop") {
|
|
1000
|
+
// Esc reaches a foreground run through its tool call's signal; a
|
|
1001
|
+
// background run's tool call returned long ago, so until this command
|
|
1002
|
+
// nothing the user could do reached its children.
|
|
1003
|
+
const wanted = words[1];
|
|
1004
|
+
const run = wanted ? runs.get(wanted) : activeRun;
|
|
1005
|
+
if (!run) {
|
|
1006
|
+
ctx.ui.notify(wanted ? `No swarm run ${wanted}.` : "No swarm run to stop.", "warning");
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (run.status !== "running") {
|
|
1010
|
+
ctx.ui.notify(`swarm ${run.runId} is not running (${run.status}).`, "warning");
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const before = live.count(run.runId);
|
|
1014
|
+
cancelRun(run, "user-abort");
|
|
1015
|
+
ctx.ui.notify(
|
|
1016
|
+
`swarm ${run.runId} stopped — ${before === 0 ? "no child agents were running" : `${before} child agent${before === 1 ? "" : "s"} stopped`}.`,
|
|
1017
|
+
"info",
|
|
1018
|
+
);
|
|
1019
|
+
renderWidget(ctx);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
896
1022
|
const routed = [...defs.values()]
|
|
897
1023
|
.map((d) => {
|
|
898
1024
|
const rules = [
|
package/package.json
CHANGED
package/skills/swarm/SKILL.md
CHANGED
|
@@ -6,8 +6,10 @@ description: Use when work splits into several independent items that can run in
|
|
|
6
6
|
# Swarm
|
|
7
7
|
|
|
8
8
|
This project has the `@pify/swarm` extension installed: `swarm_run` fans a
|
|
9
|
-
list of
|
|
10
|
-
|
|
9
|
+
list of items out to parallel child agents (concurrency 4) and returns one
|
|
10
|
+
aggregated report. A background run's report is delivered to you when it
|
|
11
|
+
finishes, and the first hard failure interrupts you early — do not poll;
|
|
12
|
+
`swarm_status` shows progress if you need it before then.
|
|
11
13
|
|
|
12
14
|
## When to fan out
|
|
13
15
|
|
|
@@ -15,9 +17,11 @@ returns one aggregated report; `swarm_status` polls background runs.
|
|
|
15
17
|
- The same question asked across many places ("check each package for X").
|
|
16
18
|
- Parallel research where items do not depend on each other.
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
Items that depend on each other's results are fine: write them as
|
|
21
|
+
`{task, id, needs: [ids]}` and each dependent receives its needs' output
|
|
22
|
+
automatically, starting only once they finish. Set
|
|
23
|
+
`on_upstream_failure: "skip"` when a dependent is pointless without its
|
|
24
|
+
input. For a single task use agent_run from @pify/subagent instead.
|
|
21
25
|
|
|
22
26
|
## Slicing items
|
|
23
27
|
|
|
@@ -34,7 +38,17 @@ item) then `match_keywords`, falling back to the read-only scout. Force one
|
|
|
34
38
|
type with `agent` when the routing does not fit. Mutating items must
|
|
35
39
|
explicitly target `worker` — the fallback never mutates.
|
|
36
40
|
|
|
41
|
+
## Verifying
|
|
42
|
+
|
|
43
|
+
Give `gate` a command every item must pass (`bun test`, `tsc --noEmit`); it
|
|
44
|
+
runs in each item's own working directory after the child finishes, a
|
|
45
|
+
failure sends the child back once to fix it, and the report says what the
|
|
46
|
+
check proved. Each item reports an outcome (succeeded / blocked / failed)
|
|
47
|
+
separately from whether its child finished; the header counts outcomes.
|
|
48
|
+
|
|
37
49
|
## Collecting
|
|
38
50
|
|
|
39
|
-
Blocking runs return the report directly.
|
|
40
|
-
|
|
51
|
+
Blocking runs return the report directly. A `background: true` run delivers
|
|
52
|
+
its report when it finishes — carry on with other work or end your turn; do
|
|
53
|
+
not call `swarm_status` in a loop. In a headless run (no UI) nothing can be
|
|
54
|
+
delivered after your turn ends, so collect with `swarm_status` within it.
|
package/src/builtin.ts
CHANGED
|
@@ -16,8 +16,18 @@ You are a disciplined review subagent. Inspect, evaluate, and report findings
|
|
|
16
16
|
with evidence — never guess; verify from the code itself. You cannot modify
|
|
17
17
|
anything: your deliverable is the report.
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
Flag only defects you can prove: a concrete, introduced problem with a real
|
|
20
|
+
impact you can name — a bug, a broken contract, data loss, a security hole. Do
|
|
21
|
+
not report style, taste, or hypotheticals; "could theoretically" is not a
|
|
22
|
+
finding. Weight the review on:
|
|
23
|
+
- correctness: logic errors, wrong edge cases, broken or unhandled contracts;
|
|
24
|
+
- untrusted input reaching a dangerous sink: SQL/command injection, path
|
|
25
|
+
traversal, SSRF, open redirect, unsafe deserialization, missing authz;
|
|
26
|
+
- clean code, but only where it bites: needless duplication, dead code, an
|
|
27
|
+
abstraction that hides a real bug — never mere preference.
|
|
28
|
+
|
|
29
|
+
For each finding give: file:line, what is wrong, why it matters (the concrete
|
|
30
|
+
impact), and a concrete fix. Rank by severity. If the code is sound, say so
|
|
21
31
|
plainly — do not invent issues. End with a one-paragraph verdict.`,
|
|
22
32
|
|
|
23
33
|
scout: `---
|
package/src/frontmatter.ts
CHANGED
|
@@ -33,11 +33,13 @@ export function parseAgentFile(
|
|
|
33
33
|
|
|
34
34
|
const thinkingRaw = fields.get("thinking")?.toLowerCase();
|
|
35
35
|
const maxTurnsRaw = Number.parseInt(fields.get("max_turns") ?? "", 10);
|
|
36
|
+
const tools = parseTools(fields.get("tools"));
|
|
37
|
+
if (tools === null) return null;
|
|
36
38
|
|
|
37
39
|
return {
|
|
38
40
|
name: name.toLowerCase(),
|
|
39
41
|
description,
|
|
40
|
-
tools
|
|
42
|
+
tools,
|
|
41
43
|
model: fields.get("model") || null,
|
|
42
44
|
thinking: (THINKING_LEVELS as readonly string[]).includes(thinkingRaw ?? "")
|
|
43
45
|
? (thinkingRaw as ThinkingLevelName)
|
|
@@ -64,11 +66,20 @@ function parseList(raw: string | undefined): string[] {
|
|
|
64
66
|
.filter(Boolean);
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Read-only default keeps a def missing `tools:` from mutating anything.
|
|
71
|
+
* A `tools:` line where NOTHING resolves is different: the author asked for
|
|
72
|
+
* a specific tool set and got the read-only default instead, so the agent
|
|
73
|
+
* runs with a contract nobody wrote. That is rejected — the file is dropped,
|
|
74
|
+
* rather than quietly running as something else. (Same rule as @pify/subagent.)
|
|
75
|
+
*/
|
|
76
|
+
function parseTools(raw: string | undefined): ValidTool[] | null {
|
|
68
77
|
if (!raw) return ["read", "grep", "find", "ls"];
|
|
69
|
-
const
|
|
78
|
+
const requested = raw
|
|
70
79
|
.split(",")
|
|
71
80
|
.map((t) => t.trim().toLowerCase())
|
|
72
|
-
.filter(
|
|
73
|
-
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
if (requested.length === 0) return ["read", "grep", "find", "ls"];
|
|
83
|
+
const valid = requested.filter((t): t is ValidTool => (VALID_TOOLS as readonly string[]).includes(t));
|
|
84
|
+
return valid.length > 0 ? valid : null;
|
|
74
85
|
}
|
package/src/gate.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* about having proved nothing either way.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
25
25
|
|
|
26
26
|
export type GateOutcome =
|
|
27
27
|
| "success"
|
|
@@ -202,61 +202,150 @@ export function contractProblems(contract: GateContract): string[] {
|
|
|
202
202
|
/** How long a gate may run before the deadline is its verdict. */
|
|
203
203
|
export const GATE_TIMEOUT_MS = 120_000;
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Output kept from a gate. A real suite prints books; the verdict is at the
|
|
207
|
+
* end, so it is the tail that is kept when a gate prints past this.
|
|
208
|
+
*/
|
|
209
|
+
const GATE_MAX_OUTPUT = 16 * 1024 * 1024;
|
|
210
|
+
|
|
205
211
|
/**
|
|
206
212
|
* Run a gate and judge it against its contract, with the shell in the subject
|
|
207
213
|
* working directory. A bare string keeps the exit-code meaning; a contract can
|
|
208
214
|
* also say what success has to look like, which is what stops a command that
|
|
209
215
|
* never ran the check from passing.
|
|
210
216
|
*
|
|
217
|
+
* Asynchronous, and that is load-bearing. A gate is a test suite or a build,
|
|
218
|
+
* and the first version ran it with spawnSync — which held pi's whole event
|
|
219
|
+
* loop for the duration: nothing rendered, Esc could not be delivered, and
|
|
220
|
+
* every other child's provider stream sat unread until the gate returned,
|
|
221
|
+
* up to the full deadline. The deadline is enforced here (the shell is
|
|
222
|
+
* killed on timeout) and output is capped rather than erroring, so a
|
|
223
|
+
* chatty-but-passing gate still passes.
|
|
224
|
+
*
|
|
211
225
|
* The command comes from the caller — the same trust level as the bash tool in
|
|
212
226
|
* this session — so this adds no capability the caller did not already have.
|
|
213
227
|
*/
|
|
214
|
-
export function runGate(gate: string | GateContract, cwd: string): GateVerdict & { output: string } {
|
|
228
|
+
export function runGate(gate: string | GateContract, cwd: string): Promise<GateVerdict & { output: string }> {
|
|
215
229
|
const contract = normalizeGate(gate);
|
|
216
230
|
const problems = contractProblems(contract);
|
|
217
231
|
if (problems.length > 0) {
|
|
218
232
|
// A gate that cannot be run is not a verdict on the work. Calling this a
|
|
219
233
|
// failure would report a typo in the gate as a defect in the code.
|
|
220
|
-
return { outcome: "no_attestation", ok: false, reason: problems.join("; "), output: "" };
|
|
234
|
+
return Promise.resolve({ outcome: "no_attestation", ok: false, reason: problems.join("; "), output: "" });
|
|
221
235
|
}
|
|
222
236
|
const timeoutMs = contract.timeoutMs ?? GATE_TIMEOUT_MS;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
let child: ChildProcess;
|
|
240
|
+
try {
|
|
241
|
+
child = spawn(contract.command, {
|
|
242
|
+
shell: true,
|
|
243
|
+
cwd,
|
|
244
|
+
windowsHide: true,
|
|
245
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
246
|
+
// POSIX: lead a process group, so a timeout can kill the whole tree
|
|
247
|
+
// and not just the shell that started it.
|
|
248
|
+
detached: process.platform !== "win32",
|
|
249
|
+
});
|
|
250
|
+
} catch (err) {
|
|
251
|
+
resolve({
|
|
252
|
+
outcome: "no_attestation",
|
|
253
|
+
ok: false,
|
|
254
|
+
reason: `gate could not be started: ${err instanceof Error ? err.message : String(err)}`,
|
|
255
|
+
output: "",
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// stdout and stderr in arrival order — how a person would have read the
|
|
261
|
+
// terminal — trimmed from the front once past the cap.
|
|
262
|
+
const chunks: Buffer[] = [];
|
|
263
|
+
let size = 0;
|
|
264
|
+
const keep = (chunk: Buffer): void => {
|
|
265
|
+
chunks.push(chunk);
|
|
266
|
+
size += chunk.length;
|
|
267
|
+
while (size > GATE_MAX_OUTPUT && chunks.length > 1) size -= chunks.shift()!.length;
|
|
268
|
+
};
|
|
269
|
+
child.stdout?.on("data", keep);
|
|
270
|
+
child.stderr?.on("data", keep);
|
|
271
|
+
|
|
272
|
+
let timedOut = false;
|
|
273
|
+
const timer = setTimeout(() => {
|
|
274
|
+
timedOut = true;
|
|
275
|
+
killTree(child);
|
|
276
|
+
}, timeoutMs);
|
|
277
|
+
|
|
278
|
+
let settled = false;
|
|
279
|
+
let exited: { status: number | null; signal: string | null } | null = null;
|
|
280
|
+
const finish = (spawnError?: string): void => {
|
|
281
|
+
if (settled) return;
|
|
282
|
+
settled = true;
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
const output = Buffer.concat(chunks).toString("utf8").trim();
|
|
285
|
+
resolve({
|
|
286
|
+
...evaluateGate(
|
|
287
|
+
{ ...contract, timeoutMs },
|
|
288
|
+
{ status: exited?.status ?? null, signal: exited?.signal ?? null, output, timedOut, spawnError },
|
|
289
|
+
),
|
|
242
290
|
output,
|
|
243
|
-
|
|
244
|
-
// spawnSync reports a failure to start in `error` rather than by
|
|
245
|
-
// throwing, and a timeout arrives the same way — so the timeout has to
|
|
246
|
-
// be ruled out first or every deadline would read as "never ran".
|
|
247
|
-
spawnError:
|
|
248
|
-
result.error && !result.error.message?.includes("ETIMEDOUT")
|
|
249
|
-
? result.error.message
|
|
250
|
-
: undefined,
|
|
251
|
-
},
|
|
252
|
-
);
|
|
253
|
-
return { ...verdict, output };
|
|
254
|
-
} catch (err) {
|
|
255
|
-
return {
|
|
256
|
-
outcome: "no_attestation",
|
|
257
|
-
ok: false,
|
|
258
|
-
reason: `gate could not be started: ${err instanceof Error ? err.message : String(err)}`,
|
|
259
|
-
output: "",
|
|
291
|
+
});
|
|
260
292
|
};
|
|
293
|
+
// A failure to start arrives as an event, not a throw. After a timeout
|
|
294
|
+
// kill, an error is the kill's doing, not a spawn failure.
|
|
295
|
+
child.on("error", (err) => {
|
|
296
|
+
exited ??= { status: null, signal: null };
|
|
297
|
+
finish(timedOut ? undefined : err.message);
|
|
298
|
+
});
|
|
299
|
+
// `close` waits for the pipes, which a grandchild that outlived the shell
|
|
300
|
+
// can hold open indefinitely; `exit` is the shell's own verdict. Wait for
|
|
301
|
+
// the pipes briefly so a normal exit keeps all of its output, then finish
|
|
302
|
+
// regardless — a gate must never hang a run past its deadline.
|
|
303
|
+
child.on("close", (status, signal) => {
|
|
304
|
+
exited ??= { status, signal };
|
|
305
|
+
finish();
|
|
306
|
+
});
|
|
307
|
+
child.on("exit", (status, signal) => {
|
|
308
|
+
exited = { status, signal };
|
|
309
|
+
const grace = setTimeout(() => finish(), EXIT_DRAIN_MS);
|
|
310
|
+
grace.unref?.();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** How long to wait for a gate's pipes after its shell has exited. */
|
|
316
|
+
const EXIT_DRAIN_MS = 500;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Kill a gate's whole process tree. The shell alone dying leaves the test
|
|
320
|
+
* suite it started running — on Windows `child.kill()` reaches only cmd.exe,
|
|
321
|
+
* and on POSIX only the shell — so a deadline that merely killed the shell
|
|
322
|
+
* would report a timeout while the suite ran on, holding the pipes open.
|
|
323
|
+
*/
|
|
324
|
+
function killTree(child: ChildProcess): void {
|
|
325
|
+
const pid = child.pid;
|
|
326
|
+
if (pid === undefined) return;
|
|
327
|
+
if (process.platform === "win32") {
|
|
328
|
+
// By absolute path: PATH is the user's, and a gate has run under odd ones.
|
|
329
|
+
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
|
|
330
|
+
try {
|
|
331
|
+
spawn(taskkill, ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }).on("error", () => {});
|
|
332
|
+
} catch {
|
|
333
|
+
// taskkill unavailable: the plain kill below is all that is left
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
child.kill();
|
|
337
|
+
} catch {
|
|
338
|
+
// already gone
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
process.kill(-pid, "SIGKILL");
|
|
344
|
+
} catch {
|
|
345
|
+
try {
|
|
346
|
+
child.kill("SIGKILL");
|
|
347
|
+
} catch {
|
|
348
|
+
// already gone
|
|
349
|
+
}
|
|
261
350
|
}
|
|
262
351
|
}
|
package/src/isolate.ts
CHANGED
|
@@ -33,7 +33,12 @@ export function sanitizeSlug(raw: string): string {
|
|
|
33
33
|
return slug || "run";
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
/** Where isolation worktrees live by default: ~/.worktrees. */
|
|
37
|
+
export function defaultWorktreeRoot(): string {
|
|
38
|
+
return join(homedir(), ".worktrees");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createIsolationWorktree(cwd: string, rawSlug: string, root: string = defaultWorktreeRoot()): Isolation {
|
|
37
42
|
let toplevel: string;
|
|
38
43
|
try {
|
|
39
44
|
toplevel = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
@@ -44,11 +49,11 @@ export function createIsolationWorktree(cwd: string, rawSlug: string): Isolation
|
|
|
44
49
|
const slug = sanitizeSlug(rawSlug);
|
|
45
50
|
|
|
46
51
|
let branch = `agent/${slug}`;
|
|
47
|
-
let path = join(
|
|
52
|
+
let path = join(root, repo, slug);
|
|
48
53
|
let counter = 2;
|
|
49
54
|
while (existsSync(path) || branchExists(cwd, branch)) {
|
|
50
55
|
branch = `agent/${slug}-${counter}`;
|
|
51
|
-
path = join(
|
|
56
|
+
path = join(root, repo, `${slug}-${counter}`);
|
|
52
57
|
counter++;
|
|
53
58
|
if (counter > 50) throw new Error("Could not find a free worktree slot.");
|
|
54
59
|
}
|
|
@@ -71,15 +76,70 @@ function branchExists(cwd: string, branch: string): boolean {
|
|
|
71
76
|
}
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Note appended to a child's report when it ran isolated and left work
|
|
81
|
+
* behind. That work is UNCOMMITTED unless the child chose to commit — the
|
|
82
|
+
* builtin worker never does — and @pify/worktree's worktree_merge refuses a
|
|
83
|
+
* dirty tree, so the old note ("merge with worktree_merge") sent the model to
|
|
84
|
+
* a tool that would turn it away. Say what state the tree is in and what to
|
|
85
|
+
* do about it.
|
|
86
|
+
*/
|
|
75
87
|
export function isolationNote(isolation: Isolation): string {
|
|
88
|
+
const at = `git -C "${isolation.path}"`;
|
|
76
89
|
return [
|
|
77
|
-
`Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}).`,
|
|
78
|
-
`
|
|
79
|
-
`
|
|
90
|
+
`Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}); the main checkout is untouched.`,
|
|
91
|
+
`Its changes are in that worktree, uncommitted unless the child committed them. To bring them back:`,
|
|
92
|
+
` review: ${at} status && ${at} diff`,
|
|
93
|
+
` commit: ${at} add -A && ${at} commit -m "<what changed>"`,
|
|
94
|
+
` merge: @pify/worktree's worktree_merge branch="${isolation.branch}" (refuses an uncommitted tree)`,
|
|
95
|
+
`or discard it: git worktree remove --force "${isolation.path}".`,
|
|
80
96
|
].join("\n");
|
|
81
97
|
}
|
|
82
98
|
|
|
99
|
+
/** Note when an isolated run changed nothing and its worktree was removed. */
|
|
100
|
+
export const CLEAN_WORKTREE_NOTE =
|
|
101
|
+
"Ran isolated in a temporary worktree; it changed nothing, so the worktree was removed.";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The fields the isolation epilogue writes onto a call record. Kept structural
|
|
105
|
+
* so isolate.ts stays free of any dependency on the extension's types — the
|
|
106
|
+
* run's AgentCallState satisfies it by shape.
|
|
107
|
+
*/
|
|
108
|
+
export interface IsolationSink {
|
|
109
|
+
worktree?: string;
|
|
110
|
+
branch?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Close out an isolated child's worktree exactly once, whatever its outcome.
|
|
115
|
+
*
|
|
116
|
+
* This is the epilogue EVERY terminal path of a child call must reach —
|
|
117
|
+
* success, schema mismatch, gate failure, abort, or a thrown error. Before,
|
|
118
|
+
* only the prose success path ran it, so a `schema:` step (or any failure or
|
|
119
|
+
* abort) left its worktree and branch behind forever. It runs
|
|
120
|
+
* removeIfUnchanged — a read-only step's worktree is deleted, a step that did
|
|
121
|
+
* work is kept — and when the worktree is kept it records where the edits live
|
|
122
|
+
* on the call so a non-prose result (a schema object, a null from an error)
|
|
123
|
+
* can still name the location instead of orphaning it.
|
|
124
|
+
*
|
|
125
|
+
* Returns the human note for callers that render prose; callers on non-prose
|
|
126
|
+
* paths rely on the pointer written to `sink`. Never throws (removeIfUnchanged
|
|
127
|
+
* already swallows its own failures): the cleanup must not sink a run.
|
|
128
|
+
*/
|
|
129
|
+
export function settleWorktree(
|
|
130
|
+
cwd: string,
|
|
131
|
+
isolation: Isolation,
|
|
132
|
+
sink: IsolationSink,
|
|
133
|
+
): { removed: boolean; note: string } {
|
|
134
|
+
const removed = removeIfUnchanged(cwd, isolation);
|
|
135
|
+
if (removed) return { removed: true, note: CLEAN_WORKTREE_NOTE };
|
|
136
|
+
// Kept: there is work to merge. Record the pointer so the location survives
|
|
137
|
+
// on the call record even when the returned value is data or null.
|
|
138
|
+
sink.worktree = isolation.path;
|
|
139
|
+
sink.branch = isolation.branch;
|
|
140
|
+
return { removed: false, note: isolationNote(isolation) };
|
|
141
|
+
}
|
|
142
|
+
|
|
83
143
|
/**
|
|
84
144
|
* Remove a worktree the child left untouched. An isolated run that changed
|
|
85
145
|
* nothing is the common case — a review, a search, a question — and keeping
|
package/src/pending.ts
CHANGED
|
@@ -29,6 +29,13 @@ export interface PendingInput {
|
|
|
29
29
|
now: number;
|
|
30
30
|
/** What the caller asks for to collect it, e.g. `agent_result`. */
|
|
31
31
|
collectWith: string;
|
|
32
|
+
/**
|
|
33
|
+
* Whether a UI/interactive session is present. Delivery needs a session
|
|
34
|
+
* that outlives the run; a headless `pi -p` run tears down when the prompt
|
|
35
|
+
* resolves, so "it will be delivered, do not poll" is a promise that cannot
|
|
36
|
+
* be kept there. Default true so existing callers keep the interactive text.
|
|
37
|
+
*/
|
|
38
|
+
interactive?: boolean;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export interface PendingResult {
|
|
@@ -39,8 +46,12 @@ export interface PendingResult {
|
|
|
39
46
|
/** True: this will resolve on its own. It is a wait, not a failure. */
|
|
40
47
|
retryable: boolean;
|
|
41
48
|
elapsedMs: number;
|
|
42
|
-
/**
|
|
43
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Interactive: false, and load-bearing — there is nothing to poll for.
|
|
51
|
+
* Headless: true — the session ends with this turn, so the model MUST
|
|
52
|
+
* collect within it or the result is lost.
|
|
53
|
+
*/
|
|
54
|
+
pollRequired: boolean;
|
|
44
55
|
};
|
|
45
56
|
}
|
|
46
57
|
|
|
@@ -58,15 +69,28 @@ function elapsed(ms: number): string {
|
|
|
58
69
|
export function pendingResult(input: PendingInput): PendingResult {
|
|
59
70
|
const ms = Math.max(0, input.now - input.startedAt);
|
|
60
71
|
const state = input.kind === "queued" ? "queued behind the concurrency cap" : "still running";
|
|
72
|
+
// A headless run has no session to deliver into — it ends when this turn
|
|
73
|
+
// does. Telling the model "do not poll, it will be delivered" there strands
|
|
74
|
+
// it awaiting a message that never comes; it must collect within the turn.
|
|
75
|
+
const headless = input.interactive === false;
|
|
76
|
+
const text = headless
|
|
77
|
+
? [
|
|
78
|
+
`${input.id} is ${state} (${elapsed(ms)}).`,
|
|
79
|
+
"",
|
|
80
|
+
"This is a headless run: nothing is delivered after your turn ends. Call",
|
|
81
|
+
`${input.collectWith} again in this same turn until it returns the result — do not end`,
|
|
82
|
+
"your turn expecting to be picked back up.",
|
|
83
|
+
].join("\n")
|
|
84
|
+
: [
|
|
85
|
+
`${input.id} is ${state} (${elapsed(ms)}).`,
|
|
86
|
+
"",
|
|
87
|
+
"Do not poll for it. The result is delivered to you automatically the moment it lands,",
|
|
88
|
+
`so there is nothing to wait for here — carry on with other work, or finish your turn and`,
|
|
89
|
+
`you will be picked back up. ${input.collectWith} is only needed if you want it early.`,
|
|
90
|
+
].join("\n");
|
|
61
91
|
return {
|
|
62
|
-
text
|
|
63
|
-
|
|
64
|
-
"",
|
|
65
|
-
"Do not poll for it. The result is delivered to you automatically the moment it lands,",
|
|
66
|
-
`so there is nothing to wait for here — carry on with other work, or finish your turn and`,
|
|
67
|
-
`you will be picked back up. ${input.collectWith} is only needed if you want it early.`,
|
|
68
|
-
].join("\n"),
|
|
69
|
-
details: { id: input.id, status: input.kind, retryable: true, elapsedMs: ms, pollRequired: false },
|
|
92
|
+
text,
|
|
93
|
+
details: { id: input.id, status: input.kind, retryable: true, elapsedMs: ms, pollRequired: headless },
|
|
70
94
|
};
|
|
71
95
|
}
|
|
72
96
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who gets sent back to fix a failed gate.
|
|
3
|
+
*
|
|
4
|
+
* repair.ts bounds *how many* repair passes a failing gate may spend; this
|
|
5
|
+
* decides whether one is worth spending at all. Two facts rule it out. An
|
|
6
|
+
* agent with no writing tool can only re-read the failure and re-report it,
|
|
7
|
+
* so the pass buys nothing. And a child that ended its report with
|
|
8
|
+
* `OUTCOME: blocked` has said the wall is outside its reach — a decision,
|
|
9
|
+
* access or information it does not have — and a failing gate does not move
|
|
10
|
+
* that wall; a repair pass would just re-discover it at the cost of a full
|
|
11
|
+
* child run.
|
|
12
|
+
*
|
|
13
|
+
* The declaration has to be read *before* the item settles: settleItem
|
|
14
|
+
* strips it from the result so the report does not repeat it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { parseDeclaredOutcome } from "./outcome.ts";
|
|
18
|
+
import type { AgentDef } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** An agent that can write is one that can fix what a gate complained about. */
|
|
21
|
+
export function canWrite(def: AgentDef): boolean {
|
|
22
|
+
return def.tools.some((t) => t === "edit" || t === "write" || t === "bash" || t === "powershell");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** May this item be sent on a repair pass, given the agent and what it said? */
|
|
26
|
+
export function repairAllowed(def: AgentDef, result: string | null | undefined): boolean {
|
|
27
|
+
return canWrite(def) && parseDeclaredOutcome(result) !== "blocked";
|
|
28
|
+
}
|
package/src/repair.ts
CHANGED
|
@@ -47,8 +47,8 @@ export function repairPrompt(
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface GateCycleDeps {
|
|
50
|
-
/** Run the gate in `cwd` and judge it (src/gate.ts runGate). */
|
|
51
|
-
runGate(contract: GateContract, cwd: string): GateVerdict & { output: string }
|
|
50
|
+
/** Run the gate in `cwd` and judge it (src/gate.ts runGate); a stub may answer synchronously. */
|
|
51
|
+
runGate(contract: GateContract, cwd: string): (GateVerdict & { output: string }) | Promise<GateVerdict & { output: string }>;
|
|
52
52
|
/** Send the child back with a repair brief; resolves when that pass settles. */
|
|
53
53
|
repair(prompt: string): Promise<void>;
|
|
54
54
|
/**
|
|
@@ -79,14 +79,14 @@ export async function runGateCycle(
|
|
|
79
79
|
cwd: string,
|
|
80
80
|
deps: GateCycleDeps,
|
|
81
81
|
): Promise<{ record: GateRecord; verification: Verification }> {
|
|
82
|
-
let verdict = deps.runGate(contract, cwd);
|
|
82
|
+
let verdict = await deps.runGate(contract, cwd);
|
|
83
83
|
let repairs = 0;
|
|
84
84
|
const limit = Math.max(0, Math.min(5, deps.maxAttempts));
|
|
85
85
|
|
|
86
86
|
while (!verdict.ok && deps.canRepair && repairs < limit && repairable(verdict.outcome)) {
|
|
87
87
|
await deps.repair(repairPrompt(task, contract, verdict));
|
|
88
88
|
repairs++;
|
|
89
|
-
verdict = deps.runGate(contract, cwd);
|
|
89
|
+
verdict = await deps.runGate(contract, cwd);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
const record: GateRecord = {
|
package/src/report.ts
CHANGED
|
@@ -66,7 +66,12 @@ export function buildReport(run: SwarmRun): string {
|
|
|
66
66
|
return `${label}\nSkipped — ${item.error ?? "something it needed did not succeed"}. Nothing ran, so nothing was spent on it.`;
|
|
67
67
|
}
|
|
68
68
|
if (item.status === "aborted") {
|
|
69
|
-
|
|
69
|
+
// cancelRun writes who stopped the run and what that cost into `error`;
|
|
70
|
+
// a turn-cap stop leaves it empty. Either way the reader should not have
|
|
71
|
+
// to guess which one it was.
|
|
72
|
+
// cancelNote ends its sentence itself; do not add a second period.
|
|
73
|
+
const why = (item.error ?? "turn cap or stop").replace(/\.$/, "");
|
|
74
|
+
return `${label}\nAborted — ${why}. Partial:\n${item.result ?? "(none)"}`;
|
|
70
75
|
}
|
|
71
76
|
return `${label}\n(${item.status})`;
|
|
72
77
|
});
|
package/src/wait.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waiting on a run without teaching the model to poll.
|
|
3
|
+
*
|
|
4
|
+
* pending.ts says "do not poll" and means it — in an interactive session the
|
|
5
|
+
* report arrives on its own. A headless `pi -p` run has no such delivery, so
|
|
6
|
+
* there the model's only move was to call swarm_status again, and again, each
|
|
7
|
+
* call a full turn. `wait` lets one call sit on the run for a bounded time
|
|
8
|
+
* instead: the answer is the same either way, it just arrives in one turn.
|
|
9
|
+
*
|
|
10
|
+
* Pure: the condition, the budget and the tool's own AbortSignal. The
|
|
11
|
+
* extension owns what is being waited for.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve true as soon as `check()` holds, false when `ms` runs out or the
|
|
16
|
+
* signal fires first. Polls rather than subscribes: the run has no event to
|
|
17
|
+
* listen to, and a check every `intervalMs` costs nothing measurable.
|
|
18
|
+
*/
|
|
19
|
+
export function waitUntil(
|
|
20
|
+
check: () => boolean,
|
|
21
|
+
ms: number,
|
|
22
|
+
intervalMs = 250,
|
|
23
|
+
signal?: AbortSignal,
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
if (check()) return Promise.resolve(true);
|
|
26
|
+
if (!(ms > 0) || signal?.aborted) return Promise.resolve(false);
|
|
27
|
+
const interval = Math.max(1, intervalMs);
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const deadline = Date.now() + ms;
|
|
30
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
31
|
+
const finish = (value: boolean): void => {
|
|
32
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
33
|
+
signal?.removeEventListener("abort", onAbort);
|
|
34
|
+
resolve(value);
|
|
35
|
+
};
|
|
36
|
+
// An abort is the caller leaving, not a verdict; if the condition happens
|
|
37
|
+
// to hold by then, say so rather than reporting a wait that never happened.
|
|
38
|
+
const onAbort = (): void => finish(check());
|
|
39
|
+
const tick = (): void => {
|
|
40
|
+
if (check()) return finish(true);
|
|
41
|
+
const left = deadline - Date.now();
|
|
42
|
+
if (left <= 0) return finish(false);
|
|
43
|
+
timer = setTimeout(tick, Math.min(interval, left));
|
|
44
|
+
};
|
|
45
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
46
|
+
timer = setTimeout(tick, Math.min(interval, ms));
|
|
47
|
+
});
|
|
48
|
+
}
|