@coreplane/switchboard 1.254.1 → 1.255.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.
Files changed (63) hide show
  1. package/dist/assets/.dockerignore +3 -0
  2. package/dist/assets/Dockerfile +12 -1
  3. package/dist/assets/config/config.example.yaml +6 -1
  4. package/dist/assets/deploy/cloudflare/worker.ts +39 -23
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +552 -5
  6. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +5 -0
  7. package/dist/assets/deploy/cloudflare-resident/Dockerfile +13 -1
  8. package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
  9. package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
  10. package/dist/assets/deploy/cloudflare-resident/worker.ts +202 -31
  11. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
  12. package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
  13. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
  14. package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
  15. package/dist/assets/deploy/secrets.manifest.json +12 -0
  16. package/dist/assets/package-lock.json +3 -3
  17. package/dist/assets/package.json +1 -1
  18. package/dist/assets/source.json +3 -3
  19. package/dist/assets/src/agents/registry.ts +21 -0
  20. package/dist/assets/src/core/budgets.ts +35 -2
  21. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  22. package/dist/assets/src/core/coordinator/driver.ts +49 -6
  23. package/dist/assets/src/core/costs.ts +39 -16
  24. package/dist/assets/src/core/pipelineStanding.ts +5 -0
  25. package/dist/assets/src/core/plane/decide.ts +389 -22
  26. package/dist/assets/src/core/refusal.ts +3 -0
  27. package/dist/assets/src/core/reviewVerdict.ts +15 -2
  28. package/dist/assets/src/core/runEvents.ts +31 -12
  29. package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
  30. package/dist/assets/src/core/runLedger/types.ts +3 -0
  31. package/dist/assets/src/core/runRecord.ts +24 -7
  32. package/dist/assets/src/core/ship/coordinator.ts +327 -61
  33. package/dist/assets/src/core/trace/workerTrace.ts +3 -0
  34. package/dist/assets/src/execution/sandboxErrors.ts +77 -6
  35. package/dist/assets/web/dist/.vite/manifest.json +59 -58
  36. package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
  37. package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
  38. package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-Be7jLLnU.js} +1 -1
  39. package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
  40. package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-D_RD6wLo.js} +1 -1
  42. package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-BLkSuCxo.js} +1 -1
  43. package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +1 -0
  44. package/dist/assets/web/dist/assets/{RunRoutePage-ysJBY8xQ.js → RunRoutePage-CmWYGR36.js} +4 -4
  45. package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-DW-HHuZa.js} +1 -1
  46. package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-3aYsDf-q.js} +1 -1
  47. package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-DG-p5Xy1.js} +1 -1
  48. package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-CEnGlyAL.js} +1 -1
  49. package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-CiunVowT.js} +1 -1
  50. package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +1 -0
  51. package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
  52. package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-luhv3YSo.js} +1 -1
  53. package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
  54. package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-mAKx_zo9.js} +2 -2
  55. package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
  56. package/dist/cli.js +6022 -4956
  57. package/package.json +1 -1
  58. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
  59. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
  60. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
  61. package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
  62. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
  63. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +0 -1
@@ -404,6 +404,11 @@ export interface RoundRef {
404
404
  /** Round 0 is the coding round; review round n and its findings step share n. */
405
405
  index: number;
406
406
  kind: RoundKind;
407
+ /** The round's attempt when it was re-run (agent-ship item 9, issue 1932):
408
+ * 2 for round 0's one re-run after a provider transient with nothing
409
+ * pushed. Absent on a first attempt; suffixes the round's step names so the
410
+ * Workflow's durable cache never hands the re-run the dead attempt's answers. */
411
+ attempt?: number;
407
412
  }
408
413
  export type ChildPreset = "coding" | "review";
409
414
 
@@ -480,7 +485,10 @@ export type CoordinatorAction =
480
485
  * over a record fact written minutes earlier. */
481
486
  pr?: number;
482
487
  }
