@coreplane/switchboard 1.260.0 → 1.260.2

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 (49) hide show
  1. package/bin/switchboard.js +15 -11
  2. package/dist/assets/config/config.example.yaml +1 -0
  3. package/dist/assets/deploy/cloudflare/worker.ts +17 -56
  4. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +6 -0
  5. package/dist/assets/deploy/cloudflare-memory/backgroundTasks.ts +25 -0
  6. package/dist/assets/deploy/cloudflare-memory/worker.ts +36 -16
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +39 -18
  8. package/dist/assets/deploy/profile.example.json +2 -1
  9. package/dist/assets/package-lock.json +3 -3
  10. package/dist/assets/package.json +1 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/agents/registry.ts +2 -2
  13. package/dist/assets/src/core/budgets.ts +19 -6
  14. package/dist/assets/src/core/coordinator/driver.ts +47 -9
  15. package/dist/assets/src/core/pipelineStanding.ts +2 -0
  16. package/dist/assets/src/core/reviewedHead.ts +16 -2
  17. package/dist/assets/src/core/runEvents.ts +9 -1
  18. package/dist/assets/src/core/runLedger/types.ts +3 -3
  19. package/dist/assets/src/core/runRecord.ts +28 -8
  20. package/dist/assets/src/core/ship/coordinator.ts +248 -44
  21. package/dist/assets/src/core/ship/renewal.ts +2 -0
  22. package/dist/assets/src/deploy/liveGate.ts +8 -0
  23. package/dist/assets/src/deploy/profile.ts +8 -0
  24. package/dist/assets/src/deploy/restart.ts +49 -35
  25. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  26. package/dist/assets/web/dist/assets/{DeliveryPage-DvMrWUg7.js → DeliveryPage-62qCSLvk.js} +1 -1
  27. package/dist/assets/web/dist/assets/{HomePage-CcGEJ4w0.js → HomePage-Dk3HRBc3.js} +1 -1
  28. package/dist/assets/web/dist/assets/{PendingTurnRow-BzGVxDYs.js → PendingTurnRow-CeTGOXUy.js} +1 -1
  29. package/dist/assets/web/dist/assets/{PlanePage-Arj9cyd5.js → PlanePage-RD0M6O6W.js} +1 -1
  30. package/dist/assets/web/dist/assets/{ResidentDetailPage-CBPPe4Sj.js → ResidentDetailPage-Dl2KId95.js} +1 -1
  31. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CEpXgGo7.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
  32. package/dist/assets/web/dist/assets/{RunFoldRow-BJq7tnLr.js → RunFoldRow-CwHDL-Mo.js} +1 -1
  33. package/dist/assets/web/dist/assets/{RunRoutePage-DWiny7LN.js → RunRoutePage-Du1HqLZs.js} +3 -3
  34. package/dist/assets/web/dist/assets/{RunsIndexPage-Cxufw5sV.js → RunsIndexPage-BZphy3Hr.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ScheduledPage-SBcuDdVg.js → ScheduledPage-B7jh898t.js} +1 -1
  36. package/dist/assets/web/dist/assets/{SettingsPage-DrD_vWdp.js → SettingsPage-3uLEsiiE.js} +1 -1
  37. package/dist/assets/web/dist/assets/{SilentTurn-Bjl7EPEN.js → SilentTurn-CKxSxnQC.js} +1 -1
  38. package/dist/assets/web/dist/assets/{StatusDot-BqteQTav.js → StatusDot-x7vcK0iE.js} +1 -1
  39. package/dist/assets/web/dist/assets/{Tooltip-KRqJXEPN.js → Tooltip-wVXCWhMp.js} +1 -1
  40. package/dist/assets/web/dist/assets/{UnitRoutePage-Cv28FV4I.js → UnitRoutePage-V5BXK7FH.js} +1 -1
  41. package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
  42. package/dist/assets/web/dist/assets/{dist-CTASUSno.js → dist-C-XGnvM4.js} +1 -1
  43. package/dist/assets/web/dist/assets/{indexRow-B1YBy_IL.js → indexRow-Dxg7C8s9.js} +1 -1
  44. package/dist/assets/web/dist/assets/{main-eTAe9-hk.js → main-DAL--hyj.js} +2 -2
  45. package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
  46. package/dist/cli.js +1405 -436
  47. package/package.json +1 -1
  48. package/dist/assets/web/dist/assets/budgets-DQXVllsm.js +0 -1
  49. package/dist/assets/web/dist/assets/sseReplay-BcsNbn2j.js +0 -11
