@coreplane/switchboard 1.251.0 → 1.252.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 (74) hide show
  1. package/dist/assets/config/config.example.yaml +4 -2
  2. package/dist/assets/deploy/cloudflare/preflight.mjs +19 -21
  3. package/dist/assets/deploy/cloudflare/worker.ts +6 -3
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +77 -12
  5. package/dist/assets/deploy/cloudflare-resident/memoryGuard.ts +212 -0
  6. package/dist/assets/deploy/cloudflare-resident/refresh.ts +1 -1
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +317 -56
  8. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +4 -2
  9. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.d.mts +31 -0
  10. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.mjs +119 -0
  11. package/dist/assets/package-lock.json +3 -3
  12. package/dist/assets/package.json +3 -2
  13. package/dist/assets/project.json +13 -9
  14. package/dist/assets/source.json +3 -3
  15. package/dist/assets/src/agents/registry.ts +5 -5
  16. package/dist/assets/src/core/budgets.ts +22 -0
  17. package/dist/assets/src/core/coordinator/contract.ts +42 -0
  18. package/dist/assets/src/core/coordinator/driver.ts +134 -10
  19. package/dist/assets/src/core/drain.ts +50 -0
  20. package/dist/assets/src/core/memory/engine.ts +98 -0
  21. package/dist/assets/src/core/memory/scorer.ts +12 -4
  22. package/dist/assets/src/core/memory/types.ts +69 -12
  23. package/dist/assets/src/core/modelCard.ts +32 -4
  24. package/dist/assets/src/core/modelPricing.ts +111 -1
  25. package/dist/assets/src/core/modelProxy/usage.ts +88 -0
  26. package/dist/assets/src/core/modelRegistry.ts +15 -1
  27. package/dist/assets/src/core/refusal.ts +4 -7
  28. package/dist/assets/src/core/reviewVerdict.ts +4 -0
  29. package/dist/assets/src/core/runEvents.ts +51 -2
  30. package/dist/assets/src/core/runFriction.ts +7 -2
  31. package/dist/assets/src/core/runLedger/types.ts +11 -0
  32. package/dist/assets/src/core/runUsage.ts +67 -13
  33. package/dist/assets/src/core/schedules.ts +3 -0
  34. package/dist/assets/src/core/ship/contract.ts +41 -14
  35. package/dist/assets/src/core/ship/coordinator.ts +380 -53
  36. package/dist/assets/src/core/ship/renewal.ts +10 -5
  37. package/dist/assets/src/core/trace/attrs.ts +24 -0
  38. package/dist/assets/src/core/types.ts +5 -5
  39. package/dist/assets/src/core/verbosity.ts +48 -0
  40. package/dist/assets/src/deploy/liveGate.ts +40 -13
  41. package/dist/assets/src/deploy/restart.ts +11 -12
  42. package/dist/assets/src/execution/residentDepCache.ts +50 -1
  43. package/dist/assets/src/execution/residentDepsStore.ts +40 -2
  44. package/dist/assets/src/execution/residentRefresh.ts +55 -3
  45. package/dist/assets/src/execution/residentSteps.ts +4 -0
  46. package/dist/assets/src/execution/sandboxErrors.ts +8 -0
  47. package/dist/assets/web/dist/.vite/manifest.json +55 -55
  48. package/dist/assets/web/dist/assets/{DeliveryPage-DUXd-Sl-.js → DeliveryPage-3ELQWM0r.js} +1 -1
  49. package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +2 -0
  50. package/dist/assets/web/dist/assets/{PendingTurnRow-DDhMhrI7.js → PendingTurnRow-ChCQOLgZ.js} +1 -1
  51. package/dist/assets/web/dist/assets/{ResidentDetailPage-BnEoOnGQ.js → ResidentDetailPage-C9y3nbo8.js} +1 -1
  52. package/dist/assets/web/dist/assets/{ResidentsIndexPage-Dxpgf-l-.js → ResidentsIndexPage-i1RG9e7g.js} +1 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +1 -0
  54. package/dist/assets/web/dist/assets/{RunRoutePage-9klVWhSF.js → RunRoutePage-B3IirUVi.js} +4 -4
  55. package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +1 -0
  56. package/dist/assets/web/dist/assets/{ScheduledPage-B_GgeJrb.js → ScheduledPage-DvYwM2TE.js} +1 -1
  57. package/dist/assets/web/dist/assets/{SettingsPage-BXX4R113.js → SettingsPage-Bo6yCyXZ.js} +1 -1
  58. package/dist/assets/web/dist/assets/{StatusDot-BOaw8le9.js → StatusDot-CAfS1AUi.js} +1 -1
  59. package/dist/assets/web/dist/assets/{Tooltip-DYZZ4l4V.js → Tooltip-tZoum_T-.js} +1 -1
  60. package/dist/assets/web/dist/assets/{UnitRoutePage-BaSW5Odq.js → UnitRoutePage-BmdOHwNn.js} +1 -1
  61. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +1 -0
  62. package/dist/assets/web/dist/assets/{dist-BCVXeBJ9.js → dist-DfbEpHXR.js} +1 -1
  63. package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +1 -0
  64. package/dist/assets/web/dist/assets/{main-Dkcbtu3u.js → main-5Gm_1Gv8.js} +2 -2
  65. package/dist/assets/web/dist/assets/sseReplay-DmyMXfRC.js +11 -0
  66. package/dist/cli.js +2470 -902
  67. package/package.json +1 -1
  68. package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +0 -68
  69. package/dist/assets/web/dist/assets/HomePage-mSiqEEcN.js +0 -2
  70. package/dist/assets/web/dist/assets/RunFoldRow-CSo4-vld.js +0 -1
  71. package/dist/assets/web/dist/assets/RunsIndexPage-BplMIgaw.js +0 -1
  72. package/dist/assets/web/dist/assets/budgets-BvWYKPsY.js +0 -1
  73. package/dist/assets/web/dist/assets/indexRow-Bde9OZxG.js +0 -1
  74. package/dist/assets/web/dist/assets/sseReplay-DPwdsaok.js +0 -9
