@coreplane/switchboard 1.260.1 → 1.260.3

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.
@@ -59,6 +59,7 @@ import { parsePlanUnit, planUnitIds } from "./contract.js";
59
59
  import {
60
60
  carve,
61
61
  DEFAULT_GRANT,
62
+ IDLE_DAYS_MAX,
62
63
  loopPosition,
63
64
  MERGE_WAIT_ASK_MINUTES,
64
65
  MINUTE_MS,
@@ -458,7 +459,7 @@ export function cursorFinished(cursor: PlanCursor): boolean {
458
459
  * findings dispatched into the unit thread as `agent:coding`, so the coding
459
460
  * session there continues with them (record 0034). Never a `fix` child briefed
460
461
  * from the review. */
461
- export type RoundKind = "coding" | "review" | "findings";
462
+ export type RoundKind = "coding" | "review" | "findings" | "rebase";
462
463
  export interface RoundRef {
463
464
  /** Round 0 is the coding round; review round n and its findings step share n. */
464
465
  index: number;
@@ -494,8 +495,10 @@ export type Brief =
494
495
  pr: number;
495
496
  headSha?: string;
496
497
  round: number;
497
- /** The previous review round's run and the coding run that answered its findings, for a re-review. */
498
- prior?: { reviewRunId: string; codingRunId?: string };
498
+ /** The previous review round and the coding run that answered it. A
499
+ * recovered posted verdict carries findings by value when no run row
500
+ * belongs to this attempt. */
501
+ prior?: { reviewRunId?: string; codingRunId?: string; findings?: Finding[] };
499
502
  /** The previous round's check findings (record 0055): they sit on no
500
503
  * run's record, so the brief carries them beside `prior` by value. */
501
504
  checks?: Finding[];
@@ -504,10 +507,22 @@ export type Brief =
504
507
  kind: "findings";
505
508
  unit: string;
506
509
  pr: number;
507
- reviewRunId: string;
510
+ /** The review run normally supplies the findings. An adopted attempt
511
+ * can recover them from the last posted verdict marker instead. */
512
+ reviewRunId?: string;
513
+ findings?: Finding[];
514
+ /** Human replies that answered a parked finding, attributed at intake. */
515
+ answers?: string[];
508
516
  /** The round's check findings (record 0055), carried by value — the
509
517
  * review run's record holds only the reviewer's own findings. */
510
518
  checks?: Finding[];
519
+ }
520
+ | {
521
+ kind: "rebase";
522
+ unit: string;
523
+ pr: number;
524
+ headSha: string;
525
+ base: string;
511
526
  };
512
527
 
513
528
  export interface PrRef {
@@ -515,6 +530,20 @@ export interface PrRef {
515
530
  url: string;
516
531
  }
517
532
 
533
+ /** A human-gated review question parked on its live unit. The review's typed
534
+ * findings and the pull request survive the wait; `reviewRunId` is absent when
535
+ * an adopted attempt recovered the question from GitHub's verdict marker. */
536
+ export interface HumanGatePending {
537
+ pr: PrRef;
538
+ round: number;
539
+ findings: Finding[];
540
+ verdict: "approve" | "request_changes";
541
+ reviewRunId?: string;
542
+ headSha?: string;
543
+ /** When the verdict asked the question, for fencing older unit events. */
544
+ askedAt?: number;
545
+ }
546
+
518
547
  /** What the coordinator asks for next. `step` is the Workflow step's name, and
519
548
  * for a spawn the second half of the child's idempotency key. */
520
549
  export type CoordinatorAction =
@@ -549,6 +578,9 @@ export type CoordinatorAction =
549
578
  * the queue's outcome (merged, still queued, or removed with the reason)
550
579
  * instead of attempting the squash. */
551
580
  | { type: "merge"; step: string; prNumber: number; headSha: string; queued?: true }
581
+ /** Rung one of an approved head's rebase: git first. A conflict alone buys
582
+ * the runner's coding round; a clean changed patch returns to review. */
583
+ | { type: "rebase"; step: string; prNumber: number; headSha: string }
552
584
  /** Read the check runs at the reviewed head (record 0055, the round verdict):
553
585
  * the merge door's own reading, folded into the round. `retry` names the
554
586
  * failed checks whose one flake re-run the machine is spending; `refire`
@@ -567,6 +599,10 @@ export type ChildFacts =
567
599
  | {
568
600
  finished: true;
569
601
  status: RunStatus;
602
+ /** The terminal record time; fallback fence for an older review record. */
603
+ finishedAt?: number;
604
+ /** When the posted verdict became the person's question. */
605
+ reviewAskedAt?: number;
570
606
  finalReply?: string;
571
607
  /** A coding child's `pr_opened`. */
572
608
  pr?: { number: number; url: string; created: boolean };
@@ -649,6 +685,16 @@ export type PrCheck =
649
685
  * read beside the checks on the ending's facts read, so a `merge_ready`
650
686
  * report tells the person their merge is queued, never a direct one. */
651
687
  baseHasMergeQueue?: boolean;
688
+ /** A person's newest pull-request comment answered the last posted
689
+ * human-gated verdict. Entry starts its fix round before any review. */
690
+ humanGate?: {
691
+ round: number;
692
+ findings: Finding[];
693
+ verdict: "approve" | "request_changes";
694
+ answer: string;
695
+ author: string;
696
+ commentId: string;
697
+ };
652
698
  }
653
699
  | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
654
700
 
@@ -678,7 +724,15 @@ export type StepReturn =
678
724
  | { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
679
725
  /** The door enqueued the pull request (or found it still queued), or the
680
726
  * 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 }
727
+ | {
728
+ type: "merge";
729
+ step: string;
730
+ outcome: "pending" | "refused" | "conflict" | "enqueued" | "removed";
731
+ reason: string;
732
+ at: number;
733
+ }
734
+ | { type: "rebase"; step: string; outcome: "carried" | "changed"; headSha: string; at: number }
735
+ | { type: "rebase"; step: string; outcome: "conflict" | "refused"; reason: string; at: number }
682
736
  /** The checks read at the reviewed head; `checks` absent means GitHub could
683
737
  * not be read, which the machine treats as pending (record 0055). Effect
684
738
  * asks answer `retried` or `refired` instead; false still spends that one
@@ -775,12 +829,9 @@ export type UnitEnding =
775
829
  * The unit is done and its dependents start on a base that carries it. */
776
830
  | { kind: "already_landed"; landed: HandoffLanded[]; round: RoundRef; runId: string; reviewRounds: number }
777
831
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
778
- /** Every finding the round would act on is human-gated — a receipt only a
779
- * person can produce (issue 1990; the reviewer set the flag through
780
- * `submit_verdict`, agent-review item 5) — so a fix round could change
781
- * nothing: the unit ends held for a person, the ending carrying the
782
- * human-gated rows, and a re-issue with the pull request resumes at the
783
- * review round once the receipt is posted (item 10's resume path). */
832
+ /** The report nested inside a parked human-gated question. The unit never
833
+ * publishes this as its terminal kind: `parkHumanGate` carries it under an
834
+ * idle ending until a person's answer opens the fix round. */
784
835
  | {
785
836
  kind: "held";
786
837
  cause?: undefined;
@@ -920,6 +971,10 @@ export type UnitEnding =
920
971
  handoff?: Handoff;
921
972
  round?: RoundRef;
922
973
  reviewRounds: number;
974
+ /** A parked human-gated review is always an idle, independent of the
975
+ * optional idle policy. Its question is the unit's pending state. */
976
+ humanGate?: HumanGatePending;
977
+ parkDays?: number;
923
978
  };
924
979
 
925
980
  /** The kinds that idle: every ending but the ended ones — the unit is
@@ -927,10 +982,9 @@ export type UnitEnding =
927
982
  * a reply could continue it. `merged`, `already_landed`, `merge_ready` and
928
983
  * `refused` never idle: the first two are done, merge-ready waits only for a
929
984
  * person's merge, and a refused child would be refused again. A gated or
930
- * draft `held` never idles either — it waits only for a person's receipt at
931
- * the pull request, which no thread reply can produce — but a blocked hold
932
- * (issue 2086) waits for exactly the person's word the idle's wake carries,
933
- * so it alone idles; `idleEnding` draws that line by cause. */
985
+ * draft `held` never idles either. A blocked hold (issue 2086) follows the
986
+ * configured idle policy; a human-gated hold takes `parkHumanGate` instead,
987
+ * which always creates the pending idle question. */
934
988
  export type IdleWhy =
935
989
  Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused"> | RenewalWhy;
936
990
 
@@ -996,6 +1050,9 @@ export interface LeaseSegmentProgress {
996
1050
  previousHandoff?: Handoff;
997
1051
  /** Words that woke an idle segment, already attributed in arrival order. */
998
1052
  texts?: string[];
1053
+ /** The human-gated question those words answer. It resumes as a findings
1054
+ * round before another review, without spending a renewal. */
1055
+ humanGate?: HumanGatePending;
999
1056
  /** A stopped segment reopens under the remainder of its cut lease. Each
1000
1057
  * reopen gets a fresh attempt so its Workflow steps cannot replay the
1001
1058
  * stopped attempt's cached answers. */
@@ -1090,6 +1147,7 @@ type Phase =
1090
1147
  * only through a merge queue (issue 2011) — so every later ask reads the
1091
1148
  * queue's outcome instead of attempting the squash again. */
1092
1149
  | { at: "merge"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1150
+ | { at: "rebase"; pr: PrRef; headSha: string; n: number }
1093
1151
  | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1094
1152
  /** The round's checks step (record 0055): the check runs at the reviewed head
1095
1153
  * are read after an approve settles, before merge_ready or the merge door.
@@ -1135,6 +1193,9 @@ export interface UnitPipelineState {
1135
1193
  readonly phase: Phase;
1136
1194
  /** Review rounds started so far. */
1137
1195
  readonly reviewRounds: number;
1196
+ /** Approved-head rebases attempted in this run. Each fresh conflict may buy
1197
+ * one fix round; the run's remaining lease, not pull-request lifetime, is the bound. */
1198
+ readonly rebaseAttempts: number;
1138
1199
  readonly pr?: PrRef;
1139
1200
  readonly lastReviewHead?: string;
1140
1201
  readonly lastVerdictSummary?: string;
@@ -1143,6 +1204,8 @@ export interface UnitPipelineState {
1143
1204
  * ids are unique within one round only. A disposition naming an id the review
1144
1205
  * never issued is dropped at the match (`matchDispositions`). */
1145
1206
  readonly findingsByRound: Readonly<Record<number, Finding[]>>;
1207
+ /** Human answers folded into a recovered/parked findings brief. */
1208
+ readonly humanAnswersByRound: Readonly<Record<number, string[]>>;
1146
1209
  readonly dispositionsByRound: Readonly<Record<number, FindingDisposition[]>>;
1147
1210
  readonly reviewRunByRound: Readonly<Record<number, string>>;
1148
1211
  /** The coding run each round's findings step dispatched. */
@@ -1186,19 +1249,29 @@ export const BUSY_RETRY_MS = SHIP_WAIT.busyRetryMinutes * MIN;
1186
1249
  // ---- the unit pipeline: opening and the next action -----------------------------------------------
1187
1250
 
1188
1251
  export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipelineState {
1252
+ const pending = input.session?.humanGate;
1189
1253
  const base: UnitPipelineState = {
1190
1254
  input,
1191
1255
  startedAt: at,
1192
1256
  clock: at,
1193
1257
  phase: { at: "pre-check" },
1194
- reviewRounds: 0,
1258
+ reviewRounds: pending?.round ?? 0,
1259
+ rebaseAttempts: 0,
1195
1260
  spentMs: { coding: 0, review: 0, waiting: 0 },
1196
1261
  spendUsd: input.session?.spendUsd ?? 0,
1197
- findingsByRound: {},
1262
+ findingsByRound: pending ? { [pending.round]: pending.findings } : {},
1263
+ humanAnswersByRound: pending && input.session?.texts !== undefined ? { [pending.round]: input.session.texts } : {},
1198
1264
  dispositionsByRound: {},
1199
- reviewRunByRound: {},
1265
+ reviewRunByRound: pending?.reviewRunId !== undefined ? { [pending.round]: pending.reviewRunId } : {},
1200
1266
  findingsRunByRound: {},
1267
+ ...(pending !== undefined
1268
+ ? {
1269
+ pr: pending.pr,
1270
+ ...(pending.headSha !== undefined ? { lastReviewHead: pending.headSha } : {}),
1271
+ }
1272
+ : {}),
1201
1273
  };
1274
+ if (pending !== undefined) return enterRound(base, { index: pending.round, kind: "findings" }).state;
1202
1275
  if (!input.resume) return base;
1203
1276
  const url = input.resume.url ?? `https://github.com/${input.repo}/pull/${input.resume.pr}`;
1204
1277
  const resumed: UnitPipelineState = {
@@ -1230,11 +1303,8 @@ const loopOf = (s: UnitPipelineState): Loop => ({ maxRounds: s.input.caps.maxRou
1230
1303
  * A review round `n` and the findings step that follows it share `n`; the
1231
1304
  * module's positions are the loop's own. */
1232
1305
  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
- );
1306
+ const kind = round.kind === "rebase" ? "findings" : round.kind;
1307
+ return carve(remainingMs(s), { kind, index: loopPosition(loopOf(s), kind, round.index) }, loopOf(s));
1238
1308
  }
1239
1309
 
1240
1310
  /** The prefix every step of this pipeline is named under: the unit id, a
@@ -1270,27 +1340,44 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1270
1340
  };
1271
1341
  }
1272
1342
  const pr = s.pr!.number;
1343
+ if (round.kind === "rebase") {
1344
+ return { kind: "rebase", unit, pr, headSha: s.lastReviewHead!, base: s.input.base };
1345
+ }
1273
1346
  if (round.kind === "findings") {
1274
- const checks = checkFindingsOf(s.findingsByRound[round.index]);
1347
+ const issued = s.findingsByRound[round.index] ?? [];
1348
+ const checks = checkFindingsOf(issued);
1349
+ const reviewRunId = s.reviewRunByRound[round.index];
1350
+ const answers = s.humanAnswersByRound[round.index];
1275
1351
  return {
1276
1352
  kind: "findings",
1277
1353
  unit,
1278
1354
  pr,
1279
- reviewRunId: s.reviewRunByRound[round.index]!,
1355
+ ...(reviewRunId !== undefined ? { reviewRunId } : {}),
1356
+ ...(reviewRunId === undefined || (answers !== undefined && answers.length > 0) ? { findings: issued } : {}),
1357
+ ...(answers !== undefined && answers.length > 0 ? { answers } : {}),
1280
1358
  ...(checks.length > 0 ? { checks } : {}),
1281
1359
  };
1282
1360
  }
1361
+ const priorFindings = s.findingsByRound[round.index - 1] ?? [];
1283
1362
  const priorReview = s.reviewRunByRound[round.index - 1];
1284
1363
  const priorCoding = s.findingsRunByRound[round.index - 1];
1285
- const priorChecks = checkFindingsOf(s.findingsByRound[round.index - 1]);
1364
+ const priorChecks = checkFindingsOf(priorFindings);
1286
1365
  return {
1287
1366
  kind: "review",
1288
1367
  unit,
1289
1368
  pr,
1290
1369
  ...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
1291
1370
  round: round.index,
1292
- ...(priorReview !== undefined
1293
- ? { prior: { reviewRunId: priorReview, ...(priorCoding !== undefined ? { codingRunId: priorCoding } : {}) } }
1371
+ ...(priorReview !== undefined || (priorCoding !== undefined && priorFindings.length > 0)
1372
+ ? {
1373
+ prior: {
1374
+ ...(priorReview !== undefined ? { reviewRunId: priorReview } : {}),
1375
+ ...(priorReview === undefined || s.humanAnswersByRound[round.index - 1] !== undefined
1376
+ ? { findings: priorFindings }
1377
+ : {}),
1378
+ ...(priorCoding !== undefined ? { codingRunId: priorCoding } : {}),
1379
+ },
1380
+ }
1294
1381
  : {}),
1295
1382
  ...(priorChecks.length > 0 ? { checks: priorChecks } : {}),
1296
1383
  };
@@ -1380,6 +1467,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1380
1467
  headSha: p.headSha,
1381
1468
  ...(p.queued === true ? { queued: true as const } : {}),
1382
1469
  };
1470
+ case "rebase":
1471
+ return {
1472
+ type: "rebase",
1473
+ step: `${unit}/rebase/${p.n}`,
1474
+ prNumber: p.pr.number,
1475
+ headSha: p.headSha,
1476
+ };
1383
1477
  case "merge-wait":
1384
1478
  return {
1385
1479
  type: "wait-checks",
@@ -1424,9 +1518,9 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
1424
1518
  function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEnding, { kind: "idle" }> | undefined {
1425
1519
  if ((s.input.idleDays ?? 0) <= 0) return undefined;
1426
1520
  if (NEVER_IDLES.has(ending.kind)) return undefined;
1427
- // A gated or draft hold waits only for a person's receipt at the pull
1428
- // request — no thread reply can continue it — while a blocked hold (issue
1429
- // 2086) waits for exactly the person's word the wake carries, so it idles.
1521
+ // A draft hold is not continued by a reply. A blocked hold (issue 2086)
1522
+ // waits for the person's word. Human-gated questions bypass this mapping:
1523
+ // `parkHumanGate` always parks them with their typed pending state.
1430
1524
  if (ending.kind === "held" && ending.cause !== "blocked") return undefined;
1431
1525
  const old = ending as Exclude<UnitEnding, { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "refused" }>;
1432
1526
  const grant = s.input.grant ?? DEFAULT_GRANT;
@@ -1482,7 +1576,7 @@ const heldEnding = (
1482
1576
  round: RoundRef,
1483
1577
  findings: Finding[],
1484
1578
  verdict: "approve" | "request_changes",
1485
- ): UnitEnding => ({
1579
+ ): Extract<UnitEnding, { kind: "held" }> => ({
1486
1580
  kind: "held",
1487
1581
  ...(s.pr !== undefined ? { pr: s.pr } : {}),
1488
1582
  round,
@@ -1491,6 +1585,48 @@ const heldEnding = (
1491
1585
  reviewRounds: s.reviewRounds,
1492
1586
  });
1493
1587
 
1588
+ /** A human-gated finding is a question, not a terminal hold. Park the live
1589
+ * unit in record 0051's idle state regardless of the optional idle policy, so
1590
+ * either a thread reply or a pull-request comment can become its fix brief. */
1591
+ function parkHumanGate(
1592
+ s: UnitPipelineState,
1593
+ round: RoundRef,
1594
+ findings: Finding[],
1595
+ verdict: "approve" | "request_changes",
1596
+ askedAt: number,
1597
+ notes: CoordinatorNote[],
1598
+ ): Transition {
1599
+ const held = heldEnding(s, round, findings, verdict);
1600
+ const humanGate: HumanGatePending = {
1601
+ pr: s.pr!,
1602
+ round: round.index,
1603
+ findings,
1604
+ verdict,
1605
+ ...(s.reviewRunByRound[round.index] !== undefined ? { reviewRunId: s.reviewRunByRound[round.index] } : {}),
1606
+ ...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
1607
+ askedAt,
1608
+ };
1609
+ const grant = s.input.grant ?? DEFAULT_GRANT;
1610
+ const ending: Extract<UnitEnding, { kind: "idle" }> = {
1611
+ kind: "idle",
1612
+ why: "held",
1613
+ idled: held,
1614
+ renewalsLeft: Math.max(0, grant.renewals - (s.input.session?.renewalsSpent ?? 0)),
1615
+ ...(s.lastReviewHead !== undefined ? { from: s.lastReviewHead } : {}),
1616
+ ...(s.lastCodingRunId !== undefined ? { runId: s.lastCodingRunId } : {}),
1617
+ spendUsd: s.spendUsd,
1618
+ ...(s.lastCodingHandoff !== undefined ? { handoff: s.lastCodingHandoff } : {}),
1619
+ round,
1620
+ reviewRounds: s.reviewRounds,
1621
+ humanGate,
1622
+ parkDays: IDLE_DAYS_MAX,
1623
+ };
1624
+ return {
1625
+ state: { ...s, phase: ENDED, ending },
1626
+ notes: [...notes, { type: "ended", ending }],
1627
+ };
1628
+ }
1629
+
1494
1630
  const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): RoundNote => ({
1495
1631
  type: "round",
1496
1632
  index: round.index,
@@ -1534,6 +1670,14 @@ function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNot
1534
1670
  };
1535
1671
  }
1536
1672
 
1673
+ /** A rebase changed the approved patch, or a person's answer enabled a
1674
+ * human-gated fix, so review is mandatory even when the ordinary findings-
1675
+ * round count is spent. The run's remaining lease is the bound: `enterRound`
1676
+ * refuses when another review no longer fits. */
1677
+ function mandatoryReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
1678
+ return enterRound(s, { index: s.reviewRounds + 1, kind: "review" }, notes);
1679
+ }
1680
+
1537
1681
  /** The next review round, or the round cap. */
1538
1682
  function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
1539
1683
  if (s.reviewRounds >= s.input.caps.maxRounds)
@@ -1597,13 +1741,16 @@ function settleCoding(
1597
1741
  [roundNote(round, "stopped")],
1598
1742
  );
1599
1743
  // A mechanical WIP push means the child did not declare the work ready for
1600
- // review. Keep the unit branch as the resumption point and abort this
1601
- // attempt; an ordinary push still takes the established recover-PR path.
1602
- if (facts.status === "failed" && checkpoint !== undefined) {
1744
+ // review, whatever terminal status its own answer produced. Keep the unit
1745
+ // branch as the resumption point and abort this attempt; an ordinary push
1746
+ // still takes the established recover-PR path.
1747
+ if (checkpoint !== undefined) {
1603
1748
  const cause =
1604
1749
  facts.failure?.kind === "provider_transient"
1605
1750
  ? "the model provider's transport retry budget was spent"
1606
- : "it failed before finishing";
1751
+ : facts.status === "failed"
1752
+ ? "it failed before finishing"
1753
+ : "it ended before its work was ready for review";
1607
1754
  return end(
1608
1755
  next,
1609
1756
  {
@@ -1729,9 +1876,10 @@ function settleReview(
1729
1876
  },
1730
1877
  notes,
1731
1878
  );
1732
- // Every gated finding is human-gated (issue 1990): a fix round could
1733
- // change nothing, so the unit ends held for a person instead.
1734
- if (allHumanGated(gated)) return end(next, heldEnding(next, round, gated, "approve"), notes);
1879
+ // Every gated finding is human-gated: ask a person and park the live
1880
+ // unit; an answer later opens the fix round with the question attached.
1881
+ if (allHumanGated(gated))
1882
+ return parkHumanGate(next, round, gated, "approve", facts.reviewAskedAt ?? facts.finishedAt ?? s.clock, notes);
1735
1883
  if (next.reviewRounds >= next.input.caps.maxRounds)
1736
1884
  return end(
1737
1885
  next,
@@ -1760,11 +1908,18 @@ function settleReview(
1760
1908
  },
1761
1909
  notes,
1762
1910
  );
1763
- // Every finding of the round is human-gated (issue 1990): no fix round can
1764
- // change anything, so the unit ends held for a person's receipt instead of
1765
- // spending a coding child — or the round cap — on it.
1911
+ // Every finding of the round is human-gated: no fix round can change
1912
+ // anything yet, so park the live unit for a person's answer before asking
1913
+ // the round cap.
1766
1914
  if (allHumanGated(verdict.findings ?? []))
1767
- return end(next, heldEnding(next, round, verdict.findings ?? [], "request_changes"), notes);
1915
+ return parkHumanGate(
1916
+ next,
1917
+ round,
1918
+ verdict.findings ?? [],
1919
+ "request_changes",
1920
+ facts.reviewAskedAt ?? facts.finishedAt ?? s.clock,
1921
+ notes,
1922
+ );
1768
1923
  if (next.reviewRounds >= next.input.caps.maxRounds)
1769
1924
  return end(
1770
1925
  next,
@@ -1987,6 +2142,21 @@ function settleChecks(
1987
2142
  }
1988
2143
  }
1989
2144
 
2145
+ /** Re-enter the approved head's rebase step. The attempt count belongs to
2146
+ * this run, not to the pull request: every later base move is a fresh
2147
+ * conflict, while the one pipeline lease remains the hard bound. */
2148
+ export function reenterApprovedRebase(s: UnitPipelineState): UnitPipelineState {
2149
+ if (s.pr === undefined || s.lastReviewHead === undefined)
2150
+ throw new Error("an approved rebase needs the pull request and reviewed head");
2151
+ const { ending: _ending, ...open } = s;
2152
+ const n = s.rebaseAttempts + 1;
2153
+ return {
2154
+ ...open,
2155
+ rebaseAttempts: n,
2156
+ phase: { at: "rebase", pr: s.pr, headSha: s.lastReviewHead, n },
2157
+ };
2158
+ }
2159
+
1990
2160
  /** An approve past the checks read: merge_ready for a person, the merge door
1991
2161
  * under `merge: runner` — the tail the round verdict guards (record 0055). */
1992
2162
  function approveOutcome(next: UnitPipelineState, notes: CoordinatorNote[]): Transition {
@@ -2237,10 +2407,28 @@ function roundOnOpenPr(
2237
2407
  const { round } = phase;
2238
2408
  const head = pr.headSha ?? phase.childHead;
2239
2409
  const next: UnitPipelineState = { ...s, pr: { number: pr.prNumber, url: pr.url } };
2410
+ if (round.kind === "rebase") {
2411
+ const pushed = normalizeHead(head);
2412
+ const approved = normalizeHead(s.lastReviewHead);
2413
+ if (pushed === undefined || (approved !== undefined && sameCommit(pushed, approved)))
2414
+ return end(
2415
+ next,
2416
+ {
2417
+ kind: "aborted",
2418
+ reason: `⚠️ Rebase round ${round.attempt ?? 1} left the approved head unchanged, so the conflict still has no resolution to review.`,
2419
+ round,
2420
+ reviewRounds: next.reviewRounds,
2421
+ },
2422
+ [roundNote(round, "aborted")],
2423
+ );
2424
+ return mandatoryReview({ ...next, lastReviewHead: pushed }, [roundNote(round, "pr_opened")]);
2425
+ }
2426
+ const hasHumanAnswer = round.kind === "findings" && (s.humanAnswersByRound[round.index]?.length ?? 0) > 0;
2240
2427
  if (round.kind === "findings") {
2241
2428
  // Nothing repushed → nothing to re-review, unless every finding of the
2242
2429
  // last review was declined on the record: that re-review verifies the
2243
- // arguments and may concede.
2430
+ // arguments and may concede. A person's answer is itself new evidence for
2431
+ // the mandatory re-review, even when accepting it changes no code.
2244
2432
  const codingHead = normalizeHead(head);
2245
2433
  const reviewedAt = normalizeHead(s.lastReviewHead);
2246
2434
  if (codingHead !== undefined && reviewedAt !== undefined && sameCommit(codingHead, reviewedAt)) {
@@ -2271,27 +2459,30 @@ function roundOnOpenPr(
2271
2459
  },
2272
2460
  [roundNote(round, "aborted")],
2273
2461
  );
2274
- const findings = s.findingsByRound[round.index] ?? [];
2275
- const dispositions = s.dispositionsByRound[round.index] ?? [];
2276
- const allDeclined =
2277
- findings.length > 0 &&
2278
- findings.every((f) => dispositions.find((d) => d.findingId === f.id)?.disposition === "declined");
2279
- if (!allDeclined)
2280
- return end(
2281
- next,
2282
- {
2283
- kind: "aborted",
2284
- reason: `⚠️ Round ${round.index}'s findings step produced no new head — the branch still sits at \`${codingHead.slice(0, 7)}\`, the commit the review already read, and not every finding was declined on the record, so there is nothing new to re-review.`,
2285
- round,
2286
- reviewRounds: next.reviewRounds,
2287
- },
2288
- [roundNote(round, "aborted")],
2289
- );
2462
+ if (!hasHumanAnswer) {
2463
+ const findings = s.findingsByRound[round.index] ?? [];
2464
+ const dispositions = s.dispositionsByRound[round.index] ?? [];
2465
+ const allDeclined =
2466
+ findings.length > 0 &&
2467
+ findings.every((f) => dispositions.find((d) => d.findingId === f.id)?.disposition === "declined");
2468
+ if (!allDeclined)
2469
+ return end(
2470
+ next,
2471
+ {
2472
+ kind: "aborted",
2473
+ reason: `⚠️ Round ${round.index}'s findings step produced no new head — the branch still sits at \`${codingHead.slice(0, 7)}\`, the commit the review already read, and not every finding was declined on the record, so there is nothing new to re-review.`,
2474
+ round,
2475
+ reviewRounds: next.reviewRounds,
2476
+ },
2477
+ [roundNote(round, "aborted")],
2478
+ );
2479
+ }
2290
2480
  }
2291
2481
  }
2292
- return nextReview({ ...next, ...(head !== undefined ? { lastReviewHead: head } : {}) }, [
2293
- roundNote(round, "pr_opened"),
2294
- ]);
2482
+ const reviewed: UnitPipelineState = { ...next, ...(head !== undefined ? { lastReviewHead: head } : {}) };
2483
+ const notes = [roundNote(round, "pr_opened")];
2484
+ if (hasHumanAnswer) return mandatoryReview(reviewed, notes);
2485
+ return nextReview(reviewed, notes);
2295
2486
  }
2296
2487
 
2297
2488
  /** Move the clock and charge the elapsed time to the budget's bucket: a phase
@@ -2353,6 +2544,20 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2353
2544
  pr: { number: r.pr.prNumber, url: r.pr.url },
2354
2545
  lastReviewHead: entryHead,
2355
2546
  };
2547
+ const answered = r.pr.humanGate;
2548
+ if (answered !== undefined) {
2549
+ const round = Math.max(1, answered.round);
2550
+ const withAnswer: UnitPipelineState = {
2551
+ ...adopted,
2552
+ reviewRounds: round,
2553
+ findingsByRound: { ...adopted.findingsByRound, [round]: answered.findings },
2554
+ humanAnswersByRound: {
2555
+ ...adopted.humanAnswersByRound,
2556
+ [round]: [`${answered.author}: ${answered.answer}`],
2557
+ },
2558
+ };
2559
+ return enterRound(withAnswer, { index: round, kind: "findings" });
2560
+ }
2356
2561
  const checks = r.pr.checks;
2357
2562
  const green =
2358
2563
  checks !== undefined && checks.total > 0 && checks.failed.length === 0 && checks.pending.length === 0;
@@ -2577,6 +2782,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2577
2782
  reviewRounds: s.reviewRounds,
2578
2783
  })
2579
2784
  : end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
2785
+ if (r.outcome === "conflict") return { state: reenterApprovedRebase(clocked), notes: [] };
2580
2786
  if (r.outcome === "refused")
2581
2787
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
2582
2788
  if (r.outcome === "removed") {
@@ -2641,6 +2847,32 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2641
2847
  : [],
2642
2848
  };
2643
2849
  }
2850
+ case "rebase": {
2851
+ const r = ret as Extract<StepReturn, { type: "rebase" }>;
2852
+ if (r.outcome === "refused")
2853
+ return end(clocked, {
2854
+ kind: "merge_refused",
2855
+ pr: p.pr,
2856
+ reason: r.reason,
2857
+ reviewRounds: s.reviewRounds,
2858
+ });
2859
+ if (r.outcome === "conflict")
2860
+ return enterRound(clocked, {
2861
+ index: s.reviewRounds,
2862
+ kind: "rebase",
2863
+ attempt: p.n,
2864
+ });
2865
+ if (!("headSha" in r))
2866
+ return end(clocked, {
2867
+ kind: "merge_refused",
2868
+ pr: p.pr,
2869
+ reason: "the rebase resolver returned no head",
2870
+ reviewRounds: s.reviewRounds,
2871
+ });
2872
+ const rebased: UnitPipelineState = { ...clocked, lastReviewHead: r.headSha };
2873
+ if (r.outcome === "changed") return mandatoryReview(rebased);
2874
+ return enterChecks(rebased, { index: s.reviewRounds, kind: "review" }, []);
2875
+ }
2644
2876
  case "merge-wait":
2645
2877
  return {
2646
2878
  state: {
@@ -2764,7 +2996,7 @@ export interface CommitChecksFacts {
2764
2996
  * the line is unchanged. */
2765
2997
  function mergeReadyHeadline(rounds: string, url: string, base: string, facts: MergeReadyFacts | undefined): string {
2766
2998
  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.`;
2999
+ 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
3000
  const fixups = facts?.fixupCommits ?? [];
2769
3001
  if (fixups.length > 0)
2770
3002
  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.`;
@@ -2909,25 +3141,16 @@ export function renderUnitReport(
2909
3141
  `No renewal was spent and no second coding child ran — a concluded round is not renewed (a renewal continues a budget that ran out mid-work). Next step: your word in this thread — answer what the child raised, and the unit continues from there. ${reissue}`,
2910
3142
  ]);
2911
3143
  }
2912
- // The person's next step is the report's whole point (issue 1990): the
2913
- // human-gated rows are named with the reviewer's own words, and the
2914
- // re-issue line says the attempt resumes at the review round — item 10's
2915
- // resume path — once the receipt stands on the pull request. That path
2916
- // fires only when the invocation carries NO new task text: re-issuing
2917
- // with the task would ADOPT the pull request and run a coding round
2918
- // first (item 10), the very round this ending exists to avoid — so the
2919
- // held case renders its own re-issue line instead of the shared one.
3144
+ // A human-gated row is a question to a person. The enclosing idle keeps
3145
+ // this unit live; either answer surface wakes a fix round carrying both
3146
+ // the typed finding and the attributed answer.
2920
3147
  const rows = e.findings.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ");
2921
- // The quiet copy is the ending in the user's words (record 0066): the
2922
- // held row named, the pull request linked, nothing about the machinery.
2923
- if (!shows(verbosity, "verbose")) return `⏸️ Held: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
2924
- const heldReissue = s.input.generated
2925
- ? `To continue, re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text.`
2926
- : reissue;
3148
+ if (!shows(verbosity, "verbose"))
3149
+ return `⏸️ Waiting for you: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
2927
3150
  return join([
2928
- `⏸️ ${e.verdict === "approve" ? "Approved but held" : "Changes requested but held"} after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — every finding of review round ${e.round.index} is human-gated, a receipt only a person can produce: ${rows}. No fix round was opened: a coding child cannot produce the receipt.`,
3151
+ `⏸️ Waiting for a person after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — every finding of review round ${e.round.index} is human-gated, a receipt only a person can produce: ${rows}. The unit stays live; no unanswered fix round was opened.`,
2929
3152
  levelLine,
2930
- `Next step: produce the receipt each finding names and post it on the pull request. ${heldReissue} The re-issued pipeline resumes at the review round — no coding round runs first.`,
3153
+ `Reply in this unit thread or comment on the pull request with the receipt. The answer and finding become the fix round's brief, then review runs again.`,
2931
3154
  ]);
2932
3155
  }
2933
3156
  case "merge_refused":