@@ -458,7 +458,7 @@ export function cursorFinished(cursor: PlanCursor): boolean {
458
458
  * findings dispatched into the unit thread as `agent:coding`, so the coding
459
459
  * session there continues with them (record 0034). Never a `fix` child briefed
460
460
  * from the review. */
461
- export type RoundKind = "coding" | "review" | "findings";
461
+ export type RoundKind = "coding" | "review" | "findings" | "rebase";
462
462
  export interface RoundRef {
463
463
  /** Round 0 is the coding round; review round n and its findings step share n. */
464
464
  index: number;
@@ -508,6 +508,13 @@ export type Brief =
508
508
  /** The round's check findings (record 0055), carried by value — the
509
509
  * review run's record holds only the reviewer's own findings. */
510
510
  checks?: Finding[];
511
+ }
512
+ | {
513
+ kind: "rebase";
514
+ unit: string;
515
+ pr: number;
516
+ headSha: string;
517
+ base: string;
511
518
  };
512
519
 
513
520
  export interface PrRef {
@@ -549,12 +556,15 @@ export type CoordinatorAction =
549
556
  * the queue's outcome (merged, still queued, or removed with the reason)
550
557
  * instead of attempting the squash. */
551
558
  | { type: "merge"; step: string; prNumber: number; headSha: string; queued?: true }
559
+ /** Rung one of an approved head's rebase: git first. A conflict alone buys
560
+ * the runner's coding round; a clean changed patch returns to review. */
561
+ | { type: "rebase"; step: string; prNumber: number; headSha: string }
552
562
  /** Read the check runs at the reviewed head (record 0055, the round verdict):
553
563
  * the merge door's own reading, folded into the round. `retry` names the
554
- * failed checks whose one flake re-run the machine is spending: the bot
555
- * re-runs their failed jobs (the CI retry the deploy already uses) instead
556
- * of reading. */
557
- | { type: "checks"; step: string; prNumber: number; headSha: string; retry?: string[] }
564
+ * failed checks whose one flake re-run the machine is spending; `refire`
565
+ * asks for the one close/reopen recovery when no required check launched.
566
+ * Either effect runs instead of a read. */
567
+ | { type: "checks"; step: string; prNumber: number; headSha: string; retry?: string[]; refire?: true }
558
568
  /** Wait for the intake's checks-settled event at the approved head, bounded as the fallback. */
559
569
  | { type: "wait-checks"; step: string; headSha: string; timeoutMs: number }
560
570
  | { type: "sleep"; step: string; ms: number }
@@ -678,12 +688,28 @@ export type StepReturn =
678
688
  | { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
679
689
  /** The door enqueued the pull request (or found it still queued), or the
680
690
  * queue removed it — the reason is the queue's own (issue 2011). */
681
- | { type: "merge"; step: string; outcome: "pending" | "refused" | "enqueued" | "removed"; reason: string; at: number }
691
+ | {
692
+ type: "merge";
693
+ step: string;
694
+ outcome: "pending" | "refused" | "conflict" | "enqueued" | "removed";
695
+ reason: string;
696
+ at: number;
697
+ }
698
+ | { type: "rebase"; step: string; outcome: "carried" | "changed"; headSha: string; at: number }
699
+ | { type: "rebase"; step: string; outcome: "conflict" | "refused"; reason: string; at: number }
682
700
  /** The checks read at the reviewed head; `checks` absent means GitHub could
683
- * not be read, which the machine treats as pending (record 0055). A retry
684
- * ask answers `retried` instead: whether the bot dispatched the re-run —
685
- * false means there is nothing to wait for at the unchanged head. */
686
- | { type: "checks"; step: string; checks?: RoundChecks; draft?: boolean; retried?: boolean; at: number }
701
+ * not be read, which the machine treats as pending (record 0055). Effect
702
+ * asks answer `retried` or `refired` instead; false still spends that one
703
+ * recovery so an unchanged head cannot loop on the write. */
704
+ | {
705
+ type: "checks";
706
+ step: string;
707
+ checks?: RoundChecks;
708
+ draft?: boolean;
709
+ retried?: boolean;
710
+ refired?: boolean;
711
+ at: number;
712
+ }
687
713
  | { type: "wait-checks"; step: string; outcome: "event" | "timeout" }
688
714
  | { type: "sleep"; step: string };
689
715
 
@@ -699,14 +725,18 @@ export interface CheckFailure {
699
725
  }
700
726
 
701
727
  /** The check runs at the reviewed head as the round's checks step reads them.
702
- * `expected` names checks the head must still gain — a required check, or the
703
- * repository's approve workflow whose run does not exist yet (issue 2063):
704
- * the base's required contexts not among the reported runs. The table reads
705
- * an expected-but-unreported check exactly as a pending one. */
728
+ * `required` carries the base's whole required-context set and `expected`
729
+ * its unreported subset. The whole set lets the table distinguish no required
730
+ * check launching from an unrelated run that did report; an ordinary missing
731
+ * subset reads exactly as pending (issue 2063). */
706
732
  export interface RoundChecks {
707
733
  total: number;
708
734
  pending: string[];
709
735
  failed: CheckFailure[];
736
+ /** Every check context required by the base. Together with `expected`, this
737
+ * distinguishes an empty required-check launch from an unrelated check
738
+ * that did report (for example the title workflow). */
739
+ required?: string[];
710
740
  expected?: string[];
711
741
  }
712
742
 
@@ -831,6 +861,8 @@ export type UnitEnding =
831
861
  finalReply?: string;
832
862
  /** A changes-requested review posted this round before the stop. */
833
863
  postedReview?: boolean;
864
+ /** The mechanical WIP checkpoint a stopped coding child pushed before teardown. */
865
+ checkpoint?: { branch: string; sha: string };
834
866
  }
835
867
  | {
836
868
  kind: "aborted";
@@ -874,7 +906,14 @@ export type UnitEnding =
874
906
  * `transient` so it reads as a condition beside `checks_failed` and `held`
875
907
  * in the plane's table, never as the child failing on its task. */
876
908
  | { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number }
877
- | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number; cause?: InterruptionCause }
909
+ | {
910
+ kind: "interrupted";
911
+ round: RoundRef;
912
+ runId: string;
913
+ reviewRounds: number;
914
+ cause?: InterruptionCause;
915
+ checkpoint?: { branch: string; sha: string };
916
+ }
878
917
  | { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number }
879
918
  /** The unit idles instead of ending (record 0051): with the resolved
880
919
  * `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
@@ -1069,12 +1108,14 @@ type Phase =
1069
1108
  * only through a merge queue (issue 2011) — so every later ask reads the
1070
1109
  * queue's outcome instead of attempting the squash again. */
1071
1110
  | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1111
+ | { at: "rebase"; pr: PrRef; headSha: string; n: number }
1072
1112
  | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1073
1113
  /** The round's checks step (record 0055): the check runs at the reviewed head
1074
1114
  * are read after an approve settles, before merge_ready or the merge door.
1075
- * `graced`: a head with no check reported has had its one-chunk grace;
1076
- * `retried`: the one flake re-run is spent; `retry` names the failed checks
1077
- * the next ask re-runs instead of reading. */
1115
+ * `graced`: a head with no required check reported has had its one-chunk
1116
+ * grace; `refired`: the one pull_request close/reopen recovery is spent;
1117
+ * `retried`: the one flake re-run is spent. `retry` and `refire` make the
1118
+ * next ask perform that effect instead of reading. */
1078
1119
  | {
1079
1120
  at: "checks";
1080
1121
  round: RoundRef;
@@ -1085,7 +1126,9 @@ type Phase =
1085
1126
  waitMs: number;
1086
1127
  graced: boolean;
1087
1128
  retried: boolean;
1129
+ refired: boolean;
1088
1130
  retry?: string[];
1131
+ refire?: true;
1089
1132
  }
1090
1133
  /** Waiting on `checks-settled-<head>` between two checks reads, in the merge
1091
1134
  * wait's own chunks — the intake wakes the machine when the head settles. */
@@ -1099,6 +1142,7 @@ type Phase =
1099
1142
  waitMs: number;
1100
1143
  graced: boolean;
1101
1144
  retried: boolean;
1145
+ refired: boolean;
1102
1146
  }
1103
1147
  | { at: "ended" };
1104
1148
 
@@ -1110,6 +1154,9 @@ export interface UnitPipelineState {
1110
1154
  readonly phase: Phase;
1111
1155
  /** Review rounds started so far. */
1112
1156
  readonly reviewRounds: number;
1157
+ /** Approved-head rebases attempted in this run. Each fresh conflict may buy
1158
+ * one fix round; the run's remaining lease, not pull-request lifetime, is the bound. */
1159
+ readonly rebaseAttempts: number;
1113
1160
  readonly pr?: PrRef;
1114
1161
  readonly lastReviewHead?: string;
1115
1162
  readonly lastVerdictSummary?: string;
@@ -1167,6 +1214,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
1167
1214
  clock: at,
1168
1215
  phase: { at: "pre-check" },
1169
1216
  reviewRounds: 0,
1217
+ rebaseAttempts: 0,
1170
1218
  spentMs: { coding: 0, review: 0, waiting: 0 },
1171
1219
  spendUsd: input.session?.spendUsd ?? 0,
1172
1220
  findingsByRound: {},
@@ -1205,11 +1253,8 @@ const loopOf = (s: UnitPipelineState): Loop => ({ maxRounds: s.input.caps.maxRou
1205
1253
  * A review round `n` and the findings step that follows it share `n`; the
1206
1254
  * module's positions are the loop's own. */
1207
1255
  function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
1208
- return carve(
1209
- remainingMs(s),
1210
- { kind: round.kind, index: loopPosition(loopOf(s), round.kind, round.index) },
1211
- loopOf(s),
1212
- );
1256
+ const kind = round.kind === "rebase" ? "findings" : round.kind;
1257
+ return carve(remainingMs(s), { kind, index: loopPosition(loopOf(s), kind, round.index) }, loopOf(s));
1213
1258
  }
1214
1259
 
1215
1260
  /** The prefix every step of this pipeline is named under: the unit id, a
@@ -1245,6 +1290,9 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1245
1290
  };
1246
1291
  }
1247
1292
  const pr = s.pr!.number;
1293
+ if (round.kind === "rebase") {
1294
+ return { kind: "rebase", unit, pr, headSha: s.lastReviewHead!, base: s.input.base };
1295
+ }
1248
1296
  if (round.kind === "findings") {
1249
1297
  const checks = checkFindingsOf(s.findingsByRound[round.index]);
1250
1298
  return {
@@ -1338,6 +1386,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1338
1386
  prNumber: p.prNumber,
1339
1387
  headSha: p.headSha,
1340
1388
  ...(p.retry !== undefined ? { retry: p.retry } : {}),
1389
+ ...(p.refire === true ? { refire: true as const } : {}),
1341
1390
  };
1342
1391
  case "checks-wait":
1343
1392
  return {
@@ -1354,6 +1403,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1354
1403
  headSha: p.headSha,
1355
1404
  ...(p.queued === true ? { queued: true as const } : {}),
1356
1405
  };
1406
+ case "rebase":
1407
+ return {
1408
+ type: "rebase",
1409
+ step: `${unit}/rebase/${p.n}`,
1410
+ prNumber: p.pr.number,
1411
+ headSha: p.headSha,
1412
+ };
1357
1413
  case "merge-wait":
1358
1414
  return {
1359
1415
  type: "wait-checks",
@@ -1508,6 +1564,13 @@ function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNot
1508
1564
  };
1509
1565
  }
1510
1566
 
1567
+ /** A rebase changed the approved patch, so review is mandatory even when
1568
+ * the ordinary findings-round count is spent. The run's remaining lease is
1569
+ * the bound: `enterRound` refuses when another review no longer fits. */
1570
+ function reviewAfterRebase(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
1571
+ return enterRound(s, { index: s.reviewRounds + 1, kind: "review" }, notes);
1572
+ }
1573
+
1511
1574
  /** The next review round, or the round cap. */
1512
1575
  function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
1513
1576
  if (s.reviewRounds >= s.input.caps.maxRounds)
@@ -1518,6 +1581,16 @@ function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transi
1518
1581
  const stopMode = (status: RunStatus): "soft" | "hard" | undefined =>
1519
1582
  status === "stopped_soft" ? "soft" : status === "stopped_hard" ? "hard" : undefined;
1520
1583
 
1584
+ /** The last mechanical WIP push to this unit branch. Unlike an ordinary push,
1585
+ * it says the child ended before its work was ready for review. */
1586
+ function interruptedCheckpoint(
1587
+ s: UnitPipelineState,
1588
+ pushed: readonly PushedHeadFact[] | undefined,
1589
+ ): { branch: string; sha: string } | undefined {
1590
+ const found = pushed?.filter((p) => p.ref === s.input.unit.branch && p.by === "salvage").at(-1);
1591
+ return found ? { branch: found.ref, sha: found.sha } : undefined;
1592
+ }
1593
+
1521
1594
  /** A coding run's confirmed end: round 0's child, or the run a findings step dispatched. */
1522
1595
  function settleCoding(
1523
1596
  s: UnitPipelineState,
@@ -1545,6 +1618,7 @@ function settleCoding(
1545
1618
  }
1546
1619
  : {}),
1547
1620
  };
1621
+ const checkpoint = interruptedCheckpoint(next, facts.pushed);
1548
1622
  const mode = stopMode(facts.status);
1549
1623
  if (mode !== undefined)
1550
1624
  return end(
@@ -1555,13 +1629,33 @@ function settleCoding(
1555
1629
  round,
1556
1630
  reviewRounds: next.reviewRounds,
1557
1631
  ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
1632
+ ...(checkpoint !== undefined ? { checkpoint } : {}),
1558
1633
  },
1559
1634
  [roundNote(round, "stopped")],
1560
1635
  );
1636
+ // A mechanical WIP push means the child did not declare the work ready for
1637
+ // review. Keep the unit branch as the resumption point and abort this
1638
+ // attempt; an ordinary push still takes the established recover-PR path.
1639
+ if (facts.status === "failed" && checkpoint !== undefined) {
1640
+ const cause =
1641
+ facts.failure?.kind === "provider_transient"
1642
+ ? "the model provider's transport retry budget was spent"
1643
+ : "it failed before finishing";
1644
+ return end(
1645
+ next,
1646
+ {
1647
+ kind: "aborted",
1648
+ reason: `⚠️ The coding child ended because ${cause}; the branch carries the interrupted work at \`${checkpoint.sha.slice(0, 7)}\` on \`${checkpoint.branch}\`. Re-issue the request to resume from it instead of starting over.`,
1649
+ round,
1650
+ reviewRounds: next.reviewRounds,
1651
+ },
1652
+ [roundNote(round, "aborted")],
1653
+ );
1654
+ }
1561
1655
  // A failed coding child no longer aborts outright: the pr-check looks at the
1562
- // branch first — a push before the death is recovered as the round's pull
1563
- // request (agent-ship items 10 and 15), and only a branch with
1564
- // nothing on it ends the unit with the child's own reason.
1656
+ // branch first — an ordinary push before the death is recovered as the
1657
+ // round's pull request, and only a branch with nothing on it ends the unit
1658
+ // with the child's own reason.
1565
1659
  if (facts.status === "failed")
1566
1660
  return {
1567
1661
  state: {
@@ -1745,6 +1839,7 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
1745
1839
  waitMs,
1746
1840
  graced: false,
1747
1841
  retried: false,
1842
+ refired: false,
1748
1843
  },
1749
1844
  },
1750
1845
  notes,
@@ -1764,14 +1859,16 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
1764
1859
  * (`expected`: a required check, or the repository's approve workflow
1765
1860
  * whose run does not exist yet), or GitHub unreadable: the head waits on
1766
1861
  * the settled event and is read again;
1767
- * (—) no check reported at all: one chunk of grace, never more;
1862
+ * (—) no required check reported: one chunk of grace, then the pull_request
1863
+ * event is re-fired once by closing and reopening the pull request;
1768
1864
  * (c) every expected check reported green: the round proceeds with no wait. */
1769
1865
  function checksVerdict(
1770
1866
  checks: RoundChecks | undefined,
1771
- p: { retried: boolean; graced: boolean },
1867
+ p: { retried: boolean; graced: boolean; refired: boolean },
1772
1868
  draft?: boolean,
1773
1869
  ):
1774
1870
  | { kind: "retry"; names: string[] }
1871
+ | { kind: "refire" }
1775
1872
  | { kind: "failed"; failed: CheckFailure[] }
1776
1873
  | { kind: "draft" }
1777
1874
  | { kind: "pending" }
@@ -1791,14 +1888,18 @@ function checksVerdict(
1791
1888
  // person's "ready" changes anything — a red check above still gets its fix
1792
1889
  // round, since that work stands whether or not the pull request is a draft.
1793
1890
  if (draft === true) return { kind: "draft" };
1891
+ const required = checks?.required ?? [];
1892
+ const missing = checks?.expected ?? [];
1893
+ const noRequiredReported = required.length > 0 && missing.length === required.length;
1894
+ // A check launcher may report an unrelated workflow while every required
1895
+ // context is still absent. Give it one normal chunk; if it remains empty,
1896
+ // re-fire pull_request once. This is the same recovery a person performs for
1897
+ // a CI run that exists but started no workflows.
1898
+ if ((checks?.total === 0 || noRequiredReported) && !p.graced) return { kind: "grace" };
1899
+ if (noRequiredReported && !p.refired) return { kind: "refire" };
1794
1900
  // GitHub unreadable answers as pending and is re-read at the chunk's end;
1795
1901
  // an expected check not yet reported (issue 2063) is pending the same way.
1796
- if (checks === undefined || checks.pending.length > 0 || (checks.expected?.length ?? 0) > 0)
1797
- return { kind: "pending" };
1798
- // No check reported at the head: one chunk of grace — the first check starts
1799
- // within minutes where CI exists — then the round proceeds, so a repository
1800
- // without CI costs one chunk per round and never idles (record 0055).
1801
- if (checks.total === 0 && !p.graced) return { kind: "grace" };
1902
+ if (checks === undefined || checks.pending.length > 0 || missing.length > 0) return { kind: "pending" };
1802
1903
  return { kind: "green" };
1803
1904
  }
1804
1905
 
@@ -1810,14 +1911,15 @@ function checksVerdict(
1810
1911
  * is still pending (issue 1991); only a head with no failed check waits a
1811
1912
  * chunk inside the step's ask on what is unreadable or pending and then
1812
1913
  * proceeds — the ending's facts read names what is still pending; a head with
1813
- * no check reported waits one chunk of grace and never more; a green head
1814
- * proceeds with no wait added. */
1914
+ * no required check reported gets one grace chunk and one event re-fire; a
1915
+ * green head proceeds with no wait added. */
1815
1916
  function settleChecks(
1816
1917
  s: UnitPipelineState,
1817
1918
  p: Extract<Phase, { at: "checks" }>,
1818
1919
  checks: RoundChecks | undefined,
1819
1920
  retried?: boolean,
1820
1921
  draft?: boolean,
1922
+ refired?: boolean,
1821
1923
  ): Transition {
1822
1924
  const { round } = p;
1823
1925
  const wait = (over: Partial<Extract<Phase, { at: "checks-wait" }>>): Transition => ({
@@ -1833,11 +1935,23 @@ function settleChecks(
1833
1935
  waitMs: p.waitMs,
1834
1936
  graced: p.graced,
1835
1937
  retried: p.retried,
1938
+ refired: p.refired,
1836
1939
  ...over,
1837
1940
  },
1838
1941
  },
1839
1942
  notes: [],
1840
1943
  });
1944
+ // The pull_request re-fire is an effect ask after a whole grace chunk with
1945
+ // no required check. Whether GitHub accepted it or not, spend the one attempt
1946
+ // and read immediately: that read registers the ordinary bounded wait. A
1947
+ // successful effect adds the round boundary the card renders.
1948
+ if (p.refire === true) {
1949
+ const { refire: _refire, ...read } = p;
1950
+ return {
1951
+ state: { ...s, phase: { ...read, n: p.n + 1, refired: true } },
1952
+ notes: refired === true ? [roundNote(p.round, "checks_restarted")] : [],
1953
+ };
1954
+ }
1841
1955
  // The retry ask. Dispatched: wait for the head to settle, then read again;
1842
1956
  // a second failure is the finding. NOT dispatched (`retried: false` — no
1843
1957
  // re-runnable run behind the checks, or GitHub refused): the one re-run is
@@ -1858,6 +1972,11 @@ function settleChecks(
1858
1972
  state: { ...s, phase: { ...p, n: p.n + 1, retry: verdict.names } },
1859
1973
  notes: [],
1860
1974
  };
1975
+ case "refire":
1976
+ return {
1977
+ state: { ...s, phase: { ...p, n: p.n + 1, refire: true } },
1978
+ notes: [],
1979
+ };
1861
1980
  case "failed": {
1862
1981
  const findings = [...(s.findingsByRound[round.index] ?? []), ...verdict.failed.map(checkFinding)];
1863
1982
  const next: UnitPipelineState = {
@@ -1905,6 +2024,21 @@ function settleChecks(
1905
2024
  }
1906
2025
  }
1907
2026
 
2027
+ /** Re-enter the approved head's rebase step. The attempt count belongs to
2028
+ * this run, not to the pull request: every later base move is a fresh
2029
+ * conflict, while the one pipeline lease remains the hard bound. */
2030
+ export function reenterApprovedRebase(s: UnitPipelineState): UnitPipelineState {
2031
+ if (s.pr === undefined || s.lastReviewHead === undefined)
2032
+ throw new Error("an approved rebase needs the pull request and reviewed head");
2033
+ const { ending: _ending, ...open } = s;
2034
+ const n = s.rebaseAttempts + 1;
2035
+ return {
2036
+ ...open,
2037
+ rebaseAttempts: n,
2038
+ phase: { at: "rebase", pr: s.pr, headSha: s.lastReviewHead, n },
2039
+ };
2040
+ }
2041
+
1908
2042
  /** An approve past the checks read: merge_ready for a person, the merge door
1909
2043
  * under `merge: runner` — the tail the round verdict guards (record 0055). */
1910
2044
  function approveOutcome(next: UnitPipelineState, notes: CoordinatorNote[]): Transition {
@@ -2155,6 +2289,22 @@ function roundOnOpenPr(
2155
2289
  const { round } = phase;
2156
2290
  const head = pr.headSha ?? phase.childHead;
2157
2291
  const next: UnitPipelineState = { ...s, pr: { number: pr.prNumber, url: pr.url } };
2292
+ if (round.kind === "rebase") {
2293
+ const pushed = normalizeHead(head);
2294
+ const approved = normalizeHead(s.lastReviewHead);
2295
+ if (pushed === undefined || (approved !== undefined && sameCommit(pushed, approved)))
2296
+ return end(
2297
+ next,
2298
+ {
2299
+ kind: "aborted",
2300
+ reason: `⚠️ Rebase round ${round.attempt ?? 1} left the approved head unchanged, so the conflict still has no resolution to review.`,
2301
+ round,
2302
+ reviewRounds: next.reviewRounds,
2303
+ },
2304
+ [roundNote(round, "aborted")],
2305
+ );
2306
+ return reviewAfterRebase({ ...next, lastReviewHead: pushed }, [roundNote(round, "pr_opened")]);
2307
+ }
2158
2308
  if (round.kind === "findings") {
2159
2309
  // Nothing repushed → nothing to re-review, unless every finding of the
2160
2310
  // last review was declined on the record: that re-review verifies the
@@ -2387,7 +2537,8 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2387
2537
  // The hosted parent's hard stop landed while this child ran (record 0060;
2388
2538
  // issue 1924): the unit ends stopped as the child ends, whatever the
2389
2539
  // child's own status — the runner runs nothing more of it.
2390
- if (r.stopped === true)
2540
+ if (r.stopped === true) {
2541
+ const checkpoint = p.round.kind === "review" ? undefined : interruptedCheckpoint(clocked, r.run.pushed);
2391
2542
  return end(
2392
2543
  clocked,
2393
2544
  {
@@ -2396,14 +2547,33 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2396
2547
  round: p.round,
2397
2548
  reviewRounds: s.reviewRounds,
2398
2549
  ...(r.run.finalReply !== undefined ? { finalReply: r.run.finalReply } : {}),
2550
+ ...(checkpoint !== undefined ? { checkpoint } : {}),
2399
2551
  },
2400
2552
  [roundNote(p.round, "stopped")],
2401
2553
  );
2554
+ }
2402
2555
  if (r.run.status === "interrupted") {
2403
2556
  const cause = r.run.interruption;
2404
- // A dead CODING child may have pushed before the ledger closed it: the
2405
- // pr-check recovers the branch. A review child has nothing on the
2406
- // branch to recover, so its interruption still ends the unit at once.
2557
+ const checkpoint = interruptedCheckpoint(clocked, r.run.pushed);
2558
+ // A mechanical WIP checkpoint is deliberately not opened for review:
2559
+ // the interrupted child never declared it finished. The next attempt
2560
+ // starts from the unit branch at that head.
2561
+ if (p.round.kind !== "review" && checkpoint !== undefined)
2562
+ return end(
2563
+ clocked,
2564
+ {
2565
+ kind: "interrupted",
2566
+ round: p.round,
2567
+ runId: p.runId,
2568
+ reviewRounds: s.reviewRounds,
2569
+ ...(cause !== undefined ? { cause } : {}),
2570
+ checkpoint,
2571
+ },
2572
+ [roundNote(p.round, "aborted")],
2573
+ );
2574
+ // A dead CODING child may have pushed normally before the ledger closed
2575
+ // it: the pr-check recovers that branch. A review child has nothing on
2576
+ // the branch to recover, so its interruption still ends the unit at once.
2407
2577
  if (p.round.kind !== "review")
2408
2578
  return {
2409
2579
  state: {
@@ -2438,7 +2608,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2438
2608
  return settlePrCheck(clocked, p, (ret as Extract<StepReturn, { type: "pr-check" }>).pr);
2439
2609
  case "checks": {
2440
2610
  const r = ret as Extract<StepReturn, { type: "checks" }>;
2441
- return settleChecks(clocked, p, r.checks, r.retried, r.draft);
2611
+ return settleChecks(clocked, p, r.checks, r.retried, r.draft, r.refired);
2442
2612
  }
2443
2613
  case "checks-wait":
2444
2614
  // The event fired or the chunk elapsed either way the head is read again.
@@ -2455,6 +2625,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2455
2625
  waitMs: p.waitMs,
2456
2626
  graced: p.graced,
2457
2627
  retried: p.retried,
2628
+ refired: p.refired,
2458
2629
  },
2459
2630
  },
2460
2631
  notes: [],
@@ -2474,6 +2645,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2474
2645
  reviewRounds: s.reviewRounds,
2475
2646
  })
2476
2647
  : end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
2648
+ if (r.outcome === "conflict") return { state: reenterApprovedRebase(clocked), notes: [] };
2477
2649
  if (r.outcome === "refused")
2478
2650
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
2479
2651
  if (r.outcome === "removed") {
@@ -2538,6 +2710,32 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2538
2710
  : [],
2539
2711
  };
2540
2712
  }
2713
+ case "rebase": {
2714
+ const r = ret as Extract<StepReturn, { type: "rebase" }>;
2715
+ if (r.outcome === "refused")
2716
+ return end(clocked, {
2717
+ kind: "merge_refused",
2718
+ pr: p.pr,
2719
+ reason: r.reason,
2720
+ reviewRounds: s.reviewRounds,
2721
+ });
2722
+ if (r.outcome === "conflict")
2723
+ return enterRound(clocked, {
2724
+ index: s.reviewRounds,
2725
+ kind: "rebase",
2726
+ attempt: p.n,
2727
+ });
2728
+ if (!("headSha" in r))
2729
+ return end(clocked, {
2730
+ kind: "merge_refused",
2731
+ pr: p.pr,
2732
+ reason: "the rebase resolver returned no head",
2733
+ reviewRounds: s.reviewRounds,
2734
+ });
2735
+ const rebased: UnitPipelineState = { ...clocked, lastReviewHead: r.headSha };
2736
+ if (r.outcome === "changed") return reviewAfterRebase(rebased);
2737
+ return enterChecks(rebased, { index: s.reviewRounds, kind: "review" }, []);
2738
+ }
2541
2739
  case "merge-wait":
2542
2740
  return {
2543
2741
  state: {
@@ -2661,7 +2859,7 @@ export interface CommitChecksFacts {
2661
2859
  * the line is unchanged. */
2662
2860
  function mergeReadyHeadline(rounds: string, url: string, base: string, facts: MergeReadyFacts | undefined): string {
2663
2861
  if (facts?.mergeableState === "dirty")
2664
- return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — the head conflicts with \`${base}\`: \`pulls rebase ${url}\` rebases it onto \`${base}\` (an unchanged patch carries the approval). The approved work stands.`;
2862
+ return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — the head conflicts with \`${base}\`; the pipeline runner owns the rebase (an unchanged patch carries the approval). The approved work stands.`;
2665
2863
  const fixups = facts?.fixupCommits ?? [];
2666
2864
  if (fixups.length > 0)
2667
2865
  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.`;
@@ -2725,6 +2923,10 @@ export function renderUnitReport(
2725
2923
  ? `Findings below ${level}, left as-is: ${skipped.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ")}`
2726
2924
  : undefined;
2727
2925
  const join = (parts: Array<string | undefined>) => parts.filter(Boolean).join("\n\n");
2926
+ const checkpointLine = (checkpoint: { branch: string; sha: string } | undefined) =>
2927
+ checkpoint === undefined
2928
+ ? undefined
2929
+ : `The branch carries the interrupted work at \`${checkpoint.sha.slice(0, 7)}\` on \`${checkpoint.branch}\`; re-issue to resume from it instead of starting over.`;
2728
2930
  switch (e.kind) {
2729
2931
  case "merged":
2730
2932
  if (e.by === "other")
@@ -2873,9 +3075,11 @@ export function renderUnitReport(
2873
3075
  aside(reissue),
2874
3076
  ]);
2875
3077
  case "stopped":
2876
- if (!shows(verbosity, "verbose")) return `${e.mode === "hard" ? "⛔" : "⏹"} Stopped.${prLine}`;
3078
+ if (!shows(verbosity, "verbose"))
3079
+ return join([`${e.mode === "hard" ? "⛔" : "⏹"} Stopped.${prLine}`, checkpointLine(e.checkpoint)]);
2877
3080
  return join([
2878
3081
  `${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
3082
+ checkpointLine(e.checkpoint),
2879
3083
  writeUpPointer(
2880
3084
  s,
2881
3085
  e.round.kind,
@@ -2930,7 +3134,7 @@ export function renderUnitReport(
2930
3134
  reissue,
2931
3135
  ]);
2932
3136
  case "interrupted":
2933
- return shipInterruptedNote(prUrl, e.cause, (s.input.idleDays ?? 0) > 0);
3137
+ return join([shipInterruptedNote(prUrl, e.cause, (s.input.idleDays ?? 0) > 0), checkpointLine(e.checkpoint)]);
2934
3138
  case "idle_expired":
2935
3139
  return "⌛ Idle expired: no reply continued this unit before its idle window closed.";
2936
3140
  case "refused":
@@ -18,6 +18,8 @@ import type { Handoff } from "./handoff.js";
18
18
  export interface PushedHeadFact {
19
19
  ref: string;
20
20
  sha: string;
21
+ /** Mechanical WIP checkpoints are preserved work, not a completed agent push. */
22
+ by?: "push" | "salvage";
21
23
  at?: number;
22
24
  }
23
25
 
@@ -22,6 +22,9 @@ export interface HealthzBody {
22
22
  build?: { commit?: unknown; builtAt?: unknown } | unknown;
23
23
  /** ISO process start — `deploy restart`'s identity (the image, hence `build.commit`, is unchanged). */
24
24
  startedAt?: unknown;
25
+ /** A refusal-only production boot's public reason. Such a generation owns a
26
+ * port so the operator can diagnose it, but is never live. */
27
+ config?: unknown;
25
28
  }
26
29
 
27
30
  /** Parse a `/healthz` response body; undefined when it is not a JSON object
@@ -83,6 +86,11 @@ function decideReady<Identity>(
83
86
  let reason: string;
84
87
  if (!body) {
85
88
  reason = "/healthz not answering with JSON (container restarting, or unreachable)";
89
+ } else if (body.ok !== true) {
90
+ reason =
91
+ typeof body.config === "string" && body.config !== ""
92
+ ? `config: ${body.config}`
93
+ : "/healthz reports this container is not ready";
86
94
  } else {
87
95
  const verdict = identify(body);
88
96
  if (verdict.live) return { kind: "live", ...verdict.identity };
@@ -79,6 +79,14 @@ export const profileSchema = z.object({
79
79
  /** Where the bot's runtime config comes from at deploy time; `deploy all`
80
80
  * materializes it into the image's build context. */
81
81
  configSource: source,
82
+ /** The ingress-token subject allowed to stop the bot container. Rendered as
83
+ * a Worker var, so config recovery never depends on the runtime document it
84
+ * may be repairing. Absent disables the restart route. */
85
+ restart: z
86
+ .object({
87
+ deployer: z.string().regex(/^[A-Za-z0-9_.:@/-]+$/, "an ingress identity subject, without whitespace"),
88
+ })
89
+ .optional(),
82
90
  /** Where `secrets put` reads values from: a directory of `<NAME>` files, or
83
91
  * `op://Vault/Item` with the secret's name as the field. Optional: the
84
92
  * secrets tooling has its own default directory. */