@@ -30,6 +30,7 @@
30
30
  // arrive in the input rather than from the agent registry.
31
31
 
32
32
  import type { ShipRoundOutcome } from "../runEvents.js";
33
+ import { shows, type Verbosity } from "../verbosity.js";
33
34
  import type { Handoff, HandoffLanded } from "./handoff.js";
34
35
  import { progressOf, renderRenewal, renewalDecision, type PushedHeadFact, type RenewalDecision } from "./renewal.js";
35
36
  import type { RunStatus } from "../runRecord.js";
@@ -51,6 +52,7 @@ import {
51
52
  carve,
52
53
  DEFAULT_GRANT,
53
54
  loopPosition,
55
+ MERGE_WAIT_ASK_MINUTES,
54
56
  MINUTE_MS,
55
57
  SHIP_WAIT,
56
58
  type Carve,
@@ -430,8 +432,19 @@ export type Brief =
430
432
  round: number;
431
433
  /** The previous review round's run and the coding run that answered its findings, for a re-review. */
432
434
  prior?: { reviewRunId: string; codingRunId?: string };
435
+ /** The previous round's check findings (record 0055): they sit on no
436
+ * run's record, so the brief carries them beside `prior` by value. */
437
+ checks?: Finding[];
433
438
  }
434
- | { kind: "findings"; unit: string; pr: number; reviewRunId: string };
439
+ | {
440
+ kind: "findings";
441
+ unit: string;
442
+ pr: number;
443
+ reviewRunId: string;
444
+ /** The round's check findings (record 0055), carried by value — the
445
+ * review run's record holds only the reviewer's own findings. */
446
+ checks?: Finding[];
447
+ };
435
448
 
436
449
  export interface PrRef {
437
450
  number: number;
@@ -462,6 +475,12 @@ export type CoordinatorAction =
462
475
  pr?: number;
463
476
  }
464
477
  | { type: "merge"; step: string; prNumber: number; headSha: string }
478
+ /** Read the check runs at the reviewed head (record 0055, the round verdict):
479
+ * the merge door's own reading, folded into the round. `retry` names the
480
+ * failed checks whose one flake re-run the machine is spending: the bot
481
+ * re-runs their failed jobs (the CI retry the deploy already uses) instead
482
+ * of reading. */
483
+ | { type: "checks"; step: string; prNumber: number; headSha: string; retry?: string[] }
465
484
  /** Wait for the intake's checks-settled event at the approved head, bounded as the fallback. */
466
485
  | { type: "wait-checks"; step: string; headSha: string; timeoutMs: number }
467
486
  | { type: "sleep"; step: string; ms: number }
@@ -529,6 +548,12 @@ export type PrCheck =
529
548
  autoMergeEnabled?: boolean;
530
549
  /** The check runs at the head, when the read asked for them (the ending's facts). */
531
550
  checks?: CommitChecksFacts;
551
+ /** GitHub's `mergeable_state`, when the read asked for the facts — `dirty`
552
+ * keeps the report from calling a conflicting head merge-ready (item 9). */
553
+ mergeableState?: string;
554
+ /** The head's self-declared fix-up commit subjects (`fixup!`/`squash!`/`amend!`),
555
+ * when the read asked for the facts — a head carrying one is not in the ready state. */
556
+ fixupCommits?: string[];
532
557
  }
533
558
  | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
534
559
 
@@ -548,9 +573,56 @@ export type StepReturn =
548
573
  // fired, or a person merged — so the runner merged nothing (`by: other`).
