@coreplane/switchboard 1.259.0 → 1.260.1

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 (56) hide show
  1. package/dist/assets/config/config.example.yaml +17 -24
  2. package/dist/assets/deploy/cloudflare/worker.ts +17 -56
  3. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +6 -0
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +178 -27
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +74 -52
  6. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +6 -3
  7. package/dist/assets/deploy/profile.example.json +2 -1
  8. package/dist/assets/package-lock.json +3 -3
  9. package/dist/assets/package.json +1 -1
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/agents/registry.ts +3 -5
  12. package/dist/assets/src/core/authz/policy.ts +5 -0
  13. package/dist/assets/src/core/budgets.ts +19 -6
  14. package/dist/assets/src/core/coordinator/contract.ts +93 -5
  15. package/dist/assets/src/core/coordinator/driver.ts +395 -128
  16. package/dist/assets/src/core/memory/types.ts +5 -0
  17. package/dist/assets/src/core/pipelineStanding.ts +13 -4
  18. package/dist/assets/src/core/plane/decide.ts +53 -12
  19. package/dist/assets/src/core/provider.ts +5 -0
  20. package/dist/assets/src/core/runEvents.ts +22 -15
  21. package/dist/assets/src/core/runLedger/types.ts +3 -3
  22. package/dist/assets/src/core/runRecord.ts +64 -11
  23. package/dist/assets/src/core/ship/coordinator.ts +202 -53
  24. package/dist/assets/src/core/ship/renewal.ts +23 -8
  25. package/dist/assets/src/core/types.ts +11 -0
  26. package/dist/assets/src/deploy/liveGate.ts +8 -0
  27. package/dist/assets/src/deploy/profile.ts +8 -0
  28. package/dist/assets/src/deploy/restart.ts +49 -35
  29. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  30. package/dist/assets/web/dist/assets/{DeliveryPage-DadntjSp.js → DeliveryPage-62qCSLvk.js} +1 -1
  31. package/dist/assets/web/dist/assets/HomePage-Dk3HRBc3.js +1 -0
  32. package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-CeTGOXUy.js} +1 -1
  33. package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-RD0M6O6W.js} +1 -1
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-Dl2KId95.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
  36. package/dist/assets/web/dist/assets/RunFoldRow-CwHDL-Mo.js +1 -0
  37. package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-Du1HqLZs.js} +4 -4
  38. package/dist/assets/web/dist/assets/RunsIndexPage-BZphy3Hr.js +1 -0
  39. package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-B7jh898t.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-3uLEsiiE.js} +1 -1
  41. package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-CKxSxnQC.js} +1 -1
  42. package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-x7vcK0iE.js} +1 -1
  43. package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-wVXCWhMp.js} +1 -1
  44. package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-V5BXK7FH.js} +1 -1
  45. package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
  46. package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-C-XGnvM4.js} +1 -1
  47. package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-Dxg7C8s9.js} +1 -1
  48. package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-DAL--hyj.js} +2 -2
  49. package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
  50. package/dist/cli.js +9136 -8197
  51. package/package.json +1 -1
  52. package/dist/assets/web/dist/assets/HomePage-BEEpTnVT.js +0 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-OPe7Z0Cz.js +0 -1
  54. package/dist/assets/web/dist/assets/RunsIndexPage-0YAClYQh.js +0 -1
  55. package/dist/assets/web/dist/assets/budgets-BRLm6oID.js +0 -1
  56. package/dist/assets/web/dist/assets/sseReplay-BjlKmQsY.js +0 -11
@@ -32,7 +32,14 @@
32
32
  import type { ShipRoundOutcome } from "../runEvents.js";
33
33
  import { shows, type Verbosity } from "../verbosity.js";
34
34
  import type { Handoff, HandoffLanded } from "./handoff.js";
35
- import { progressOf, renderRenewal, renewalDecision, type PushedHeadFact, type RenewalDecision } from "./renewal.js";
35
+ import {
36
+ progressOf,
37
+ renderRenewal,
38
+ renewalDecision,
39
+ type PushedHeadFact,
40
+ type RenewalDecision,
41
+ type RenewalWhy,
42
+ } from "./renewal.js";
36
43
  import type { RunStatus } from "../runRecord.js";
37
44
  import { normalizeHead, sameCommit } from "../reviewedHead.js";
38
45
  import {
@@ -125,13 +132,15 @@ export function interruptionCauseOfWords(words: string): InterruptionCause | und
125
132
  return undefined;
126
133
  }
127
134
 
