@coreplane/switchboard 1.260.1 → 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.
@@ -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,6 +556,9 @@ 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
564
  * failed checks whose one flake re-run the machine is spending; `refire`
@@ -678,7 +688,15 @@ 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
701
  * not be read, which the machine treats as pending (record 0055). Effect
684
702
  * asks answer `retried` or `refired` instead; false still spends that one
@@ -1090,6 +1108,7 @@ type Phase =
1090
1108
  * only through a merge queue (issue 2011) — so every later ask reads the
1091
1109
  * queue's outcome instead of attempting the squash again. */
1092
1110
  | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1111
+ | { at: "rebase"; pr: PrRef; headSha: string; n: number }
1093
1112
  | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1094
1113
  /** The round's checks step (record 0055): the check runs at the reviewed head
1095
1114
  * are read after an approve settles, before merge_ready or the merge door.
@@ -1135,6 +1154,9 @@ export interface UnitPipelineState {
1135
1154
  readonly phase: Phase;
1136
1155
  /** Review rounds started so far. */
1137
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;
1138
1160
  readonly pr?: PrRef;
1139
1161
  readonly lastReviewHead?: string;
1140
1162
  readonly lastVerdictSummary?: string;
@@ -1192,6 +1214,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
1192
1214
  clock: at,
1193
1215
  phase: { at: "pre-check" },
1194
1216
  reviewRounds: 0,
1217
+ rebaseAttempts: 0,
1195
1218
  spentMs: { coding: 0, review: 0, waiting: 0 },
1196
1219
  spendUsd: input.session?.spendUsd ?? 0,
1197
1220
  findingsByRound: {},
@@ -1230,11 +1253,8 @@ const loopOf = (s: UnitPipelineState): Loop => ({ maxRounds: s.input.caps.maxRou
1230
1253
  * A review round `n` and the findings step that follows it share `n`; the
1231
1254
  * module's positions are the loop's own. */
1232
1255
  function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
1233
- return carve(
1234
- remainingMs(s),
1235
- { kind: round.kind, index: loopPosition(loopOf(s), round.kind, round.index) },
1236
- loopOf(s),
1237
- );
1256
+ const kind = round.kind === "rebase" ? "findings" : round.kind;
1257
+ return carve(remainingMs(s), { kind, index: loopPosition(loopOf(s), kind, round.index) }, loopOf(s));
1238
1258
  }
1239
1259
 
1240
1260
  /** The prefix every step of this pipeline is named under: the unit id, a
@@ -1270,6 +1290,9 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1270
1290
  };
1271
1291
  }
1272
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
+ }
1273
1296
  if (round.kind === "findings") {
1274
1297
  const checks = checkFindingsOf(s.findingsByRound[round.index]);
1275
1298
  return {
@@ -1380,6 +1403,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1380
1403
  headSha: p.headSha,
1381
1404
  ...(p.queued === true ? { queued: true as const } : {}),
1382
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
+ };
1383
1413
  case "merge-wait":
1384
1414
  return {
1385
1415
  type: "wait-checks",
@@ -1534,6 +1564,13 @@ function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNot
1534
1564
  };
1535
1565
  }
1536
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
+
1537
1574
  /** The next review round, or the round cap. */
1538
1575
  function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
1539
1576
  if (s.reviewRounds >= s.input.caps.maxRounds)
@@ -1987,6 +2024,21 @@ function settleChecks(
1987
2024
  }
1988
2025
  }
1989
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
+
1990
2042
  /** An approve past the checks read: merge_ready for a person, the merge door
1991
2043
  * under `merge: runner` — the tail the round verdict guards (record 0055). */
1992
2044
  function approveOutcome(next: UnitPipelineState, notes: CoordinatorNote[]): Transition {
@@ -2237,6 +2289,22 @@ function roundOnOpenPr(
2237
2289
  const { round } = phase;
2238
2290
  const head = pr.headSha ?? phase.childHead;
2239
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
+ }
2240
2308
  if (round.kind === "findings") {
2241
2309
  // Nothing repushed → nothing to re-review, unless every finding of the
2242
2310
  // last review was declined on the record: that re-review verifies the
@@ -2577,6 +2645,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2577
2645
  reviewRounds: s.reviewRounds,
2578
2646
  })
2579
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: [] };
2580
2649
  if (r.outcome === "refused")
2581
2650
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
2582
2651
  if (r.outcome === "removed") {
@@ -2641,6 +2710,32 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2641
2710
  : [],
2642
2711
  };
2643
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
+ }
2644
2739
  case "merge-wait":
2645
2740
  return {
2646
2741
  state: {
@@ -2764,7 +2859,7 @@ export interface CommitChecksFacts {
2764
2859
  * the line is unchanged. */
2765
2860
  function mergeReadyHeadline(rounds: string, url: string, base: string, facts: MergeReadyFacts | undefined): string {
2766
2861
  if (facts?.mergeableState === "dirty")
2767
- 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.`;
2768
2863
  const fixups = facts?.fixupCommits ?? [];
2769
2864
  if (fixups.length > 0)
2770
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.`;