549
574
  | { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
550
575
  | { type: "merge"; step: string; outcome: "pending" | "refused"; reason: string; at: number }
576
+ /** The checks read at the reviewed head; `checks` absent means GitHub could
577
+ * not be read, which the machine treats as pending (record 0055). A retry
578
+ * ask answers `retried` instead: whether the bot dispatched the re-run —
579
+ * false means there is nothing to wait for at the unchanged head. */
580
+ | { type: "checks"; step: string; checks?: RoundChecks; retried?: boolean; at: number }
551
581
  | { type: "wait-checks"; step: string; outcome: "event" | "timeout" }
552
582
  | { type: "sleep"; step: string };
553
583
 
584
+ /** One failed check run at the reviewed head, as the round's checks step reads
585
+ * it (record 0055): the name, GitHub's conclusion, the run's URL, and whether
586
+ * the bot's classifier suspects a flake — a test timeout or runner stall on a
587
+ * shard whose test files the pull request's changed paths never touch. */
588
+ export interface CheckFailure {
589
+ name: string;
590
+ conclusion: string;
591
+ url?: string;
592
+ flakeSuspect?: boolean;
593
+ }
594
+
595
+ /** The check runs at the reviewed head as the round's checks step reads them. */
596
+ export interface RoundChecks {
597
+ total: number;
598
+ pending: string[];
599
+ failed: CheckFailure[];
600
+ }
601
+
602
+ /** A failed check as a finding of the round (record 0055): a finding row like
603
+ * a reviewer's — id `check:<name>`, the check's name as the file, severity
604
+ * `blocking` so the level in force always counts it, the conclusion and URL
605
+ * as the title — so the ledger, the findings brief and the dispositions
606
+ * carry it exactly as a reviewer's. */
607
+ export function checkFinding(f: CheckFailure): Finding {
608
+ return {
609
+ id: `check:${f.name}`,
610
+ severity: "blocking",
611
+ file: f.name,
612
+ title: `CI check failed (${f.conclusion})${f.url !== undefined ? ` — ${f.url}` : ""}`,
613
+ check: true,
614
+ };
615
+ }
616
+
617
+ /** The check findings of a review round: the rows the checks step appended to
618
+ * the round's findings, told apart by provenance — the `check` flag only
619
+ * `checkFinding` sets, never the id: a reviewer's id is a free string, and a
620
+ * re-review brief names the prior check findings by id, so a reviewer
621
+ * plausibly reuses `check:…` in its own verdict — a check finding sits on no
622
+ * run's record, so a brief carries them by value. */
623
+ export const checkFindingsOf = (findings: readonly Finding[] | undefined): Finding[] =>
624
+ (findings ?? []).filter((f) => f.check === true);
625
+
554
626
  /** How one unit's pipeline ended — the truthful vocabulary the ship pipeline
555
627
  * has, plus the merge's own: `merged` by the runner, or found merged (`by:
556
628
  * other` — a person's merge, or an earlier attempt's that died after it, so
@@ -698,6 +770,9 @@ export interface UnitPipelineInput {
698
770
  * it yet: the renewal decision is the segment's end, not this unit's. */
699
771
  grant?: Grant;
700
772
  grantSource?: GrantSource;
773
+ /** The request's verbosity (routing-and-config item 28): the level the
774
+ * thread's copy of the unit report is rendered at; absent reads as quiet. */
775
+ verbosity?: Verbosity;
701
776
  /** The segment this pipeline runs (decision 0046, Renewal): absent for the
702
777
  * first; a renewal's carries its number, the renewals spent before it, the
703
778
  * session's spend so far, the sha it continues from and the previous
@@ -747,6 +822,36 @@ type Phase =
747
822
  }
748
823
  | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
749
824
  | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number }
825
+ /** The round's checks step (record 0055): the check runs at the reviewed head
826
+ * are read after an approve settles, before merge_ready or the merge door.
827
+ * `graced`: a head with no check reported has had its one-chunk grace;
828
+ * `retried`: the one flake re-run is spent; `retry` names the failed checks
829
+ * the next ask re-runs instead of reading. */
830
+ | {
831
+ at: "checks";
832
+ round: RoundRef;
833
+ prNumber: number;
834
+ headSha: string;
835
+ n: number;
836
+ since: number;
837
+ waitMs: number;
838
+ graced: boolean;
839
+ retried: boolean;
840
+ retry?: string[];
841
+ }
842
+ /** Waiting on `checks-settled-<head>` between two checks reads, in the merge
843
+ * wait's own chunks — the intake wakes the machine when the head settles. */
844
+ | {
845
+ at: "checks-wait";
846
+ round: RoundRef;
847
+ prNumber: number;
848
+ headSha: string;
849
+ n: number;
850
+ since: number;
851
+ waitMs: number;
852
+ graced: boolean;
853
+ retried: boolean;
854
+ }
750
855
  | { at: "ended" };
751
856
 
752
857
  export interface UnitPipelineState {
@@ -879,9 +984,19 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
879
984
  };
880
985
  }
881
986
  const pr = s.pr!.number;
