@coreplane/switchboard 1.254.1 → 1.256.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/dist/assets/.dockerignore +3 -0
- package/dist/assets/Dockerfile +12 -1
- package/dist/assets/config/config.example.yaml +6 -1
- package/dist/assets/deploy/cloudflare/worker.ts +39 -23
- package/dist/assets/deploy/cloudflare-memory/worker.ts +552 -5
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +5 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +13 -1
- package/dist/assets/deploy/cloudflare-resident/drain.ts +55 -1
- package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
- package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +358 -42
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
- package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
- package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
- package/dist/assets/deploy/secrets.manifest.json +12 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +52 -0
- package/dist/assets/src/core/budgets.ts +35 -2
- package/dist/assets/src/core/coordinator/contract.ts +6 -0
- package/dist/assets/src/core/coordinator/driver.ts +59 -6
- package/dist/assets/src/core/costs.ts +39 -16
- package/dist/assets/src/core/pipelineStanding.ts +62 -0
- package/dist/assets/src/core/plane/decide.ts +389 -22
- package/dist/assets/src/core/refusal.ts +3 -0
- package/dist/assets/src/core/reviewVerdict.ts +64 -2
- package/dist/assets/src/core/runEvents.ts +31 -12
- package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
- package/dist/assets/src/core/runLedger/types.ts +3 -0
- package/dist/assets/src/core/runRecord.ts +24 -7
- package/dist/assets/src/core/ship/contract.ts +20 -30
- package/dist/assets/src/core/ship/coordinator.ts +519 -126
- package/dist/assets/src/core/trace/workerTrace.ts +3 -0
- package/dist/assets/src/execution/sandboxErrors.ts +77 -6
- package/dist/assets/web/dist/.vite/manifest.json +61 -60
- package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
- package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
- package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-BEiStFwW.js} +1 -1
- package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
- package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
- package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-JqVSDOok.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-Viim-bus.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-CYUy8McC.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-Dv6UMaPy.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-Bd-5Rypv.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
- package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
- package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-Nl3uaxrP.js} +1 -1
- package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
- package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-DRxWlffc.js} +2 -2
- package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
- package/dist/cli.js +4400 -3079
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
- package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
- package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +0 -9
- package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
- package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
- package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +0 -1
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
findingsAtOrAbove,
|
|
42
42
|
formatFinding,
|
|
43
43
|
isAddressSeverity,
|
|
44
|
+
severityCounts,
|
|
44
45
|
type AddressSeverity,
|
|
45
46
|
type AddressSeveritySource,
|
|
46
47
|
type Finding,
|
|
@@ -76,21 +77,70 @@ export interface ShipCaps {
|
|
|
76
77
|
* rounds that must still follow, capped at the preset's ask, refused under the
|
|
77
78
|
* round's floor. Nothing here holds a reserve of its own. */
|
|
78
79
|
|
|
79
|
-
/** What
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
export
|
|
80
|
+
/** What actually ended an interrupted child, from the ledger that saw it
|
|
81
|
+
* (issue 1876): the ending's sentence names the cause in the user's nouns —
|
|
82
|
+
* the bot restarted, the resident container was replaced, the sandbox failed
|
|
83
|
+
* — never "the bot restarted" for a site three causes reach. Read off the
|
|
84
|
+
* child's record by the bot's `read-record` (`child_interrupted` / the resume
|
|
85
|
+
* notes) and carried on the `interrupted` ending; absent when the record
|
|
86
|
+
* named none. */
|
|
87
|
+
export type InterruptionCause = "bot_restart" | "container_replaced" | "sandbox_fault";
|
|
88
|
+
|
|
89
|
+
/** What a ship pipeline's thread and card say when its child died under it
|
|
90
|
+
* (run-history item 36): the work it did stands on GitHub with nobody driving
|
|
91
|
+
* it, so the note names the actual cause (issue 1876 — the site is reached by
|
|
92
|
+
* a bot restart, a resident container replacement and a sandbox fault, and
|
|
93
|
+
* only the ledger's word picks one; with none the sentence claims no cause),
|
|
94
|
+
* the PR when one was opened and the exact re-issue that continues the loop —
|
|
95
|
+
* the same entry the preflight's resume-at-review takes (agent-ship item 10).
|
|
96
|
+
* Without a PR the task itself is the re-issue: round 0 runs again on the
|
|
97
|
+
* pipeline's own deterministic branch. A child whose container was replaced
|
|
98
|
+
* resumes from its request by itself (issue 1903) and the pipeline keeps
|
|
99
|
+
* waiting — this note is written only when that resume failed or no resume
|
|
100
|
+
* applied. */
|
|
101
|
+
/** The cause an interruption's recorded words name (issue 1876): the ledger
|
|
102
|
+
* that saw the roll wrote them — the relaunch refusals and the lost-workspace
|
|
103
|
+
* notes name the replaced container, the sandbox executor's wordings the
|
|
104
|
+
* sandbox, the boot gap names the restart — and the ending's sentence repeats
|
|
105
|
+
* the cause, never "the bot restarted" for words that say otherwise. Each
|
|
106
|
+
* pattern is anchored on the exact phrases those ledgers write —
|
|
107
|
+
* `src/core/harness/contract.ts` and `src/core/dispatch/relaunch.ts`
|
|
108
|
+
* ("container replaced under the run", "replacement container"),
|
|
109
|
+
* `src/core/dispatch/reattach.ts` ("workspace lost", "could not be
|
|
110
|
+
* re-attached"), `src/execution/sandboxLifecycle.ts` and the sandbox worker's
|
|
111
|
+
* failures ("sandbox recycled", "sandbox worker", "sandbox exec"),
|
|
112
|
+
* `src/core/boot.ts` and the resume notes ("bot restarted", the deploy
|
|
113
|
+
* hand-off's "next generation") — so an unrelated word ("regenerating…", a
|
|
114
|
+
* reason merely mentioning a sandbox path) cannot classify. Unrecognized
|
|
115
|
+
* words are no cause: the sentence then claims none. */
|
|
116
|
+
export function interruptionCauseOfWords(words: string): InterruptionCause | undefined {
|
|
117
|
+
if (
|
|
118
|
+
/\bcontainer (?:was )?replaced\b|\breplac(?:ed|ement) container\b|\bworkspace lost\b|\bcould not be re-attached\b/i.test(
|
|
119
|
+
words,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return "container_replaced";
|
|
123
|
+
if (/\bsandbox (?:recycled|worker|exec|runtime)\b/i.test(words)) return "sandbox_fault";
|
|
124
|
+
if (/\bbot restart(?:ed|s)?\b|\b(?:next|previous|another) generation\b/i.test(words)) return "bot_restart";
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function shipInterruptedNote(prUrl?: string, cause?: InterruptionCause): string {
|
|
87
129
|
const stands = prUrl
|
|
88
130
|
? `Its work stands on GitHub: ${prUrl}.`
|
|
89
131
|
: "Whatever it pushed stands on its pipeline branch; no PR was opened yet.";
|
|
90
132
|
const reissue = prUrl
|
|
91
133
|
? `To continue the review loop, re-issue \`agent:ship\` in this thread with only the PR URL (${prUrl}).`
|
|
92
134
|
: "To continue, re-issue `agent:ship` in this thread with the task — round 0 runs again on the same branch.";
|
|
93
|
-
|
|
135
|
+
const opening =
|
|
136
|
+
cause === "container_replaced"
|
|
137
|
+
? "⚠️ The resident container running this pipeline's child was replaced (a deploy's image swap) and the child could not resume, so the pipeline stopped."
|
|
138
|
+
: cause === "sandbox_fault"
|
|
139
|
+
? "⚠️ The sandbox running this pipeline's child failed and the child could not resume, so the pipeline stopped."
|
|
140
|
+
: cause === "bot_restart"
|
|
141
|
+
? "⚠️ The bot restarted while this ship pipeline was running, so the pipeline stopped."
|
|
142
|
+
: "⚠️ This ship pipeline's child was interrupted and could not resume, so the pipeline stopped.";
|
|
143
|
+
return `${opening} ${stands} ${reissue}`;
|
|
94
144
|
}
|
|
95
145
|
|
|
96
146
|
// ---- the plan graph --------------------------------------------------------------------------------
|
|
@@ -404,6 +454,11 @@ export interface RoundRef {
|
|
|
404
454
|
/** Round 0 is the coding round; review round n and its findings step share n. */
|
|
405
455
|
index: number;
|
|
406
456
|
kind: RoundKind;
|
|
457
|
+
/** The round's attempt when it was re-run (agent-ship item 9, issue 1932):
|
|
458
|
+
* 2 for round 0's one re-run after a provider transient with nothing
|
|
459
|
+
* pushed. Absent on a first attempt; suffixes the round's step names so the
|
|
460
|
+
* Workflow's durable cache never hands the re-run the dead attempt's answers. */
|
|
461
|
+
attempt?: number;
|
|
407
462
|
}
|
|
408
463
|
export type ChildPreset = "coding" | "review";
|
|
409
464
|
|
|
@@ -468,9 +523,10 @@ export type CoordinatorAction =
|
|
|
468
523
|
* over an already-shipped pull request. */
|
|
469
524
|
entry?: true;
|
|
470
525
|
/** Set after a coding child died: the bot opens the pull request from the
|
|
471
|
-
* pushed branch itself (title from
|
|
472
|
-
*
|
|
473
|
-
*
|
|
526
|
+
* pushed branch itself (title from this run's submitted description when
|
|
527
|
+
* the record holds one, else a conventional fallback from the unit's
|
|
528
|
+
* title — issue 1877; body from the description when the record holds
|
|
529
|
+
* one) instead of answering `none` over stranded work. */
|
|
474
530
|
recover?: { runId: string };
|
|
475
531
|
/** The pull request the machine has adopted (`state.pr`), when it holds
|
|
476
532
|
* one: the child may have worked that pull request's own head branch,
|
|
@@ -480,7 +536,10 @@ export type CoordinatorAction =
|
|
|
480
536
|
* over a record fact written minutes earlier. */
|
|
481
537
|
pr?: number;
|
|
482
538
|
}
|
|
483
|
-
|
|
539
|
+
/** `queued`: the pull request is in the base's merge queue — the bot reads
|
|
540
|
+
* the queue's outcome (merged, still queued, or removed with the reason)
|
|
541
|
+
* instead of attempting the squash. */
|
|
542
|
+
| { type: "merge"; step: string; prNumber: number; headSha: string; queued?: true }
|
|
484
543
|
/** Read the check runs at the reviewed head (record 0055, the round verdict):
|
|
485
544
|
* the merge door's own reading, folded into the round. `retry` names the
|
|
486
545
|
* failed checks whose one flake re-run the machine is spending: the bot
|
|
@@ -524,6 +583,14 @@ export type ChildFacts =
|
|
|
524
583
|
leaseStartedAt?: number;
|
|
525
584
|
costUsd?: number | null;
|
|
526
585
|
handoffLists?: Handoff;
|
|
586
|
+
/** The failure by name off a `failed` run's record (run-history item 57):
|
|
587
|
+
* `provider_transient` marks a child a gateway 5xx, a cut stream or a
|
|
588
|
+
* gateway timeout ended past the harness's retry ladder (issue 1932). */
|
|
589
|
+
failure?: { kind: string };
|
|
590
|
+
/** What ended an `interrupted` child, off its record's own events (issue
|
|
591
|
+
* 1876): the ending's sentence names it instead of claiming a bot
|
|
592
|
+
* restart for every cause. */
|
|
593
|
+
interruption?: InterruptionCause;
|
|
527
594
|
};
|
|
528
595
|
|
|
529
596
|
/** What heads the unit's branch on GitHub: nothing, an open pull request, or —
|
|
@@ -580,14 +647,25 @@ export type StepReturn =
|
|
|
580
647
|
| { type: "spawn"; step: string; outcome: "busy"; runId?: string; at: number }
|
|
581
648
|
| { type: "spawn"; step: string; outcome: "refused"; refusal: string; message?: string; at: number }
|
|
582
649
|
| { type: "spawn"; step: string; outcome: "failed"; reason: string; at: number }
|
|
650
|
+
// The hosted parent's hard stop landed (record 0060; issue 1924): the bot
|
|
651
|
+
// refuses the spawn over the instance row's stop mark, and the unit ends
|
|
652
|
+
// `stopped` — nothing more is run.
|
|
653
|
+
| { type: "spawn"; step: string; outcome: "stopped"; at: number }
|
|
583
654
|
| { type: "wait"; step: string; outcome: "event" | "timeout" }
|
|
584
|
-
|
|
655
|
+
// `stopped` on a read: the instance row carries the hard stop's mark, so a
|
|
656
|
+
// finished child ends its unit `stopped` whatever the child's own status.
|
|
657
|
+
/** `restartedAs`: the interrupted child restarted from its request as this
|
|
658
|
+
* run (issue 1903 — a replaced container's child resumes by itself), so the
|
|
659
|
+
* machine keeps waiting on the successor instead of ending the unit. */
|
|
660
|
+
| { type: "read-record"; step: string; run: ChildFacts; stopped?: true; restartedAs?: string; at: number }
|
|
585
661
|
| { type: "pr-check"; step: string; pr: PrCheck; at: number }
|
|
586
662
|
| { type: "merge"; step: string; outcome: "merged"; sha: string; at: number }
|
|
587
663
|
// The door found the pull request already merged after the approval — auto-merge
|
|
588
664
|
// fired, or a person merged — so the runner merged nothing (`by: other`).
|
|
589
665
|
| { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
|
|
590
|
-
|
|
666
|
+
/** The door enqueued the pull request (or found it still queued), or the
|
|
667
|
+
* queue removed it — the reason is the queue's own (issue 2011). */
|
|
668
|
+
| { type: "merge"; step: string; outcome: "pending" | "refused" | "enqueued" | "removed"; reason: string; at: number }
|
|
591
669
|
/** The checks read at the reviewed head; `checks` absent means GitHub could
|
|
592
670
|
* not be read, which the machine treats as pending (record 0055). A retry
|
|
593
671
|
* ask answers `retried` instead: whether the bot dispatched the re-run —
|
|
@@ -629,6 +707,20 @@ export function checkFinding(f: CheckFailure): Finding {
|
|
|
629
707
|
};
|
|
630
708
|
}
|
|
631
709
|
|
|
710
|
+
/** A merge-queue removal as a finding of the round (issue 2011): the queue's
|
|
711
|
+
* reason — a failing check inside the queue, a conflict — rides the findings
|
|
712
|
+
* brief exactly as a red check does, severity `blocking` so the level in
|
|
713
|
+
* force always counts it, and a fix round follows. */
|
|
714
|
+
export function queueRemovalFinding(reason: string): Finding {
|
|
715
|
+
return {
|
|
716
|
+
id: "check:merge-queue",
|
|
717
|
+
severity: "blocking",
|
|
718
|
+
file: "merge queue",
|
|
719
|
+
title: `removed from the merge queue — ${reason}`,
|
|
720
|
+
check: true,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
632
724
|
/** The check findings of a review round: the rows the checks step appended to
|
|
633
725
|
* the round's findings, told apart by provenance — the `check` flag only
|
|
634
726
|
* `checkFinding` sets, never the id: a reviewer's id is a free string, and a
|
|
@@ -653,6 +745,20 @@ export type UnitEnding =
|
|
|
653
745
|
* The unit is done and its dependents start on a base that carries it. */
|
|
654
746
|
| { kind: "already_landed"; landed: HandoffLanded[]; round: RoundRef; runId: string; reviewRounds: number }
|
|
655
747
|
| { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
|
|
748
|
+
/** Every finding the round would act on is human-gated — a receipt only a
|
|
749
|
+
* person can produce (issue 1990; the reviewer set the flag through
|
|
750
|
+
* `submit_verdict`, agent-review item 5) — so a fix round could change
|
|
751
|
+
* nothing: the unit ends held for a person, the ending carrying the
|
|
752
|
+
* human-gated rows, and a re-issue with the pull request resumes at the
|
|
753
|
+
* review round once the receipt is posted (item 10's resume path). */
|
|
754
|
+
| {
|
|
755
|
+
kind: "held";
|
|
756
|
+
pr?: PrRef;
|
|
757
|
+
round: RoundRef;
|
|
758
|
+
findings: Finding[];
|
|
759
|
+
verdict: "approve" | "request_changes";
|
|
760
|
+
reviewRounds: number;
|
|
761
|
+
}
|
|
656
762
|
| { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
|
|
657
763
|
| { kind: "round_cap"; maxRounds: number; reviewRounds: number }
|
|
658
764
|
| {
|
|
@@ -710,7 +816,15 @@ export type UnitEnding =
|
|
|
710
816
|
spent: ShipBudgetSpent;
|
|
711
817
|
}
|
|
712
818
|
| { kind: "no_verdict"; round: RoundRef; reviewRounds: number; finalReply?: string }
|
|
713
|
-
|
|
819
|
+
/** Round 0's coding child died on a provider transient — a model-gateway
|
|
820
|
+
* 5xx, a cut stream, a gateway timeout, past the harness's retry ladder —
|
|
821
|
+
* with nothing pushed, TWICE: the first such death re-ran the round once
|
|
822
|
+
* (the ledger row and the branch untouched, a re-run costs only minutes),
|
|
823
|
+
* and the second is the ending (agent-ship item 9, issue 1932). Named
|
|
824
|
+
* `transient` so it reads as a condition beside `checks_failed` and `held`
|
|
825
|
+
* in the plane's table, never as the child failing on its task. */
|
|
826
|
+
| { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number }
|
|
827
|
+
| { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number; cause?: InterruptionCause }
|
|
714
828
|
| { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number }
|
|
715
829
|
/** The unit idles instead of ending (record 0051): with the resolved
|
|
716
830
|
* `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
|
|
@@ -737,18 +851,24 @@ export type UnitEnding =
|
|
|
737
851
|
reviewRounds: number;
|
|
738
852
|
};
|
|
739
853
|
|
|
740
|
-
/** The kinds that idle: every ending but the
|
|
854
|
+
/** The kinds that idle: every ending but the ended ones — the unit is
|
|
741
855
|
* unfinished (a cap, a stop, an abort, a refused merge, a segment's end) and
|
|
742
|
-
* a reply could continue it. `merged`, `already_landed`, `merge_ready
|
|
743
|
-
* `refused` never idle: the first two are done, merge-ready waits
|
|
744
|
-
* person's merge,
|
|
745
|
-
|
|
856
|
+
* a reply could continue it. `merged`, `already_landed`, `merge_ready`,
|
|
857
|
+
* `held` and `refused` never idle: the first two are done, merge-ready waits
|
|
858
|
+
* only for a person's merge, held waits only for a person's receipt (a fix
|
|
859
|
+
* round could change nothing, so nothing here can continue it), and a
|
|
860
|
+
* refused child would be refused again. */
|
|
861
|
+
export type IdleWhy = Exclude<
|
|
862
|
+
UnitEnding["kind"],
|
|
863
|
+
"idle" | "merged" | "already_landed" | "merge_ready" | "held" | "refused"
|
|
864
|
+
>;
|
|
746
865
|
|
|
747
866
|
const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
|
|
748
867
|
"idle",
|
|
749
868
|
"merged",
|
|
750
869
|
"already_landed",
|
|
751
870
|
"merge_ready",
|
|
871
|
+
"held",
|
|
752
872
|
"refused",
|
|
753
873
|
]);
|
|
754
874
|
|
|
@@ -878,9 +998,19 @@ type Phase =
|
|
|
878
998
|
* pushed: the pr-check recovers a pushed branch by opening its pull
|
|
879
999
|
* request; with nothing pushed the unit ends with the child's own reason. */
|
|
880
1000
|
dead?: "failed" | "interrupted";
|
|
1001
|
+
/** What ended the dead child (issue 1876), for the `interrupted` ending's sentence. */
|
|
1002
|
+
cause?: InterruptionCause;
|
|
1003
|
+
/** The dead child's record names a provider transient (`failure:
|
|
1004
|
+
* provider_transient`, issue 1932): with nothing pushed, round 0 is
|
|
1005
|
+
* re-run once instead of the unit aborting; a second transient in the
|
|
1006
|
+
* same round is the `transient` ending. */
|
|
1007
|
+
transient?: true;
|
|
881
1008
|
}
|
|
882
|
-
|
|
883
|
-
|
|
1009
|
+
/** `queued`: the door enqueued the pull request — the base takes changes
|
|
1010
|
+
* only through a merge queue (issue 2011) — so every later ask reads the
|
|
1011
|
+
* queue's outcome instead of attempting the squash again. */
|
|
1012
|
+
| { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
|
|
1013
|
+
| { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
|
|
884
1014
|
/** The round's checks step (record 0055): the check runs at the reviewed head
|
|
885
1015
|
* are read after an approve settles, before merge_ready or the merge door.
|
|
886
1016
|
* `graced`: a head with no check reported has had its one-chunk grace;
|
|
@@ -1025,7 +1155,8 @@ function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
|
|
|
1025
1155
|
export const stepPrefixOf = (unit: string, session: UnitSession | undefined): string =>
|
|
1026
1156
|
session !== undefined && session.segment > 1 ? `${unit}/s${session.segment}` : unit;
|
|
1027
1157
|
const stepPrefix = (s: UnitPipelineState) => stepPrefixOf(s.input.unit.id, s.input.session);
|
|
1028
|
-
const roundStep = (s: UnitPipelineState, round: RoundRef) =>
|
|
1158
|
+
const roundStep = (s: UnitPipelineState, round: RoundRef) =>
|
|
1159
|
+
`${stepPrefix(s)}/${round.index}/${round.kind}${round.attempt !== undefined ? `/a${round.attempt}` : ""}`;
|
|
1029
1160
|
|
|
1030
1161
|
function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
|
|
1031
1162
|
const unit = s.input.unit.id;
|
|
@@ -1149,7 +1280,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
|
1149
1280
|
timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, p.waitMs - (s.clock - p.since))),
|
|
1150
1281
|
};
|
|
1151
1282
|
case "merge":
|
|
1152
|
-
return {
|
|
1283
|
+
return {
|
|
1284
|
+
type: "merge",
|
|
1285
|
+
step: `${unit}/merge/${p.n}`,
|
|
1286
|
+
prNumber: p.pr.number,
|
|
1287
|
+
headSha: p.headSha,
|
|
1288
|
+
...(p.queued === true ? { queued: true as const } : {}),
|
|
1289
|
+
};
|
|
1153
1290
|
case "merge-wait":
|
|
1154
1291
|
return {
|
|
1155
1292
|
type: "wait-checks",
|
|
@@ -1194,7 +1331,10 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
|
|
|
1194
1331
|
function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEnding, { kind: "idle" }> | undefined {
|
|
1195
1332
|
if ((s.input.idleDays ?? 0) <= 0) return undefined;
|
|
1196
1333
|
if (NEVER_IDLES.has(ending.kind)) return undefined;
|
|
1197
|
-
const old = ending as Exclude<
|
|
1334
|
+
const old = ending as Exclude<
|
|
1335
|
+
UnitEnding,
|
|
1336
|
+
{ kind: "idle" | "merged" | "already_landed" | "merge_ready" | "held" | "refused" }
|
|
1337
|
+
>;
|
|
1198
1338
|
const grant = s.input.grant ?? DEFAULT_GRANT;
|
|
1199
1339
|
// Unspent: an idle spends no renewal — the wake's segment does (this plan's
|
|
1200
1340
|
// fifth unit) — so the row says what the grant still holds.
|
|
@@ -1234,6 +1374,29 @@ function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEndin
|
|
|
1234
1374
|
/** A gated finding as the gate note names it: `F1 (minor)`. */
|
|
1235
1375
|
const gateLabel = (f: Finding): string => `${f.id} (${f.severity})`;
|
|
1236
1376
|
|
|
1377
|
+
/** The held decision (issue 1990): the findings the round would act on — the
|
|
1378
|
+
* gated set on an approve, every finding on a request_changes — are all
|
|
1379
|
+
* human-gated, read off the reviewer's own flag and never prose. One
|
|
1380
|
+
* actionable finding beside a human-gated one keeps the fix round: the
|
|
1381
|
+
* dispositions cover the human-gated row like any other (declined, with the
|
|
1382
|
+
* person named). An empty set decides nothing. */
|
|
1383
|
+
const allHumanGated = (findings: readonly Finding[]): boolean =>
|
|
1384
|
+
findings.length > 0 && findings.every((f) => f.humanGated === true);
|
|
1385
|
+
|
|
1386
|
+
const heldEnding = (
|
|
1387
|
+
s: UnitPipelineState,
|
|
1388
|
+
round: RoundRef,
|
|
1389
|
+
findings: Finding[],
|
|
1390
|
+
verdict: "approve" | "request_changes",
|
|
1391
|
+
): UnitEnding => ({
|
|
1392
|
+
kind: "held",
|
|
1393
|
+
...(s.pr !== undefined ? { pr: s.pr } : {}),
|
|
1394
|
+
round,
|
|
1395
|
+
findings,
|
|
1396
|
+
verdict,
|
|
1397
|
+
reviewRounds: s.reviewRounds,
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1237
1400
|
const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): RoundNote => ({
|
|
1238
1401
|
type: "round",
|
|
1239
1402
|
index: round.index,
|
|
@@ -1332,7 +1495,19 @@ function settleCoding(
|
|
|
1332
1495
|
// request (agent-ship items 10 and 15), and only a branch with
|
|
1333
1496
|
// nothing on it ends the unit with the child's own reason.
|
|
1334
1497
|
if (facts.status === "failed")
|
|
1335
|
-
return {
|
|
1498
|
+
return {
|
|
1499
|
+
state: {
|
|
1500
|
+
...next,
|
|
1501
|
+
phase: {
|
|
1502
|
+
at: "pr-check",
|
|
1503
|
+
round,
|
|
1504
|
+
runId,
|
|
1505
|
+
dead: "failed",
|
|
1506
|
+
...(facts.failure?.kind === "provider_transient" ? { transient: true as const } : {}),
|
|
1507
|
+
},
|
|
1508
|
+
},
|
|
1509
|
+
notes: [],
|
|
1510
|
+
};
|
|
1336
1511
|
next = {
|
|
1337
1512
|
...next,
|
|
1338
1513
|
phase: {
|
|
@@ -1429,6 +1604,9 @@ function settleReview(
|
|
|
1429
1604
|
},
|
|
1430
1605
|
notes,
|
|
1431
1606
|
);
|
|
1607
|
+
// Every gated finding is human-gated (issue 1990): a fix round could
|
|
1608
|
+
// change nothing, so the unit ends held for a person instead.
|
|
1609
|
+
if (allHumanGated(gated)) return end(next, heldEnding(next, round, gated, "approve"), notes);
|
|
1432
1610
|
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
1433
1611
|
return end(
|
|
1434
1612
|
next,
|
|
@@ -1457,6 +1635,11 @@ function settleReview(
|
|
|
1457
1635
|
},
|
|
1458
1636
|
notes,
|
|
1459
1637
|
);
|
|
1638
|
+
// Every finding of the round is human-gated (issue 1990): no fix round can
|
|
1639
|
+
// change anything, so the unit ends held for a person's receipt instead of
|
|
1640
|
+
// spending a coding child — or the round cap — on it.
|
|
1641
|
+
if (allHumanGated(verdict.findings ?? []))
|
|
1642
|
+
return end(next, heldEnding(next, round, verdict.findings ?? [], "request_changes"), notes);
|
|
1460
1643
|
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
1461
1644
|
return end(
|
|
1462
1645
|
next,
|
|
@@ -1500,14 +1683,51 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
|
|
|
1500
1683
|
};
|
|
1501
1684
|
}
|
|
1502
1685
|
|
|
1686
|
+
/** The order the checks step reads a head in (record 0055, issue 1991):
|
|
1687
|
+
* a failed check is the round's answer as soon as it is read, whatever else
|
|
1688
|
+
* is still pending — with the flake rule spending its one re-run first when
|
|
1689
|
+
* every failure is a suspect — and only a head with no failed check waits on
|
|
1690
|
+
* the pending ones; a head with no check reported gets one chunk of grace;
|
|
1691
|
+
* the rest is green. One function so the siblings that fold more outcomes
|
|
1692
|
+
* into the step (a held ending, a transient round re-run) read the same order. */
|
|
1693
|
+
function checksVerdict(
|
|
1694
|
+
checks: RoundChecks | undefined,
|
|
1695
|
+
p: { retried: boolean; graced: boolean },
|
|
1696
|
+
):
|
|
1697
|
+
| { kind: "retry"; names: string[] }
|
|
1698
|
+
| { kind: "failed"; failed: CheckFailure[] }
|
|
1699
|
+
| { kind: "pending" }
|
|
1700
|
+
| { kind: "grace" }
|
|
1701
|
+
| { kind: "green" } {
|
|
1702
|
+
if (checks !== undefined && checks.failed.length > 0) {
|
|
1703
|
+
// The flake rule (record 0055): a suspected flake — a test timeout or
|
|
1704
|
+
// runner stall on a shard whose test files the pull request's changed
|
|
1705
|
+
// paths never touch — is re-run once before it becomes a finding. One
|
|
1706
|
+
// real failure among them makes the round's answer already known, so the
|
|
1707
|
+
// re-run is spent only when every failure is a suspect.
|
|
1708
|
+
if (!p.retried && checks.failed.every((f) => f.flakeSuspect === true))
|
|
1709
|
+
return { kind: "retry", names: checks.failed.map((f) => f.name) };
|
|
1710
|
+
return { kind: "failed", failed: checks.failed };
|
|
1711
|
+
}
|
|
1712
|
+
// GitHub unreadable answers as pending and is re-read at the chunk's end.
|
|
1713
|
+
if (checks === undefined || checks.pending.length > 0) return { kind: "pending" };
|
|
1714
|
+
// No check reported at the head: one chunk of grace — the first check starts
|
|
1715
|
+
// within minutes where CI exists — then the round proceeds, so a repository
|
|
1716
|
+
// without CI costs one chunk per round and never idles (record 0055).
|
|
1717
|
+
if (checks.total === 0 && !p.graced) return { kind: "grace" };
|
|
1718
|
+
return { kind: "green" };
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1503
1721
|
/** What the checks step answered, folded into the round (record 0055). Pure
|
|
1504
1722
|
* over the phase: a retry ask waits for the head to settle and reads again;
|
|
1505
|
-
*
|
|
1723
|
+
* a failed check becomes a check finding under a round note of its own and
|
|
1724
|
+
* the findings step runs as for any changes-requested round — never
|
|
1725
|
+
* merge_ready, never the merge door — as soon as it is read, whatever else
|
|
1726
|
+
* is still pending (issue 1991); only a head with no failed check waits a
|
|
1727
|
+
* chunk inside the step's ask on what is unreadable or pending and then
|
|
1506
1728
|
* proceeds — the ending's facts read names what is still pending; a head with
|
|
1507
|
-
* no check reported waits one chunk of grace and never more; a
|
|
1508
|
-
*
|
|
1509
|
-
* runs as for any changes-requested round — never merge_ready, never the
|
|
1510
|
-
* merge door; a green head proceeds with no wait added. */
|
|
1729
|
+
* no check reported waits one chunk of grace and never more; a green head
|
|
1730
|
+
* proceeds with no wait added. */
|
|
1511
1731
|
function settleChecks(
|
|
1512
1732
|
s: UnitPipelineState,
|
|
1513
1733
|
p: Extract<Phase, { at: "checks" }>,
|
|
@@ -1546,48 +1766,43 @@ function settleChecks(
|
|
|
1546
1766
|
}
|
|
1547
1767
|
return wait({ retried: true });
|
|
1548
1768
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
// merge door (under `merge: runner`) is the guard that never merges over it.
|
|
1553
|
-
if (checks === undefined || checks.pending.length > 0) {
|
|
1554
|
-
if (s.clock - p.since >= p.waitMs) return approveOutcome(s, []);
|
|
1555
|
-
return wait({});
|
|
1556
|
-
}
|
|
1557
|
-
if (checks.failed.length > 0) {
|
|
1558
|
-
// The flake rule (record 0055): a suspected flake — a test timeout or
|
|
1559
|
-
// runner stall on a shard whose test files the pull request's changed
|
|
1560
|
-
// paths never touch — is re-run once before it becomes a finding. One
|
|
1561
|
-
// real failure among them makes the round's answer already known, so the
|
|
1562
|
-
// re-run is spent only when every failure is a suspect.
|
|
1563
|
-
if (!p.retried && checks.failed.every((f) => f.flakeSuspect === true))
|
|
1769
|
+
const verdict = checksVerdict(checks, p);
|
|
1770
|
+
switch (verdict.kind) {
|
|
1771
|
+
case "retry":
|
|
1564
1772
|
return {
|
|
1565
|
-
state: { ...s, phase: { ...p, n: p.n + 1, retry:
|
|
1773
|
+
state: { ...s, phase: { ...p, n: p.n + 1, retry: verdict.names } },
|
|
1566
1774
|
notes: [],
|
|
1567
1775
|
};
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1776
|
+
case "failed": {
|
|
1777
|
+
const findings = [...(s.findingsByRound[round.index] ?? []), ...verdict.failed.map(checkFinding)];
|
|
1778
|
+
const next: UnitPipelineState = {
|
|
1779
|
+
...s,
|
|
1780
|
+
findingsByRound: { ...s.findingsByRound, [round.index]: findings },
|
|
1781
|
+
};
|
|
1782
|
+
// The failed checks get a round note of their own (record 0055) — never
|
|
1783
|
+
// the parser-mismatch gate — and the findings step runs as for any
|
|
1784
|
+
// changes-requested round: dispositions, a fix round, a re-review.
|
|
1785
|
+
const notes: CoordinatorNote[] = [roundNote(round, "checks_failed")];
|
|
1786
|
+
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
1787
|
+
return end(
|
|
1788
|
+
next,
|
|
1789
|
+
{ kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
|
|
1790
|
+
notes,
|
|
1791
|
+
);
|
|
1792
|
+
return enterRound(next, { index: round.index, kind: "findings" }, notes);
|
|
1793
|
+
}
|
|
1794
|
+
case "pending":
|
|
1795
|
+
// A head still pending or unreadable at the ask's end proceeds — the
|
|
1796
|
+
// ending's facts read names what is still pending, and the merge door
|
|
1797
|
+
// (under `merge: runner`) is the guard that never merges over it.
|
|
1798
|
+
if (s.clock - p.since >= p.waitMs) return approveOutcome(s, []);
|
|
1799
|
+
return wait({});
|
|
1800
|
+
case "grace":
|
|
1801
|
+
return wait({ graced: true });
|
|
1802
|
+
case "green":
|
|
1803
|
+
// Green (or none reported after the grace): today's path, no wait added.
|
|
1804
|
+
return approveOutcome(s, []);
|
|
1584
1805
|
}
|
|
1585
|
-
// No check reported at the head: one chunk of grace — the first check starts
|
|
1586
|
-
// within minutes where CI exists — then the round proceeds, so a repository
|
|
1587
|
-
// without CI costs one chunk per round and never idles (record 0055).
|
|
1588
|
-
if (checks.total === 0 && !p.graced) return wait({ graced: true });
|
|
1589
|
-
// Green (or none reported after the grace): today's path, no wait added.
|
|
1590
|
-
return approveOutcome(s, []);
|
|
1591
1806
|
}
|
|
1592
1807
|
|
|
1593
1808
|
/** An approve past the checks read: merge_ready for a person, the merge door
|
|
@@ -1657,10 +1872,28 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
|
|
|
1657
1872
|
// A dead child left nothing on the branch to recover: the unit ends with
|
|
1658
1873
|
// the child's own reason — never the budget clip.
|
|
1659
1874
|
if (phase.dead === "interrupted")
|
|
1660
|
-
return end(
|
|
1661
|
-
|
|
1662
|
-
|
|
1875
|
+
return end(
|
|
1876
|
+
s,
|
|
1877
|
+
{
|
|
1878
|
+
kind: "interrupted",
|
|
1879
|
+
round,
|
|
1880
|
+
runId: phase.runId,
|
|
1881
|
+
reviewRounds: s.reviewRounds,
|
|
1882
|
+
...(phase.cause !== undefined ? { cause: phase.cause } : {}),
|
|
1883
|
+
},
|
|
1884
|
+
[roundNote(round, "aborted")],
|
|
1885
|
+
);
|
|
1663
1886
|
if (phase.dead === "failed") {
|
|
1887
|
+
// A provider transient with nothing pushed is not the child's failure
|
|
1888
|
+
// (issue 1932): the ledger row and the branch are untouched, so round 0
|
|
1889
|
+
// is re-run once — a fresh attempt under fresh step names — and only a
|
|
1890
|
+
// second transient in the same round is the ending, named `transient`.
|
|
1891
|
+
if (phase.transient && round.kind === "coding" && pr.unrecovered === "no_commits") {
|
|
1892
|
+
if ((round.attempt ?? 1) < 2) return enterRound(s, { ...round, attempt: 2 }, [roundNote(round, "transient")]);
|
|
1893
|
+
return end(s, { kind: "transient", round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
|
|
1894
|
+
roundNote(round, "transient"),
|
|
1895
|
+
]);
|
|
1896
|
+
}
|
|
1664
1897
|
// The abort repeats the bot's reason for recovering nothing, and claims
|
|
1665
1898
|
// no more than the answer carried.
|
|
1666
1899
|
const why =
|
|
@@ -1806,9 +2039,17 @@ function roundOnOpenPr(
|
|
|
1806
2039
|
// ends with the child's own reason — the ship-restart note for a bot
|
|
1807
2040
|
// roll, the failure for a failed run — never as the round's inaction.
|
|
1808
2041
|
if (phase.dead === "interrupted")
|
|
1809
|
-
return end(
|
|
1810
|
-
|
|
1811
|
-
|
|
2042
|
+
return end(
|
|
2043
|
+
next,
|
|
2044
|
+
{
|
|
2045
|
+
kind: "interrupted",
|
|
2046
|
+
round,
|
|
2047
|
+
runId: phase.runId,
|
|
2048
|
+
reviewRounds: next.reviewRounds,
|
|
2049
|
+
...(phase.cause !== undefined ? { cause: phase.cause } : {}),
|
|
2050
|
+
},
|
|
2051
|
+
[roundNote(round, "aborted")],
|
|
2052
|
+
);
|
|
1812
2053
|
if (phase.dead === "failed")
|
|
1813
2054
|
return end(
|
|
1814
2055
|
next,
|
|
@@ -1967,6 +2208,12 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
1967
2208
|
round: p.round,
|
|
1968
2209
|
reviewRounds: s.reviewRounds,
|
|
1969
2210
|
});
|
|
2211
|
+
case "stopped":
|
|
2212
|
+
// The hosted parent's hard stop (record 0060; issue 1924): the spawn
|
|
2213
|
+
// was refused over the stop mark, so the unit ends stopped here.
|
|
2214
|
+
return end(clocked, { kind: "stopped", mode: "hard", round: p.round, reviewRounds: s.reviewRounds }, [
|
|
2215
|
+
roundNote(p.round, "stopped"),
|
|
2216
|
+
]);
|
|
1970
2217
|
case "failed":
|
|
1971
2218
|
return end(clocked, {
|
|
1972
2219
|
kind: "aborted",
|
|
@@ -1998,25 +2245,62 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
1998
2245
|
case "read": {
|
|
1999
2246
|
const r = ret as Extract<StepReturn, { type: "read-record" }>;
|
|
2000
2247
|
if (!r.run.finished)
|
|
2248
|
+
// `restartedAs` (issues 1903/1876): the child's container was replaced
|
|
2249
|
+
// and it restarted from its request as a new run — the round carries on
|
|
2250
|
+
// waiting on the successor, and the unit never ends over a resume that
|
|
2251
|
+
// succeeded. The next wait and read follow the successor's id.
|
|
2001
2252
|
return {
|
|
2002
2253
|
state: {
|
|
2003
2254
|
...clocked,
|
|
2004
|
-
phase: { at: "wait", round: p.round, runId: p.runId, n: p.n + 1, until: p.until },
|
|
2255
|
+
phase: { at: "wait", round: p.round, runId: r.restartedAs ?? p.runId, n: p.n + 1, until: p.until },
|
|
2005
2256
|
},
|
|
2006
2257
|
notes: [],
|
|
2007
2258
|
};
|
|
2259
|
+
// The hosted parent's hard stop landed while this child ran (record 0060;
|
|
2260
|
+
// issue 1924): the unit ends stopped as the child ends, whatever the
|
|
2261
|
+
// child's own status — the runner runs nothing more of it.
|
|
2262
|
+
if (r.stopped === true)
|
|
2263
|
+
return end(
|
|
2264
|
+
clocked,
|
|
2265
|
+
{
|
|
2266
|
+
kind: "stopped",
|
|
2267
|
+
mode: "hard",
|
|
2268
|
+
round: p.round,
|
|
2269
|
+
reviewRounds: s.reviewRounds,
|
|
2270
|
+
...(r.run.finalReply !== undefined ? { finalReply: r.run.finalReply } : {}),
|
|
2271
|
+
},
|
|
2272
|
+
[roundNote(p.round, "stopped")],
|
|
2273
|
+
);
|
|
2008
2274
|
if (r.run.status === "interrupted") {
|
|
2275
|
+
const cause = r.run.interruption;
|
|
2009
2276
|
// A dead CODING child may have pushed before the ledger closed it: the
|
|
2010
2277
|
// pr-check recovers the branch. A review child has nothing on the
|
|
2011
2278
|
// branch to recover, so its interruption still ends the unit at once.
|
|
2012
2279
|
if (p.round.kind !== "review")
|
|
2013
2280
|
return {
|
|
2014
|
-
state: {
|
|
2281
|
+
state: {
|
|
2282
|
+
...clocked,
|
|
2283
|
+
phase: {
|
|
2284
|
+
at: "pr-check",
|
|
2285
|
+
round: p.round,
|
|
2286
|
+
runId: p.runId,
|
|
2287
|
+
dead: "interrupted",
|
|
2288
|
+
...(cause !== undefined ? { cause } : {}),
|
|
2289
|
+
},
|
|
2290
|
+
},
|
|
2015
2291
|
notes: [],
|
|
2016
2292
|
};
|
|
2017
|
-
return end(
|
|
2018
|
-
|
|
2019
|
-
|
|
2293
|
+
return end(
|
|
2294
|
+
clocked,
|
|
2295
|
+
{
|
|
2296
|
+
kind: "interrupted",
|
|
2297
|
+
round: p.round,
|
|
2298
|
+
runId: p.runId,
|
|
2299
|
+
reviewRounds: s.reviewRounds,
|
|
2300
|
+
...(cause !== undefined ? { cause } : {}),
|
|
2301
|
+
},
|
|
2302
|
+
[roundNote(p.round, "aborted")],
|
|
2303
|
+
);
|
|
2020
2304
|
}
|
|
2021
2305
|
return p.round.kind === "review"
|
|
2022
2306
|
? settleReview(clocked, p.round, r.run)
|
|
@@ -2064,27 +2348,81 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
2064
2348
|
: end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
|
|
2065
2349
|
if (r.outcome === "refused")
|
|
2066
2350
|
return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
|
|
2351
|
+
if (r.outcome === "removed") {
|
|
2352
|
+
// The queue removed the pull request — a failing check in the queue, a
|
|
2353
|
+
// conflict: the removal reason becomes a finding of the round, like a
|
|
2354
|
+
// red check does through the checks step, and a fix round follows
|
|
2355
|
+
// (issue 2011). With no review run to brief the fix from (a resume
|
|
2356
|
+
// straight at the merge decision) a person decides, the refusal
|
|
2357
|
+
// carrying the queue's own reason.
|
|
2358
|
+
const round: RoundRef = { index: s.reviewRounds, kind: "findings" };
|
|
2359
|
+
if (s.reviewRunByRound[round.index] === undefined)
|
|
2360
|
+
return end(clocked, {
|
|
2361
|
+
kind: "merge_refused",
|
|
2362
|
+
pr: p.pr,
|
|
2363
|
+
reason: `the merge queue removed the pull request: ${r.reason}`,
|
|
2364
|
+
reviewRounds: s.reviewRounds,
|
|
2365
|
+
});
|
|
2366
|
+
const findings = [...(s.findingsByRound[round.index] ?? []), queueRemovalFinding(r.reason)];
|
|
2367
|
+
const next: UnitPipelineState = {
|
|
2368
|
+
...clocked,
|
|
2369
|
+
findingsByRound: { ...s.findingsByRound, [round.index]: findings },
|
|
2370
|
+
};
|
|
2371
|
+
const notes: CoordinatorNote[] = [roundNote(round, "dequeued")];
|
|
2372
|
+
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
2373
|
+
return end(
|
|
2374
|
+
next,
|
|
2375
|
+
{ kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
|
|
2376
|
+
notes,
|
|
2377
|
+
);
|
|
2378
|
+
return enterRound(next, round, notes);
|
|
2379
|
+
}
|
|
2067
2380
|
const waited = r.at - p.since;
|
|
2068
2381
|
if (waited >= p.waitMs)
|
|
2069
2382
|
return end(clocked, {
|
|
2070
2383
|
kind: "merge_refused",
|
|
2071
2384
|
pr: p.pr,
|
|
2072
|
-
reason:
|
|
2385
|
+
reason:
|
|
2386
|
+
r.outcome === "enqueued"
|
|
2387
|
+
? `still in the merge queue after ${Math.round(waited / MIN)} minutes (${r.reason}) — the queue merges it on its own; a re-issued plan finds it merged`
|
|
2388
|
+
: `still pending after ${Math.round(waited / MIN)} minutes (${r.reason})`,
|
|
2073
2389
|
reviewRounds: s.reviewRounds,
|
|
2074
2390
|
});
|
|
2391
|
+
// `enqueued` waits like `pending`, marked so every later ask reads the
|
|
2392
|
+
// queue's outcome; the boundary is noted once, when the queue takes it.
|
|
2393
|
+
const queued = r.outcome === "enqueued" ? true : p.queued;
|
|
2075
2394
|
return {
|
|
2076
2395
|
state: {
|
|
2077
2396
|
...clocked,
|
|
2078
|
-
phase: {
|
|
2397
|
+
phase: {
|
|
2398
|
+
at: "merge-wait",
|
|
2399
|
+
pr: p.pr,
|
|
2400
|
+
headSha: p.headSha,
|
|
2401
|
+
n: p.n,
|
|
2402
|
+
since: p.since,
|
|
2403
|
+
waitMs: p.waitMs,
|
|
2404
|
+
...(queued === true ? { queued: true as const } : {}),
|
|
2405
|
+
},
|
|
2079
2406
|
},
|
|
2080
|
-
notes:
|
|
2407
|
+
notes:
|
|
2408
|
+
r.outcome === "enqueued" && p.queued !== true
|
|
2409
|
+
? [roundNote({ index: s.reviewRounds, kind: "review" }, "enqueued")]
|
|
2410
|
+
: [],
|
|
2081
2411
|
};
|
|
2082
2412
|
}
|
|
2083
2413
|
case "merge-wait":
|
|
2084
2414
|
return {
|
|
2085
2415
|
state: {
|
|
2086
2416
|
...s,
|
|
2087
|
-
phase: {
|
|
2417
|
+
phase: {
|
|
2418
|
+
at: "merge",
|
|
2419
|
+
pr: p.pr,
|
|
2420
|
+
headSha: p.headSha,
|
|
2421
|
+
n: p.n + 1,
|
|
2422
|
+
since: p.since,
|
|
2423
|
+
waitMs: p.waitMs,
|
|
2424
|
+
...(p.queued === true ? { queued: true as const } : {}),
|
|
2425
|
+
},
|
|
2088
2426
|
},
|
|
2089
2427
|
notes: [],
|
|
2090
2428
|
};
|
|
@@ -2210,13 +2548,16 @@ function mergeReadyHeadline(rounds: string, url: string, base: string, facts: Me
|
|
|
2210
2548
|
export function renderUnitReport(
|
|
2211
2549
|
s: UnitPipelineState,
|
|
2212
2550
|
facts?: MergeReadyFacts,
|
|
2213
|
-
/** The level the report speaks at (routing-and-config item 28
|
|
2214
|
-
* is the full report — the row's and the board's copy;
|
|
2215
|
-
* rendered at the request's level, where
|
|
2216
|
-
*
|
|
2217
|
-
*
|
|
2218
|
-
*
|
|
2219
|
-
*
|
|
2551
|
+
/** The level the report speaks at (routing-and-config item 28, record
|
|
2552
|
+
* 0066). The default is the full report — the row's and the board's copy;
|
|
2553
|
+
* the thread's copy is rendered at the request's level, where only the
|
|
2554
|
+
* outcome is everyone's: ONE line in the user's words — `✅ Merge-ready
|
|
2555
|
+
* after 2 review rounds: <url>`, `Held: <the human-gated row>`, `Stopped`,
|
|
2556
|
+
* `Round cap reached: <counts>` — and the verdict, the findings left below
|
|
2557
|
+
* the gate, the declined ones, the remaining-gate sentence, the level in
|
|
2558
|
+
* force, the grant, the write-up pointer, the budget split, the re-issue
|
|
2559
|
+
* prompt and a segment boundary are `verbose` asides; the full detail
|
|
2560
|
+
* stays on the pull request, where the round routes put it. */
|
|
2220
2561
|
verbosity: Verbosity = "verbose",
|
|
2221
2562
|
): string {
|
|
2222
2563
|
const e = s.ending;
|
|
@@ -2258,11 +2599,11 @@ export function renderUnitReport(
|
|
|
2258
2599
|
return `✅ Already merged: ${e.pr.url} (merge commit \`${e.sha.slice(0, 7)}\`, merged ${e.mergedAt}) — the pull request heading \`${s.input.unit.branch}\` was merged before this attempt reached it, by a person or by an earlier attempt of this plan; the runner merged nothing. The unit is done and its dependents start on a base that carries it.`;
|
|
2259
2600
|
return [
|
|
2260
2601
|
`✅ Merged after ${rounds}: ${e.pr.url} (squash \`${e.sha.slice(0, 7)}\`) — merged by the plan runner under \`plan:merge\`: the review approved at this head and the guards were green.`,
|
|
2261
|
-
verdictLine,
|
|
2602
|
+
aside(verdictLine),
|
|
2262
2603
|
aside(levelLine),
|
|
2263
2604
|
aside(grantLine),
|
|
2264
|
-
skippedLine,
|
|
2265
|
-
declinedLine,
|
|
2605
|
+
aside(skippedLine),
|
|
2606
|
+
aside(declinedLine),
|
|
2266
2607
|
]
|
|
2267
2608
|
.filter(Boolean)
|
|
2268
2609
|
.join("\n");
|
|
@@ -2276,22 +2617,48 @@ export function renderUnitReport(
|
|
|
2276
2617
|
facts?.merged
|
|
2277
2618
|
? `✅ Merge-ready after ${rounds}: ${e.pr.url}`
|
|
2278
2619
|
: mergeReadyHeadline(rounds, e.pr.url, s.input.base, facts),
|
|
2279
|
-
verdictLine,
|
|
2620
|
+
aside(verdictLine),
|
|
2280
2621
|
aside(levelLine),
|
|
2281
2622
|
aside(grantLine),
|
|
2282
|
-
skippedLine,
|
|
2283
|
-
declinedLine,
|
|
2623
|
+
aside(skippedLine),
|
|
2624
|
+
aside(declinedLine),
|
|
2284
2625
|
// What the driver read at the approved head when it composed this
|
|
2285
2626
|
// ending (agent-ship item 9): a merge that already happened is named
|
|
2286
2627
|
// as such, else the pull request's own auto-merge fact, else the gate.
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2628
|
+
// A `verbose` aside like the rest — the quiet thread copy is the
|
|
2629
|
+
// headline alone (record 0066).
|
|
2630
|
+
aside(
|
|
2631
|
+
facts?.merged
|
|
2632
|
+
? `Already merged: ${e.pr.url} (merge commit \`${facts.merged.sha.slice(0, 7)}\`, merged ${facts.merged.mergedAt}) — auto-merge or a person merged it after the approval; the runner merged nothing.`
|
|
2633
|
+
: facts?.autoMergeEnabled
|
|
2634
|
+
? "Auto-merge is on for this pull request: the approval merges it once checks pass."
|
|
2635
|
+
: "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
|
|
2636
|
+
),
|
|
2292
2637
|
]
|
|
2293
2638
|
.filter(Boolean)
|
|
2294
2639
|
.join("\n");
|
|
2640
|
+
case "held": {
|
|
2641
|
+
// The person's next step is the report's whole point (issue 1990): the
|
|
2642
|
+
// human-gated rows are named with the reviewer's own words, and the
|
|
2643
|
+
// re-issue line says the attempt resumes at the review round — item 10's
|
|
2644
|
+
// resume path — once the receipt stands on the pull request. That path
|
|
2645
|
+
// fires only when the invocation carries NO new task text: re-issuing
|
|
2646
|
+
// with the task would ADOPT the pull request and run a coding round
|
|
2647
|
+
// first (item 10), the very round this ending exists to avoid — so the
|
|
2648
|
+
// held case renders its own re-issue line instead of the shared one.
|
|
2649
|
+
const rows = e.findings.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ");
|
|
2650
|
+
// The quiet copy is the ending in the user's words (record 0066): the
|
|
2651
|
+
// held row named, the pull request linked, nothing about the machinery.
|
|
2652
|
+
if (!shows(verbosity, "verbose")) return `⏸️ Held: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
|
|
2653
|
+
const heldReissue = s.input.generated
|
|
2654
|
+
? `To continue, re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text.`
|
|
2655
|
+
: reissue;
|
|
2656
|
+
return join([
|
|
2657
|
+
`⏸️ ${e.verdict === "approve" ? "Approved but held" : "Changes requested but held"} after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — every finding of review round ${e.round.index} is human-gated, a receipt only a person can produce: ${rows}. No fix round was opened: a coding child cannot produce the receipt.`,
|
|
2658
|
+
levelLine,
|
|
2659
|
+
`Next step: produce the receipt each finding names and post it on the pull request. ${heldReissue} The re-issued attempt resumes at the review round — no coding round runs first.`,
|
|
2660
|
+
]);
|
|
2661
|
+
}
|
|
2295
2662
|
case "merge_refused":
|
|
2296
2663
|
// The approved work is on the branch, so the remedy is a person's hand
|
|
2297
2664
|
// merge, never a re-run: a seeded plan re-issued afterwards finds the
|
|
@@ -2299,22 +2666,39 @@ export function renderUnitReport(
|
|
|
2299
2666
|
// `already_landed`) and moves on to the dependents. The generated
|
|
2300
2667
|
// plan's line already says to re-issue with the PR URL, which takes the
|
|
2301
2668
|
// same recognition path.
|
|
2669
|
+
// The quiet copy: the outcome in the user's words with the person's
|
|
2670
|
+
// remedy — the hand merge is what a person must act on (record 0066).
|
|
2671
|
+
if (!shows(verbosity, "verbose")) return `⚠️ Not merged: ${e.reason} — ${e.pr.url}`;
|
|
2302
2672
|
return join([
|
|
2303
2673
|
`⚠️ The review approved ${e.pr.url} but the runner did not merge it: ${e.reason}. A person decides what becomes of the pull request.`,
|
|
2304
2674
|
s.input.generated
|
|
2305
2675
|
? reissue
|
|
2306
2676
|
: `The approved work is on the branch: rebase or fix it, push, and merge it by hand. Then re-issue the plan naming the remaining units — a unit whose pull request has merged is recognized and not run again, and its dependents start from there.`,
|
|
2307
2677
|
]);
|
|
2308
|
-
case "round_cap":
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2678
|
+
case "round_cap": {
|
|
2679
|
+
// The quiet copy counts the last review's open findings, only the
|
|
2680
|
+
// non-zero severities (record 0066): `Round cap reached: 2 blockers, 1 major`.
|
|
2681
|
+
if (!shows(verbosity, "verbose")) {
|
|
2682
|
+
const counts = severityCounts(s.findingsByRound[e.reviewRounds] ?? []);
|
|
2683
|
+
return `🧢 Round cap reached${counts ? `: ${counts}` : ""}${prUrl !== undefined ? ` — ${prUrl}` : ""}`;
|
|
2684
|
+
}
|
|
2685
|
+
// The cap bounds fix rounds, never the terminal steps (issue 2023): an
|
|
2686
|
+
// approval in the last allowed round still runs the checks step and the
|
|
2687
|
+
// merge, so a round_cap after an approve means the checks (or the merge
|
|
2688
|
+
// queue) failed at the approved head with no fix round left — and the
|
|
2689
|
+
// report names those findings instead of claiming no approval landed.
|
|
2690
|
+
const failedChecks = checkFindingsOf(s.findingsByRound[s.reviewRounds]);
|
|
2691
|
+
const headline =
|
|
2692
|
+
failedChecks.length > 0
|
|
2693
|
+
? `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — the review of round ${e.reviewRounds} approved, but ${failedChecks.map((f) => `\`${f.id}\``).join(", ")} failed at the approved head and no fix round remains.${prLine}`
|
|
2694
|
+
: `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — no approval after ${rounds}.${prLine}`;
|
|
2695
|
+
return join([headline, splitReport(s), reissue]);
|
|
2696
|
+
}
|
|
2314
2697
|
case "wall_clock_cap":
|
|
2698
|
+
if (!shows(verbosity, "verbose")) return `🧢 Out of budget — no approval after ${rounds}.${prLine}`;
|
|
2315
2699
|
return join([
|
|
2316
2700
|
`🧢 Ship stopped at a cap: the remaining pipeline time (~${Math.max(0, Math.round(e.remainingMs / MIN))} min of the ${s.input.caps.maxMinutes}-minute budget) cannot hold another round${e.refused ? ` (the ${e.refused.round} round would get ${e.refused.minutes} min, under its floor of ${e.refused.floor})` : ""} — no approval after ${rounds}.${prLine}`,
|
|
2317
|
-
|
|
2701
|
+
budgetSplitLine(e.spent, s.input.caps.maxMinutes),
|
|
2318
2702
|
splitReport(s),
|
|
2319
2703
|
reissue,
|
|
2320
2704
|
]);
|
|
@@ -2322,17 +2706,16 @@ export function renderUnitReport(
|
|
|
2322
2706
|
return join([
|
|
2323
2707
|
`⏳ Review pending: the coding child shipped ${e.pr.url}${e.headSha !== undefined ? ` (head \`${e.headSha.slice(0, 7)}\`)` : ""} but the remaining pipeline time cannot hold the review round — the work stands, only the review is missing. The next attempt starts at the review round while the pull request still heads at the child's own last push.`,
|
|
2324
2708
|
aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
|
|
2325
|
-
reissue,
|
|
2709
|
+
aside(reissue),
|
|
2326
2710
|
]);
|
|
2327
2711
|
case "stopped":
|
|
2712
|
+
if (!shows(verbosity, "verbose")) return `${e.mode === "hard" ? "⛔" : "⏹"} Stopped.${prLine}`;
|
|
2328
2713
|
return join([
|
|
2329
2714
|
`${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
|
|
2335
|
-
),
|
|
2715
|
+
writeUpPointer(
|
|
2716
|
+
s,
|
|
2717
|
+
e.round.kind,
|
|
2718
|
+
e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
|
|
2336
2719
|
),
|
|
2337
2720
|
e.postedReview
|
|
2338
2721
|
? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
|
|
@@ -2340,14 +2723,13 @@ export function renderUnitReport(
|
|
|
2340
2723
|
reissue,
|
|
2341
2724
|
]);
|
|
2342
2725
|
case "aborted":
|
|
2726
|
+
if (!shows(verbosity, "verbose")) return `⚠️ Aborted after ${rounds}: ${e.reason}${prLine}`;
|
|
2343
2727
|
return join([
|
|
2344
2728
|
e.reason,
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
|
|
2350
|
-
),
|
|
2729
|
+
writeUpPointer(
|
|
2730
|
+
s,
|
|
2731
|
+
e.round?.kind ?? "coding",
|
|
2732
|
+
e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
|
|
2351
2733
|
),
|
|
2352
2734
|
e.renewal !== undefined ? `🔁 Not renewed: ${e.renewal.line}.` : undefined,
|
|
2353
2735
|
`⚠️ Ship aborted after ${rounds}.`,
|
|
@@ -2363,19 +2745,30 @@ export function renderUnitReport(
|
|
|
2363
2745
|
`🔁 Segment ${e.segment - 1} ended at its lease with the unit unfinished — ${e.line}. Segment ${e.segment} opens in this thread${e.from !== undefined ? ` from \`${e.from.slice(0, 7)}\`` : ""} under a fresh ${s.input.caps.maxMinutes}-minute lease, with this segment's write-up as its request; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`,
|
|
2364
2746
|
aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
|
|
2365
2747
|
]);
|
|
2748
|
+
case "transient":
|
|
2749
|
+
if (!shows(verbosity, "verbose"))
|
|
2750
|
+
return `⚠️ Aborted after ${rounds}: the model provider failed twice; re-issue once it settles.${prLine}`;
|
|
2751
|
+
return join([
|
|
2752
|
+
`⚠️ The coding child of round ${e.round.index} (run ${e.runId}) died on a provider transient — a model-gateway 5xx, a cut stream or a gateway timeout past the harness's retry ladder — with nothing pushed, after the round was already re-run once for the same reason. The task itself was never the problem.`,
|
|
2753
|
+
writeUpPointer(s, e.round.kind, s.lastCodingRunId),
|
|
2754
|
+
`⚠️ Ship ended after ${rounds}; re-issue once the provider settles.`,
|
|
2755
|
+
reissue,
|
|
2756
|
+
]);
|
|
2366
2757
|
case "no_verdict":
|
|
2758
|
+
if (!shows(verbosity, "verbose"))
|
|
2759
|
+
return `⚠️ No verdict from review round ${e.round.index} — aborted after ${rounds}.${prLine}`;
|
|
2367
2760
|
return join([
|
|
2368
2761
|
`⚠️ Review round ${e.round.index} ended without a submitted verdict (budget, refusal, or stop) — ship never converts that into a request for changes, so no findings step ran.`,
|
|
2369
|
-
|
|
2762
|
+
writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index]),
|
|
2370
2763
|
`⚠️ Ship aborted after ${rounds}.`,
|
|
2371
2764
|
reissue,
|
|
2372
2765
|
]);
|
|
2373
2766
|
case "interrupted":
|
|
2374
|
-
return shipInterruptedNote(prUrl);
|
|
2767
|
+
return shipInterruptedNote(prUrl, e.cause);
|
|
2375
2768
|
case "refused":
|
|
2376
2769
|
return join([
|
|
2377
2770
|
`🚫 The ${presetOf(e.round.kind)} child of round ${e.round.index} was refused by the authorize stage (${e.refusal})${e.message ? `: ${e.message}` : ""} — every child is authorized as the requesting user, so the pipeline ends here.`,
|
|
2378
|
-
reissue,
|
|
2771
|
+
aside(reissue),
|
|
2379
2772
|
]);
|
|
2380
2773
|
case "idle":
|
|
2381
2774
|
// The old kind's sentence at this copy's level — the report is unchanged
|