483
- | { type: "merge"; step: string; prNumber: number; headSha: string }
488
+ /** `queued`: the pull request is in the base's merge queue — the bot reads
489
+ * the queue's outcome (merged, still queued, or removed with the reason)
490
+ * instead of attempting the squash. */
491
+ | { type: "merge"; step: string; prNumber: number; headSha: string; queued?: true }
484
492
  /** Read the check runs at the reviewed head (record 0055, the round verdict):
485
493
  * the merge door's own reading, folded into the round. `retry` names the
486
494
  * failed checks whose one flake re-run the machine is spending: the bot
@@ -524,6 +532,10 @@ export type ChildFacts =
524
532
  leaseStartedAt?: number;
525
533
  costUsd?: number | null;
526
534
  handoffLists?: Handoff;
535
+ /** The failure by name off a `failed` run's record (run-history item 57):
536
+ * `provider_transient` marks a child a gateway 5xx, a cut stream or a
537
+ * gateway timeout ended past the harness's retry ladder (issue 1932). */
538
+ failure?: { kind: string };
527
539
  };
528
540
 
529
541
  /** What heads the unit's branch on GitHub: nothing, an open pull request, or —
@@ -580,14 +592,22 @@ export type StepReturn =
580
592
  | { type: "spawn"; step: string; outcome: "busy"; runId?: string; at: number }
581
593
  | { type: "spawn"; step: string; outcome: "refused"; refusal: string; message?: string; at: number }
582
594
  | { type: "spawn"; step: string; outcome: "failed"; reason: string; at: number }
595
+ // The hosted parent's hard stop landed (record 0060; issue 1924): the bot
596
+ // refuses the spawn over the instance row's stop mark, and the unit ends
597
+ // `stopped` — nothing more is run.
598
+ | { type: "spawn"; step: string; outcome: "stopped"; at: number }
583
599
  | { type: "wait"; step: string; outcome: "event" | "timeout" }
584
- | { type: "read-record"; step: string; run: ChildFacts; at: number }
600
+ // `stopped` on a read: the instance row carries the hard stop's mark, so a
601
+ // finished child ends its unit `stopped` whatever the child's own status.
602
+ | { type: "read-record"; step: string; run: ChildFacts; stopped?: true; at: number }
585
603
  | { type: "pr-check"; step: string; pr: PrCheck; at: number }
586
604
  | { type: "merge"; step: string; outcome: "merged"; sha: string; at: number }
587
605
  // The door found the pull request already merged after the approval — auto-merge
588
606
  // fired, or a person merged — so the runner merged nothing (`by: other`).
589
607
  | { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
590
- | { type: "merge"; step: string; outcome: "pending" | "refused"; reason: string; at: number }
608
+ /** The door enqueued the pull request (or found it still queued), or the
609
+ * queue removed it — the reason is the queue's own (issue 2011). */
610
+ | { type: "merge"; step: string; outcome: "pending" | "refused" | "enqueued" | "removed"; reason: string; at: number }
591
611
  /** The checks read at the reviewed head; `checks` absent means GitHub could
592
612
  * not be read, which the machine treats as pending (record 0055). A retry
593
613
  * ask answers `retried` instead: whether the bot dispatched the re-run —
@@ -629,6 +649,20 @@ export function checkFinding(f: CheckFailure): Finding {
629
649
  };
630
650
  }
631
651
 
652
+ /** A merge-queue removal as a finding of the round (issue 2011): the queue's
653
+ * reason — a failing check inside the queue, a conflict — rides the findings
654
+ * brief exactly as a red check does, severity `blocking` so the level in
655
+ * force always counts it, and a fix round follows. */
656
+ export function queueRemovalFinding(reason: string): Finding {
657
+ return {
658
+ id: "check:merge-queue",
659
+ severity: "blocking",
660
+ file: "merge queue",
661
+ title: `removed from the merge queue — ${reason}`,
662
+ check: true,
663
+ };
664
+ }
665
+
632
666
  /** The check findings of a review round: the rows the checks step appended to
633
667
  * the round's findings, told apart by provenance — the `check` flag only
634
668
  * `checkFinding` sets, never the id: a reviewer's id is a free string, and a
@@ -653,6 +687,20 @@ export type UnitEnding =
653
687
  * The unit is done and its dependents start on a base that carries it. */
654
688
  | { kind: "already_landed"; landed: HandoffLanded[]; round: RoundRef; runId: string; reviewRounds: number }