882
- if (round.kind === "findings") return { kind: "findings", unit, pr, reviewRunId: s.reviewRunByRound[round.index]! };
987
+ if (round.kind === "findings") {
988
+ const checks = checkFindingsOf(s.findingsByRound[round.index]);
989
+ return {
990
+ kind: "findings",
991
+ unit,
992
+ pr,
993
+ reviewRunId: s.reviewRunByRound[round.index]!,
994
+ ...(checks.length > 0 ? { checks } : {}),
995
+ };
996
+ }
883
997
  const priorReview = s.reviewRunByRound[round.index - 1];
884
998
  const priorCoding = s.findingsRunByRound[round.index - 1];
999
+ const priorChecks = checkFindingsOf(s.findingsByRound[round.index - 1]);
885
1000
  return {
886
1001
  kind: "review",
887
1002
  unit,
@@ -891,6 +1006,7 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
891
1006
  ...(priorReview !== undefined
892
1007
  ? { prior: { reviewRunId: priorReview, ...(priorCoding !== undefined ? { codingRunId: priorCoding } : {}) } }
893
1008
  : {}),
1009
+ ...(priorChecks.length > 0 ? { checks: priorChecks } : {}),
894
1010
  };
895
1011
  }
896
1012
 
@@ -954,6 +1070,21 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
954
1070
  // when nothing heads the unit's branch (issue 1799).
955
1071
  ...(s.pr !== undefined ? { pr: s.pr.number } : {}),
956
1072
  };
1073
+ case "checks":
1074
+ return {
1075
+ type: "checks",
1076
+ step: `${roundStep(s, p.round)}/checks/${p.n}`,
1077
+ prNumber: p.prNumber,
1078
+ headSha: p.headSha,
1079
+ ...(p.retry !== undefined ? { retry: p.retry } : {}),
1080
+ };
1081
+ case "checks-wait":
1082
+ return {
1083
+ type: "wait-checks",
1084
+ step: `${roundStep(s, p.round)}/checks/wait/${p.n}`,
1085
+ headSha: p.headSha,
1086
+ timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, p.waitMs - (s.clock - p.since))),
1087
+ };
957
1088
  case "merge":
958
1089
  return { type: "merge", step: `${unit}/merge/${p.n}`, prNumber: p.pr.number, headSha: p.headSha };
959
1090
  case "merge-wait":
@@ -1192,6 +1323,163 @@ function settleReview(
1192
1323
  );
1193
1324
  return enterRound(next, { index: round.index, kind: "findings" }, notes);
1194
1325
  }
