@coreplane/switchboard 1.260.4 → 1.260.5

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 (50) hide show
  1. package/dist/assets/deploy/cloudflare/package.json +2 -2
  2. package/dist/assets/deploy/cloudflare-docs/package.json +1 -1
  3. package/dist/assets/deploy/cloudflare-memory/package.json +2 -2
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +205 -8
  5. package/dist/assets/deploy/cloudflare-resident/package.json +2 -2
  6. package/dist/assets/deploy/cloudflare-resident/worker.ts +124 -40
  7. package/dist/assets/deploy/cloudflare-sandbox/package.json +2 -2
  8. package/dist/assets/package-lock.json +712 -97
  9. package/dist/assets/package.json +4 -4
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/core/budgets.ts +5 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +5 -0
  13. package/dist/assets/src/core/coordinator/driver.ts +114 -25
  14. package/dist/assets/src/core/pipelineStanding.ts +2 -0
  15. package/dist/assets/src/core/plane/decide.ts +19 -2
  16. package/dist/assets/src/core/provider.ts +380 -0
  17. package/dist/assets/src/core/refusal.ts +1 -0
  18. package/dist/assets/src/core/runEvents.ts +8 -0
  19. package/dist/assets/src/core/runLedger/types.ts +10 -1
  20. package/dist/assets/src/core/runRecord.ts +31 -0
  21. package/dist/assets/src/core/ship/contract.ts +2 -0
  22. package/dist/assets/src/core/ship/coordinator.ts +487 -25
  23. package/dist/assets/src/core/types.ts +6 -0
  24. package/dist/assets/src/execution/residentRebind.ts +15 -0
  25. package/dist/assets/src/execution/residentReuse.ts +23 -2
  26. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  27. package/dist/assets/web/dist/assets/{DeliveryPage-BaemWKW8.js → DeliveryPage-Cf1joUyA.js} +1 -1
  28. package/dist/assets/web/dist/assets/{HomePage-fgKQHLaO.js → HomePage-DZEAnCCv.js} +1 -1
  29. package/dist/assets/web/dist/assets/{PendingTurnRow-vuWiDzvz.js → PendingTurnRow-4B8JwETU.js} +1 -1
  30. package/dist/assets/web/dist/assets/{PlanePage-DXMJf8FB.js → PlanePage-CHU2szQs.js} +1 -1
  31. package/dist/assets/web/dist/assets/{ResidentDetailPage-DdC8yU_S.js → ResidentDetailPage-L5SRcN_O.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CSM-KODv.js → ResidentsIndexPage-BIFf8qyE.js} +1 -1
  33. package/dist/assets/web/dist/assets/RunFoldRow-DzPIshJx.js +1 -0
  34. package/dist/assets/web/dist/assets/{RunRoutePage-jo6NZxsP.js → RunRoutePage-Cru5IVux.js} +3 -3
  35. package/dist/assets/web/dist/assets/{RunsIndexPage-Dx1Y4sSq.js → RunsIndexPage-CBkdPAdj.js} +1 -1
  36. package/dist/assets/web/dist/assets/{ScheduledPage-BuB_NZyR.js → ScheduledPage-D_R55BDk.js} +1 -1
  37. package/dist/assets/web/dist/assets/{SettingsPage-Cn96jB6Q.js → SettingsPage-DlmZvAvf.js} +1 -1
  38. package/dist/assets/web/dist/assets/{SilentTurn-D2cJfpEq.js → SilentTurn-PWMDMbMG.js} +1 -1
  39. package/dist/assets/web/dist/assets/{StatusDot-DDHUCPVJ.js → StatusDot-Bq0mYMFo.js} +1 -1
  40. package/dist/assets/web/dist/assets/{Tooltip-B4VIHkAI.js → Tooltip-Bs6zd7yE.js} +1 -1
  41. package/dist/assets/web/dist/assets/{UnitRoutePage-C-2DfjLI.js → UnitRoutePage-BMGEZCvn.js} +1 -1
  42. package/dist/assets/web/dist/assets/budgets-0szUBIMv.js +1 -0
  43. package/dist/assets/web/dist/assets/{dist-Cf1CmCSK.js → dist-EkKVbIM-.js} +11 -11
  44. package/dist/assets/web/dist/assets/{indexRow-Dxg7C8s9.js → indexRow-5s3hNzZ9.js} +1 -1
  45. package/dist/assets/web/dist/assets/{main-DxPqaFu9.js → main-Disot0n_.js} +2 -2
  46. package/dist/assets/web/dist/assets/{sseReplay-BzoeVxZB.js → sseReplay-9gcMpylx.js} +1 -1
  47. package/dist/cli.js +1855 -401
  48. package/package.json +4 -4
  49. package/dist/assets/web/dist/assets/RunFoldRow-Csik3wD6.js +0 -1
  50. package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +0 -1
@@ -551,6 +551,10 @@ export type CoordinatorAction =
551
551
  | { type: "spawn"; step: string; preset: ChildPreset; round: RoundRef; budgetMinutes: number; brief: Brief }
552
552
  | { type: "wait"; step: string; runId: string; timeoutMs: number }
553
553
  | { type: "read-record"; step: string; runId: string }
