@pify/swarm 0.9.1 → 0.10.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
@@ -23,9 +23,25 @@ The catch is that "independent" is usually a small lie — the items do not depe
23
23
  | `agent` | string, optional | Force one agent type for all items instead of routing |
24
24
  | `isolation` | `"worktree"`, optional | Give each item its own git worktree — use it when items write |
25
25
  | `mailbox` | boolean, optional | Give the children `swarm_post` / `swarm_inbox` |
26
+ | `gate` | string, optional | A command every item must pass — `bun test`, `tsc --noEmit` — run in that item's own working directory |
27
+ | `gateExpect` | string, optional | Regex the gate output must match, for checks that exit 0 without proving anything |
28
+ | `gateRepairs` | number, optional | Repair passes per item after a failed gate, 0–5 (default 1) |
29
+ | `on_upstream_failure` | `"continue"` / `"skip"`, optional | What a dependent does when something it needs did not succeed (default `continue`) |
26
30
  | `background` | boolean, optional | Return a `runId` immediately instead of blocking |
27
31
 
28
- Blocking by default: returns `N done, M error` plus a per-item report.
32
+ Blocking by default: returns `N succeeded, M failed` plus a per-item report.
33
+
34
+ ### Gates and outcomes
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.
37
+
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
+
40
+ Every item then reports two facts. Its **status** says whether the child finished; its **outcome** says whether the task did. A failed gate outranks a child that claims success; without a gate the outcome is the child's own account, and a child that could not finish can end its report with `OUTCOME: blocked` or `OUTCOME: failed` to say so in one parseable place. The header counts outcomes, so an item that ran to the end and failed its check is filed under `failed`, not `done` — and the widget shows it as ✗, not ✓.
41
+
42
+ **Failures interrupt.** A background run wakes you once, as soon as the first item fails hard, while the rest are still running — the same rule `@pify/subagent` uses for a failed background child. The message says it is a warning and that the full report still follows; it does not ask you to poll. Subsequent failures wait for the aggregate report, since N interrupts for N failures would be worse than none.
43
+
44
+ **`on_upstream_failure`.** By default a dependent still runs when something it needed failed, with a `(failed: …)` notice in place of that item's output — visible, but it spends a child on a step that is usually doomed. `skip` settles the dependent instead, marks it `skipped` (its own state, not a second failure), and the skip cascades down the branch. Independent items are unaffected either way.
29
45
 
30
46
  ### Dependencies: `needs`
31
47
 
@@ -22,6 +22,7 @@ import {
22
22
  type ExtensionContext,
23
23
  } from "@earendil-works/pi-coding-agent";
24
24
  import { Text } from "@earendil-works/pi-tui";
25
+ import { StringEnum } from "@earendil-works/pi-ai";
25
26
  import { Type } from "typebox";
26
27
 
27
28
  import { BUILTIN_AGENTS } from "../src/builtin.ts";