1326
+ // The round verdict (record 0055): the approve is joined with the check
1327
+ // runs at the reviewed head before merge_ready or the merge door — the
1328
+ // checks step reads them and a failed one becomes a finding of the round.
1329
+ return enterChecks(next, round, notes);
1330
+ }
1331
+ // request_changes: the verdict settled (and posted) — now a stop
1332
+ // short-circuits the findings step, naming the review standing on the pull request.
1333
+ if (mode !== undefined)
1334
+ return end(
1335
+ next,
1336
+ {
1337
+ kind: "stopped",
1338
+ mode,
1339
+ round,
1340
+ reviewRounds: next.reviewRounds,
1341
+ ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
1342
+ ...(facts.reviewPosted === true ? { postedReview: true } : {}),
1343
+ },
1344
+ notes,
1345
+ );
1346
+ if (next.reviewRounds >= next.input.caps.maxRounds)
1347
+ return end(
1348
+ next,
1349
+ { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
1350
+ notes,
1351
+ );
1352
+ // The findings step (agent-ship item 7): the review's findings dispatched into
1353
+ // the unit thread as a coding run, under the review round's index. A run live
1354
+ // there is a person's (this machine awaited its own child's end), so the
1355
+ // spawn answers busy and the step waits under the unit's clock like any round.
1356
+ return enterRound(next, { index: round.index, kind: "findings" }, notes);
1357
+ }
1358
+
1359
+ /** The round's checks step (record 0055): after an approve settles, the check
1360
+ * runs at the reviewed head are read with the merge door's own reading and
1361
+ * folded into the round verdict. With no reviewed head known the step is
1362
+ * skipped as the merge step's guard is, and the unit ends as today. The wait
1363
+ * on a pending head is bounded by the merge wait's own ask, clipped to the
1364
+ * pipeline's remainder. */
1365
+ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNote[]): Transition {
1366
+ const headSha = normalizeHead(s.lastReviewHead);
1367
+ const pr = s.pr;
1368
+ if (headSha === undefined || pr === undefined) return approveOutcome(s, notes);
1369
+ const waitMs = Math.max(MIN, Math.min(MERGE_WAIT_ASK_MINUTES * MIN, remainingMs(s)));
1370
+ return {
1371
+ state: {
1372
+ ...s,
1373
+ phase: {
1374
+ at: "checks",
1375
+ round,
1376
+ prNumber: pr.number,
1377
+ headSha,
1378
+ n: 1,
1379
+ since: s.clock,
1380
+ waitMs,
1381
+ graced: false,
1382
+ retried: false,
1383
+ },
1384
+ },
1385
+ notes,
1386
+ };
1387
+ }
1388
+
1389
+ /** What the checks step answered, folded into the round (record 0055). Pure
1390
+ * over the phase: a retry ask waits for the head to settle and reads again;
1391
+ * an unreadable or pending head waits a chunk inside the step's ask and then
1392
+ * proceeds — the ending's facts read names what is still pending; a head with
1393
+ * no check reported waits one chunk of grace and never more; a failed check
1394
+ * becomes a check finding under a round note of its own and the findings step
1395
+ * runs as for any changes-requested round — never merge_ready, never the
1396
+ * merge door; a green head proceeds with no wait added. */
1397
+ function settleChecks(
1398
+ s: UnitPipelineState,
1399
+ p: Extract<Phase, { at: "checks" }>,
1400
+ checks: RoundChecks | undefined,
1401
+ retried?: boolean,
1402
+ ): Transition {
1403
+ const { round } = p;
1404
+ const wait = (over: Partial<Extract<Phase, { at: "checks-wait" }>>): Transition => ({
1405
+ state: {
1406
+ ...s,
1407
+ phase: {
1408
+ at: "checks-wait",
1409
+ round,
1410
+ prNumber: p.prNumber,
1411
+ headSha: p.headSha,
1412
+ n: p.n,
1413
+ since: p.since,
1414
+ waitMs: p.waitMs,
1415
+ graced: p.graced,
1416
+ retried: p.retried,
1417
+ ...over,
1418
+ },
1419
+ },
1420
+ notes: [],
1421
+ });
1422
+ // The retry ask. Dispatched: wait for the head to settle, then read again;
1423
+ // a second failure is the finding. NOT dispatched (`retried: false` — no
1424
+ // re-runnable run behind the checks, or GitHub refused): the one re-run is
1425
+ // spent all the same — the machine never asks twice — but nothing changes
1426
+ // at the head, so the checks are read again at once instead of waiting on a
1427
+ // settle event that never comes, and the failure becomes the finding.
1428
+ if (p.retry !== undefined) {
1429
+ if (retried === false) {
1430
+ const { retry: _retry, ...read } = p;
1431
+ return { state: { ...s, phase: { ...read, n: p.n + 1, retried: true } }, notes: [] };
1432
+ }
1433
+ return wait({ retried: true });
1434
+ }
1435
+ // GitHub unreadable answers as pending and is re-read at the chunk's end
1436
+ // (record 0055); a head still pending or unreadable at the ask's end
1437
+ // proceeds — the ending's facts read names what is still pending, and the
1438
+ // merge door (under `merge: runner`) is the guard that never merges over it.
1439
+ if (checks === undefined || checks.pending.length > 0) {
1440
+ if (s.clock - p.since >= p.waitMs) return approveOutcome(s, []);
1441
+ return wait({});
1442
+ }
1443
+ if (checks.failed.length > 0) {
1444
+ // The flake rule (record 0055): a suspected flake — a test timeout or
1445
+ // runner stall on a shard whose test files the pull request's changed
1446
+ // paths never touch — is re-run once before it becomes a finding. One
1447
+ // real failure among them makes the round's answer already known, so the
1448
+ // re-run is spent only when every failure is a suspect.
1449
+ if (!p.retried && checks.failed.every((f) => f.flakeSuspect === true))
1450
+ return {
1451
+ state: { ...s, phase: { ...p, n: p.n + 1, retry: checks.failed.map((f) => f.name) } },
1452
+ notes: [],
1453
+ };
1454
+ const findings = [...(s.findingsByRound[round.index] ?? []), ...checks.failed.map(checkFinding)];
1455
+ const next: UnitPipelineState = {
1456
+ ...s,
1457
+ findingsByRound: { ...s.findingsByRound, [round.index]: findings },
1458
+ };
1459
+ // The failed checks get a round note of their own (record 0055) — never
1460
+ // the parser-mismatch gate — and the findings step runs as for any
1461
+ // changes-requested round: dispositions, a fix round, a re-review.
1462
+ const notes: CoordinatorNote[] = [roundNote(round, "checks_failed")];
1463
+ if (next.reviewRounds >= next.input.caps.maxRounds)
1464
+ return end(
1465
+ next,
1466
+ { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
1467
+ notes,
1468
+ );
1469
+ return enterRound(next, { index: round.index, kind: "findings" }, notes);
1470
+ }
1471
+ // No check reported at the head: one chunk of grace — the first check starts
1472
+ // within minutes where CI exists — then the round proceeds, so a repository
1473
+ // without CI costs one chunk per round and never idles (record 0055).
1474
+ if (checks.total === 0 && !p.graced) return wait({ graced: true });
1475
+ // Green (or none reported after the grace): today's path, no wait added.
1476
+ return approveOutcome(s, []);
1477
+ }
1478
+
1479
+ /** An approve past the checks read: merge_ready for a person, the merge door
1480
+ * under `merge: runner` — the tail the round verdict guards (record 0055). */
1481
+ function approveOutcome(next: UnitPipelineState, notes: CoordinatorNote[]): Transition {
1482
+ {
1195
1483
  const pr = next.pr!;
1196
1484
  if (next.input.merge !== "runner")
1197
1485
  return end(next, { kind: "merge_ready", pr, reviewRounds: next.reviewRounds }, notes);
@@ -1225,32 +1513,6 @@ function settleReview(
1225
1513
  notes,
1226
1514
  };
1227
1515
  }
1228
- // request_changes: the verdict settled (and posted) — now a stop
1229
- // short-circuits the findings step, naming the review standing on the pull request.
1230
- if (mode !== undefined)
1231
- return end(
1232
- next,
1233
- {
1234
- kind: "stopped",
1235
- mode,
1236
- round,
1237
- reviewRounds: next.reviewRounds,
1238
- ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
1239
- ...(facts.reviewPosted === true ? { postedReview: true } : {}),
1240
- },
1241
- notes,
1242
- );
1243
- if (next.reviewRounds >= next.input.caps.maxRounds)
1244
- return end(
1245
- next,
1246
- { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
1247
- notes,
1248
- );
1249
- // The findings step (agent-ship item 7): the review's findings dispatched into
1250
- // the unit thread as a coding run, under the review round's index. A run live
1251
- // there is a person's (this machine awaited its own child's end), so the
1252
- // spawn answers busy and the step waits under the unit's clock like any round.
1253
- return enterRound(next, { index: round.index, kind: "findings" }, notes);
1254
1516
  }
1255
1517
 
1256
1518
  /** The unit's ending when its pull request is found merged — by a person, or
@@ -1627,6 +1889,29 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
1627
1889
  }
1628
1890
  case "pr-check":
1629
1891
  return settlePrCheck(clocked, p, (ret as Extract<StepReturn, { type: "pr-check" }>).pr);
1892
+ case "checks": {
1893
+ const r = ret as Extract<StepReturn, { type: "checks" }>;
1894
+ return settleChecks(clocked, p, r.checks, r.retried);
1895
+ }
1896
+ case "checks-wait":
1897
+ // The event fired or the chunk elapsed either way the head is read again.
1898
+ return {
1899
+ state: {
1900
+ ...s,
1901
+ phase: {
1902
+ at: "checks",
1903
+ round: p.round,
1904
+ prNumber: p.prNumber,
1905
+ headSha: p.headSha,
1906
+ n: p.n + 1,
1907
+ since: p.since,
1908
+ waitMs: p.waitMs,
1909
+ graced: p.graced,
1910
+ retried: p.retried,
1911
+ },
1912
+ },
1913
+ notes: [],
1914
+ };
1630
1915
  case "merge": {
1631
1916
  const r = ret as Extract<StepReturn, { type: "merge" }>;
1632
1917
  if (r.outcome === "merged")
@@ -1745,6 +2030,14 @@ export interface MergeReadyFacts {
1745
2030
  merged?: { sha: string; mergedAt: string };
1746
2031
  /** The check runs at the approved head as the merge door reads them (record 0055). */
1747
2032
  checks?: CommitChecksFacts;
2033
+ /** GitHub's `mergeable_state` for the pull request — `dirty` is a conflict
2034
+ * with the base, and the report says so instead of "merge-ready", exactly
2035
+ * as the merge door refuses it before reading the checks (agent-ship item 9). */
2036
+ mergeableState?: string;
2037
+ /** The subjects of the head's commits that mark themselves as fix-ups
2038
+ * (git's autosquash prefixes `fixup!`, `squash!`, `amend!`): a head
2039
+ * carrying one is by its own words not in the ready state. */
2040
+ fixupCommits?: string[];
1748
2041
  }
1749
2042
 
1750
2043
  /** The check runs at one commit: how many, which still run, which failed. */
@@ -1755,9 +2048,19 @@ export interface CommitChecksFacts {
1755
2048
  }
1756
2049
 
1757
2050
  /** The merge-ready report's headline is a claim about the approved head
1758
- * (record 0055): a failed check is never called merge-ready, a pending one
1759
- * is named, green is said, and without the fact the line is unchanged. */
1760
- function mergeReadyHeadline(rounds: string, url: string, checks: CommitChecksFacts | undefined): string {
2051
+ * (record 0055, agent-ship item 9): a head that conflicts with its base is
2052
+ * never called merge-ready and neither is one carrying an unsquashed fix-up
2053
+ * commit — the ready state is read beside the checks, in the merge door's
2054
+ * order (the conflict first) — then a failed check is never called
2055
+ * merge-ready, a pending one is named, green is said, and without any fact
2056
+ * the line is unchanged. */
2057
+ function mergeReadyHeadline(rounds: string, url: string, base: string, facts: MergeReadyFacts | undefined): string {
2058
+ if (facts?.mergeableState === "dirty")
2059
+ return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — the head conflicts with \`${base}\`: rebase onto \`${base}\`, push, and re-review. The approved work stands.`;
2060
+ const fixups = facts?.fixupCommits ?? [];
2061
+ if (fixups.length > 0)
2062
+ return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — ${fixups.length} unsquashed fix-up commit${fixups.length === 1 ? "" : "s"} on the head (${fixups.join("; ")}): squash into the unit's commit, push, and re-review.`;
2063
+ const checks = facts?.checks;
1761
2064
  if (checks === undefined) return `✅ Merge-ready after ${rounds}: ${url}`;
1762
2065
  if (checks.failed.length > 0) {
1763
2066
  const pending = checks.pending.length > 0 ? `; pending: ${checks.pending.join(", ")}` : "";
@@ -1769,9 +2072,21 @@ function mergeReadyHeadline(rounds: string, url: string, checks: CommitChecksFac
1769
2072
  return `✅ Merge-ready after ${rounds}: ${url} — ${checks.total} check${checks.total === 1 ? "" : "s"} green at the approved head.`;
1770
2073
  }
1771
2074
 
1772
- export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts): string {
2075
+ export function renderUnitReport(
2076
+ s: UnitPipelineState,
2077
+ facts?: MergeReadyFacts,
2078
+ /** The level the report speaks at (routing-and-config item 28). The default
2079
+ * is the full report — the row's and the board's copy; the thread's copy is
2080
+ * rendered at the request's level, where the asides about the machinery
2081
+ * (the level in force, the grant, the write-up pointer, the budget split,
2082
+ * a segment boundary) are `verbose` and the outcome, the verdict, the
2083
+ * findings left below the gate, the declined ones and what to do next are
2084
+ * everyone's. */
2085
+ verbosity: Verbosity = "verbose",
2086
+ ): string {
1773
2087
  const e = s.ending;
1774
2088
  if (!e) return "";
2089
+ const aside = (line: string | undefined) => (shows(verbosity, "verbose") ? line : undefined);
1775
2090
  const rounds = `${e.reviewRounds} review round${e.reviewRounds === 1 ? "" : "s"}`;
1776
2091
  const prUrl = s.pr?.url;
1777
2092
  const prLine = prUrl ? ` PR: ${prUrl}` : "";
@@ -1809,11 +2124,13 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1809
2124
  return [
1810
2125
  `✅ 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.`,
1811
2126
  verdictLine,
1812
- levelLine,
1813
- grantLine,
1814
- ...(skippedLine ? [skippedLine] : []),
2127
+ aside(levelLine),
2128
+ aside(grantLine),
2129
+ skippedLine,
1815
2130
  declinedLine,
1816
- ].join("\n");
2131
+ ]
2132
+ .filter(Boolean)
2133
+ .join("\n");
1817
2134
  case "already_landed":
1818
2135
  // No compare link, no renewal line, no re-issue prompt: there was
1819
2136
  // nothing to ship, so none of them has a question to answer.
@@ -1823,11 +2140,11 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1823
2140
  // A merge that already happened outranks the checks: there is no head left to gate.
1824
2141
  facts?.merged
1825
2142
  ? `✅ Merge-ready after ${rounds}: ${e.pr.url}`
1826
- : mergeReadyHeadline(rounds, e.pr.url, facts?.checks),
2143
+ : mergeReadyHeadline(rounds, e.pr.url, s.input.base, facts),
1827
2144
  verdictLine,
1828
- levelLine,
1829
- grantLine,
1830
- ...(skippedLine ? [skippedLine] : []),
2145
+ aside(levelLine),
2146
+ aside(grantLine),
2147
+ skippedLine,
1831
2148
  declinedLine,
1832
2149
  // What the driver read at the approved head when it composed this
1833
2150
  // ending (agent-ship item 9): a merge that already happened is named
@@ -1837,7 +2154,9 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1837
2154
  : facts?.autoMergeEnabled
1838
2155
  ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
1839
2156
  : "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
1840
- ].join("\n");
2157
+ ]
2158
+ .filter(Boolean)
2159
+ .join("\n");
1841
2160
  case "merge_refused":
1842
2161
  // The approved work is on the branch, so the remedy is a person's hand
1843
2162
  // merge, never a re-run: a seeded plan re-issued afterwards finds the
@@ -1860,23 +2179,25 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1860
2179
  case "wall_clock_cap":
1861
2180
  return join([
1862
2181
  `🧢 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}`,
1863
- budgetSplitLine(e.spent, s.input.caps.maxMinutes),
2182
+ aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
1864
2183
  splitReport(s),
1865
2184
  reissue,
1866
2185
  ]);
1867
2186
  case "review_pending":
1868
2187
  return join([
1869
2188
  `⏳ 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.`,
1870
- budgetSplitLine(e.spent, s.input.caps.maxMinutes),
2189
+ aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
1871
2190
  reissue,
1872
2191
  ]);
1873
2192
  case "stopped":
1874
2193
  return join([
1875
2194
  `${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
1876
- writeUpPointer(
1877
- s,
1878
- e.round.kind,
1879
- e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2195
+ aside(
2196
+ writeUpPointer(
2197
+ s,
2198
+ e.round.kind,
2199
+ e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2200
+ ),
1880
2201
  ),
1881
2202
  e.postedReview
1882
2203
  ? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
@@ -1886,25 +2207,31 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1886
2207
  case "aborted":
1887
2208
  return join([
1888
2209
  e.reason,
1889
- writeUpPointer(
1890
- s,
1891
- e.round?.kind ?? "coding",
1892
- e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2210
+ aside(
2211
+ writeUpPointer(
2212
+ s,
2213
+ e.round?.kind ?? "coding",
2214
+ e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2215
+ ),
1893
2216
  ),
1894
2217
  e.renewal !== undefined ? `🔁 Not renewed: ${e.renewal.line}.` : undefined,
1895
2218
  `⚠️ Ship aborted after ${rounds}.`,
1896
2219
  reissue,
1897
2220
  ]);
1898
2221
  case "continued":
2222
+ // A segment boundary is the runner continuing — an acknowledgement, verbose
2223
+ // material (item 28); the thread's copy is empty at quiet, and the row keeps
2224
+ // no ending either way (decision 0046).
2225
+ if (!shows(verbosity, "verbose")) return "";
1899
2226
  return join([
1900
2227
  writeUpPointer(s, e.round.kind, e.runId),
1901
2228
  `🔁 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` : ""}.`,
1902
- budgetSplitLine(e.spent, s.input.caps.maxMinutes),
2229
+ aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
1903
2230
  ]);
1904
2231
  case "no_verdict":
1905
2232
  return join([
1906
2233
  `⚠️ 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.`,
1907
- writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index]),
2234
+ aside(writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index])),
1908
2235
  `⚠️ Ship aborted after ${rounds}.`,