128
- export function shipInterruptedNote(prUrl?: string, cause?: InterruptionCause): string {
135
+ export function shipInterruptedNote(prUrl?: string, cause?: InterruptionCause, idle = false): string {
129
136
  const stands = prUrl
130
137
  ? `Its work stands on GitHub: ${prUrl}.`
131
138
  : "Whatever it pushed stands on its pipeline branch; no PR was opened yet.";
132
- const reissue = prUrl
133
- ? `To continue the review loop, re-issue \`agent:ship\` in this thread with only the PR URL (${prUrl}).`
134
- : "To continue, re-issue `agent:ship` in this thread with the task — round 0 runs again on the same branch.";
139
+ const reissue = idle
140
+ ? "To continue, reply in this thread to continue."
141
+ : prUrl
142
+ ? `To continue the review loop, re-issue \`agent:ship\` in this thread with only the PR URL (${prUrl}).`
143
+ : "To continue, re-issue `agent:ship` in this thread with the task — round 0 runs again on the same branch.";
135
144
  const opening =
136
145
  cause === "container_replaced"
137
146
  ? "⚠️ The resident container running this pipeline's child was replaced (a deploy's image swap) and the child could not resume, so the pipeline stopped."
@@ -477,7 +486,7 @@ export type Brief =
477
486
  rebase: { branch: string; onto: string };
478
487
  /** A renewal's segment (decision 0046): the child continues the previous
479
488
  * segment's work from `from`, briefed with that run's write-up and handoff. */
480
- continue?: { segment: number; from?: string; previousRunId?: string };
489
+ continue?: { segment: number; from?: string; previousRunId?: string; texts?: string[] };
481
490
  }
482
491
  | {
483
492
  kind: "review";
@@ -542,10 +551,10 @@ export type CoordinatorAction =
542
551
  | { type: "merge"; step: string; prNumber: number; headSha: string; queued?: true }
543
552
  /** Read the check runs at the reviewed head (record 0055, the round verdict):
544
553
  * the merge door's own reading, folded into the round. `retry` names the
545
- * failed checks whose one flake re-run the machine is spending: the bot
546
- * re-runs their failed jobs (the CI retry the deploy already uses) instead
547
- * of reading. */
548
- | { type: "checks"; step: string; prNumber: number; headSha: string; retry?: string[] }
554
+ * failed checks whose one flake re-run the machine is spending; `refire`
555
+ * asks for the one close/reopen recovery when no required check launched.
556
+ * Either effect runs instead of a read. */
557
+ | { type: "checks"; step: string; prNumber: number; headSha: string; retry?: string[]; refire?: true }
549
558
  /** Wait for the intake's checks-settled event at the approved head, bounded as the fallback. */
550
559
  | { type: "wait-checks"; step: string; headSha: string; timeoutMs: number }
551
560
  | { type: "sleep"; step: string; ms: number }
@@ -636,6 +645,10 @@ export type PrCheck =
636
645
  /** The head's self-declared fix-up commit subjects (`fixup!`/`squash!`/`amend!`),
637
646
  * when the read asked for the facts — a head carrying one is not in the ready state. */
638
647
  fixupCommits?: string[];
648
+ /** The base branch takes changes only through a merge queue (issue 2011):
649
+ * read beside the checks on the ending's facts read, so a `merge_ready`
650
+ * report tells the person their merge is queued, never a direct one. */
651
+ baseHasMergeQueue?: boolean;
639
652
  }
640
653
  | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
641
654
 
@@ -667,10 +680,18 @@ export type StepReturn =
667
680
  * queue removed it — the reason is the queue's own (issue 2011). */
668
681
  | { type: "merge"; step: string; outcome: "pending" | "refused" | "enqueued" | "removed"; reason: string; at: number }
669
682
  /** The checks read at the reviewed head; `checks` absent means GitHub could
670
- * not be read, which the machine treats as pending (record 0055). A retry
671
- * ask answers `retried` instead: whether the bot dispatched the re-run —
672
- * false means there is nothing to wait for at the unchanged head. */
673
- | { type: "checks"; step: string; checks?: RoundChecks; draft?: boolean; retried?: boolean; at: number }
683
+ * not be read, which the machine treats as pending (record 0055). Effect
684
+ * asks answer `retried` or `refired` instead; false still spends that one
685
+ * recovery so an unchanged head cannot loop on the write. */
686
+ | {
687
+ type: "checks";
688
+ step: string;
689
+ checks?: RoundChecks;
690
+ draft?: boolean;
691
+ retried?: boolean;
692
+ refired?: boolean;
693
+ at: number;
694
+ }
674
695
  | { type: "wait-checks"; step: string; outcome: "event" | "timeout" }
675
696
  | { type: "sleep"; step: string };
676
697
 
@@ -686,14 +707,18 @@ export interface CheckFailure {
686
707
  }
687
708
 
688
709
  /** The check runs at the reviewed head as the round's checks step reads them.
689
- * `expected` names checks the head must still gain — a required check, or the
690
- * repository's approve workflow whose run does not exist yet (issue 2063):
691
- * the base's required contexts not among the reported runs. The table reads
692
- * an expected-but-unreported check exactly as a pending one. */
710
+ * `required` carries the base's whole required-context set and `expected`
711
+ * its unreported subset. The whole set lets the table distinguish no required
712
+ * check launching from an unrelated run that did report; an ordinary missing
713
+ * subset reads exactly as pending (issue 2063). */
693
714
  export interface RoundChecks {
694
715
  total: number;
695
716
  pending: string[];
696
717
  failed: CheckFailure[];
718
+ /** Every check context required by the base. Together with `expected`, this
719
+ * distinguishes an empty required-check launch from an unrelated check
720
+ * that did report (for example the title workflow). */
721
+ required?: string[];
697
722
  expected?: string[];
698
723
  }
699
724
 
@@ -818,6 +843,8 @@ export type UnitEnding =
818
843
  finalReply?: string;
819
844
  /** A changes-requested review posted this round before the stop. */
820
845
  postedReview?: boolean;
846
+ /** The mechanical WIP checkpoint a stopped coding child pushed before teardown. */
847
+ checkpoint?: { branch: string; sha: string };
821
848
  }
