@pify/swarm 0.9.2 → 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 +17 -1
- package/extensions/swarm.ts +206 -9
- package/package.json +1 -1
- package/src/gate.ts +262 -0
- package/src/outcome.ts +94 -0
- package/src/repair.ts +104 -0
- package/src/report.ts +77 -9
- package/src/types.ts +34 -1
- package/src/widget.ts +25 -13
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
|
|
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
|
|
package/extensions/swarm.ts
CHANGED
|
@@ -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";
|
|
@@ -50,7 +51,10 @@ import {
|
|
|
50
51
|
readInbox,
|
|
51
52
|
} from "../src/mailbox.ts";
|
|
52
53
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
53
|
-
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";
|
|
54
58
|
import { routeItem } from "../src/routing.ts";
|
|
55
59
|
import { normalizeItems } from "../src/graph.ts";
|
|
56
60
|
import { runGraph } from "../src/schedule.ts";
|
|
@@ -62,6 +66,7 @@ import {
|
|
|
62
66
|
type AgentDef,
|
|
63
67
|
type ItemState,
|
|
64
68
|
type SwarmRun,
|
|
69
|
+
type UpstreamFailurePolicy,
|
|
65
70
|
} from "../src/types.ts";
|
|
66
71
|
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
67
72
|
import { basename, join } from "node:path";
|
|
@@ -349,14 +354,96 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
349
354
|
* DEFAULT_CONCURRENCY at once. A flat run (no needs anywhere) makes every
|
|
350
355
|
* item ready immediately, so this is identical to the old parallel pool.
|
|
351
356
|
*/
|
|
352
|
-
|
|
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(
|
|
353
390
|
ctx: UiContext,
|
|
354
391
|
run: SwarmRun,
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
useMailbox?: boolean,
|
|
392
|
+
item: ItemState,
|
|
393
|
+
def: AgentDef,
|
|
394
|
+
opts: RunOptions,
|
|
359
395
|
): Promise<void> {
|
|
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;
|
|
360
447
|
// One shared log per run; only created when the caller asked for it. Keyed
|
|
361
448
|
// on a per-run token (not the reused "s1" run id), so one run never reads a
|
|
362
449
|
// previous run's stale messages, and removed at the end so it never leaks.
|
|
@@ -375,6 +462,18 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
375
462
|
return [context, ...blocks].filter((s) => s && s.trim()).join("\n\n");
|
|
376
463
|
};
|
|
377
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
|
+
|
|
378
477
|
// Run each item once its needs finish, up to the concurrency cap; a flat
|
|
379
478
|
// run (no needs) has everything ready at once, exactly like the old pool.
|
|
380
479
|
try {
|
|
@@ -382,13 +481,27 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
382
481
|
run.items,
|
|
383
482
|
DEFAULT_CONCURRENCY,
|
|
384
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
|
+
}
|
|
385
496
|
const def = routeItem(item.item, defs, fixed);
|
|
386
497
|
item.agent = def.name;
|
|
387
498
|
const itemContext = contextFor(upstream);
|
|
388
499
|
if (isolate) {
|
|
389
500
|
try {
|
|
390
501
|
const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
|
|
502
|
+
item.workDir = iso.path;
|
|
391
503
|
await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
|
|
504
|
+
await gateItem(ctx, run, item, def, opts);
|
|
392
505
|
// Remove the worktree when the item changed nothing (the leak
|
|
393
506
|
// removeIfUnchanged fixes); keep it when there is work to merge.
|
|
394
507
|
const removed = removeIfUnchanged(ctx.cwd, iso);
|
|
@@ -401,7 +514,9 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
401
514
|
}
|
|
402
515
|
} else {
|
|
403
516
|
await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
|
|
517
|
+
await gateItem(ctx, run, item, def, opts);
|
|
404
518
|
}
|
|
519
|
+
settle(item);
|
|
405
520
|
},
|
|
406
521
|
() => run.status === "cancelled",
|
|
407
522
|
);
|
|
@@ -418,6 +533,10 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
418
533
|
}
|
|
419
534
|
|
|
420
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);
|
|
421
540
|
run.finishedAt = Date.now();
|
|
422
541
|
pi.appendEntry(RUN_ENTRY, run);
|
|
423
542
|
renderWidget();
|
|
@@ -459,7 +578,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
459
578
|
"conventions instead of silently conflicting. " +
|
|
460
579
|
"An item can be a plain string (independent) OR an object {task, id, needs:[ids]} to declare a " +
|
|
461
580
|
"dependency: a needed item's output is prepended to the dependent automatically, and the dependent " +
|
|
462
|
-
"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.",
|
|
463
586
|
parameters: Type.Object({
|
|
464
587
|
items: Type.Array(
|
|
465
588
|
Type.Union([
|
|
@@ -480,6 +603,27 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
480
603
|
mailbox: Type.Optional(
|
|
481
604
|
Type.Boolean({ description: "Give the agents swarm_post/swarm_inbox to share facts mid-run" }),
|
|
482
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
|
+
),
|
|
483
627
|
background: Type.Optional(Type.Boolean()),
|
|
484
628
|
}),
|
|
485
629
|
async execute(
|
|
@@ -491,6 +635,10 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
491
635
|
background?: boolean;
|
|
492
636
|
isolation?: string;
|
|
493
637
|
mailbox?: boolean;
|
|
638
|
+
gate?: string;
|
|
639
|
+
gateExpect?: string;
|
|
640
|
+
gateRepairs?: number;
|
|
641
|
+
on_upstream_failure?: UpstreamFailurePolicy;
|
|
494
642
|
},
|
|
495
643
|
signal,
|
|
496
644
|
_onUpdate,
|
|
@@ -509,6 +657,25 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
509
657
|
throw new Error(`Swarm ${activeRun.runId} is still running — wait or check swarm_status.`);
|
|
510
658
|
}
|
|
511
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
|
+
|
|
512
679
|
runCounter++;
|
|
513
680
|
const run: SwarmRun = {
|
|
514
681
|
runId: `s${runCounter}`,
|
|
@@ -545,8 +712,38 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
545
712
|
}
|
|
546
713
|
}
|
|
547
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
|
+
|
|
548
725
|
if (run.background) {
|
|
549
|
-
|
|
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)
|
|
550
747
|
.then(() => {
|
|
551
748
|
notify(uiCtx, `swarm ${run.runId} finished`, "info");
|
|
552
749
|
// The report goes to the agent, not only to the screen — otherwise
|
|
@@ -578,7 +775,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
578
775
|
}
|
|
579
776
|
|
|
580
777
|
try {
|
|
581
|
-
await executeRun(uiCtx, run,
|
|
778
|
+
await executeRun(uiCtx, run, options);
|
|
582
779
|
} finally {
|
|
583
780
|
if (stopListening) stopListening();
|
|
584
781
|
}
|
package/package.json
CHANGED
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/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
|
|
1
|
+
import { outcomeLine } from "./outcome.ts";
|
|
2
|
+
import type { ItemState, SwarmRun } from "./types.ts";
|
|
2
3
|
|
|
3
|
-
/**
|
|
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 = {
|
|
45
|
+
const counts = { succeeded: 0, blocked: 0, failed: 0, skipped: 0 };
|
|
6
46
|
for (const item of run.items) {
|
|
7
|
-
if (item.status === "
|
|
8
|
-
else if (item.
|
|
9
|
-
else if (item.status === "
|
|
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 — ${
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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);
|