655
689
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
690
+ /** Every finding the round would act on is human-gated — a receipt only a
691
+ * person can produce (issue 1990; the reviewer set the flag through
692
+ * `submit_verdict`, agent-review item 5) — so a fix round could change
693
+ * nothing: the unit ends held for a person, the ending carrying the
694
+ * human-gated rows, and a re-issue with the pull request resumes at the
695
+ * review round once the receipt is posted (item 10's resume path). */
696
+ | {
697
+ kind: "held";
698
+ pr?: PrRef;
699
+ round: RoundRef;
700
+ findings: Finding[];
701
+ verdict: "approve" | "request_changes";
702
+ reviewRounds: number;
703
+ }
656
704
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
657
705
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
658
706
  | {
@@ -710,6 +758,14 @@ export type UnitEnding =
710
758
  spent: ShipBudgetSpent;
711
759
  }
712
760
  | { kind: "no_verdict"; round: RoundRef; reviewRounds: number; finalReply?: string }
761
+ /** Round 0's coding child died on a provider transient — a model-gateway
762
+ * 5xx, a cut stream, a gateway timeout, past the harness's retry ladder —
763
+ * with nothing pushed, TWICE: the first such death re-ran the round once
764
+ * (the ledger row and the branch untouched, a re-run costs only minutes),
765
+ * and the second is the ending (agent-ship item 9, issue 1932). Named
766
+ * `transient` so it reads as a condition beside `checks_failed` and `held`
767
+ * in the plane's table, never as the child failing on its task. */
768
+ | { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number }
713
769
  | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number }
714
770
  | { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number }
715
771
  /** The unit idles instead of ending (record 0051): with the resolved
@@ -737,18 +793,24 @@ export type UnitEnding =
737
793
  reviewRounds: number;
738
794
  };
739
795
 
740
- /** The kinds that idle: every ending but the four ended ones — the unit is
796
+ /** The kinds that idle: every ending but the ended ones — the unit is
741
797
  * 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` and
743
- * `refused` never idle: the first two are done, merge-ready waits only for a
744
- * person's merge, and a refused child would be refused again. */
745
- export type IdleWhy = Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused">;
798
+ * a reply could continue it. `merged`, `already_landed`, `merge_ready`,
799
+ * `held` and `refused` never idle: the first two are done, merge-ready waits
800
+ * only for a person's merge, held waits only for a person's receipt (a fix
801
+ * round could change nothing, so nothing here can continue it), and a
802
+ * refused child would be refused again. */
803
+ export type IdleWhy = Exclude<
804
+ UnitEnding["kind"],
805
+ "idle" | "merged" | "already_landed" | "merge_ready" | "held" | "refused"
806
+ >;
746
807
 
747
808
  const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
748
809
  "idle",
749
810
  "merged",
750
811
  "already_landed",
751
812
  "merge_ready",
813
+ "held",
752
814
  "refused",
753
815
  ]);
754
816
 
@@ -878,9 +940,17 @@ type Phase =
878
940
  * pushed: the pr-check recovers a pushed branch by opening its pull
879
941
  * request; with nothing pushed the unit ends with the child's own reason. */
880
942
  dead?: "failed" | "interrupted";
943
+ /** The dead child's record names a provider transient (`failure:
944
+ * provider_transient`, issue 1932): with nothing pushed, round 0 is
945
+ * re-run once instead of the unit aborting; a second transient in the
946
+ * same round is the `transient` ending. */
947
+ transient?: true;
881
948
  }