822
849
  | {
823
850
  kind: "aborted";
@@ -852,6 +879,7 @@ export type UnitEnding =
852
879
  spent: ShipBudgetSpent;
853
880
  }
854
881
  | { kind: "no_verdict"; round: RoundRef; reviewRounds: number; finalReply?: string }
882
+ | { kind: "idle_expired"; reviewRounds: number }
855
883
  /** Round 0's coding child died on a provider transient — a model-gateway
856
884
  * 5xx, a cut stream, a gateway timeout, past the harness's retry ladder —
857
885
  * with nothing pushed, TWICE: the first such death re-ran the round once
@@ -860,7 +888,14 @@ export type UnitEnding =
860
888
  * `transient` so it reads as a condition beside `checks_failed` and `held`
861
889
  * in the plane's table, never as the child failing on its task. */
862
890
  | { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number }
863
- | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number; cause?: InterruptionCause }
891
+ | {
892
+ kind: "interrupted";
893
+ round: RoundRef;
894
+ runId: string;
895
+ reviewRounds: number;
896
+ cause?: InterruptionCause;
897
+ checkpoint?: { branch: string; sha: string };
898
+ }
864
899
  | { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number }
865
900
  /** The unit idles instead of ending (record 0051): with the resolved
866
901
  * `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
@@ -896,7 +931,8 @@ export type UnitEnding =
896
931
  * the pull request, which no thread reply can produce — but a blocked hold
897
932
  * (issue 2086) waits for exactly the person's word the idle's wake carries,
898
933
  * so it alone idles; `idleEnding` draws that line by cause. */
899
- export type IdleWhy = Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused">;
934
+ export type IdleWhy =
935
+ Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused"> | RenewalWhy;
900
936
 
901
937
  const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
902
938
  "idle",
@@ -904,6 +940,7 @@ const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
904
940
  "already_landed",
905
941
  "merge_ready",
906
942
  "refused",
943
+ "idle_expired",
907
944
  ]);
908
945
 
909
946
  /** What a transition tells the driver beyond the next action: a round boundary
@@ -957,6 +994,12 @@ export interface LeaseSegmentProgress {
957
994
  /** The previous segment's coding run: its write-up and handoff brief the continuation. */
958
995
  previousRunId?: string;
959
996
  previousHandoff?: Handoff;
997
+ /** Words that woke an idle segment, already attributed in arrival order. */
998
+ texts?: string[];
999
+ /** A stopped segment reopens under the remainder of its cut lease. Each
1000
+ * reopen gets a fresh attempt so its Workflow steps cannot replay the
1001
+ * stopped attempt's cached answers. */
1002
+ resume?: { leaseMs: number; attempt: number };
960
1003
  }
961
1004
 
962
1005
  export interface UnitPipelineInput {
@@ -1050,9 +1093,10 @@ type Phase =
1050
1093
  | { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number; waitMs: number; queued?: true }
1051
1094
  /** The round's checks step (record 0055): the check runs at the reviewed head
1052
1095
  * are read after an approve settles, before merge_ready or the merge door.
1053
- * `graced`: a head with no check reported has had its one-chunk grace;
1054
- * `retried`: the one flake re-run is spent; `retry` names the failed checks
1055
- * the next ask re-runs instead of reading. */
1096
+ * `graced`: a head with no required check reported has had its one-chunk
1097
+ * grace; `refired`: the one pull_request close/reopen recovery is spent;
1098
+ * `retried`: the one flake re-run is spent. `retry` and `refire` make the
1099
+ * next ask perform that effect instead of reading. */
1056
1100
  | {
1057
1101
  at: "checks";
1058
1102
  round: RoundRef;
@@ -1063,7 +1107,9 @@ type Phase =
1063
1107
  waitMs: number;
1064
1108
  graced: boolean;
1065
1109
  retried: boolean;
1110
+ refired: boolean;
1066
1111
  retry?: string[];
1112
+ refire?: true;
1067
1113
  }
1068
1114
  /** Waiting on `checks-settled-<head>` between two checks reads, in the merge
1069
1115
  * wait's own chunks — the intake wakes the machine when the head settles. */
@@ -1077,6 +1123,7 @@ type Phase =
1077
1123
  waitMs: number;
1078
1124
  graced: boolean;
1079
1125
  retried: boolean;
1126
+ refired: boolean;
1080
1127
  }
1081
1128
  | { at: "ended" };
1082
1129
 
@@ -1162,7 +1209,8 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
1162
1209
  return nextReview(resumed).state;
1163
1210
  }
1164
1211
 
1165
- const deadlineAt = (s: UnitPipelineState) => s.startedAt + s.input.caps.maxMinutes * MIN;
1212
+ const deadlineAt = (s: UnitPipelineState) =>
1213
+ s.startedAt + (s.input.session?.resume?.leaseMs ?? s.input.caps.maxMinutes * MIN);
1166
1214
  const remainingMs = (s: UnitPipelineState) => deadlineAt(s) - s.clock;