@@ -38,9 +39,22 @@ import {
38
39
  import { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
39
40
  import { DELIVERY_TYPE, deliveryMessage, pendingResult } from "../src/pending.ts";
40
41
  import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
41
- import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
42
+ import {
43
+ MAILBOX_INBOX_TOOL,
44
+ MAILBOX_POST_TOOL,
45
+ MAILBOX_TOOL_NAMES,
46
+ formatInbox,
47
+ mailboxDir,
48
+ mailboxKey,
49
+ mailboxPrompt,
50
+ postMessage,
51
+ readInbox,
52
+ } from "../src/mailbox.ts";
42
53
  import { parseAgentFile } from "../src/frontmatter.ts";
43
- import { buildReport, buildStatusLine } from "../src/report.ts";
54
+ import { buildReport, buildStatusLine, earlyFailureNotice } from "../src/report.ts";
55
+ import { normalizeGate, runGate, sharedWith, type GateContract, type GateSibling } from "../src/gate.ts";
56
+ import { runGateCycle } from "../src/repair.ts";
57
+ import { deriveOutcome, parseDeclaredOutcome, stripDeclaration } from "../src/outcome.ts";
44
58
  import { routeItem } from "../src/routing.ts";
45
59
  import { normalizeItems } from "../src/graph.ts";
46
60
  import { runGraph } from "../src/schedule.ts";
@@ -52,8 +66,9 @@ import {
52
66
  type AgentDef,
53
67
  type ItemState,
54
68
  type SwarmRun,
69
+ type UpstreamFailurePolicy,
55
70
  } from "../src/types.ts";
56
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
71
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
57
72
  import { basename, join } from "node:path";
58
73
 
59
74
  const RUN_ENTRY = "swarm-run";
@@ -136,7 +151,7 @@ export default function swarm(pi: ExtensionAPI) {
136
151
  let seen = 0;
137
152
  return [
138
153
  {
139
- name: "swarm_post",
154
+ name: MAILBOX_POST_TOOL,
140
155
  label: "Post to swarm",
141
156
  description:
142
157
  "Tell the other agents in this swarm something that changes their work: a shared file you " +
@@ -154,7 +169,7 @@ export default function swarm(pi: ExtensionAPI) {
154
169
  },
155
170
  },
156
171
  {
157
- name: "swarm_inbox",
172
+ name: MAILBOX_INBOX_TOOL,
158
173
  label: "Read swarm inbox",
159
174
  description:
160
175
  "Read what the other agents in this swarm have posted since you last checked. Call it " +
@@ -227,7 +242,11 @@ export default function swarm(pi: ExtensionAPI) {
227
242
  sessionManager: SessionManager.inMemory(workDir ?? ctx.cwd),
228
243
  model,
229
244
  thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
230
- tools: def.tools,
245
+ // `tools` is an allowlist and it filters customTools too, so a mailbox
246
+ // tool that is not named here is registered and then dropped — the
247
+ // child is told it has no such tool, and mailbox:true does nothing.
248
+ // Admit them exactly as @pify/subagent admits ask_supervisor.
249
+ tools: mailbox ? [...def.tools, ...MAILBOX_TOOL_NAMES] : def.tools,
231
250
  ...(mailbox ? { customTools: mailboxTools(mailbox, item.agent + "-" + item.index) } : {}),
232
251
  resourceLoader: loader,
233
252
  });
@@ -335,16 +354,102 @@ export default function swarm(pi: ExtensionAPI) {
335
354
  * DEFAULT_CONCURRENCY at once. A flat run (no needs anywhere) makes every
336
355
  * item ready immediately, so this is identical to the old parallel pool.
337
356
  */
338
- async function executeRun(
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
+ /**
362
+ * Settle the two facts the status alone cannot give: what the item's task came
363
+ * to, and how well that is known. Settled once — re-running it after the
364
+ * isolation note is appended would find the declaration already stripped and
365
+ * quietly promote a blocked item to a successful one.
366
+ */
367
+ function settleItem(item: ItemState): void {
368
+ if (item.status === "queued" || item.status === "running" || item.outcome) return;
369
+ // A skipped item was never attempted, so it has no outcome to report — not
370
+ // a failure of its own, and calling it one would double-count the upstream
371
+ // failure that caused it.
372
+ if (item.status === "skipped") return;
373
+ const declared = parseDeclaredOutcome(item.result);
374
+ if (declared && item.result) item.result = stripDeclaration(item.result);
375
+ item.verification ??= "not-requested";
376
+ item.outcome = deriveOutcome({
377
+ status: item.status === "done" ? "done" : item.status === "aborted" ? "aborted" : "error",
378
+ declared,
379
+ verification: item.verification,
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Run the caller's gate in the tree this item worked in and, if it failed,
385
+ * send the child back to fix it. Recorded either way — a gate that passed is
386
+ * a fact worth saying, and a gate that could not run says so rather than
387
+ * blaming the work.
388
+ */
389
+ async function gateItem(
339
390
  ctx: UiContext,
340
391
  run: SwarmRun,
341
- context: string,
342
- fixed?: string,
343
- isolate?: boolean,
344
- useMailbox?: boolean,
392
+ item: ItemState,
393
+ def: AgentDef,
394
+ opts: RunOptions,
345
395
  ): Promise<void> {
346
- // One shared log per run; only created when the caller asked for it.
347
- const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
396
+ if (!opts.gate || item.status !== "done") return;
397
+ const subject = item.workDir ?? ctx.cwd;
398
+ const self: GateSibling = { id: item.index, label: item.id, status: item.status, workDir: item.workDir };
399
+ const siblings: GateSibling[] = run.items
400
+ .filter((i) => i.index !== item.index)
401
+ .map((i) => ({ id: i.index, label: i.id, status: i.status, workDir: i.workDir }));
402
+ try {
403
+ const { record, verification } = await runGateCycle(item.item, opts.gate, subject, {
404
+ runGate,
405
+ canRepair: canWrite(def),
406
+ maxAttempts: opts.gateRepairs ?? 1,
407
+ sharedWith: sharedWith(self, subject, siblings),
408
+ repair: async (prompt) => {
409
+ // The repair is the same child type over the same tree; its report
410
+ // 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
+ await runItem(ctx, run.runId, def, fix, prompt, item.workDir, undefined);
413
+ item.turns += fix.turns;
414
+ item.tokens += fix.tokens;
415
+ if (fix.status === "done" && fix.result?.trim()) item.result = fix.result;
416
+ },
417
+ });
418
+ item.gate = record;
419
+ item.verification = verification;
420
+ } catch (err) {
421
+ // A gate that throws proved nothing; say so rather than losing the
422
+ // child's work to an error in the checking machinery.
423
+ item.gate = {
424
+ command: opts.gate.command,
425
+ outcome: "no_attestation",
426
+ ok: false,
427
+ reason: `gate could not be run: ${err instanceof Error ? err.message : String(err)}`,
428
+ };
429
+ item.verification = "inconclusive";
430
+ }
431
+ }
432
+
433
+ interface RunOptions {
434
+ context: string;
435
+ fixed?: string;
436
+ isolate?: boolean;
437
+ useMailbox?: boolean;
438
+ gate?: GateContract;
439
+ gateRepairs?: number;
440
+ onUpstreamFailure?: UpstreamFailurePolicy;
441
+ /** Called once, for the first item that settles badly while others run. */
442
+ onEarlyFailure?: (item: ItemState) => void;
443
+ }
444
+
445
+ async function executeRun(ctx: UiContext, run: SwarmRun, opts: RunOptions): Promise<void> {
446
+ const { context, fixed, isolate, useMailbox } = opts;
447
+ // One shared log per run; only created when the caller asked for it. Keyed
448
+ // on a per-run token (not the reused "s1" run id), so one run never reads a
449
+ // previous run's stale messages, and removed at the end so it never leaks.
450
+ const mailbox = useMailbox
451
+ ? mailboxDir(getAgentDir(), mailboxKey(run.runId, run.startedAt))
452
+ : undefined;
348
453
 
349
454
  // A dependent structurally receives each upstream's output — the thing a
350
455
  // hand-sequenced coordinator forgets. Prepended to the shared preamble.
@@ -357,37 +462,81 @@ export default function swarm(pi: ExtensionAPI) {
357
462
  return [context, ...blocks].filter((s) => s && s.trim()).join("\n\n");
358
463
  };
359
464
 
465
+ // The first hard failure wakes the caller once, and only while there is
466
+ // still a run to warn it about; after that the aggregate report is the
467
+ // report. N interrupts for N failures would be worse than none.
468
+ let warned = false;
469
+ const settle = (item: ItemState): void => {
470
+ settleItem(item);
471
+ if (warned || !opts.onEarlyFailure) return;
472
+ if (item.outcome !== "failed" || item.status === "aborted") return;
473
+ warned = true;
474
+ opts.onEarlyFailure(item);
475
+ };
476
+
360
477
  // Run each item once its needs finish, up to the concurrency cap; a flat
361
478
  // run (no needs) has everything ready at once, exactly like the old pool.
362
- await runGraph(
363
- run.items,
364
- DEFAULT_CONCURRENCY,
365
- async (item, upstream) => {
366
- const def = routeItem(item.item, defs, fixed);
367
- item.agent = def.name;
368
- const itemContext = contextFor(upstream);
369
- if (isolate) {
370
- try {
371
- const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
372
- await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
373
- // Remove the worktree when the item changed nothing (the leak
374
- // removeIfUnchanged fixes); keep it when there is work to merge.
375
- const removed = removeIfUnchanged(ctx.cwd, iso);
376
- if (item.result !== null) {
377
- item.result = `${item.result}\n\n${removed ? CLEAN_WORKTREE_NOTE : isolationNote(iso)}`;
479
+ try {
480
+ await runGraph(
481
+ run.items,
482
+ DEFAULT_CONCURRENCY,
483
+ async (item, upstream) => {
484
+ // A failed node still counts as done so the graph drains rather than
485
+ // wedging but "unblocked" and "worth running" are different
486
+ // questions. With skip, a dependent of work that did not succeed is
487
+ // settled without spending a child on input that is a failure notice.
488
+ const broken = upstream.filter((up) => up.status !== "done" || up.outcome !== "succeeded");
489
+ if (opts.onUpstreamFailure === "skip" && broken.length > 0) {
490
+ item.status = "skipped";
491
+ item.error = `${broken.map((u) => u.id).join(", ")} did not succeed`;
492
+ settleItem(item);
493
+ renderWidget();
494
+ return;
495
+ }
496
+ const def = routeItem(item.item, defs, fixed);
497
+ item.agent = def.name;
498
+ const itemContext = contextFor(upstream);
499
+ if (isolate) {
500
+ try {
501
+ const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
502
+ item.workDir = iso.path;
503
+ await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
504
+ await gateItem(ctx, run, item, def, opts);
505
+ // Remove the worktree when the item changed nothing (the leak
506
+ // removeIfUnchanged fixes); keep it when there is work to merge.
507
+ const removed = removeIfUnchanged(ctx.cwd, iso);
508
+ if (item.result !== null) {
509
+ item.result = `${item.result}\n\n${removed ? CLEAN_WORKTREE_NOTE : isolationNote(iso)}`;
510
+ }
511
+ } catch (err) {
512
+ item.status = "error";
513
+ item.error = err instanceof Error ? err.message : String(err);
378
514
  }
379
- } catch (err) {
380
- item.status = "error";
381
- item.error = err instanceof Error ? err.message : String(err);
515
+ } else {
516
+ await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
517
+ await gateItem(ctx, run, item, def, opts);
382
518
  }
383
- } else {
384
- await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
519
+ settle(item);
520
+ },
521
+ () => run.status === "cancelled",
522
+ );
523
+ } finally {
524
+ // The mailbox is per-run scratch: reclaim it however the run ends
525
+ // (finished, cancelled, or thrown) so a directory never accumulates.
526
+ if (mailbox) {
527
+ try {
528
+ rmSync(mailbox, { recursive: true, force: true });
529
+ } catch {
530
+ // best-effort: an unremovable scratch dir is not worth failing a run
385
531
  }
386
- },
387
- () => run.status === "cancelled",
388
- );
532
+ }
533
+ }
389
534
 
390
535
  if (run.status !== "cancelled") run.status = "done";
536
+ // A cancelled run's items were marked aborted without going through the
537
+ // scheduler's settle path; the report still has to be able to name what
538
+ // each one came to.
539
+ for (const item of run.items) settleItem(item);
391
540
  run.finishedAt = Date.now();
392
541
  pi.appendEntry(RUN_ENTRY, run);
393
542
  renderWidget();
@@ -429,7 +578,11 @@ export default function swarm(pi: ExtensionAPI) {
429
578
  "conventions instead of silently conflicting. " +
430
579
  "An item can be a plain string (independent) OR an object {task, id, needs:[ids]} to declare a " +
431
580
  "dependency: a needed item's output is prepended to the dependent automatically, and the dependent " +
432
- "starts only once its needs finish. A cycle, a self-edge, or an unknown id is rejected before anything runs.",
581
+ "starts only once its needs finish. A cycle, a self-edge, or an unknown id is rejected before anything runs. " +
582
+ "gate is a command every item must pass — it runs in that item's own working directory when it finishes, " +
583
+ "a failure sends the child back to fix it once, and the report says what the check proved rather than " +
584
+ "only what the child claims. on_upstream_failure=skip settles a dependent without spending a child when " +
585
+ "something it needed did not succeed.",
433
586
  parameters: Type.Object({
434
587
  items: Type.Array(
435
588
  Type.Union([
@@ -450,6 +603,27 @@ export default function swarm(pi: ExtensionAPI) {
450
603
  mailbox: Type.Optional(
451
604
  Type.Boolean({ description: "Give the agents swarm_post/swarm_inbox to share facts mid-run" }),
452
605
  ),
606
+ gate: Type.Optional(
607
+ Type.String({
608
+ description:
609
+ "Shell command every item must pass, e.g. \"bun test\". Run in that item's working directory once it finishes.",
610
+ }),
611
+ ),
612
+ gateExpect: Type.Optional(
613
+ Type.String({
614
+ description:
615
+ "Regex the gate output must match. Use it when exit 0 does not prove the check ran; exiting 0 without a match is reported as verifying nothing.",
616
+ }),
617
+ ),
618
+ gateRepairs: Type.Optional(
619
+ Type.Number({ description: "Repair passes per item after a failed gate, 0-5 (default 1)" }),
620
+ ),
621
+ on_upstream_failure: Type.Optional(
622
+ StringEnum(["continue", "skip"], {
623
+ description:
624
+ "What a dependent does when something it needs did not succeed: continue (default, it runs and is told) or skip (it is settled without spending a child)",
625
+ }),
626
+ ),
453
627
  background: Type.Optional(Type.Boolean()),
454
628
  }),
455
629
  async execute(
@@ -461,6 +635,10 @@ export default function swarm(pi: ExtensionAPI) {
461
635
  background?: boolean;
462
636
  isolation?: string;
463
637
  mailbox?: boolean;
638
+ gate?: string;
639
+ gateExpect?: string;
640
+ gateRepairs?: number;
641
+ on_upstream_failure?: UpstreamFailurePolicy;
464
642
  },
465
643
  signal,
466
644
  _onUpdate,
@@ -479,6 +657,25 @@ export default function swarm(pi: ExtensionAPI) {
479
657
  throw new Error(`Swarm ${activeRun.runId} is still running — wait or check swarm_status.`);
480
658
  }
481
659
 
660
+ // A gate is validated up front: a broken contract should be a tool error
661
+ // the caller can fix now, not a "verified nothing" verdict on every item
662
+ // after a whole fan-out has already been spent.
663
+ let gate: GateContract | undefined;
664
+ if (params.gate?.trim()) {
665
+ gate = normalizeGate(params.gate.trim());
666
+ const expect = params.gateExpect?.trim();
667
+ if (expect) {
668
+ try {
669
+ new RegExp(expect, "m");
670
+ } catch {
671
+ throw new Error(`gateExpect is not a valid regular expression: ${expect}`);
672
+ }
673
+ gate.expect = expect;
674
+ }
675
+ } else if (params.gateExpect?.trim()) {
676
+ throw new Error("gateExpect needs a gate command to judge.");
677
+ }
678
+
482
679
  runCounter++;
483
680
  const run: SwarmRun = {
484
681
  runId: `s${runCounter}`,
@@ -515,8 +712,38 @@ export default function swarm(pi: ExtensionAPI) {
515
712
  }
516
713
  }
517
714
 
715
+ const options: RunOptions = {
716
+ context: params.context ?? "",
717
+ fixed: params.agent,
718
+ isolate: params.isolation === "worktree",
719
+ useMailbox: params.mailbox === true,
720
+ gate,
721
+ gateRepairs: params.gateRepairs,
722
+ onUpstreamFailure: params.on_upstream_failure,
723
+ };
724
+
518
725
  if (run.background) {
519
- void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true)
726
+ // A background run's caller is off doing something else, so the first
727
+ // hard failure interrupts it the way @pify/subagent already interrupts
728
+ // for a failed background child. A foreground run is already blocking
729
+ // that turn, so there is nothing to interrupt.
730
+ options.onEarlyFailure = (item) => {
731
+ try {
732
+ pi.sendMessage(
733
+ {
734
+ customType: DELIVERY_TYPE,
735
+ content: earlyFailureNotice(run, item),
736
+ display: true,
737
+ details: { runId: run.runId, item: item.id, status: item.status },
738
+ },
739
+ { deliverAs: "steer", triggerTurn: true },
740
+ );
741
+ } catch {
742
+ // A warning that cannot be delivered must not take the run with it;
743
+ // the aggregate report is still coming.
744
+ }
745
+ };
746
+ void executeRun(uiCtx, run, options)
520
747
  .then(() => {
521
748
  notify(uiCtx, `swarm ${run.runId} finished`, "info");
522
749
  // The report goes to the agent, not only to the screen — otherwise
@@ -548,7 +775,7 @@ export default function swarm(pi: ExtensionAPI) {
548
775
  }
549
776
 
550
777
  try {
551
- await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
778
+ await executeRun(uiCtx, run, options);
552
779
  } finally {
553
780
  if (stopListening) stopListening();
554
781
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Coordinate multiple pi agents in parallel: swarm_run fan-out with per-item auto-routing, concurrency queue, aggregated reports",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/gate.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * What a gate actually proved.
3
+ *
4
+ * A gate exists so a step is verified by running something rather than by a
5
+ * model saying it went well. Judging that run by its exit code alone leaves a
6
+ * hole big enough to drive a workflow through: a command that never ran the
7
+ * check still exits 0. A mistyped script name under `sh -c`, a test runner
8
+ * that matched no tests, a `|| true` someone left in — each one reports
9
+ * success while proving nothing.
10
+ *
11
+ * So a gate may state what success looks like. When it does, exiting 0
12
+ * without that evidence is its own outcome (`result_missing`) rather than a
13
+ * pass. The vocabulary is FradSer/pi-monitor's result contract.
14
+ *
15
+ * The same distinction runs one step further. A check that ran and said no is
16
+ * evidence; a check that could not run at all is *not evidence of anything*.
17
+ * A misspelled command, a runner that is not installed, a gate whose own regex
18
+ * does not compile — none of those are the code failing, and reporting them as
19
+ * `failure` sends the reader looking for a bug in the work instead of a typo
20
+ * in the gate. That case is `no_attestation`: still not a pass, but honest
21
+ * about having proved nothing either way.
22
+ */
23
+
24
+ import { spawnSync } from "node:child_process";
25
+
26
+ export type GateOutcome =
27
+ | "success"
28
+ | "failure"
29
+ | "result_missing"
30
+ | "timeout"
31
+ | "no_attestation";
32
+
33
+ export interface GateContract {
34
+ /** The command to run. */
35
+ command: string;
36
+ /** Regex source: success requires a match in the combined output. */
37
+ expect?: string;
38
+ /** Regex source: a match means failure even when the command exits 0. */
39
+ failure?: string;
40
+ timeoutMs?: number;
41
+ }
42
+
43
+ export interface GateRun {
44
+ /** Exit status, or null when the process was killed (timeout/signal). */
45
+ status: number | null;
46
+ /** Signal that killed it, when one did. */
47
+ signal?: string | null;
48
+ output: string;
49
+ /** True when the runner stopped it at the timeout. */
50
+ timedOut?: boolean;
51
+ /**
52
+ * The command never became a process — it could not be spawned, the shell
53
+ * was missing, the working directory was gone. Not a verdict on the work.
54
+ */
55
+ spawnError?: string;
56
+ }
57
+
58
+ export interface GateVerdict {
59
+ outcome: GateOutcome;
60
+ ok: boolean;
61
+ /** One line for the run log, in this package's words. */
62
+ reason: string;
63
+ }
64
+
65
+ /** Accept a bare command string or a full contract. */
66
+ export function normalizeGate(gate: string | GateContract): GateContract {
67
+ return typeof gate === "string" ? { command: gate } : gate;
68
+ }
69
+
70
+ function compile(source: string | undefined): RegExp | null {
71
+ if (!source) return null;
72
+ try {
73
+ return new RegExp(source, "m");
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Judge a finished gate run. Order matters: a timeout is a timeout whatever
81
+ * else happened, an explicit failure pattern beats a zero exit, and a missing
82
+ * success pattern is never a pass.
83
+ */
84
+ export function evaluateGate(contract: GateContract, run: GateRun): GateVerdict {
85
+ if (run.spawnError) {
86
+ return {
87
+ outcome: "no_attestation",
88
+ ok: false,
89
+ reason: `gate never ran (${run.spawnError}) — nothing was proved either way`,
90
+ };
91
+ }
92
+
93
+ // A timeout is a real verdict: the check was given its deadline and did not
94
+ // clear it. That is different from the case below.
95
+ if (run.timedOut || (run.status === null && run.signal)) {
96
+ return {
97
+ outcome: "timeout",
98
+ ok: false,
99
+ reason: `gate timed out after ${contract.timeoutMs ?? "the default"}ms`,
100
+ };
101
+ }
102
+
103
+ // Neither an exit code nor a signal: the process did not run to a verdict
104
+ // and nothing killed it, so there is no result to report as one.
105
+ if (run.status === null) {
106
+ return {
107
+ outcome: "no_attestation",
108
+ ok: false,
109
+ reason: "gate produced no exit status — nothing was proved either way",
110
+ };
111
+ }
112
+
113
+ const failurePattern = compile(contract.failure);
114
+ if (failurePattern && failurePattern.test(run.output)) {
115
+ return {
116
+ outcome: "failure",
117
+ ok: false,
118
+ reason: `gate output matched its failure pattern /${contract.failure}/`,
119
+ };
120
+ }
121
+
122
+ if (run.status !== 0) {
123
+ return { outcome: "failure", ok: false, reason: `gate exited ${run.status}` };
124
+ }
125
+
126
+ const expectPattern = compile(contract.expect);
127
+ if (expectPattern && !expectPattern.test(run.output)) {
128
+ // The hole this closes: the command ran, said nothing that proves the
129
+ // check happened, and exited 0.
130
+ return {
131
+ outcome: "result_missing",
132
+ ok: false,
133
+ reason: `gate exited 0 but its output never matched /${contract.expect}/ — nothing was verified`,
134
+ };
135
+ }
136
+
137
+ return {
138
+ outcome: "success",
139
+ ok: true,
140
+ reason: expectPattern ? `gate passed and matched /${contract.expect}/` : "gate exited 0",
141
+ };
142
+ }
143
+
144
+ /** The part of a call a gate needs in order to know who else was in the room. */
145
+ export interface GateSibling {
146
+ id: number;
147
+ label: string;
148
+ status: string;
149
+ workDir?: string;
150
+ }
151
+
152
+ /**
153
+ * Which other calls were live in the same working directory while this gate
154
+ * ran — the ones that make its verdict unattributable.
155
+ *
156
+ * Only concurrency in the *same* directory counts. Two agents under
157
+ * `isolation: "worktree"` have their own checkouts and cannot disturb each
158
+ * other, which is exactly why isolation is the fix rather than a warning.
159
+ */
160
+ export function sharedWith(
161
+ self: GateSibling,
162
+ subject: string,
163
+ siblings: readonly GateSibling[],
164
+ ): string[] {
165
+ return siblings
166
+ .filter((s) => s.id !== self.id && s.status === "running" && (s.workDir ?? subject) === subject)
167
+ .map((s) => s.label);
168
+ }
169
+
170
+ /**
171
+ * One line saying what a verdict is worth, given who else was editing.
172
+ * A pass earned over a tree two other agents were changing is reported as what
173
+ * it is: true of the tree, not of this agent's work.
174
+ */
175
+ export function attributionNote(record: {
176
+ ok: boolean;
177
+ sharedWith: readonly string[];
178
+ }): string | null {
179
+ if (record.sharedWith.length === 0) return null;
180
+ const others = record.sharedWith.join(", ");
181
+ return record.ok
182
+ ? `judged a directory ${others} ${record.sharedWith.length === 1 ? "was" : "were"} also changing — true of the tree, not of this agent's work alone`
183
+ : `judged a directory ${others} ${record.sharedWith.length === 1 ? "was" : "were"} also changing — the cause may not be this agent's work`;
184
+ }
185
+
186
+ /** An unparseable pattern is a broken contract, not a passing one. */
187
+ export function contractProblems(contract: GateContract): string[] {
188
+ const problems: string[] = [];
189
+ if (!contract.command.trim()) problems.push("gate has no command");
190
+ for (const [field, source] of [
191
+ ["expect", contract.expect],
192
+ ["failure", contract.failure],
193
+ ] as const) {
194
+ if (source && !compile(source)) problems.push(`gate ${field} is not a valid regular expression`);
195
+ }
196
+ if (contract.timeoutMs !== undefined && !(contract.timeoutMs > 0)) {
197
+ problems.push("gate timeoutMs must be positive");
198
+ }
199
+ return problems;
200
+ }
201
+
202
+ /** How long a gate may run before the deadline is its verdict. */
203
+ export const GATE_TIMEOUT_MS = 120_000;
204
+
205
+ /**
206
+ * Run a gate and judge it against its contract, with the shell in the subject
207
+ * working directory. A bare string keeps the exit-code meaning; a contract can
208
+ * also say what success has to look like, which is what stops a command that
209
+ * never ran the check from passing.
210
+ *
211
+ * The command comes from the caller — the same trust level as the bash tool in
212
+ * this session — so this adds no capability the caller did not already have.
213
+ */
214
+ export function runGate(gate: string | GateContract, cwd: string): GateVerdict & { output: string } {
215
+ const contract = normalizeGate(gate);
216
+ const problems = contractProblems(contract);
217
+ if (problems.length > 0) {
218
+ // A gate that cannot be run is not a verdict on the work. Calling this a
219
+ // 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: "" };
221
+ }
222
+ const timeoutMs = contract.timeoutMs ?? GATE_TIMEOUT_MS;
223
+ try {
224
+ const result = spawnSync(contract.command, {
225
+ shell: true,
226
+ cwd,
227
+ encoding: "utf8",
228
+ timeout: timeoutMs,
229
+ windowsHide: true,
230
+ // A real test suite or build easily prints past spawnSync's 1 MiB
231
+ // default; overflow surfaces as an ENOBUFS `error`, which this code
232
+ // would otherwise read as spawnError → no_attestation, rejecting work
233
+ // that in fact passed. Give the gate room to actually report.
234
+ maxBuffer: 16 * 1024 * 1024,
235
+ });
236
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
237
+ const verdict = evaluateGate(
238
+ { ...contract, timeoutMs },
239
+ {
240
+ status: result.status,
241
+ signal: result.signal,
242
+ output,
243
+ timedOut: result.error?.message?.includes("ETIMEDOUT") || result.signal === "SIGTERM",
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: "",
260
+ };
261
+ }
262
+ }
package/src/mailbox.ts CHANGED
@@ -23,6 +23,30 @@ export interface MailMessage {
23
23
  export const MAX_MESSAGE_CHARS = 1200;
24
24
  export const MAX_INBOX_MESSAGES = 30;
25
25
 
26
+ /**
27
+ * The two mailbox tool names. pi's child-session `tools` allowlist filters
28
+ * customTools too, so a mailbox tool that is not also named in the child's
29
+ * allowlist is registered and then silently dropped — the child is told it has
30
+ * no such tool. Naming them here keeps the allowlist and the tool definitions
31
+ * from drifting apart (mirrors how @pify/subagent wires ask_supervisor).
32
+ */
33
+ export const MAILBOX_POST_TOOL = "swarm_post";
34
+ export const MAILBOX_INBOX_TOOL = "swarm_inbox";
35
+ export const MAILBOX_TOOL_NAMES = [MAILBOX_POST_TOOL, MAILBOX_INBOX_TOOL] as const;
36
+
37
+ /**
38
+ * A per-run key for the mailbox directory. The run id ("s1", "s2", …) restarts
39
+ * at "s1" every session, so keying the dir on it alone made two different runs
40
+ * — a fresh "s1" and a previous session's "s1" — share one directory: the new
41
+ * run read the old run's stale messages, and the old dir was never reclaimed.
42
+ * Folding in the run's start time (base36-compact, and derived from the clock
43
+ * rather than Math.random so it stays deterministic and testable) gives every
44
+ * run its own directory, even two "s1"s from different sessions.
45
+ */
46
+ export function mailboxKey(runId: string, startedAt: number): string {
47
+ return `${runId}-${Math.trunc(startedAt).toString(36)}`;
48
+ }
49
+
26
50
  /**
27
51
  * One directory per run under the agent dir. Run ids are generated locally,
28
52
  * but this builds a filesystem path, so it stays a single flat segment: no
package/src/outcome.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * "It finished" and "it worked" are different facts.
3
+ *
4
+ * RunStatus answers the first one: did the child session run to the end, error
5
+ * out, or get stopped. It says nothing about whether the task was actually
6
+ * accomplished, so a child that hits its brief's wall at turn three and writes
7
+ * a polite report explaining why is recorded exactly like one that shipped the
8
+ * feature — `done`. The caller then has to read prose to find out which.
9
+ *
10
+ * So record the task outcome separately, and record *how well it is known*
11
+ * separately again. A gate that ran and failed is evidence and overrides a
12
+ * child's claim of success. A gate that could not run, or that exited 0 without
13
+ * proving anything, is not evidence of failure either — reporting it as one
14
+ * would blame the work for a typo in the gate. That case leaves the outcome
15
+ * alone and says the verification was inconclusive.
16
+ *
17
+ * Pure and dependency-free: every rule here is a function of facts the
18
+ * extension already has.
19
+ */
20
+
21
+ import type { GateOutcome } from "./gate.ts";
22
+
23
+ /** What the delegated task actually came to. */
24
+ export type TaskOutcome = "succeeded" | "blocked" | "failed";
25
+
26
+ /** How well that outcome is known. Orthogonal to the outcome itself. */
27
+ export type Verification = "not-requested" | "passed" | "failed" | "inconclusive";
28
+
29
+ /** The marker a child may end its report with to declare its own outcome. */
30
+ const DECLARATION = /^\s*outcome:\s*(succeeded|blocked|failed)\s*$/gim;
31
+
32
+ /**
33
+ * Read a child's self-declared outcome, if it made one. Last declaration wins:
34
+ * a report that revises itself means the later line.
35
+ *
36
+ * This is a *claim*, not evidence — the value is that it is parseable, and that
37
+ * a blocked child can say so in one place instead of burying it in prose.
38
+ * Absent or unparseable means "no claim", never a failure.
39
+ */
40
+ export function parseDeclaredOutcome(text: string | null | undefined): TaskOutcome | undefined {
41
+ if (!text) return undefined;
42
+ let found: TaskOutcome | undefined;
43
+ DECLARATION.lastIndex = 0;
44
+ for (const m of text.matchAll(DECLARATION)) found = m[1]!.toLowerCase() as TaskOutcome;
45
+ return found;
46
+ }
47
+
48
+ /** Strip the declaration line so it does not also show up in the report body. */
49
+ export function stripDeclaration(text: string): string {
50
+ return text.replace(DECLARATION, "").replace(/\n{3,}/g, "\n\n").trim();
51
+ }
52
+
53
+ /** What a finished gate proved about the work, in verification terms. */
54
+ export function gateVerification(outcome: GateOutcome): Verification {
55
+ if (outcome === "success") return "passed";
56
+ if (outcome === "failure" || outcome === "timeout") return "failed";
57
+ // result_missing and no_attestation both mean the gate settled without
58
+ // establishing anything — not a verdict against the work.
59
+ return "inconclusive";
60
+ }
61
+
62
+ export interface OutcomeInput {
63
+ /** Lifecycle: did the session itself run to the end? */
64
+ status: "running" | "done" | "error" | "aborted";
65
+ /** The child's own claim, when it made one. */
66
+ declared?: TaskOutcome;
67
+ /** What the gate proved, when one ran. */
68
+ verification?: Verification;
69
+ }
70
+
71
+ /**
72
+ * Settle the task outcome from the facts, most authoritative first:
73
+ * a session that did not finish cannot have succeeded; a gate that failed
74
+ * outranks any claim; then the child's own claim; then success by default.
75
+ */
76
+ export function deriveOutcome(input: OutcomeInput): TaskOutcome {
77
+ if (input.status !== "done") return "failed";
78
+ if (input.verification === "failed") return "failed";
79
+ if (input.declared) return input.declared;
80
+ return "succeeded";
81
+ }
82
+
83
+ /** One line for the report, naming both facts. */
84
+ export function outcomeLine(outcome: TaskOutcome, verification: Verification): string {
85
+ const how =
86
+ verification === "not-requested"
87
+ ? "no gate was requested, so this is the agent's own account"
88
+ : verification === "passed"
89
+ ? "a gate ran and passed"
90
+ : verification === "failed"
91
+ ? "a gate ran and failed"
92
+ : "a gate ran but proved nothing either way";
93
+ return `[outcome] ${outcome} — ${how}`;
94
+ }
package/src/repair.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Verification a model cannot talk its way past.
3
+ *
4
+ * `verify` asks a reviewer agent whether the work is good; that is a second
5
+ * opinion, and it defaults to PASS when the reply is unclear because a mangled
6
+ * review must not block a good result. A gate is the other kind of check: a
7
+ * command runs in the tree the child actually worked in, and its exit status
8
+ * and output are facts. The suite already had this — in @pify/workflow, where
9
+ * only a workflow script could reach it — while agent_run, the tool where
10
+ * children actually edit code, had nothing but the reviewer.
11
+ *
12
+ * A failed gate then gets one thing a workflow step does not: the child is
13
+ * still there, so it can be sent back with the failure and the gate re-run.
14
+ * Bounded, because an agent that cannot fix a build in two tries will not fix
15
+ * it in ten, and each attempt costs a full child run.
16
+ *
17
+ * Pure orchestration with injected seams, like verify.ts: the gate runner and
18
+ * the repair spawn are both parameters, so the whole cycle is testable without
19
+ * a shell or a live session.
20
+ */
21
+
22
+ import type { GateContract, GateOutcome, GateVerdict } from "./gate.ts";
23
+ import { gateVerification, type Verification } from "./outcome.ts";
24
+ import type { GateRecord } from "./types.ts";
25
+
26
+ /** The brief for a repair pass: the check, what it said, nothing else. */
27
+ export function repairPrompt(
28
+ task: string,
29
+ contract: GateContract,
30
+ verdict: { reason: string; output: string },
31
+ ): string {
32
+ const output = verdict.output.trim();
33
+ return [
34
+ "Your work did not pass its verification check. Fix the cause and stop — do not change anything the",
35
+ "check did not complain about, and do not modify the check itself to make it pass.",
36
+ "",
37
+ "== Original task ==",
38
+ task.trim(),
39
+ "",
40
+ `== Check ==\n${contract.command}`,
41
+ "",
42
+ `== Verdict ==\n${verdict.reason}`,
43
+ ...(output ? ["", `== Output ==\n${output.length > 4000 ? `…\n${output.slice(-4000)}` : output}`] : []),
44
+ "",
45
+ "When you are done, report exactly what you changed and why it fixes the check.",
46
+ ].join("\n");
47
+ }
48
+
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 };
52
+ /** Send the child back with a repair brief; resolves when that pass settles. */
53
+ repair(prompt: string): Promise<void>;
54
+ /**
55
+ * Can this agent change anything? A read-only agent handed a failing gate can
56
+ * only re-report it, so asking it to repair burns a child run to no purpose.
57
+ */
58
+ canRepair: boolean;
59
+ /** Repair passes allowed before the failure stands (0 disables). */
60
+ maxAttempts: number;
61
+ /** Other runs live in the same directory while the gate ran. */
62
+ sharedWith?: string[];
63
+ }
64
+
65
+ /**
66
+ * A repair is only worth spawning when the gate actually judged the work.
67
+ * `no_attestation` means the gate never produced a verdict — a missing runner,
68
+ * a typo, an unparseable pattern — and sending a child to fix a defect that was
69
+ * never demonstrated is how an agent ends up "fixing" working code.
70
+ */
71
+ export function repairable(outcome: GateOutcome): boolean {
72
+ return outcome === "failure" || outcome === "result_missing" || outcome === "timeout";
73
+ }
74
+
75
+ /** Run the gate, repair once (or `maxAttempts` times) if it failed, re-run. */
76
+ export async function runGateCycle(
77
+ task: string,
78
+ contract: GateContract,
79
+ cwd: string,
80
+ deps: GateCycleDeps,
81
+ ): Promise<{ record: GateRecord; verification: Verification }> {
82
+ let verdict = deps.runGate(contract, cwd);
83
+ let repairs = 0;
84
+ const limit = Math.max(0, Math.min(5, deps.maxAttempts));
85
+
86
+ while (!verdict.ok && deps.canRepair && repairs < limit && repairable(verdict.outcome)) {
87
+ await deps.repair(repairPrompt(task, contract, verdict));
88
+ repairs++;
89
+ verdict = deps.runGate(contract, cwd);
90
+ }
91
+
92
+ const record: GateRecord = {
93
+ command: contract.command,
94
+ outcome: verdict.outcome,
95
+ ok: verdict.ok,
96
+ reason: verdict.reason,
97
+ // A passing gate's output is noise in the caller's context; a failing one's
98
+ // is the whole point.
99
+ ...(verdict.ok ? {} : { output: verdict.output }),
100
+ ...(deps.sharedWith?.length ? { sharedWith: [...deps.sharedWith] } : {}),
101
+ ...(repairs > 0 ? { repairs } : {}),
102
+ };
103
+ return { record, verification: gateVerification(verdict.outcome) };
104
+ }
package/src/report.ts CHANGED
@@ -1,20 +1,70 @@
1
- import type { SwarmRun } from "./types.ts";
1
+ import { outcomeLine } from "./outcome.ts";
2
+ import type { ItemState, SwarmRun } from "./types.ts";
2
3
 
3
- /** Aggregated report returned to the parent model when a run finishes. */
4
+ /** Longest gate output kept per item; a failing suite prints books. */
5
+ const GATE_TAIL = 1200;
6
+
7
+ /**
8
+ * What the gate proved about one item. Shown whenever a gate ran — a pass is as
9
+ * much a fact as a failure, and silence would make "verified" and "never
10
+ * checked" look identical.
11
+ */
12
+ function gateLines(item: ItemState): string[] {
13
+ const gate = item.gate;
14
+ if (!gate) return [];
15
+ const lines = [`[gate] ${gate.outcome} — ${gate.reason} (\`${gate.command}\`)`];
16
+ if (gate.repairs) {
17
+ lines.push(` repaired ${gate.repairs} time${gate.repairs === 1 ? "" : "s"} and re-run.`);
18
+ }
19
+ if (gate.sharedWith?.length) {
20
+ lines.push(
21
+ ` ${gate.sharedWith.join(", ")} ${gate.sharedWith.length === 1 ? "was" : "were"} also changing this directory — the verdict is true of the tree, not of this item's work alone.`,
22
+ );
23
+ }
24
+ if (!gate.ok && gate.output) {
25
+ const tail = gate.output.length > GATE_TAIL ? `…\n${gate.output.slice(-GATE_TAIL)}` : gate.output;
26
+ lines.push(tail.replace(/^/gm, " "));
27
+ }
28
+ return lines;
29
+ }
30
+
31
+ function verdict(item: ItemState): string {
32
+ const parts = gateLines(item);
33
+ if (item.outcome) parts.push(outcomeLine(item.outcome, item.verification ?? "not-requested"));
34
+ return parts.length > 0 ? `\n\n${parts.join("\n")}` : "";
35
+ }
36
+
37
+ /**
38
+ * Aggregated report returned to the parent model when a run finishes.
39
+ *
40
+ * The header counts *outcomes*, not statuses. An item whose child ran to the
41
+ * end and then failed its gate is not something the caller wants filed under
42
+ * "done" — that is the whole reason the two are recorded separately.
43
+ */
4
44
  export function buildReport(run: SwarmRun): string {
5
- const counts = { done: 0, error: 0, aborted: 0 };
45
+ const counts = { succeeded: 0, blocked: 0, failed: 0, skipped: 0 };
6
46
  for (const item of run.items) {
7
- if (item.status === "done") counts.done++;
8
- else if (item.status === "error") counts.error++;
9
- else if (item.status === "aborted") counts.aborted++;
47
+ if (item.status === "skipped") counts.skipped++;
48
+ else if (item.outcome) counts[item.outcome]++;
49
+ else if (item.status === "done") counts.succeeded++;
50
+ else counts.failed++;
10
51
  }
52
+ const tally = [
53
+ `${counts.succeeded} succeeded`,
54
+ ...(counts.blocked ? [`${counts.blocked} blocked`] : []),
55
+ `${counts.failed} failed`,
56
+ ...(counts.skipped ? [`${counts.skipped} skipped`] : []),
57
+ ].join(", ");
11
58
 
12
- const header = `[swarm ${run.runId}] ${run.items.length} items — ${counts.done} done, ${counts.error} error, ${counts.aborted} aborted`;
59
+ const header = `[swarm ${run.runId}] ${run.items.length} items — ${tally}`;
13
60
 
14
61
  const sections = run.items.map((item) => {
15
62
  const label = `### ${item.index + 1}. [${item.agent}] ${item.item}`;
16
- if (item.status === "done") return `${label}\n${item.result ?? "(empty report)"}`;
17
- if (item.status === "error") return `${label}\nError: ${item.error ?? "unknown"}`;
63
+ if (item.status === "done") return `${label}\n${item.result ?? "(empty report)"}${verdict(item)}`;
64
+ if (item.status === "error") return `${label}\nError: ${item.error ?? "unknown"}${verdict(item)}`;
65
+ if (item.status === "skipped") {
66
+ return `${label}\nSkipped — ${item.error ?? "something it needed did not succeed"}. Nothing ran, so nothing was spent on it.`;
67
+ }
18
68
  if (item.status === "aborted") {
19
69
  return `${label}\nAborted (turn cap or stop). Partial:\n${item.result ?? "(none)"}`;
20
70
  }
@@ -31,3 +81,21 @@ export function buildStatusLine(run: SwarmRun): string {
31
81
  );
32
82
  return `[swarm ${run.runId}] ${run.status} — ${parts.join(" · ")}`;
33
83
  }
84
+
85
+ /**
86
+ * The interrupt for an item that failed while the rest of the run is still
87
+ * going. @pify/subagent already treats a failed background child as a steer
88
+ * rather than a polite follow-up — a broken intermediate the caller is likely
89
+ * building on should arrive now, not after the remaining items finish. This is
90
+ * the same rule for a swarm: one wake, for the first hard failure only.
91
+ */
92
+ export function earlyFailureNotice(run: SwarmRun, item: ItemState): string {
93
+ const reason = item.gate && !item.gate.ok ? item.gate.reason : (item.error ?? "unknown failure");
94
+ const pending = run.items.filter((i) => i.status === "queued" || i.status === "running").length;
95
+ return [
96
+ `[swarm ${run.runId}] item ${item.index + 1} (${item.id}, ${item.agent}) failed: ${reason}`,
97
+ pending > 0
98
+ ? `${pending} item${pending === 1 ? " is" : "s are"} still running and the full report follows when they settle — this is a warning, not the result. Do not build on this item's output, and do not poll swarm_status.`
99
+ : "The full report follows.",
100
+ ].join("\n");
101
+ }
package/src/types.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  * No imports from pi packages: src/ typechecks and runs standalone.
4
4
  */
5
5
 
6
+ import type { GateOutcome } from "./gate.ts";
7
+ import type { TaskOutcome, Verification } from "./outcome.ts";
8
+
6
9
  export const VALID_TOOLS = [
7
10
  "read",
8
11
  "bash",
@@ -48,7 +51,27 @@ export const DEFAULT_CONCURRENCY = 4;
48
51
  /** Safe default when no routing rule matches: read-only exploration. */
49
52
  export const FALLBACK_AGENT = "scout";
50
53
 
51
- export type ItemStatus = "queued" | "running" | "done" | "error" | "aborted";
54
+ /**
55
+ * "skipped" is a settled state, not a failure: the item never ran because
56
+ * something it needed did not produce usable input, and the caller asked for
57
+ * that to stop the branch rather than feed it a failure notice.
58
+ */
59
+ export type ItemStatus = "queued" | "running" | "done" | "error" | "aborted" | "skipped";
60
+
61
+ /** What a gate proved about one item, kept alongside the item it judged. */
62
+ export interface GateRecord {
63
+ command: string;
64
+ outcome: GateOutcome;
65
+ ok: boolean;
66
+ /** One line in this package's words. */
67
+ reason: string;
68
+ /** Trimmed output, kept only when the gate did not pass. */
69
+ output?: string;
70
+ /** Other items that were live in the same directory while it ran. */
71
+ sharedWith?: string[];
72
+ /** Repair passes spent trying to make it pass. */
73
+ repairs?: number;
74
+ }
52
75
 
53
76
  export interface ItemState {
54
77
  index: number;
@@ -63,8 +86,18 @@ export interface ItemState {
63
86
  tokens: number;
64
87
  result: string | null;
65
88
  error: string | null;
89
+ /** The directory the child worked in — its worktree when isolated. */
90
+ workDir?: string;
91
+ /** Set once the item settles: what the task came to, apart from whether the child finished. */
92
+ outcome?: TaskOutcome;
93
+ /** How well that outcome is known. "not-requested" when no gate ran. */
94
+ verification?: Verification;
95
+ gate?: GateRecord;
66
96
  }
67
97
 
98
+ /** How a dependent behaves when something it needs did not succeed. */
99
+ export type UpstreamFailurePolicy = "continue" | "skip";
100
+
68
101
  /**
69
102
  * "cancelled" is its own outcome, not a completion: someone stopped the run,
70
103
  * and calling it done would report results nobody produced.
package/src/widget.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  import { clampRows, clampWidth, MAX_WIDGET_ROWS } from "./widget-clamp.ts";
2
- import type { SwarmRun, ThemeLike } from "./types.ts";
2
+ import type { ItemState, SwarmRun, ThemeLike } from "./types.ts";
3
3
 
4
4
  const WIDTH = 54;
5
5
 
6
- function icon(status: string): string {
7
- switch (status) {
6
+ /**
7
+ * The row reports the *task*, not the child session. An item whose agent ran to
8
+ * the end and then failed its gate used to sit here as a green ✓, which is
9
+ * precisely the confusion the outcome field exists to remove.
10
+ */
11
+ function icon(item: ItemState): string {
12
+ switch (item.status) {
8
13
  case "queued":
9
14
  return "·";
10
15
  case "running":
11
16
  return "⟳";
17
+ case "skipped":
18
+ return "⊘";
12
19
  case "done":
13
- return "✓";
20
+ return item.outcome === "failed" ? "✗" : item.outcome === "blocked" ? "⚠" : "✓";
14
21
  case "error":
15
22
  return "✗";
16
23
  default:
@@ -18,6 +25,17 @@ function icon(status: string): string {
18
25
  }
19
26
  }
20
27
 
28
+ type Tone = "dim" | "warning" | "success" | "error";
29
+
30
+ function tone(item: ItemState): Tone {
31
+ if (item.status === "queued" || item.status === "skipped") return "dim";
32
+ if (item.status === "running") return "warning";
33
+ if (item.status !== "done") return "error";
34
+ if (item.outcome === "failed") return "error";
35
+ if (item.outcome === "blocked") return "warning";
36
+ return "success";
37
+ }
38
+
21
39
  /** Widget above the editor for the active (or just-finished) run. */
22
40
  export function buildWidgetLines(run: SwarmRun | null, theme: ThemeLike, now: number): string[] {
23
41
  if (!run) return [];
@@ -33,15 +51,9 @@ export function buildWidgetLines(run: SwarmRun | null, theme: ThemeLike, now: nu
33
51
  // Cap the rows: a large swarm would otherwise push the editor off screen,
34
52
  // since a Text-factory widget bypasses pi's ten-line guard.
35
53
  const rows = run.items.map((item) => {
36
- const paint =
37
- item.status === "running"
38
- ? (s: string) => theme.fg("warning", s)
39
- : item.status === "done"
40
- ? (s: string) => theme.fg("success", s)
41
- : item.status === "queued"
42
- ? dim
43
- : (s: string) => theme.fg("error", s);
44
- return `${dim("│ ")}${paint(`${icon(item.status)} ${clampWidth(item.agent, 24)}`)}${dim(` ${clampWidth(item.item, 32)}`)}`;
54
+ const color = tone(item);
55
+ const paint = (s: string) => theme.fg(color, s);
56
+ return `${dim("│ ")}${paint(`${icon(item)} ${clampWidth(item.agent, 24)}`)}${dim(` ${clampWidth(item.item, 32)}`)}`;
45
57
  });
46
58
  for (const row of clampRows(rows, MAX_WIDGET_ROWS, (hidden) => dim(`│ … +${hidden} more`))) {
47
59
  lines.push(row);