882
- | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
883
- | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
949
+ /** `queued`: the door enqueued the pull request — the base takes changes
950
+ * only through a merge queue (issue 2011) — so every later ask reads the
951
+ * queue's outcome instead of attempting the squash again. */
952
+ | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
953
+ | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
884
954
  /** The round's checks step (record 0055): the check runs at the reviewed head
885
955
  * are read after an approve settles, before merge_ready or the merge door.
886
956
  * `graced`: a head with no check reported has had its one-chunk grace;
@@ -1025,7 +1095,8 @@ function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
1025
1095
  export const stepPrefixOf = (unit: string, session: UnitSession | undefined): string =>
1026
1096
  session !== undefined && session.segment > 1 ? `${unit}/s${session.segment}` : unit;
1027
1097
  const stepPrefix = (s: UnitPipelineState) => stepPrefixOf(s.input.unit.id, s.input.session);
1028
- const roundStep = (s: UnitPipelineState, round: RoundRef) => `${stepPrefix(s)}/${round.index}/${round.kind}`;
1098
+ const roundStep = (s: UnitPipelineState, round: RoundRef) =>
1099
+ `${stepPrefix(s)}/${round.index}/${round.kind}${round.attempt !== undefined ? `/a${round.attempt}` : ""}`;
1029
1100
 
1030
1101
  function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1031
1102
  const unit = s.input.unit.id;
@@ -1149,7 +1220,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1149
1220
  timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, p.waitMs - (s.clock - p.since))),
1150
1221
  };
1151
1222
  case "merge":
1152
- return { type: "merge", step: `${unit}/merge/${p.n}`, prNumber: p.pr.number, headSha: p.headSha };
1223
+ return {
1224
+ type: "merge",
1225
+ step: `${unit}/merge/${p.n}`,
1226
+ prNumber: p.pr.number,
1227
+ headSha: p.headSha,
1228
+ ...(p.queued === true ? { queued: true as const } : {}),
1229
+ };
1153
1230
  case "merge-wait":
1154
1231
  return {
1155
1232
  type: "wait-checks",
@@ -1194,7 +1271,10 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
1194
1271
  function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEnding, { kind: "idle" }> | undefined {
1195
1272
  if ((s.input.idleDays ?? 0) <= 0) return undefined;
1196
1273
  if (NEVER_IDLES.has(ending.kind)) return undefined;
1197
- const old = ending as Exclude<UnitEnding, { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "refused" }>;
1274
+ const old = ending as Exclude<
1275
+ UnitEnding,
1276
+ { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "held" | "refused" }
1277
+ >;
1198
1278
  const grant = s.input.grant ?? DEFAULT_GRANT;
1199
1279
  // Unspent: an idle spends no renewal — the wake's segment does (this plan's
1200
1280
  // fifth unit) — so the row says what the grant still holds.
@@ -1234,6 +1314,29 @@ function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEndin
1234
1314
  /** A gated finding as the gate note names it: `F1 (minor)`. */
1235
1315
  const gateLabel = (f: Finding): string => `${f.id} (${f.severity})`;
1236
1316
 