1167
1215
 
1168
1216
  /** The next slice of a wait: a chunk; the remainder when less is left before
@@ -1189,11 +1237,14 @@ function roundCarve(s: UnitPipelineState, round: RoundRef): Carve {
1189
1237
  );
1190
1238
  }
1191
1239
 
1192
- /** The prefix every step of this pipeline is named under: the unit id, and for
1193
- * a renewal's segment the segment too (`U10/s2/…`), so the Workflow's durable
1194
- * step cache never hands segment two the answers of segment one. */
1195
- export const stepPrefixOf = (unit: string, session: LeaseSegmentProgress | undefined): string =>
1196
- session !== undefined && session.segment > 1 ? `${unit}/s${session.segment}` : unit;
1240
+ /** The prefix every step of this pipeline is named under: the unit id, a
1241
+ * renewal's segment (`U10/s2/…`), and a stopped segment's resumed attempt
1242
+ * (`U10/s2/r1/…`). Both transitions need fresh Workflow identities so the
1243
+ * durable step cache cannot answer a new attempt with an older one's result. */
1244
+ export const stepPrefixOf = (unit: string, session: LeaseSegmentProgress | undefined): string => {
1245
+ const segment = session !== undefined && session.segment > 1 ? `${unit}/s${session.segment}` : unit;
1246
+ return session?.resume !== undefined ? `${segment}/r${session.resume.attempt}` : segment;
1247
+ };
1197
1248
  const stepPrefix = (s: UnitPipelineState) => stepPrefixOf(s.input.unit.id, s.input.session);
1198
1249
  const roundStep = (s: UnitPipelineState, round: RoundRef) =>
1199
1250
  `${stepPrefix(s)}/${round.index}/${round.kind}${round.attempt !== undefined ? `/a${round.attempt}` : ""}`;
@@ -1212,6 +1263,7 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1212
1263
  segment: session.segment,
1213
1264
  ...(session.continueFrom !== undefined ? { from: session.continueFrom } : {}),
1214
1265
  ...(session.previousRunId !== undefined ? { previousRunId: session.previousRunId } : {}),
1266
+ ...(session.texts !== undefined ? { texts: session.texts } : {}),
1215
1267
  },
1216
1268
  }
1217
1269
  : {}),
@@ -1311,6 +1363,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1311
1363
  prNumber: p.prNumber,
1312
1364
  headSha: p.headSha,
1313
1365
  ...(p.retry !== undefined ? { retry: p.retry } : {}),
1366
+ ...(p.refire === true ? { refire: true as const } : {}),
1314
1367
  };
1315
1368
  case "checks-wait":