554
+ /** Tell a child whose pull request became terminal or moved to end at its
555
+ * next turn boundary without publishing anything. This is a steer through
556
+ * the child's inbox, never a stop that tears down its workspace. */
557
+ | { type: "steer"; step: string; runId: string; reason: ChildSupersession["reason"] }
554
558
  | {
555
559
  type: "pr-check";
556
560
  step: string;
@@ -663,6 +667,10 @@ export type PrCheck =
663
667
  prNumber: number;
664
668
  url: string;
665
669
  headSha?: string;
670
+ /** Whether the same-repository head ref still exists. False is decisive:
671
+ * no child may be dispatched onto a deleted branch. Undefined means the
672
+ * ref could not be asked, so the machine makes no claim from it. */
673
+ headBranchExists?: boolean;
666
674
  /** The unit branch's own tip, read beside the listing on the entry check
667
675
  * (issue 1689): the listing's `headSha` can lag a force-push, so the
668
676
  * machine resumes at review only when the pull request heads the
@@ -696,7 +704,17 @@ export type PrCheck =
696
704
  commentId: string;
697
705
  };
698
706
  }
699
- | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
707
+ | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string; mergedBy?: string }
708
+ | { state: "closed"; prNumber: number; url: string; closedBy: string };
709
+
710
+ /** Why a live child is being drained before the unit moves on. The pull
711
+ * request fact is the fresh read that decided it; a late child result remains
712
+ * on that child's record but is never folded back into the unit. */
713
+ type ChildSupersession =
714
+ | { reason: "merged"; pullRequest: Extract<PrCheck, { state: "merged" }> }
715
+ | { reason: "closed"; pullRequest: Extract<PrCheck, { state: "closed" }> }
716
+ | { reason: "head_moved"; pullRequest: Extract<PrCheck, { state: "open" }> }
717
+ | { reason: "branch_deleted"; pullRequest: Extract<PrCheck, { state: "open" }> };
700
718
 
701
719
  /** What a step answered. Every bot answer carries `at`, the bot's clock — the machine's time. */
702
720
  export type StepReturn =
@@ -716,12 +734,32 @@ export type StepReturn =
716
734
  /** `restartedAs`: the interrupted child restarted from its request as this
717
735
  * run (issue 1903 — a replaced container's child resumes by itself), so the
718
736
  * machine keeps waiting on the successor instead of ending the unit. */
719
- | { type: "read-record"; step: string; run: ChildFacts; stopped?: true; restartedAs?: string; at: number }
737
+ | {
738
+ type: "read-record";
739
+ step: string;
740
+ run: ChildFacts;
741
+ /** The unit's pull request read fresh in the same bot transition. */
742
+ pullRequest?: PrCheck;
743
+ stopped?: true;
744
+ restartedAs?: string;
745
+ at: number;
746
+ }
747
+ | { type: "steer"; step: string; outcome: "steered" | "not_live"; at: number }
720
748
  | { type: "pr-check"; step: string; pr: PrCheck; at: number }
721
749
  | { type: "merge"; step: string; outcome: "merged"; sha: string; at: number }
750
+ | { type: "merge"; step: string; outcome: "recheck"; pullRequest: PrCheck; at: number }
722
751
  // The door found the pull request already merged after the approval — auto-merge
723
752
  // fired, or a person merged — so the runner merged nothing (`by: other`).
724
- | { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
753
+ | {
754
+ type: "merge";
755
+ step: string;
756
+ outcome: "merged";
757
+ by: "other";
758
+ sha: string;
759
+ mergedAt: string;
760
+ mergedBy?: string;
761
+ at: number;
762
+ }
725
763
  /** The door enqueued the pull request (or found it still queued), or the
726
764
  * queue removed it — the reason is the queue's own (issue 2011). */
727
765
  | {
@@ -741,6 +779,8 @@ export type StepReturn =
741
779
  type: "checks";
742
780
  step: string;
743
781
  checks?: RoundChecks;
782
+ /** The pull request read in the same checks transition. */
783
+ pullRequest?: PrCheck;
744
784
  draft?: boolean;
745
785
  retried?: boolean;
746
786
  refired?: boolean;
@@ -822,7 +862,16 @@ export const checkFindingsOf = (findings: readonly Finding[] | undefined): Findi
822
862
  * authorize stage never started. */
823
863
  export type UnitEnding =
824
864
  | { kind: "merged"; by: "runner"; pr: PrRef; sha: string; reviewRounds: number }
825
- | { kind: "merged"; by: "other"; pr: PrRef; sha: string; mergedAt: string; reviewRounds: number }
865
+ | {
866
+ kind: "merged";
867
+ by: "other";
868
+ pr: PrRef;
869
+ sha: string;
870
+ mergedAt: string;
871
+ mergedBy?: string;
872
+ reviewRounds: number;
873
+ }
874
+ | { kind: "closed"; pr: PrRef; closedBy: string; reviewRounds: number }
826
875
  /** Round 0 found the unit's scope already on the base (agent-ship item 12):
827
876
  * the coding child's handoff names where it landed and the branch has no
828
877
  * commits over the base, so there is no pull request to open or review.
@@ -986,11 +1035,12 @@ export type UnitEnding =
986
1035
  * configured idle policy; a human-gated hold takes `parkHumanGate` instead,
987
1036
  * which always creates the pending idle question. */
988
1037
  export type IdleWhy =
989
- Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused"> | RenewalWhy;
1038
+ Exclude<UnitEnding["kind"], "idle" | "merged" | "closed" | "already_landed" | "merge_ready" | "refused"> | RenewalWhy;
990
1039
 
991
1040
  const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
992
1041
  "idle",
993
1042
  "merged",
1043
+ "closed",
994
1044
  "already_landed",
995
1045
  "merge_ready",
996
1046
  "refused",
@@ -1121,6 +1171,36 @@ type Phase =
1121
1171
  /** `until`: when the child's budget plus the margin runs out, counted from the spawn's answer — the wait's last slice ends there. */
1122
1172
  | { at: "wait"; round: RoundRef; runId: string; n: number; until: number }
1123
1173
  | { at: "read"; round: RoundRef; runId: string; n: number; until: number }
1174
+ /** A live child lost authority because its pull request became terminal,
1175
+ * its reviewed head moved, or its branch disappeared. Steer once, then
1176
+ * drain the child to its recorded end while ignoring every late artifact. */
1177
+ | {
1178
+ at: "steer";
1179
+ round: RoundRef;
1180
+ runId: string;
1181
+ n: number;
1182
+ until: number;
1183
+ supersession: ChildSupersession;
1184
+ }
1185
+ | {
1186
+ at: "superseded-wait";
1187
+ round: RoundRef;
1188
+ runId: string;
1189
+ n: number;
1190
+ until: number;
1191
+ supersession: ChildSupersession;
1192
+ }
1193
+ | {
1194
+ at: "superseded-read";
1195
+ round: RoundRef;
1196
+ runId: string;
1197
+ n: number;
1198
+ until: number;
1199
+ supersession: ChildSupersession;
1200
+ }
1201
+ /** A moved-head child has drained. Re-read the adopted pull request before
1202
+ * restarting review because it may have merged, closed or moved again. */
1203
+ | { at: "superseded-pr-check"; round: RoundRef }
1124
1204
  | {
1125
1205
  at: "pr-check";
1126
1206
  round: RoundRef;
@@ -1131,6 +1211,10 @@ type Phase =
1131
1211
  childPushed?: PushedHeadFact[];
1132
1212
  childLeaseStartedAt?: number;
1133
1213
  childHandoff?: Handoff;
1214
+ /** Open or recover a pull request from the branch. A completed child
1215
+ * whose same-head salvage merely republished ready work needs this path
1216
+ * without being classified as dead. */
1217
+ recover?: true;
1134
1218
  /** The coding child died (`failed` or `interrupted`) after it may have
1135
1219
  * pushed: the pr-check recovers a pushed branch by opening its pull
1136
1220
  * request; with nothing pushed the unit ends with the child's own reason. */
@@ -1148,6 +1232,9 @@ type Phase =
1148
1232
  * queue's outcome instead of attempting the squash again. */
1149
1233
  | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1150
1234
  | { at: "rebase"; pr: PrRef; headSha: string; n: number }
1235
+ /** A rebase can run long enough for the pull request to change again. Both
1236
+ * dispatching outcomes stop here for one fresh state/head/ref read. */
1237
+ | { at: "rebase-pr-check"; pr: PrRef; n: number; outcome: "conflict" | "changed" }
1151
1238
  | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1152
1239
  /** The round's checks step (record 0055): the check runs at the reviewed head
1153
1240
  * are read after an approve settles, before merge_ready or the merge door.
@@ -1196,6 +1283,9 @@ export interface UnitPipelineState {
1196
1283
  /** Approved-head rebases attempted in this run. Each fresh conflict may buy
1197
1284
  * one fix round; the run's remaining lease, not pull-request lifetime, is the bound. */
1198
1285
  readonly rebaseAttempts: number;
1286
+ /** Review dispatches superseded by an external head move. The count gives
1287
+ * each restarted round a fresh durable `/a<n>` step identity. */
1288
+ readonly reviewRestarts: number;
1199
1289
  readonly pr?: PrRef;
1200
1290
  readonly lastReviewHead?: string;
1201
1291
  readonly lastVerdictSummary?: string;
@@ -1234,6 +1324,10 @@ export const WAIT_MARGIN_MS = SHIP_WAIT.marginMinutes * MIN;
1234
1324
  * margin and the merge poll are the same number), and it keeps a round to a
1235
1325
  * few steps: a coding child's 90 minutes are eighteen waits and eighteen reads. */
1236
1326
  export const WAIT_CHUNK_MS = SHIP_WAIT.chunkMinutes * MIN;
1327
+ /** A shipped unit's child is polled more often than an unshipped coding round:
1328
+ * a person's merge/close must reach its inbox before a minutes-long review or
1329
+ * fix can publish against a terminal pull request. */
1330
+ export const PR_TRANSITION_GUARD_MS = MIN;
1237
1331
  /** The merge wait is a round of its own: its minutes are carved from the
1238
1332
  * pipeline's remainder when the door is first asked (the merge wait's ask and
1239
1333
  * floor are rows of `src/core/budgets.ts`), and the wait below is sliced
@@ -1257,6 +1351,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
1257
1351
  phase: { at: "pre-check" },
1258
1352
  reviewRounds: pending?.round ?? 0,
1259
1353
  rebaseAttempts: 0,
1354
+ reviewRestarts: 0,
1260
1355
  spentMs: { coding: 0, review: 0, waiting: 0 },
1261
1356
  spendUsd: input.session?.spendUsd ?? 0,
1262
1357
  findingsByRound: pending ? { [pending.round]: pending.findings } : {},
@@ -1271,7 +1366,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
1271
1366
  }
1272
1367
  : {}),
1273
1368
  };
1274
- if (pending !== undefined) return enterRound(base, { index: pending.round, kind: "findings" }).state;
1369
+ if (pending !== undefined) return base;
1275
1370
  if (!input.resume) return base;
1276
1371
  const url = input.resume.url ?? `https://github.com/${input.repo}/pull/${input.resume.pr}`;
1277
1372
  const resumed: UnitPipelineState = {
@@ -1279,7 +1374,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
1279
1374
  pr: { number: input.resume.pr, url },
1280
1375
  ...(input.resume.headSha !== undefined ? { lastReviewHead: input.resume.headSha } : {}),
1281
1376
  };
1282
- return nextReview(resumed).state;
1377
+ return resumed;
1283
1378
  }
1284
1379
 
1285
1380
  const deadlineAt = (s: UnitPipelineState) =>
@@ -1406,7 +1501,12 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1406
1501
  const p = s.phase;
1407
1502
  switch (p.at) {
1408
1503
  case "pre-check":
1409
- return { type: "pr-check", step: `${unit}/pr-check`, entry: true };
1504
+ return {
1505
+ type: "pr-check",
1506
+ step: `${unit}/pr-check`,
1507
+ entry: true,
1508
+ ...(s.pr !== undefined ? { pr: s.pr.number } : {}),
1509
+ };
1410
1510
  case "branch":
1411
1511
  return { type: "branch", step: `${unit}/branch`, branch: s.input.unit.branch, from: s.input.base };
1412
1512
  case "spawn": {
@@ -1430,15 +1530,41 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1430
1530
  type: "wait",
1431
1531
  step: `${roundStep(s, p.round)}/wait/${p.n}`,
1432
1532
  runId: p.runId,
1433
- timeoutMs: waitSliceMs(s.clock, p.until),
1533
+ timeoutMs: Math.min(waitSliceMs(s.clock, p.until), s.pr !== undefined ? PR_TRANSITION_GUARD_MS : WAIT_CHUNK_MS),
1434
1534
  };
1435
1535
  case "read":
1436
1536
  return { type: "read-record", step: `${roundStep(s, p.round)}/read/${p.n}`, runId: p.runId };
1537
+ case "steer":
1538
+ return {
1539
+ type: "steer",
1540
+ step: `${roundStep(s, p.round)}/steer/${p.n}`,
1541
+ runId: p.runId,
1542
+ reason: p.supersession.reason,
1543
+ };
1544
+ case "superseded-wait":
1545
+ return {
1546
+ type: "wait",
1547
+ step: `${roundStep(s, p.round)}/superseded/wait/${p.n}`,
1548
+ runId: p.runId,
1549
+ timeoutMs: waitSliceMs(s.clock, p.until),
1550
+ };
1551
+ case "superseded-read":
1552
+ return {
1553
+ type: "read-record",
1554
+ step: `${roundStep(s, p.round)}/superseded/read/${p.n}`,
1555
+ runId: p.runId,
1556
+ };
1557
+ case "superseded-pr-check":
1558
+ return {
1559
+ type: "pr-check",
1560
+ step: `${roundStep(s, p.round)}/superseded/pr-check`,
1561
+ ...(s.pr !== undefined ? { pr: s.pr.number } : {}),
1562
+ };
1437
1563
  case "pr-check":
1438
1564
  return {
1439
1565
  type: "pr-check",
1440
1566
  step: `${roundStep(s, p.round)}/pr-check`,
1441
- ...(p.dead !== undefined ? { recover: { runId: p.runId } } : {}),
1567
+ ...(p.dead !== undefined || p.recover === true ? { recover: { runId: p.runId } } : {}),
1442
1568
  // The adopted pull request rides the check so the bot can follow it
1443
1569
  // when nothing heads the unit's branch (issue 1799).
1444
1570
  ...(s.pr !== undefined ? { pr: s.pr.number } : {}),
@@ -1474,6 +1600,12 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1474
1600
  prNumber: p.pr.number,
1475
1601
  headSha: p.headSha,
1476
1602
  };
1603
+ case "rebase-pr-check":
1604
+ return {
1605
+ type: "pr-check",
1606
+ step: `${unit}/rebase/${p.n}/pr-check`,
1607
+ pr: p.pr.number,
1608
+ };
1477
1609
  case "merge-wait":
1478
1610
  return {
1479
1611
  type: "wait-checks",
@@ -1522,7 +1654,10 @@ function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEndin
1522
1654
  // waits for the person's word. Human-gated questions bypass this mapping:
1523
1655
  // `parkHumanGate` always parks them with their typed pending state.
1524
1656
  if (ending.kind === "held" && ending.cause !== "blocked") return undefined;
1525
- const old = ending as Exclude<UnitEnding, { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "refused" }>;
1657
+ const old = ending as Exclude<
1658
+ UnitEnding,
1659
+ { kind: "idle" | "merged" | "closed" | "already_landed" | "merge_ready" | "refused" }
1660
+ >;
1526
1661
  const grant = s.input.grant ?? DEFAULT_GRANT;
1527
1662
  // Unspent: an idle spends no renewal — the wake's segment does (this plan's
1528
1663
  // fifth unit) — so the row says what the grant still holds.
@@ -1688,14 +1823,37 @@ function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transi
1688
1823
  const stopMode = (status: RunStatus): "soft" | "hard" | undefined =>
1689
1824
  status === "stopped_soft" ? "soft" : status === "stopped_hard" ? "hard" : undefined;
1690
1825
 
1826
+ /** The last mechanical salvage push to this unit branch. */
1827
+ function salvagePush(s: UnitPipelineState, pushed: readonly PushedHeadFact[] | undefined): PushedHeadFact | undefined {
1828
+ return pushed?.filter((p) => p.ref === s.input.unit.branch && p.by === "salvage").at(-1);
1829
+ }
1830
+
1831
+ /** A completed child independently witnessed at the same head and carrying its
1832
+ * handoff already declared the work ready; salvage merely republished its commit. */
1833
+ function completedSameHeadSalvage(
1834
+ s: UnitPipelineState,
1835
+ pushed: readonly PushedHeadFact[] | undefined,
1836
+ child?: { status: RunStatus; handoff?: boolean; headSha?: string },
1837
+ ): boolean {
1838
+ if (child === undefined) return false;
1839
+ const found = salvagePush(s, pushed);
1840
+ const salvageHead = normalizeHead(found?.sha);
1841
+ const childHead = normalizeHead(child.headSha);
1842
+ return (
1843
+ child.status === "completed" && child.handoff === true && salvageHead !== undefined && salvageHead === childHead
1844
+ );
1845
+ }
1846
+
1691
1847
  /** The last mechanical WIP push to this unit branch. Unlike an ordinary push,
1692
- * it says the child ended before its work was ready for review. */
1848
+ * it says the child ended before its work was ready for review. */
1693
1849
  function interruptedCheckpoint(
1694
1850
  s: UnitPipelineState,
1695
1851
  pushed: readonly PushedHeadFact[] | undefined,
1852
+ child?: { status: RunStatus; handoff?: boolean; headSha?: string },
1696
1853
  ): { branch: string; sha: string } | undefined {
1697
- const found = pushed?.filter((p) => p.ref === s.input.unit.branch && p.by === "salvage").at(-1);
1698
- return found ? { branch: found.ref, sha: found.sha } : undefined;
1854
+ const found = salvagePush(s, pushed);
1855
+ if (found === undefined || completedSameHeadSalvage(s, pushed, child)) return undefined;
1856
+ return { branch: found.ref, sha: found.sha };
1699
1857
  }
1700
1858
 
1701
1859
  /** A coding run's confirmed end: round 0's child, or the run a findings step dispatched. */
@@ -1725,7 +1883,8 @@ function settleCoding(
1725
1883
  }
1726
1884
  : {}),
1727
1885
  };
1728
- const checkpoint = interruptedCheckpoint(next, facts.pushed);
1886
+ const readySalvage = completedSameHeadSalvage(next, facts.pushed, facts);
1887
+ const checkpoint = interruptedCheckpoint(next, facts.pushed, facts);
1729
1888
  const mode = stopMode(facts.status);
1730
1889
  if (mode !== undefined)
1731
1890
  return end(
@@ -1787,6 +1946,7 @@ function settleCoding(
1787
1946
  round,
1788
1947
  runId,
1789
1948
  ...(facts.headSha !== undefined ? { childHead: facts.headSha } : {}),
1949
+ ...(readySalvage ? { recover: true as const } : {}),
1790
1950
  ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
1791
1951
  ...(facts.pushed !== undefined ? { childPushed: facts.pushed } : {}),
1792
1952
  ...(facts.leaseStartedAt !== undefined ? { childLeaseStartedAt: facts.leaseStartedAt } : {}),
@@ -2038,8 +2198,25 @@ function settleChecks(
2038
2198
  retried?: boolean,
2039
2199
  draft?: boolean,
2040
2200
  refired?: boolean,
2201
+ pullRequest?: PrCheck,
2041
2202
  ): Transition {
2042
2203
  const { round } = p;
2204
+ if (pullRequest?.state === "merged") return foundMerged(s, pullRequest);
2205
+ if (pullRequest?.state === "closed") return foundClosed(s, pullRequest);
2206
+ if (pullRequest?.state === "open" && pullRequest.headBranchExists === false) return missingHeadBranch(s, pullRequest);
2207
+ if (pullRequest?.state === "open") {
2208
+ const actual = normalizeHead(pullRequest.headSha);
2209
+ if (actual !== undefined && !sameCommit(actual, p.headSha)) {
2210
+ const reviewRestarts = s.reviewRestarts + 1;
2211
+ const next: UnitPipelineState = {
2212
+ ...s,
2213
+ reviewRestarts,
2214
+ pr: { number: pullRequest.prNumber, url: pullRequest.url },
2215
+ lastReviewHead: actual,
2216
+ };
2217
+ return enterRound(next, { ...round, attempt: reviewRestarts + 1 });
2218
+ }
2219
+ }
2043
2220
  const wait = (over: Partial<Extract<Phase, { at: "checks-wait" }>>): Transition => ({
2044
2221
  state: {
2045
2222
  ...s,
@@ -2208,11 +2385,118 @@ function foundMerged(
2208
2385
  const ref: PrRef = { number: pr.prNumber, url: pr.url };
2209
2386
  return end(
2210
2387
  { ...s, pr: ref },
2211
- { kind: "merged", by: "other", pr: ref, sha: pr.sha, mergedAt: pr.mergedAt, reviewRounds: s.reviewRounds },
2388
+ {
2389
+ kind: "merged",
2390
+ by: "other",
2391
+ pr: ref,
2392
+ sha: pr.sha,
2393
+ mergedAt: pr.mergedAt,
2394
+ ...(pr.mergedBy !== undefined ? { mergedBy: pr.mergedBy } : {}),
2395
+ reviewRounds: s.reviewRounds,
2396
+ },
2397
+ notes,
2398
+ );
2399
+ }
2400
+
2401
+ /** A person closed the unit's pull request without merging it. Closed is a
2402
+ * terminal unit state: no child, check, or merge step may follow. */
2403
+ function foundClosed(
2404
+ s: UnitPipelineState,
2405
+ pr: Extract<PrCheck, { state: "closed" }>,
2406
+ notes: CoordinatorNote[] = [],
2407
+ ): Transition {
2408
+ const ref: PrRef = { number: pr.prNumber, url: pr.url };
2409
+ return end(
2410
+ { ...s, pr: ref },
2411
+ { kind: "closed", pr: ref, closedBy: pr.closedBy, reviewRounds: s.reviewRounds },
2412
+ notes,
2413
+ );
2414
+ }
2415
+
2416
+ function missingHeadBranch(
2417
+ s: UnitPipelineState,
2418
+ pr: Extract<PrCheck, { state: "open" }>,
2419
+ notes: CoordinatorNote[] = [],
2420
+ ): Transition {
2421
+ const ref: PrRef = { number: pr.prNumber, url: pr.url };
2422
+ return end(
2423
+ { ...s, pr: ref },
2424
+ {
2425
+ kind: "aborted",
2426
+ reason: `⚠️ ${pr.url} is still open, but its head branch no longer exists; no child was dispatched onto the deleted branch.`,
2427
+ reviewRounds: s.reviewRounds,
2428
+ },
2212
2429
  notes,
2213
2430
  );
2214
2431
  }
2215
2432
 
2433
+ /** What a fresh pull-request read says about a child already in flight. A
2434
+ * review is pinned, so a moved head supersedes it; a coding child may itself
2435
+ * be moving the head and is superseded only by a terminal or deleted ref. */
2436
+ function childSupersession(
2437
+ s: UnitPipelineState,
2438
+ round: RoundRef,
2439
+ pr: PrCheck | undefined,
2440
+ ): ChildSupersession | undefined {
2441
+ if (pr?.state === "merged") return { reason: "merged", pullRequest: pr };
2442
+ if (pr?.state === "closed") return { reason: "closed", pullRequest: pr };
2443
+ if (pr?.state !== "open") return undefined;
2444
+ if (pr.headBranchExists === false) return { reason: "branch_deleted", pullRequest: pr };
2445
+ if (round.kind !== "review") return undefined;
2446
+ const expected = normalizeHead(s.lastReviewHead);
2447
+ const actual = normalizeHead(pr.headSha);
2448
+ if (expected !== undefined && actual !== undefined && !sameCommit(expected, actual))
2449
+ return { reason: "head_moved", pullRequest: pr };
2450
+ return undefined;
2451
+ }
2452
+
2453
+ function finishSupersededChild(s: UnitPipelineState, phase: Extract<Phase, { at: "superseded-read" }>): Transition {
2454
+ const supersession = phase.supersession;
2455
+ if (supersession.reason === "merged") return foundMerged(s, supersession.pullRequest);
2456
+ if (supersession.reason === "closed") return foundClosed(s, supersession.pullRequest);
2457
+ if (supersession.reason === "branch_deleted") return missingHeadBranch(s, supersession.pullRequest);
2458
+ return {
2459
+ state: {
2460
+ ...s,
2461
+ pr: { number: supersession.pullRequest.prNumber, url: supersession.pullRequest.url },
2462
+ phase: { at: "superseded-pr-check", round: phase.round },
2463
+ },
2464
+ notes: [],
2465
+ };
2466
+ }
2467
+
2468
+ function restartSupersededReview(
2469
+ s: UnitPipelineState,
2470
+ round: RoundRef,
2471
+ pr: Extract<PrCheck, { state: "open" }>,
2472
+ ): Transition {
2473
+ if (pr.headBranchExists === false) return missingHeadBranch(s, pr);
2474
+ const head = normalizeHead(pr.headSha);
2475
+ if (head === undefined)
2476
+ return end(s, {
2477
+ kind: "aborted",
2478
+ reason: `⚠️ ${pr.url} moved while review was running, but its fresh head could not be read; no child was dispatched on a guessed head.`,
2479
+ reviewRounds: s.reviewRounds,
2480
+ });
2481
+ const reviewRestarts = s.reviewRestarts + 1;
2482
+ const next: UnitPipelineState = {
2483
+ ...s,
2484
+ reviewRestarts,
2485
+ pr: { number: pr.prNumber, url: pr.url },
2486
+ lastReviewHead: head,
2487
+ };
2488
+ return enterRound(next, { ...round, attempt: reviewRestarts + 1 });
2489
+ }
2490
+
2491
+ /** Reconcile the ending-time facts read. A person can merge or close after
2492
+ * the machine chose merge-ready but before the driver publishes it; terminal
2493
+ * state wins and becomes the unit's actual ending, not merely report prose. */
2494
+ export function reconcileTerminalPr(s: UnitPipelineState, pr: PrCheck): UnitPipelineState {
2495
+ if (pr.state === "merged") return foundMerged(s, pr).state;
2496
+ if (pr.state === "closed") return foundClosed(s, pr).state;
2497
+ return s;
2498
+ }
2499
+
2216
2500
  /** The pull request heading the branch after round 0 or a findings step. */
2217
2501
  function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-check" }>, pr: PrCheck): Transition {
2218
2502
  const { round } = phase;
@@ -2220,6 +2504,9 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
2220
2504
  // (or shipped into a pull request a person merged under it). The round
2221
2505
  // completed without a pull request of its own, and the unit is done.
2222
2506
  if (pr.state === "merged") return foundMerged(s, pr, [roundNote(round, "completed")]);
2507
+ if (pr.state === "closed") return foundClosed(s, pr, [roundNote(round, "completed")]);
2508
+ if (pr.state === "open" && pr.headBranchExists === false)
2509
+ return missingHeadBranch(s, pr, [roundNote(round, "aborted")]);
2223
2510
  if (pr.state === "none") {
2224
2511
  // A dead child left nothing on the branch to recover: the unit ends with
2225
2512
  // the child's own reason — never the budget clip.
@@ -2516,6 +2803,8 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2516
2803
  // by the coding child and adopted at the round's own pr-check.
2517
2804
  const r = ret as Extract<StepReturn, { type: "pr-check" }>;
2518
2805
  if (r.pr.state === "merged") return foundMerged(clocked, r.pr);
2806
+ if (r.pr.state === "closed") return foundClosed(clocked, r.pr);
2807
+ if (r.pr.state === "open" && r.pr.headBranchExists === false) return missingHeadBranch(clocked, r.pr);
2519
2808
  // The open pull request still heads the branch — its head is the
2520
2809
  // branch's own tip (the entry facts, issue 1689) or the child's own last
2521
2810
  // push (the previous attempt ended `review_pending`, the row's
@@ -2527,6 +2816,25 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2527
2816
  // `merge: runner`, which re-verifies the approval and the checks itself.
2528
2817
  if (r.pr.state === "open") {
2529
2818
  const head = normalizeHead(r.pr.headSha);
2819
+ const pending = s.input.session?.humanGate;
2820
+ if (pending !== undefined) {
2821
+ const adopted: UnitPipelineState = {
2822
+ ...clocked,
2823
+ pr: { number: r.pr.prNumber, url: r.pr.url },
2824
+ ...(head !== undefined ? { lastReviewHead: head } : {}),
2825
+ };
2826
+ const expected = normalizeHead(pending.headSha);
2827
+ if (head !== undefined && expected !== undefined && !sameCommit(head, expected)) return nextReview(adopted);
2828
+ return enterRound(adopted, { index: pending.round, kind: "findings" });
2829
+ }
2830
+ if (s.input.resume !== undefined) {
2831
+ const adopted: UnitPipelineState = {
2832
+ ...clocked,
2833
+ pr: { number: r.pr.prNumber, url: r.pr.url },
2834
+ ...(head !== undefined ? { lastReviewHead: head } : {}),
2835
+ };
2836
+ return nextReview(adopted);
2837
+ }
2530
2838
  const branchHead = normalizeHead(r.pr.branchHead);
2531
2839
  const lastPush = normalizeHead(s.input.lastPush);
2532
2840
  const atBranchHead = head !== undefined && branchHead !== undefined && sameCommit(head, branchHead);
@@ -2659,6 +2967,36 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2659
2967
  };
2660
2968
  case "read": {
2661
2969
  const r = ret as Extract<StepReturn, { type: "read-record" }>;
2970
+ const supersession = childSupersession(clocked, p.round, r.pullRequest);
2971
+ if (supersession !== undefined) {
2972
+ // A finished child needs no steer; its verdict, dispositions or push
2973
+ // remain on its own record but cannot change the terminal/moved PR.
2974
+ if (r.run.finished) {
2975
+ const terminalPhase: Extract<Phase, { at: "superseded-read" }> = {
2976
+ at: "superseded-read",
2977
+ round: p.round,
2978
+ runId: p.runId,
2979
+ n: p.n,
2980
+ until: p.until,
2981
+ supersession,
2982
+ };
2983
+ return finishSupersededChild(clocked, terminalPhase);
2984
+ }
2985
+ return {
2986
+ state: {
2987
+ ...clocked,
2988
+ phase: {
2989
+ at: "steer",
2990
+ round: p.round,
2991
+ runId: r.restartedAs ?? p.runId,
2992
+ n: p.n,
2993
+ until: p.until,
2994
+ supersession,
2995
+ },
2996
+ },
2997
+ notes: [],
2998
+ };
2999
+ }
2662
3000
  if (!r.run.finished)
2663
3001
  // `restartedAs` (issues 1903/1876): the child's container was replaced
2664
3002
  // and it restarted from its request as a new run — the round carries on
@@ -2741,11 +3079,74 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2741
3079
  ? settleReview(clocked, p.round, r.run)
2742
3080
  : settleCoding(clocked, p.round, p.runId, r.run);
2743
3081
  }
3082
+ case "steer": {
3083
+ // Delivery is best effort with respect to a child that may have ended
3084
+ // between the fresh PR read and this effect. Either answer is followed
3085
+ // by a record read; a late artifact is recorded there and ignored.
3086
+ return {
3087
+ state: {
3088
+ ...clocked,
3089
+ phase: {
3090
+ at: "superseded-wait",
3091
+ round: p.round,
3092
+ runId: p.runId,
3093
+ n: p.n,
3094
+ until: p.until,
3095
+ supersession: p.supersession,
3096
+ },
3097
+ },
3098
+ notes: [],
3099
+ };
3100
+ }
3101
+ case "superseded-wait":
3102
+ return {
3103
+ state: {
3104
+ ...s,
3105
+ phase: {
3106
+ at: "superseded-read",
3107
+ round: p.round,
3108
+ runId: p.runId,
3109
+ n: p.n,
3110
+ until: p.until,
3111
+ supersession: p.supersession,
3112
+ },
3113
+ },
3114
+ notes: [],
3115
+ };
3116
+ case "superseded-read": {
3117
+ const r = ret as Extract<StepReturn, { type: "read-record" }>;
3118
+ if (r.run.finished) return finishSupersededChild(clocked, p);
3119
+ return {
3120
+ state: {
3121
+ ...clocked,
3122
+ phase: {
3123
+ at: "superseded-wait",
3124
+ round: p.round,
3125
+ runId: r.restartedAs ?? p.runId,
3126
+ n: p.n + 1,
3127
+ until: p.until,
3128
+ supersession: p.supersession,
3129
+ },
3130
+ },
3131
+ notes: [],
3132
+ };
3133
+ }
3134
+ case "superseded-pr-check": {
3135
+ const r = ret as Extract<StepReturn, { type: "pr-check" }>;
3136
+ if (r.pr.state === "merged") return foundMerged(clocked, r.pr);
3137
+ if (r.pr.state === "closed") return foundClosed(clocked, r.pr);
3138
+ if (r.pr.state === "open") return restartSupersededReview(clocked, p.round, r.pr);
3139
+ return end(clocked, {
3140
+ kind: "aborted",
3141
+ reason: `⚠️ The adopted pull request disappeared after the superseded review child drained; no replacement review was dispatched.`,
3142
+ reviewRounds: clocked.reviewRounds,
3143
+ });
3144
+ }
2744
3145
  case "pr-check":
2745
3146
  return settlePrCheck(clocked, p, (ret as Extract<StepReturn, { type: "pr-check" }>).pr);
2746
3147
  case "checks": {
2747
3148
  const r = ret as Extract<StepReturn, { type: "checks" }>;
2748
- return settleChecks(clocked, p, r.checks, r.retried, r.draft, r.refired);
3149
+ return settleChecks(clocked, p, r.checks, r.retried, r.draft, r.refired, r.pullRequest);
2749
3150
  }
2750
3151
  case "checks-wait":
2751
3152
  // The event fired or the chunk elapsed either way the head is read again.
@@ -2769,6 +3170,35 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2769
3170
  };
2770
3171
  case "merge": {
2771
3172
  const r = ret as Extract<StepReturn, { type: "merge" }>;
3173
+ if (r.outcome === "recheck") {
3174
+ if (r.pullRequest.state === "merged") return foundMerged(clocked, r.pullRequest);
3175
+ if (r.pullRequest.state === "closed") return foundClosed(clocked, r.pullRequest);
3176
+ if (r.pullRequest.state === "open" && r.pullRequest.headBranchExists === false)
3177
+ return missingHeadBranch(clocked, r.pullRequest);
3178
+ if (r.pullRequest.state === "open") {
3179
+ const actual = normalizeHead(r.pullRequest.headSha);
3180
+ if (actual !== undefined && !sameCommit(actual, p.headSha)) {
3181
+ const reviewRestarts = s.reviewRestarts + 1;
3182
+ const next: UnitPipelineState = {
3183
+ ...clocked,
3184
+ reviewRestarts,
3185
+ pr: { number: r.pullRequest.prNumber, url: r.pullRequest.url },
3186
+ lastReviewHead: actual,
3187
+ };
3188
+ return enterRound(next, {
3189
+ index: Math.max(1, s.reviewRounds),
3190
+ kind: "review",
3191
+ attempt: reviewRestarts + 1,
3192
+ });
3193
+ }
3194
+ }
3195
+ return end(clocked, {
3196
+ kind: "merge_refused",
3197
+ pr: p.pr,
3198
+ reason: "the pull request changed before the merge and could not be reconciled",
3199
+ reviewRounds: s.reviewRounds,
3200
+ });
3201
+ }
2772
3202
  if (r.outcome === "merged")
2773
3203
  // Found already merged at the door — auto-merge or a person, after the
2774
3204
  // approval: the unit is done, the runner merged nothing (`by: other`).
@@ -2779,6 +3209,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2779
3209
  pr: p.pr,
2780
3210
  sha: r.sha,
2781
3211
  mergedAt: r.mergedAt,
3212
+ ...(r.mergedBy !== undefined ? { mergedBy: r.mergedBy } : {}),
2782
3213
  reviewRounds: s.reviewRounds,
2783
3214
  })
2784
3215
  : end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
@@ -2856,12 +3287,11 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2856
3287
  reason: r.reason,
2857
3288
  reviewRounds: s.reviewRounds,
2858
3289
  });
2859
- if (r.outcome === "conflict")
2860
- return enterRound(clocked, {
2861
- index: s.reviewRounds,
2862
- kind: "rebase",
2863
- attempt: p.n,
2864
- });
3290
+ if (r.outcome === "conflict" || r.outcome === "changed")
3291
+ return {
3292
+ state: { ...clocked, phase: { at: "rebase-pr-check", pr: p.pr, n: p.n, outcome: r.outcome } },
3293
+ notes: [],
3294
+ };
2865
3295
  if (!("headSha" in r))
2866
3296
  return end(clocked, {
2867
3297
  kind: "merge_refused",
@@ -2870,9 +3300,37 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2870
3300
  reviewRounds: s.reviewRounds,
2871
3301
  });
2872
3302
  const rebased: UnitPipelineState = { ...clocked, lastReviewHead: r.headSha };
2873
- if (r.outcome === "changed") return mandatoryReview(rebased);
2874
3303
  return enterChecks(rebased, { index: s.reviewRounds, kind: "review" }, []);
2875
3304
  }
3305
+ case "rebase-pr-check": {
3306
+ const pr = (ret as Extract<StepReturn, { type: "pr-check" }>).pr;
3307
+ if (pr.state === "merged") return foundMerged(clocked, pr);
3308
+ if (pr.state === "closed") return foundClosed(clocked, pr);
3309
+ if (pr.state === "open" && pr.headBranchExists === false) return missingHeadBranch(clocked, pr);
3310
+ if (pr.state !== "open")
3311
+ return end(clocked, {
3312
+ kind: "merge_refused",
3313
+ pr: p.pr,
3314
+ reason: "the pull request disappeared after the rebase resolver finished",
3315
+ reviewRounds: s.reviewRounds,
3316
+ });
3317
+ const head = normalizeHead(pr.headSha);
3318
+ if (pr.headBranchExists !== true || head === undefined)
3319
+ return end(clocked, {
3320
+ kind: "merge_refused",
3321
+ pr: { number: pr.prNumber, url: pr.url },
3322
+ reason: "the pull request's head branch could not be verified after the rebase resolver finished",
3323
+ reviewRounds: s.reviewRounds,
3324
+ });
3325
+ const refreshed: UnitPipelineState = {
3326
+ ...clocked,
3327
+ pr: { number: pr.prNumber, url: pr.url },
3328
+ lastReviewHead: head,
3329
+ };
3330
+ return p.outcome === "conflict"
3331
+ ? enterRound(refreshed, { index: s.reviewRounds, kind: "rebase", attempt: p.n })
3332
+ : mandatoryReview(refreshed);
3333
+ }
2876
3334
  case "merge-wait":
2877
3335
  return {
2878
3336
  state: {
@@ -3067,7 +3525,9 @@ export function renderUnitReport(
3067
3525
  switch (e.kind) {
3068
3526
  case "merged":
3069
3527
  if (e.by === "other")
3070
- return `✅ Already merged: ${e.pr.url} (merge commit \`${e.sha.slice(0, 7)}\`, merged ${e.mergedAt}) — the pull request heading \`${s.input.unit.branch}\` was merged before this pipeline reached it, by a person or by an earlier pipeline of this plan; the pipeline merged nothing. The unit is done and its dependents start on a base that carries it.`;
3528
+ return e.mergedBy !== undefined
3529
+ ? `✅ Merged: ${e.pr.url} — merged by ${e.mergedBy} at \`${e.sha.slice(0, 7)}\` (${e.mergedAt}); the pipeline observed that merge and ran nothing further. The unit is done and its dependents start on a base that carries it.`
3530
+ : `✅ Already merged: ${e.pr.url} (merge commit \`${e.sha.slice(0, 7)}\`, merged ${e.mergedAt}) — the pull request heading \`${s.input.unit.branch}\` was merged before this pipeline reached it, by a person or by an earlier pipeline of this plan; the pipeline merged nothing. The unit is done and its dependents start on a base that carries it.`;
3071
3531
  return [
3072
3532
  `✅ Merged after ${rounds}: ${e.pr.url} (squash \`${e.sha.slice(0, 7)}\`) — merged by the pipeline under \`plan:merge\`: the review approved at this head and the guards were green.`,
3073
3533
  aside(verdictLine),
@@ -3078,6 +3538,8 @@ export function renderUnitReport(
3078
3538
  ]
3079
3539
  .filter(Boolean)
3080
3540
  .join("\n");
3541
+ case "closed":
3542
+ return `⛔ Closed: ${e.pr.url} — closed by ${e.closedBy} without merging; the unit is terminal and no review, findings, checks or merge step follows.`;
3081
3543
  case "already_landed":
3082
3544
  // No compare link, no renewal line, no re-issue prompt: there was
3083
3545
  // nothing to ship, so none of them has a question to answer.