1317
+ /** The held decision (issue 1990): the findings the round would act on — the
1318
+ * gated set on an approve, every finding on a request_changes — are all
1319
+ * human-gated, read off the reviewer's own flag and never prose. One
1320
+ * actionable finding beside a human-gated one keeps the fix round: the
1321
+ * dispositions cover the human-gated row like any other (declined, with the
1322
+ * person named). An empty set decides nothing. */
1323
+ const allHumanGated = (findings: readonly Finding[]): boolean =>
1324
+ findings.length > 0 && findings.every((f) => f.humanGated === true);
1325
+
1326
+ const heldEnding = (
1327
+ s: UnitPipelineState,
1328
+ round: RoundRef,
1329
+ findings: Finding[],
1330
+ verdict: "approve" | "request_changes",
1331
+ ): UnitEnding => ({
1332
+ kind: "held",
1333
+ ...(s.pr !== undefined ? { pr: s.pr } : {}),
1334
+ round,
1335
+ findings,
1336
+ verdict,
1337
+ reviewRounds: s.reviewRounds,
1338
+ });
1339
+
1237
1340
  const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): RoundNote => ({
1238
1341
  type: "round",
1239
1342
  index: round.index,
@@ -1332,7 +1435,19 @@ function settleCoding(
1332
1435
  // request (agent-ship items 10 and 15), and only a branch with
1333
1436
  // nothing on it ends the unit with the child's own reason.
1334
1437
  if (facts.status === "failed")
1335
- return { state: { ...next, phase: { at: "pr-check", round, runId, dead: "failed" } }, notes: [] };
1438
+ return {
1439
+ state: {
1440
+ ...next,
1441
+ phase: {
1442
+ at: "pr-check",
1443
+ round,
1444
+ runId,
1445
+ dead: "failed",
1446
+ ...(facts.failure?.kind === "provider_transient" ? { transient: true as const } : {}),
1447
+ },
1448
+ },
1449
+ notes: [],
1450
+ };
1336
1451
  next = {
1337
1452
  ...next,
1338
1453
  phase: {
@@ -1429,6 +1544,9 @@ function settleReview(
1429
1544
  },
1430
1545
  notes,
1431
1546
  );
1547
+ // Every gated finding is human-gated (issue 1990): a fix round could
1548
+ // change nothing, so the unit ends held for a person instead.
1549
+ if (allHumanGated(gated)) return end(next, heldEnding(next, round, gated, "approve"), notes);
1432
1550
  if (next.reviewRounds >= next.input.caps.maxRounds)
1433
1551
  return end(
1434
1552
  next,
@@ -1457,6 +1575,11 @@ function settleReview(
1457
1575
  },
1458
1576
  notes,
1459
1577
  );
1578
+ // Every finding of the round is human-gated (issue 1990): no fix round can
1579
+ // change anything, so the unit ends held for a person's receipt instead of
1580
+ // spending a coding child — or the round cap — on it.
1581
+ if (allHumanGated(verdict.findings ?? []))
1582
+ return end(next, heldEnding(next, round, verdict.findings ?? [], "request_changes"), notes);
1460
1583
  if (next.reviewRounds >= next.input.caps.maxRounds)
1461
1584
  return end(
1462
1585
  next,
@@ -1500,14 +1623,51 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
1500
1623
  };
1501
1624
  }
1502
1625
 
1626
+ /** The order the checks step reads a head in (record 0055, issue 1991):
1627
+ * a failed check is the round's answer as soon as it is read, whatever else
1628
+ * is still pending — with the flake rule spending its one re-run first when
1629
+ * every failure is a suspect — and only a head with no failed check waits on
1630
+ * the pending ones; a head with no check reported gets one chunk of grace;
1631
+ * the rest is green. One function so the siblings that fold more outcomes
1632
+ * into the step (a held ending, a transient round re-run) read the same order. */
1633
+ function checksVerdict(
1634
+ checks: RoundChecks | undefined,
1635
+ p: { retried: boolean; graced: boolean },
1636
+ ):
1637
+ | { kind: "retry"; names: string[] }
1638
+ | { kind: "failed"; failed: CheckFailure[] }
1639
+ | { kind: "pending" }
1640
+ | { kind: "grace" }
1641
+ | { kind: "green" } {
1642
+ if (checks !== undefined && checks.failed.length > 0) {
1643
+ // The flake rule (record 0055): a suspected flake — a test timeout or
1644
+ // runner stall on a shard whose test files the pull request's changed
1645
+ // paths never touch — is re-run once before it becomes a finding. One
1646
+ // real failure among them makes the round's answer already known, so the
1647
+ // re-run is spent only when every failure is a suspect.
1648
+ if (!p.retried && checks.failed.every((f) => f.flakeSuspect === true))
1649
+ return { kind: "retry", names: checks.failed.map((f) => f.name) };
1650
+ return { kind: "failed", failed: checks.failed };
1651
+ }
1652
+ // GitHub unreadable answers as pending and is re-read at the chunk's end.
1653
+ if (checks === undefined || checks.pending.length > 0) return { kind: "pending" };
1654
+ // No check reported at the head: one chunk of grace — the first check starts
1655
+ // within minutes where CI exists — then the round proceeds, so a repository
1656
+ // without CI costs one chunk per round and never idles (record 0055).
1657
+ if (checks.total === 0 && !p.graced) return { kind: "grace" };
1658
+ return { kind: "green" };
1659
+ }
1660
+
1503
1661
  /** What the checks step answered, folded into the round (record 0055). Pure
1504
1662
  * over the phase: a retry ask waits for the head to settle and reads again;
1505
- * an unreadable or pending head waits a chunk inside the step's ask and then
1663
+ * a failed check becomes a check finding under a round note of its own and
1664
+ * the findings step runs as for any changes-requested round — never
1665
+ * merge_ready, never the merge door — as soon as it is read, whatever else
1666
+ * is still pending (issue 1991); only a head with no failed check waits a
1667
+ * chunk inside the step's ask on what is unreadable or pending and then
1506
1668
  * 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 failed check
1508
- * becomes a check finding under a round note of its own and the findings step
1509
- * runs as for any changes-requested round — never merge_ready, never the
1510
- * merge door; a green head proceeds with no wait added. */
1669
+ * no check reported waits one chunk of grace and never more; a green head
1670
+ * proceeds with no wait added. */
1511
1671
  function settleChecks(
1512
1672
  s: UnitPipelineState,
1513
1673
  p: Extract<Phase, { at: "checks" }>,
@@ -1546,48 +1706,43 @@ function settleChecks(
1546
1706
  }
1547
1707
  return wait({ retried: true });
1548
1708
  }
1549
- // GitHub unreadable answers as pending and is re-read at the chunk's end
1550
- // (record 0055); a head still pending or unreadable at the ask's end
1551
- // proceeds — the ending's facts read names what is still pending, and the
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))
1709
+ const verdict = checksVerdict(checks, p);
1710
+ switch (verdict.kind) {
1711
+ case "retry":
1564
1712
  return {
1565
- state: { ...s, phase: { ...p, n: p.n + 1, retry: checks.failed.map((f) => f.name) } },
1713
+ state: { ...s, phase: { ...p, n: p.n + 1, retry: verdict.names } },
1566
1714
  notes: [],
1567
1715
  };
1568
- const findings = [...(s.findingsByRound[round.index] ?? []), ...checks.failed.map(checkFinding)];
1569
- const next: UnitPipelineState = {
1570
- ...s,
1571
- findingsByRound: { ...s.findingsByRound, [round.index]: findings },
1572
- };
1573
- // The failed checks get a round note of their own (record 0055) — never
1574
- // the parser-mismatch gate — and the findings step runs as for any
1575
- // changes-requested round: dispositions, a fix round, a re-review.
1576
- const notes: CoordinatorNote[] = [roundNote(round, "checks_failed")];
1577
- if (next.reviewRounds >= next.input.caps.maxRounds)
1578
- return end(
1579
- next,
1580
- { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
1581
- notes,
1582
- );
1583
- return enterRound(next, { index: round.index, kind: "findings" }, notes);
1716
+ case "failed": {
1717
+ const findings = [...(s.findingsByRound[round.index] ?? []), ...verdict.failed.map(checkFinding)];
1718
+ const next: UnitPipelineState = {
1719
+ ...s,
1720
+ findingsByRound: { ...s.findingsByRound, [round.index]: findings },
1721
+ };
1722
+ // The failed checks get a round note of their own (record 0055) — never
1723
+ // the parser-mismatch gate — and the findings step runs as for any
1724
+ // changes-requested round: dispositions, a fix round, a re-review.
1725
+ const notes: CoordinatorNote[] = [roundNote(round, "checks_failed")];
1726
+ if (next.reviewRounds >= next.input.caps.maxRounds)
1727
+ return end(
1728
+ next,
1729
+ { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
1730
+ notes,
1731
+ );
1732
+ return enterRound(next, { index: round.index, kind: "findings" }, notes);
1733
+ }
1734
+ case "pending":
1735
+ // A head still pending or unreadable at the ask's end proceeds — the
1736
+ // ending's facts read names what is still pending, and the merge door
1737
+ // (under `merge: runner`) is the guard that never merges over it.
1738
+ if (s.clock - p.since >= p.waitMs) return approveOutcome(s, []);
1739
+ return wait({});
1740
+ case "grace":
1741
+ return wait({ graced: true });
1742
+ case "green":
1743
+ // Green (or none reported after the grace): today's path, no wait added.
1744
+ return approveOutcome(s, []);
1584
1745
  }
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
1746
  }
1592
1747
 
1593
1748
  /** An approve past the checks read: merge_ready for a person, the merge door
@@ -1661,6 +1816,16 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1661
1816
  roundNote(round, "aborted"),
1662
1817
  ]);
1663
1818
  if (phase.dead === "failed") {
1819
+ // A provider transient with nothing pushed is not the child's failure
1820
+ // (issue 1932): the ledger row and the branch are untouched, so round 0
1821
+ // is re-run once — a fresh attempt under fresh step names — and only a
1822
+ // second transient in the same round is the ending, named `transient`.
1823
+ if (phase.transient && round.kind === "coding" && pr.unrecovered === "no_commits") {
1824
+ if ((round.attempt ?? 1) < 2) return enterRound(s, { ...round, attempt: 2 }, [roundNote(round, "transient")]);
1825
+ return end(s, { kind: "transient", round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
1826
+ roundNote(round, "transient"),
1827
+ ]);
1828
+ }
1664
1829
  // The abort repeats the bot's reason for recovering nothing, and claims
1665
1830
  // no more than the answer carried.
1666
1831
  const why =
@@ -1967,6 +2132,12 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1967
2132
  round: p.round,
1968
2133
  reviewRounds: s.reviewRounds,
1969
2134
  });
2135
+ case "stopped":
2136
+ // The hosted parent's hard stop (record 0060; issue 1924): the spawn
2137
+ // was refused over the stop mark, so the unit ends stopped here.
2138
+ return end(clocked, { kind: "stopped", mode: "hard", round: p.round, reviewRounds: s.reviewRounds }, [
2139
+ roundNote(p.round, "stopped"),
2140
+ ]);
1970
2141
  case "failed":
1971
2142
  return end(clocked, {
1972
2143
  kind: "aborted",
@@ -2005,6 +2176,21 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2005
2176
  },
2006
2177
  notes: [],
2007
2178
  };
2179
+ // The hosted parent's hard stop landed while this child ran (record 0060;
2180
+ // issue 1924): the unit ends stopped as the child ends, whatever the
2181
+ // child's own status — the runner runs nothing more of it.
2182
+ if (r.stopped === true)
2183
+ return end(
2184
+ clocked,
2185
+ {
2186
+ kind: "stopped",
2187
+ mode: "hard",
2188
+ round: p.round,
2189
+ reviewRounds: s.reviewRounds,
2190
+ ...(r.run.finalReply !== undefined ? { finalReply: r.run.finalReply } : {}),
2191
+ },
2192
+ [roundNote(p.round, "stopped")],
2193
+ );
2008
2194
  if (r.run.status === "interrupted") {
2009
2195
  // A dead CODING child may have pushed before the ledger closed it: the
2010
2196
  // pr-check recovers the branch. A review child has nothing on the
@@ -2064,27 +2250,81 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2064
2250
  : end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
2065
2251
  if (r.outcome === "refused")
2066
2252
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
2253
+ if (r.outcome === "removed") {
2254
+ // The queue removed the pull request — a failing check in the queue, a
2255
+ // conflict: the removal reason becomes a finding of the round, like a
2256
+ // red check does through the checks step, and a fix round follows
2257
+ // (issue 2011). With no review run to brief the fix from (a resume
2258
+ // straight at the merge decision) a person decides, the refusal
2259
+ // carrying the queue's own reason.
2260
+ const round: RoundRef = { index: s.reviewRounds, kind: "findings" };
2261
+ if (s.reviewRunByRound[round.index] === undefined)
2262
+ return end(clocked, {
2263
+ kind: "merge_refused",
2264
+ pr: p.pr,
2265
+ reason: `the merge queue removed the pull request: ${r.reason}`,
2266
+ reviewRounds: s.reviewRounds,
2267
+ });
2268
+ const findings = [...(s.findingsByRound[round.index] ?? []), queueRemovalFinding(r.reason)];
2269
+ const next: UnitPipelineState = {
2270
+ ...clocked,
2271
+ findingsByRound: { ...s.findingsByRound, [round.index]: findings },
2272
+ };
2273
+ const notes: CoordinatorNote[] = [roundNote(round, "dequeued")];
2274
+ if (next.reviewRounds >= next.input.caps.maxRounds)
2275
+ return end(
2276
+ next,
2277
+ { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
2278
+ notes,
2279
+ );
2280
+ return enterRound(next, round, notes);
2281
+ }
2067
2282
  const waited = r.at - p.since;
2068
2283
  if (waited >= p.waitMs)
2069
2284
  return end(clocked, {
2070
2285
  kind: "merge_refused",
2071
2286
  pr: p.pr,
2072
- reason: `still pending after ${Math.round(waited / MIN)} minutes (${r.reason})`,
2287
+ reason:
2288
+ r.outcome === "enqueued"
2289
+ ? `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`
2290
+ : `still pending after ${Math.round(waited / MIN)} minutes (${r.reason})`,
2073
2291
  reviewRounds: s.reviewRounds,
2074
2292
  });
2293
+ // `enqueued` waits like `pending`, marked so every later ask reads the
2294
+ // queue's outcome; the boundary is noted once, when the queue takes it.
2295
+ const queued = r.outcome === "enqueued" ? true : p.queued;
2075
2296
  return {
2076
2297
  state: {
2077
2298
  ...clocked,
2078
- phase: { at: "merge-wait", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since, waitMs: p.waitMs },
2299
+ phase: {
2300
+ at: "merge-wait",
2301
+ pr: p.pr,
2302
+ headSha: p.headSha,
2303
+ n: p.n,
2304
+ since: p.since,
2305
+ waitMs: p.waitMs,
2306
+ ...(queued === true ? { queued: true as const } : {}),
2307
+ },
2079
2308
  },
2080
- notes: [],
2309
+ notes:
2310
+ r.outcome === "enqueued" && p.queued !== true
2311
+ ? [roundNote({ index: s.reviewRounds, kind: "review" }, "enqueued")]
2312
+ : [],
2081
2313
  };
2082
2314
  }
2083
2315
  case "merge-wait":
2084
2316
  return {
2085
2317
  state: {
2086
2318
  ...s,
2087
- phase: { at: "merge", pr: p.pr, headSha: p.headSha, n: p.n + 1, since: p.since, waitMs: p.waitMs },
2319
+ phase: {
2320
+ at: "merge",
2321
+ pr: p.pr,
2322
+ headSha: p.headSha,
2323
+ n: p.n + 1,
2324
+ since: p.since,
2325
+ waitMs: p.waitMs,
2326
+ ...(p.queued === true ? { queued: true as const } : {}),
2327
+ },
2088
2328
  },
2089
2329
  notes: [],
2090
2330
  };
@@ -2292,6 +2532,25 @@ export function renderUnitReport(
2292
2532
  ]
2293
2533
  .filter(Boolean)
2294
2534
  .join("\n");
2535
+ case "held": {
2536
+ // The person's next step is the report's whole point (issue 1990): the
2537
+ // human-gated rows are named with the reviewer's own words, and the
2538
+ // re-issue line says the attempt resumes at the review round — item 10's
2539
+ // resume path — once the receipt stands on the pull request. That path
2540
+ // fires only when the invocation carries NO new task text: re-issuing
2541
+ // with the task would ADOPT the pull request and run a coding round
2542
+ // first (item 10), the very round this ending exists to avoid — so the
2543
+ // held case renders its own re-issue line instead of the shared one.
2544
+ const rows = e.findings.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ");
2545
+ const heldReissue = s.input.generated
2546
+ ? `To continue, re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text.`
2547
+ : reissue;
2548
+ return join([
2549
+ `⏸️ ${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.`,
2550
+ aside(levelLine),
2551
+ `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.`,
2552
+ ]);
2553
+ }
2295
2554
  case "merge_refused":
2296
2555
  // The approved work is on the branch, so the remedy is a person's hand
2297
2556
  // merge, never a re-run: a seeded plan re-issued afterwards finds the
@@ -2363,6 +2622,13 @@ export function renderUnitReport(
2363
2622
  `🔁 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
2623
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2365
2624
  ]);
2625
+ case "transient":
2626
+ return join([
2627
+ `⚠️ 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.`,
2628
+ aside(writeUpPointer(s, e.round.kind, s.lastCodingRunId)),
2629
+ `⚠️ Ship ended after ${rounds}; re-issue once the provider settles.`,
2630
+ reissue,
2631
+ ]);
2366
2632
  case "no_verdict":
2367
2633
  return join([
2368
2634
  `⚠️ 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.`,
@@ -54,6 +54,9 @@ export function shimRoute(pathname: string): string | undefined {
54
54
  if (pathname.startsWith("/admin/")) return "admin";
55
55
  // The artifact copy (deploy/cloudflare/artifactsCopy.ts): the shim's own route, a 1 GB stream.
56
56
  if (pathname === "/artifacts/copy") return "artifacts";
57
+ // The plane's effect push (record 0064): the state Worker's service-binding
58
+ // call, bearer-gated by the shim and forwarded to the container.
59
+ if (pathname === "/plane/effects") return "plane";
57
60
  // The model proxy's three routes (docs/reference/specs/model-proxy.md): a bounded
58
61
  // request per model call, forwarded to the container like everything else.
59
62
  if (pathname === "/v1/messages" || pathname === "/v1/chat/completions" || pathname === "/v1/responses")