1316
1369
  return {
@@ -1400,7 +1453,7 @@ function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEndin
1400
1453
  const round = "round" in old ? old.round : undefined;
1401
1454
  return {
1402
1455
  kind: "idle",
1403
- why: old.kind,
1456
+ why: old.kind === "aborted" && old.renewal !== undefined ? old.renewal.decision.why : old.kind,
1404
1457
  idled: old,
1405
1458
  renewalsLeft,
1406
1459
  ...(from !== undefined ? { from } : {}),
@@ -1491,6 +1544,16 @@ function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transi
1491
1544
  const stopMode = (status: RunStatus): "soft" | "hard" | undefined =>
1492
1545
  status === "stopped_soft" ? "soft" : status === "stopped_hard" ? "hard" : undefined;
1493
1546
 
1547
+ /** The last mechanical WIP push to this unit branch. Unlike an ordinary push,
1548
+ * it says the child ended before its work was ready for review. */
1549
+ function interruptedCheckpoint(
1550
+ s: UnitPipelineState,
1551
+ pushed: readonly PushedHeadFact[] | undefined,
1552
+ ): { branch: string; sha: string } | undefined {
1553
+ const found = pushed?.filter((p) => p.ref === s.input.unit.branch && p.by === "salvage").at(-1);
1554
+ return found ? { branch: found.ref, sha: found.sha } : undefined;
1555
+ }
1556
+
1494
1557
  /** A coding run's confirmed end: round 0's child, or the run a findings step dispatched. */
1495
1558
  function settleCoding(
1496
1559
  s: UnitPipelineState,
@@ -1518,6 +1581,7 @@ function settleCoding(
1518
1581
  }
1519
1582
  : {}),
1520
1583
  };
1584
+ const checkpoint = interruptedCheckpoint(next, facts.pushed);
1521
1585
  const mode = stopMode(facts.status);
1522
1586
  if (mode !== undefined)
1523
1587
  return end(
@@ -1528,13 +1592,33 @@ function settleCoding(
1528
1592
  round,
1529
1593
  reviewRounds: next.reviewRounds,
1530
1594
  ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
1595
+ ...(checkpoint !== undefined ? { checkpoint } : {}),
1531
1596
  },
1532
1597
  [roundNote(round, "stopped")],
1533
1598
  );
1599
+ // 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) {
1603
+ const cause =
1604
+ facts.failure?.kind === "provider_transient"
1605
+ ? "the model provider's transport retry budget was spent"
1606
+ : "it failed before finishing";
1607
+ return end(
1608
+ next,
1609
+ {
1610
+ kind: "aborted",
1611
+ 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.`,
1612
+ round,
1613
+ reviewRounds: next.reviewRounds,
1614
+ },
1615
+ [roundNote(round, "aborted")],
1616
+ );
1617
+ }
1534
1618
  // A failed coding child no longer aborts outright: the pr-check looks at the
1535
- // branch first — a push before the death is recovered as the round's pull
1536
- // request (agent-ship items 10 and 15), and only a branch with
1537
- // nothing on it ends the unit with the child's own reason.
1619
+ // branch first — an ordinary push before the death is recovered as the
1620
+ // round's pull request, and only a branch with nothing on it ends the unit
1621
+ // with the child's own reason.
1538
1622
  if (facts.status === "failed")
1539
1623
  return {
1540
1624
  state: {
@@ -1718,6 +1802,7 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
1718
1802
  waitMs,
1719
1803
  graced: false,
1720
1804
  retried: false,
1805
+ refired: false,
1721
1806
  },
1722
1807
  },
1723
1808
  notes,
@@ -1737,14 +1822,16 @@ function enterChecks(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNo
1737
1822
  * (`expected`: a required check, or the repository's approve workflow
1738
1823
  * whose run does not exist yet), or GitHub unreadable: the head waits on
1739
1824
  * the settled event and is read again;
1740
- * (—) no check reported at all: one chunk of grace, never more;
1825
+ * (—) no required check reported: one chunk of grace, then the pull_request
1826
+ * event is re-fired once by closing and reopening the pull request;
1741
1827
  * (c) every expected check reported green: the round proceeds with no wait. */
1742
1828
  function checksVerdict(
1743
1829
  checks: RoundChecks | undefined,
1744
- p: { retried: boolean; graced: boolean },
1830
+ p: { retried: boolean; graced: boolean; refired: boolean },
1745
1831
  draft?: boolean,
1746
1832
  ):
1747
1833
  | { kind: "retry"; names: string[] }
1834
+ | { kind: "refire" }
1748
1835
  | { kind: "failed"; failed: CheckFailure[] }
1749
1836
  | { kind: "draft" }
1750
1837
  | { kind: "pending" }
@@ -1764,14 +1851,18 @@ function checksVerdict(
1764
1851
  // person's "ready" changes anything — a red check above still gets its fix
1765
1852
  // round, since that work stands whether or not the pull request is a draft.
1766
1853
  if (draft === true) return { kind: "draft" };
1854
+ const required = checks?.required ?? [];
1855
+ const missing = checks?.expected ?? [];
1856
+ const noRequiredReported = required.length > 0 && missing.length === required.length;
1857
+ // A check launcher may report an unrelated workflow while every required
1858
+ // context is still absent. Give it one normal chunk; if it remains empty,
1859
+ // re-fire pull_request once. This is the same recovery a person performs for
1860
+ // a CI run that exists but started no workflows.
1861
+ if ((checks?.total === 0 || noRequiredReported) && !p.graced) return { kind: "grace" };
1862
+ if (noRequiredReported && !p.refired) return { kind: "refire" };
1767
1863
  // GitHub unreadable answers as pending and is re-read at the chunk's end;
1768
1864
  // an expected check not yet reported (issue 2063) is pending the same way.
1769
- if (checks === undefined || checks.pending.length > 0 || (checks.expected?.length ?? 0) > 0)
1770
- return { kind: "pending" };
1771
- // No check reported at the head: one chunk of grace — the first check starts
1772
- // within minutes where CI exists — then the round proceeds, so a repository
1773
- // without CI costs one chunk per round and never idles (record 0055).
1774
- if (checks.total === 0 && !p.graced) return { kind: "grace" };
1865
+ if (checks === undefined || checks.pending.length > 0 || missing.length > 0) return { kind: "pending" };
1775
1866
  return { kind: "green" };
1776
1867
  }
1777
1868
 
@@ -1783,14 +1874,15 @@ function checksVerdict(
1783
1874
  * is still pending (issue 1991); only a head with no failed check waits a
1784
1875
  * chunk inside the step's ask on what is unreadable or pending and then
1785
1876
  * proceeds — the ending's facts read names what is still pending; a head with
1786
- * no check reported waits one chunk of grace and never more; a green head
1787
- * proceeds with no wait added. */
1877
+ * no required check reported gets one grace chunk and one event re-fire; a
1878
+ * green head proceeds with no wait added. */
1788
1879
  function settleChecks(
1789
1880
  s: UnitPipelineState,
1790
1881
  p: Extract<Phase, { at: "checks" }>,
1791
1882
  checks: RoundChecks | undefined,
1792
1883
  retried?: boolean,
1793
1884
  draft?: boolean,
1885
+ refired?: boolean,
1794
1886
  ): Transition {
1795
1887
  const { round } = p;
1796
1888
  const wait = (over: Partial<Extract<Phase, { at: "checks-wait" }>>): Transition => ({
@@ -1806,11 +1898,23 @@ function settleChecks(
1806
1898
  waitMs: p.waitMs,
1807
1899
  graced: p.graced,
1808
1900
  retried: p.retried,
1901
+ refired: p.refired,
1809
1902
  ...over,
1810
1903
  },
1811
1904
  },
1812
1905
  notes: [],
1813
1906
  });
1907
+ // The pull_request re-fire is an effect ask after a whole grace chunk with
1908
+ // no required check. Whether GitHub accepted it or not, spend the one attempt
1909
+ // and read immediately: that read registers the ordinary bounded wait. A
1910
+ // successful effect adds the round boundary the card renders.
1911
+ if (p.refire === true) {
1912
+ const { refire: _refire, ...read } = p;
1913
+ return {
1914
+ state: { ...s, phase: { ...read, n: p.n + 1, refired: true } },
1915
+ notes: refired === true ? [roundNote(p.round, "checks_restarted")] : [],
1916
+ };
1917
+ }
1814
1918
  // The retry ask. Dispatched: wait for the head to settle, then read again;
1815
1919
  // a second failure is the finding. NOT dispatched (`retried: false` — no
1816
1920
  // re-runnable run behind the checks, or GitHub refused): the one re-run is
@@ -1831,6 +1935,11 @@ function settleChecks(
1831
1935
  state: { ...s, phase: { ...p, n: p.n + 1, retry: verdict.names } },
1832
1936
  notes: [],
1833
1937
  };
1938
+ case "refire":
1939
+ return {
1940
+ state: { ...s, phase: { ...p, n: p.n + 1, refire: true } },
1941
+ notes: [],
1942
+ };
1834
1943
  case "failed": {
1835
1944
  const findings = [...(s.findingsByRound[round.index] ?? []), ...verdict.failed.map(checkFinding)];
1836
1945
  const next: UnitPipelineState = {
@@ -2360,7 +2469,8 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2360
2469
  // The hosted parent's hard stop landed while this child ran (record 0060;
2361
2470
  // issue 1924): the unit ends stopped as the child ends, whatever the
2362
2471
  // child's own status — the runner runs nothing more of it.
2363
- if (r.stopped === true)
2472
+ if (r.stopped === true) {
2473
+ const checkpoint = p.round.kind === "review" ? undefined : interruptedCheckpoint(clocked, r.run.pushed);
2364
2474
  return end(
2365
2475
  clocked,
2366
2476
  {
@@ -2369,14 +2479,33 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2369
2479
  round: p.round,
2370
2480
  reviewRounds: s.reviewRounds,
2371
2481
  ...(r.run.finalReply !== undefined ? { finalReply: r.run.finalReply } : {}),
2482
+ ...(checkpoint !== undefined ? { checkpoint } : {}),
2372
2483
  },
2373
2484
  [roundNote(p.round, "stopped")],
2374
2485
  );
2486
+ }
2375
2487
  if (r.run.status === "interrupted") {
2376
2488
  const cause = r.run.interruption;
2377
- // A dead CODING child may have pushed before the ledger closed it: the
2378
- // pr-check recovers the branch. A review child has nothing on the
2379
- // branch to recover, so its interruption still ends the unit at once.
2489
+ const checkpoint = interruptedCheckpoint(clocked, r.run.pushed);
2490
+ // A mechanical WIP checkpoint is deliberately not opened for review:
2491
+ // the interrupted child never declared it finished. The next attempt
2492
+ // starts from the unit branch at that head.
2493
+ if (p.round.kind !== "review" && checkpoint !== undefined)
2494
+ return end(
2495
+ clocked,
2496
+ {
2497
+ kind: "interrupted",
2498
+ round: p.round,
2499
+ runId: p.runId,
2500
+ reviewRounds: s.reviewRounds,
2501
+ ...(cause !== undefined ? { cause } : {}),
2502
+ checkpoint,
2503
+ },
2504
+ [roundNote(p.round, "aborted")],
2505
+ );
2506
+ // A dead CODING child may have pushed normally before the ledger closed
2507
+ // it: the pr-check recovers that branch. A review child has nothing on
2508
+ // the branch to recover, so its interruption still ends the unit at once.
2380
2509
  if (p.round.kind !== "review")
2381
2510
  return {
2382
2511
  state: {
@@ -2411,7 +2540,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2411
2540
  return settlePrCheck(clocked, p, (ret as Extract<StepReturn, { type: "pr-check" }>).pr);
2412
2541
  case "checks": {
2413
2542
  const r = ret as Extract<StepReturn, { type: "checks" }>;
2414
- return settleChecks(clocked, p, r.checks, r.retried, r.draft);
2543
+ return settleChecks(clocked, p, r.checks, r.retried, r.draft, r.refired);
2415
2544
  }
2416
2545
  case "checks-wait":
2417
2546
  // The event fired or the chunk elapsed either way the head is read again.
@@ -2428,6 +2557,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2428
2557
  waitMs: p.waitMs,
2429
2558
  graced: p.graced,
2430
2559
  retried: p.retried,
2560
+ refired: p.refired,
2431
2561
  },
2432
2562
  },
2433
2563
  notes: [],
@@ -2612,6 +2742,10 @@ export interface MergeReadyFacts {
2612
2742
  * (git's autosquash prefixes `fixup!`, `squash!`, `amend!`): a head
2613
2743
  * carrying one is by its own words not in the ready state. */
2614
2744
  fixupCommits?: string[];
2745
+ /** The base branch takes changes only through a merge queue (issue 2011):
2746
+ * the merge:person path names "queued" in its line — the person's merge
2747
+ * enqueues the pull request and the queue merges it on its own. */
2748
+ baseHasMergeQueue?: boolean;
2615
2749
  }
2616
2750
 
2617
2751
  /** The check runs at one commit: how many, which still run, which failed. */
@@ -2694,6 +2828,10 @@ export function renderUnitReport(
2694
2828
  ? `Findings below ${level}, left as-is: ${skipped.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ")}`
2695
2829
  : undefined;
2696
2830
  const join = (parts: Array<string | undefined>) => parts.filter(Boolean).join("\n\n");
2831
+ const checkpointLine = (checkpoint: { branch: string; sha: string } | undefined) =>
2832
+ checkpoint === undefined
2833
+ ? undefined
2834
+ : `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.`;
2697
2835
  switch (e.kind) {
2698
2836
  case "merged":
2699
2837
  if (e.by === "other")
@@ -2733,7 +2871,12 @@ export function renderUnitReport(
2733
2871
  ? `Already merged: ${e.pr.url} (merge commit \`${facts.merged.sha.slice(0, 7)}\`, merged ${facts.merged.mergedAt}) — auto-merge or a person merged it after the approval; the pipeline merged nothing.`
2734
2872
  : facts?.autoMergeEnabled
2735
2873
  ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
2736
- : "Remaining gate: a person's merge — the pipeline merges only when the plan's `merge` setting says so, and ship never approves.",
2874
+ : facts?.baseHasMergeQueue === true
2875
+ ? // The merge:person path names "queued" in its line (issue 2011):
2876
+ // the base takes changes only through a merge queue, so the
2877
+ // person's merge enqueues — never a direct merge.
2878
+ `Remaining gate: a person's merge, queued — \`${s.input.base}\` takes changes only through a merge queue, so the merge enqueues the pull request (\`gh pr merge --auto\`) and the queue merges it on its own; the pipeline merges only when the plan's \`merge\` setting says so, and ship never approves.`
2879
+ : "Remaining gate: a person's merge — the pipeline merges only when the plan's `merge` setting says so, and ship never approves.",
2737
2880
  ),
2738
2881
  ]
2739
2882
  .filter(Boolean)
@@ -2837,9 +2980,11 @@ export function renderUnitReport(
2837
2980
  aside(reissue),
2838
2981
  ]);
2839
2982
  case "stopped":
2840
- if (!shows(verbosity, "verbose")) return `${e.mode === "hard" ? "⛔" : "⏹"} Stopped.${prLine}`;
2983
+ if (!shows(verbosity, "verbose"))
2984
+ return join([`${e.mode === "hard" ? "⛔" : "⏹"} Stopped.${prLine}`, checkpointLine(e.checkpoint)]);
2841
2985
  return join([
2842
2986
  `${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
2987
+ checkpointLine(e.checkpoint),
2843
2988
  writeUpPointer(
2844
2989
  s,
2845
2990
  e.round.kind,
@@ -2848,7 +2993,7 @@ export function renderUnitReport(
2848
2993
  e.postedReview
2849
2994
  ? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
2850
2995
  : undefined,
2851
- reissue,
2996
+ s.input.idleDays && s.input.idleDays > 0 ? "Next step: reply in this thread to continue." : reissue,
2852
2997
  ]);
2853
2998
  case "aborted":
2854
2999
  if (!shows(verbosity, "verbose")) return `⚠️ Aborted after ${rounds}: ${e.reason}${prLine}`;
@@ -2870,7 +3015,9 @@ export function renderUnitReport(
2870
3015
  if (!shows(verbosity, "verbose")) return "";
2871
3016
  return join([
2872
3017
  writeUpPointer(s, e.round.kind, e.runId),
2873
- `🔁 The unit's budget ran out with the unit unfinished — ${e.line}. A fresh ${s.input.caps.maxMinutes}-minute budget opens in this thread${e.from !== undefined ? ` from \`${e.from.slice(0, 7)}\`` : ""}, with the last run's write-up as its request; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`,
3018
+ s.input.idleDays && s.input.idleDays > 0
3019
+ ? `🔁 The unit's budget ran out with the unit unfinished — ${e.line}. Next step: reply in this thread to continue; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`
3020
+ : `🔁 The unit's budget ran out with the unit unfinished — ${e.line}. A fresh ${s.input.caps.maxMinutes}-minute budget opens in this thread${e.from !== undefined ? ` from \`${e.from.slice(0, 7)}\`` : ""}, with the last run's write-up as its request; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`,
2874
3021
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2875
3022
  ]);
2876
3023
  case "transient":
@@ -2892,7 +3039,9 @@ export function renderUnitReport(
2892
3039
  reissue,
2893
3040
  ]);
2894
3041
  case "interrupted":
2895
- return shipInterruptedNote(prUrl, e.cause);
3042
+ return join([shipInterruptedNote(prUrl, e.cause, (s.input.idleDays ?? 0) > 0), checkpointLine(e.checkpoint)]);
3043
+ case "idle_expired":
3044
+ return "⌛ Idle expired: no reply continued this unit before its idle window closed.";
2896
3045
  case "refused":
2897
3046
  return join([
2898
3047
  `🚫 The ${presetOf(e.round.kind)} child of round ${e.round.index} was refused by the authorize stage (${e.refusal})${e.message ? `: ${e.message}` : ""} — every child is authorized as the requesting user, so the pipeline ends here.`,
@@ -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
 
@@ -82,7 +84,10 @@ export interface RenewalInput {
82
84
  /** Dollars spent under this request so far; null when a run's model had no
83
85
  * price, since a total that left a model's tokens out would understate it. */
84
86
  spendUsd: number | null;
85
- progress: Progress;
87
+ /** Progress normally comes from the segment's row. An idle wake sets it
88
+ * aside because the person's reply is the new work to run; the count, cap
89
+ * and fit still decide whether another lease may open. */
90
+ progress: Progress | "set_aside";
86
91
  /** The pipeline the next segment would run: its fit is re-asserted before every segment. */
87
92
  pipeline: Pipeline;
88
93
  }
@@ -101,7 +106,8 @@ const usd = (n: number): string => `$${n.toFixed(2)}`;
101
106
  * the progress. */
102
107
  export function renewalDecision(input: RenewalInput): RenewalDecision {
103
108
  const renewalsLeft = Math.max(0, input.grant.renewals - input.renewalsSpent);
104
- if (!input.progress.progressed) return { renew: false, why: "no_progress", detail: input.progress.why, renewalsLeft };
109
+ if (input.progress !== "set_aside" && !input.progress.progressed)
110
+ return { renew: false, why: "no_progress", detail: input.progress.why, renewalsLeft };
105
111
  if (renewalsLeft === 0) {
106
112
  const detail =
107
113
  input.grant.renewals === 0
@@ -139,7 +145,7 @@ export function renewalDecision(input: RenewalInput): RenewalDecision {
139
145
  return {
140
146
  renew: true,
141
147
  segment: input.renewalsSpent + 2,
142
- ...(input.progress.by === "push" ? { from: input.progress.sha } : {}),
148
+ ...(input.progress !== "set_aside" && input.progress.by === "push" ? { from: input.progress.sha } : {}),
143
149
  renewalsLeft: renewalsLeft - 1,
144
150
  };
145
151
  }
@@ -149,22 +155,31 @@ export function renewalDecision(input: RenewalInput): RenewalDecision {
149
155
  * names the clause and, when renewals remain, what actually spends one — a
150
156
  * budget that made progress — and the honest recourse (re-issue the request).
151
157
  * It teaches no keyword: the router has none (routing-and-config item 3). */
152
- export function renderRenewal(decision: RenewalDecision, grant: Grant): string {
158
+ export function renderRenewal(
159
+ decision: RenewalDecision,
160
+ grant: Grant,
161
+ options: { idle?: boolean; senders?: readonly string[] } = {},
162
+ ): string {
163
+ const recourse = options.idle ? "reply in this thread to continue" : "re-issue the request to try again";
164
+ const senders = options.senders?.length
165
+ ? `, with ${options.senders.length} message${options.senders.length === 1 ? "" : "s"} from ${options.senders.join(", ")}`
166
+ : "";
153
167
  if (decision.renew)
154
- return `budget renewed, ${decision.segment - 1} of ${grant.renewals}, continues ${decision.from !== undefined ? decision.from.slice(0, 7) : "the branch's head"}`;
168
+ return `budget renewed, ${decision.segment - 1} of ${grant.renewals}, continues ${decision.from !== undefined ? decision.from.slice(0, 7) : "the branch's head"}${senders}`;
155
169
  const holds =
156
170
  decision.renewalsLeft > 0
157
171
  ? `${decision.renewalsLeft} renewal${decision.renewalsLeft === 1 ? "" : "s"} left`
158
172
  : "no renewals left";
173
+ const idleRecourse = (line: string): string => (options.idle ? `${line}; ${recourse}` : line);
159
174
  switch (decision.why) {
160
175
  case "no_progress":
161
176
  return decision.renewalsLeft > 0
162
- ? `no progress on the last budget; ${holds} unspent — a renewal is spent only by a budget that pushed to the unit's branch or moved its write-up; re-issue the request to try again`
177
+ ? `no progress on the last budget; ${holds} unspent — a renewal is spent only by a budget that pushed to the unit's branch or moved its write-up; ${recourse}`
163
178
  : `no progress on the last budget; ${holds}`;
164
179
  case "cost_cap":
165
- return decision.renewalsLeft > 0 ? `${decision.detail}; ${holds} unspent` : decision.detail;
180
+ return idleRecourse(decision.renewalsLeft > 0 ? `${decision.detail}; ${holds} unspent` : decision.detail);
166
181
  case "grant_exhausted":
167
182
  case "unfit":
168
- return decision.detail;
183
+ return idleRecourse(decision.detail);
169
184
  }
170
185
  }