1909
2236
  reissue,
1910
2237
  ]);
@@ -4,9 +4,12 @@
4
4
  // progress off the row — never off a model's word — and renews only when three
5
5
  // things hold: the row shows progress, the grant has a renewal left and the
6
6
  // spend is under its cap, and the pipeline's ask still passes the fit.
7
- // Otherwise it stops and names the clause that failed; a person's reply spends
8
- // a renewal by hand. Pure and node-free: the unit machine (a Worker) and the
9
- // bot's routes compute the same decision from the same facts.
7
+ // Otherwise it stops and names the clause that failed. Nothing spends a renewal
8
+ // by hand today — follow-ups route by thread context, never by a keyword
9
+ // (routing-and-config item 3), and a re-issued request opens a fresh grant — so
10
+ // the stop says what actually spends one (a segment's progress) and to re-issue
11
+ // the request. Pure and node-free: the unit machine (a Worker) and the bot's
12
+ // routes compute the same decision from the same facts.
10
13
 
11
14
  import { fit, type Grant, type Pipeline } from "../budgets.js";
12
15
  import type { Handoff } from "./handoff.js";
@@ -140,7 +143,9 @@ export function renewalDecision(input: RenewalInput): RenewalDecision {
140
143
 
141
144
  /** The card's words (the record's trace, steps 4 and 9): a renewal names its
142
145
  * number of the grant's and the sha it continues from; a stop names the
143
- * clause and, when renewals remain, the reply that spends one by hand. */
146
+ * clause and, when renewals remain, what actually spends one — a segment's
147
+ * progress — and the honest recourse (re-issue the request). It teaches no
148
+ * keyword: the router has none (routing-and-config item 3). */
144
149
  export function renderRenewal(decision: RenewalDecision, grant: Grant): string {
145
150
  if (decision.renew)
146
151
  return `renewal ${decision.segment - 1} of ${grant.renewals}, continues ${decision.from !== undefined ? decision.from.slice(0, 7) : "the branch's head"}`;
@@ -151,7 +156,7 @@ export function renderRenewal(decision: RenewalDecision, grant: Grant): string {
151
156
  switch (decision.why) {
152
157
  case "no_progress":
153
158
  return decision.renewalsLeft > 0
154
- ? `no progress in the last lease; ${holds}; reply continue to spend one`
159
+ ? `no progress in the last lease; ${holds} unspent — a renewal is spent only by a segment that pushed to the unit's branch or moved its handoff; re-issue the request to try again`
155
160
  : `no progress in the last lease; ${holds}`;
156
161
  case "cost_cap":
157
162
  return decision.renewalsLeft > 0 ? `${decision.detail}; ${holds} unspent` : decision.detail;