@coreplane/switchboard 1.261.0 → 1.261.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.
@@ -94,18 +94,15 @@ export interface ShipCaps {
94
94
  * named none. */
95
95
  export type InterruptionCause = "bot_restart" | "container_replaced" | "sandbox_fault";
96
96
 
97
- /** What a ship pipeline's thread and card say when its child died under it
98
- * (run-history item 36): the work it did stands on GitHub with nobody driving
99
- * it, so the note names the actual cause (issue 1876 — the site is reached by
100
- * a bot restart, a resident container replacement and a sandbox fault, and
101
- * only the ledger's word picks one; with none the sentence claims no cause),
102
- * the PR when one was opened and the exact re-issue that continues the loop —
103
- * the same entry the preflight's resume-at-review takes (agent-ship item 10).
104
- * Without a PR the task itself is the re-issue: round 0 runs again on the
105
- * pipeline's own deterministic branch. A child whose container was replaced
106
- * resumes from its request by itself (issue 1903) and the pipeline keeps
107
- * waiting — this note is written only when that resume failed or no resume
108
- * applied. */
97
+ /** What a ship pipeline's thread and card say when a run died under it
98
+ * (run-history item 36): the note names the recorded cause and only remote
99
+ * facts that were proved. A recorded pull request is not proof that the
100
+ * stopped run's latest commit reached it. A continuation is named only for
101
+ * an idle unit, whose reply wake is durable; otherwise the note names a new
102
+ * ship run as the next action without promising that a reply resumes work.
103
+ * A run whose container was replaced first resumes from its request by itself
104
+ * (issue 1903); this note is written only when that resume failed or did not
105
+ * apply. */
109
106
  /** The cause an interruption's recorded words name (issue 1876): the ledger
110
107
  * that saw the roll wrote them — the relaunch refusals and the lost-workspace
111
108
  * notes name the replaced container, the sandbox executor's wordings the
@@ -134,23 +131,21 @@ export function interruptionCauseOfWords(words: string): InterruptionCause | und
134
131
  }
135
132
 
136
133
  export function shipInterruptedNote(prUrl?: string, cause?: InterruptionCause, idle = false): string {
137
- const stands = prUrl
138
- ? `Its work stands on GitHub: ${prUrl}.`
139
- : "Whatever it pushed stands on its pipeline branch; no PR was opened yet.";
140
- const continuation = idle
141
- ? "The next reply in this thread continues the unit."
142
- : prUrl
143
- ? `The pipeline kept its task, branch and pull request; the next reply in this thread continues the review loop at ${prUrl}.`
144
- : "The pipeline kept its task and branch; the next reply in this thread starts round 0 again on that branch.";
145
134
  const opening =
146
135
  cause === "container_replaced"
147
- ? "⚠️ 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."
136
+ ? "⚠️ Work stopped before the next review because the repository container was replaced and the run could not continue."
148
137
  : cause === "sandbox_fault"
149
- ? "⚠️ The sandbox running this pipeline's child failed and the child could not resume, so the pipeline stopped."
138
+ ? "⚠️ Work stopped before the next review because the sandbox failed and the run could not continue."
150
139
  : cause === "bot_restart"
151
- ? "⚠️ The bot restarted while this ship pipeline was running, so the pipeline stopped."
152
- : "⚠️ This ship pipeline's child was interrupted and could not resume, so the pipeline stopped.";
153
- return `${opening} ${stands} ${continuation}`;
140
+ ? "⚠️ Work stopped before the next review because Switchboard restarted and the run could not continue."
141
+ : "⚠️ Work stopped before the next review because the run could not continue.";
142
+ const saved = prUrl
143
+ ? `The existing pull request is ${prUrl}; Switchboard did not verify the stopped run's latest commit there.`
144
+ : "No open pull request was verified for the stopped run's latest commit.";
145
+ const next = idle
146
+ ? "The unit remains live; a reply in this thread is recorded as its continuation action."
147
+ : "No review was started. Next action: start ship again after the remote branch and pull request state can be verified.";
148
+ return `${opening} ${saved} ${next}`;
154
149
  }
155
150
 
156
151
  // ---- the plan graph --------------------------------------------------------------------------------
@@ -704,7 +699,17 @@ export type PrCheck =
704
699
  commentId: string;
705
700
  };
706
701
  }
707
- | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string; mergedBy?: string }
702
+ | {
703
+ state: "merged";
704
+ prNumber: number;
705
+ url: string;
706
+ /** The pull request's last source head, distinct from `sha`, the commit
707
+ * the merge put on the base. Required for exact findings reconciliation. */
708
+ headSha?: string;
709
+ sha: string;
710
+ mergedAt: string;
711
+ mergedBy?: string;
712
+ }
708
713
  | { state: "closed"; prNumber: number; url: string; closedBy: string };
709
714
 
710
715
  /** Why a live child is being drained before the unit moves on. The pull
@@ -952,6 +957,15 @@ export type UnitEnding =
952
957
  round?: RoundRef;
953
958
  reviewRounds: number;
954
959
  finalReply?: string;
960
+ /** The mechanical WIP checkpoint recorded before this terminal abort. */
961
+ checkpoint?: { branch: string; sha: string };
962
+ /** A completed findings run could not safely advance to review. These
963
+ * causes render their own saved-work, failure and next-action facts,
964
+ * without the generic continuation promise. */
965
+ findingsStop?: "incomplete_outputs" | "unfinished" | "missing_remote" | "head_mismatch" | "remote_unreadable";
966
+ observedHead?: string;
967
+ remoteHead?: string;
968
+ missingOutputs?: string[];
955
969
  /** A round-0 end without a pull request that was judged for renewal and
956
970
  * refused: the decision and the card's sentence (decision 0046). */
957
971
  renewal?: { decision: Extract<RenewalDecision, { renew: false }>; line: string };
@@ -1000,8 +1014,8 @@ export type UnitEnding =
1000
1014
  /** The unit idles instead of ending (record 0051): with the resolved
1001
1015
  * `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
1002
1016
  * the four ended ones (`merged`, `already_landed`, `merge_ready`,
1003
- * `refused`) — in this ending: the old kind as `why`, the old kind's
1004
- * report unchanged, and what a continuation needs (the renewals the grant
1017
+ * `refused`) — in this ending: the old kind as `why`, its outcome report
1018
+ * with the terminal restart replaced by the durable wake action, and what a continuation needs (the renewals the grant
1005
1019
  * still holds — unspent: the wake spends one, this plan's fifth unit — the
1006
1020
  * head to continue from, the last coding child's run, the spend and its
1007
1021
  * handoff). `runId` is absent when the pipeline capped before any coding
@@ -1212,10 +1226,14 @@ type Phase =
1212
1226
  childPushed?: PushedHeadFact[];
1213
1227
  childLeaseStartedAt?: number;
1214
1228
  childHandoff?: Handoff;
1215
- /** Open or recover a pull request from the branch. A completed child
1216
- * whose same-head salvage merely republished ready work needs this path
1217
- * without being classified as dead. */
1229
+ /** Open or recover a pull request from the branch. A completed round-zero
1230
+ * child whose same-head salvage merely republished ready work needs this
1231
+ * path without being classified as dead. */
1218
1232
  recover?: true;
1233
+ /** A completed findings run supplied every typed result. The next step
1234
+ * must independently prove an open branch and pull request at exactly
1235
+ * `childHead`; neither a push record nor this flag is remote evidence. */
1236
+ findingsReady?: true;
1219
1237
  /** The coding child died (`failed` or `interrupted`) after it may have
1220
1238
  * pushed: the pr-check recovers a pushed branch by opening its pull
1221
1239
  * request; with nothing pushed the unit ends with the child's own reason. */
@@ -1863,6 +1881,13 @@ function interruptedCheckpoint(
1863
1881
  return { branch: found.ref, sha: found.sha };
1864
1882
  }
1865
1883
 
1884
+ /** A full commit id for findings completion and remote reconciliation. A
1885
+ * prefix can identify a commit for display, but cannot prove exact equality. */
1886
+ function fullHead(value: unknown): string | undefined {
1887
+ const head = normalizeHead(value);
1888
+ return head?.length === 40 ? head : undefined;
1889
+ }
1890
+
1866
1891
  /** A coding run's confirmed end: round 0's child, or the run a findings step dispatched. */
1867
1892
  function settleCoding(
1868
1893
  s: UnitPipelineState,
@@ -1890,8 +1915,8 @@ function settleCoding(
1890
1915
  }
1891
1916
  : {}),
1892
1917
  };
1893
- const readySalvage = completedSameHeadSalvage(next, facts.pushed, facts);
1894
- const checkpoint = interruptedCheckpoint(next, facts.pushed, facts);
1918
+ const readySalvage = round.kind === "coding" && completedSameHeadSalvage(next, facts.pushed, facts);
1919
+ const checkpoint = interruptedCheckpoint(next, facts.pushed, round.kind === "findings" ? undefined : facts);
1895
1920
  const mode = stopMode(facts.status);
1896
1921
  if (mode !== undefined)
1897
1922
  return end(
@@ -1906,6 +1931,64 @@ function settleCoding(
1906
1931
  },
1907
1932
  [roundNote(round, "stopped")],
1908
1933
  );
1934
+ // Findings rounds have their own typed completion contract. They do not
1935
+ // require round zero's handoff, but they do require a completed run, one
1936
+ // matching disposition for every finding, an updated PR description and a
1937
+ // final observed head. Only then may a fresh remote read decide readiness.
1938
+ if (round.kind === "findings" && facts.status === "completed") {
1939
+ const issued = next.findingsByRound[round.index] ?? [];
1940
+ const matched = matchDispositions(issued, facts.dispositions ?? []).matched;
1941
+ const missingOutputs = issued
1942
+ .filter((finding) => !matched.some((disposition) => disposition.findingId === finding.id))
1943
+ .map((finding) => `missing disposition for ${finding.id}`);
1944
+ if (facts.description !== true) missingOutputs.push("missing updated pull request description");
1945
+ const observedHead = fullHead(facts.headSha);
1946
+ if (observedHead === undefined) missingOutputs.push("missing final observed commit");
1947
+ if (missingOutputs.length > 0)
1948
+ return end(
1949
+ next,
1950
+ {
1951
+ kind: "aborted",
1952
+ reason: "The completed findings work did not record every required result.",
1953
+ findingsStop: "incomplete_outputs",
1954
+ ...(observedHead !== undefined ? { observedHead } : {}),
1955
+ missingOutputs,
1956
+ round,
1957
+ reviewRounds: next.reviewRounds,
1958
+ },
1959
+ [roundNote(round, "aborted")],
1960
+ );
1961
+ return {
1962
+ state: {
1963
+ ...next,
1964
+ phase: {
1965
+ at: "pr-check",
1966
+ round,
1967
+ runId,
1968
+ childHead: observedHead,
1969
+ findingsReady: true,
1970
+ ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
1971
+ },
1972
+ },
1973
+ notes: [],
1974
+ };
1975
+ }
1976
+ // A findings run must be completed before any remote state can make it
1977
+ // review-ready. A failed run stays resumable even when its record says a
1978
+ // push was attempted; no remote read upgrades an unfinished result.
1979
+ if (round.kind === "findings")
1980
+ return end(
1981
+ next,
1982
+ {
1983
+ kind: "aborted",
1984
+ reason: "The findings work did not complete, so no review was started.",
1985
+ findingsStop: "unfinished",
1986
+ ...(fullHead(facts.headSha) !== undefined ? { observedHead: fullHead(facts.headSha) } : {}),
1987
+ round,
1988
+ reviewRounds: next.reviewRounds,
1989
+ },
1990
+ [roundNote(round, "aborted")],
1991
+ );
1909
1992
  // A mechanical WIP push means the child did not declare the work ready for
1910
1993
  // review, whatever terminal status its own answer produced. Keep the unit
1911
1994
  // branch as the resumption point and abort this attempt; an ordinary push
@@ -1921,7 +2004,8 @@ function settleCoding(
1921
2004
  next,
1922
2005
  {
1923
2006
  kind: "aborted",
1924
- reason: `⚠️ The coding child ended because ${cause}; the branch carries the interrupted work at \`${checkpoint.sha.slice(0, 7)}\` on \`${checkpoint.branch}\`. The next reply in this thread resumes from that checkpoint instead of starting over.`,
2007
+ reason: `⚠️ The coding child ended because ${cause}.`,
2008
+ checkpoint,
1925
2009
  round,
1926
2010
  reviewRounds: next.reviewRounds,
1927
2011
  },
@@ -2457,9 +2541,27 @@ function childSupersession(
2457
2541
  return undefined;
2458
2542
  }
2459
2543
 
2460
- function finishSupersededChild(s: UnitPipelineState, phase: Extract<Phase, { at: "superseded-read" }>): Transition {
2544
+ function finishSupersededChild(
2545
+ s: UnitPipelineState,
2546
+ phase: Extract<Phase, { at: "superseded-read" }>,
2547
+ facts: Extract<ChildFacts, { finished: true }>,
2548
+ ): Transition {
2461
2549
  const supersession = phase.supersession;
2462
- if (supersession.reason === "merged") return foundMerged(s, supersession.pullRequest);
2550
+ if (supersession.reason === "merged") {
2551
+ // A findings child can observe a merge before its own record is read. Fold
2552
+ // that record through the same typed-output gate as an ordinary completion,
2553
+ // then reconcile the merged PR's source head with the completed child head.
2554
+ // Neither the earlier terminal observation nor steering the child proves
2555
+ // that its final commit reached the pull request before the merge.
2556
+ if (phase.round.kind === "findings") {
2557
+ const settled = settleCoding(s, phase.round, phase.runId, facts);
2558
+ const ready = settled.state.phase;
2559
+ if (ready.at === "pr-check" && ready.findingsReady === true)
2560
+ return settlePrCheck(settled.state, ready, supersession.pullRequest);
2561
+ return settled;
2562
+ }
2563
+ return foundMerged(s, supersession.pullRequest);
2564
+ }
2463
2565
  if (supersession.reason === "closed") return foundClosed(s, supersession.pullRequest);
2464
2566
  if (supersession.reason === "branch_deleted") return missingHeadBranch(s, supersession.pullRequest);
2465
2567
  return {
@@ -2507,6 +2609,87 @@ export function reconcileTerminalPr(s: UnitPipelineState, pr: PrCheck): UnitPipe
2507
2609
  /** The pull request heading the branch after round 0 or a findings step. */
2508
2610
  function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-check" }>, pr: PrCheck): Transition {
2509
2611
  const { round } = phase;
2612
+ // A completed findings run is not ready because it says it pushed. It is
2613
+ // ready only when this independent read sees the adopted pull request at the
2614
+ // exact full observed source head (and, while open, its live branch). Every
2615
+ // other remote fact is a resumable stop with no review or merged success.
2616
+ if (phase.findingsReady === true) {
2617
+ const observedHead = fullHead(phase.childHead)!;
2618
+ const withPr = pr.state !== "none" ? { ...s, pr: { number: pr.prNumber, url: pr.url } } : s;
2619
+ if (pr.state === "merged") {
2620
+ const remoteHead = fullHead(pr.headSha);
2621
+ if (remoteHead === undefined)
2622
+ return end(
2623
+ withPr,
2624
+ {
2625
+ kind: "aborted",
2626
+ reason: "The merged pull request's exact source head could not be verified.",
2627
+ findingsStop: "remote_unreadable",
2628
+ observedHead,
2629
+ round,
2630
+ reviewRounds: withPr.reviewRounds,
2631
+ },
2632
+ [roundNote(round, "aborted")],
2633
+ );
2634
+ if (remoteHead !== observedHead)
2635
+ return end(
2636
+ withPr,
2637
+ {
2638
+ kind: "aborted",
2639
+ reason: "The merged pull request does not hold the completed changes.",
2640
+ findingsStop: "head_mismatch",
2641
+ observedHead,
2642
+ remoteHead,
2643
+ round,
2644
+ reviewRounds: withPr.reviewRounds,
2645
+ },
2646
+ [roundNote(round, "aborted")],
2647
+ );
2648
+ return foundMerged(withPr, pr, [roundNote(round, "completed")]);
2649
+ }
2650
+ if (pr.state !== "open" || pr.headBranchExists === false)
2651
+ return end(
2652
+ withPr,
2653
+ {
2654
+ kind: "aborted",
2655
+ reason: "No open pull request and branch could be verified for the completed changes.",
2656
+ findingsStop: "missing_remote",
2657
+ observedHead,
2658
+ round,
2659
+ reviewRounds: withPr.reviewRounds,
2660
+ },
2661
+ [roundNote(round, "aborted")],
2662
+ );
2663
+ const remoteHead = fullHead(pr.headSha);
2664
+ if (pr.headBranchExists !== true || remoteHead === undefined)
2665
+ return end(
2666
+ withPr,
2667
+ {
2668
+ kind: "aborted",
2669
+ reason: "The open pull request's branch and exact head could not be verified.",
2670
+ findingsStop: "remote_unreadable",
2671
+ observedHead,
2672
+ round,
2673
+ reviewRounds: withPr.reviewRounds,
2674
+ },
2675
+ [roundNote(round, "aborted")],
2676
+ );
2677
+ if (remoteHead !== observedHead)
2678
+ return end(
2679
+ withPr,
2680
+ {
2681
+ kind: "aborted",
2682
+ reason: "The pull request does not hold the completed changes.",
2683
+ findingsStop: "head_mismatch",
2684
+ observedHead,
2685
+ remoteHead,
2686
+ round,
2687
+ reviewRounds: withPr.reviewRounds,
2688
+ },
2689
+ [roundNote(round, "aborted")],
2690
+ );
2691
+ return roundOnOpenPr(withPr, phase, pr);
2692
+ }
2510
2693
  // The merge landed during the round: the child found nothing left to ship
2511
2694
  // (or shipped into a pull request a person merged under it). The round
2512
2695
  // completed without a pull request of its own, and the unit is done.
@@ -3000,7 +3183,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
3000
3183
  supersession,
3001
3184
  finishedObserved: p.finishedObserved,
3002
3185
  };
3003
- return finishSupersededChild(clocked, terminalPhase);
3186
+ return finishSupersededChild(clocked, terminalPhase, r.run);
3004
3187
  }
3005
3188
  return {
3006
3189
  state: {
@@ -3066,10 +3249,26 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
3066
3249
  },
3067
3250
  [roundNote(p.round, "aborted")],
3068
3251
  );
3069
- // A dead CODING child may have pushed normally before the ledger closed
3070
- // it: the pr-check recovers that branch. A review child has nothing on
3071
- // the branch to recover, so its interruption still ends the unit at once.
3072
- if (p.round.kind !== "review")
3252
+ // A findings run must complete before another review. An interrupted
3253
+ // one remains resumable even if it recorded an attempted push; round
3254
+ // zero keeps its established recovery check.
3255
+ if (p.round.kind === "findings")
3256
+ return end(
3257
+ clocked,
3258
+ {
3259
+ kind: "interrupted",
3260
+ round: p.round,
3261
+ runId: p.runId,
3262
+ reviewRounds: s.reviewRounds,
3263
+ ...(cause !== undefined ? { cause } : {}),
3264
+ },
3265
+ [roundNote(p.round, "aborted")],
3266
+ );
3267
+ // A dead round-zero coding child may have pushed normally before the
3268
+ // ledger closed it: the pr-check recovers that branch. A review child
3269
+ // has nothing on the branch to recover, so its interruption ends the
3270
+ // unit at once.
3271
+ if (p.round.kind === "coding")
3073
3272
  return {
3074
3273
  state: {
3075
3274
  ...clocked,
@@ -3138,7 +3337,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
3138
3337
  }
3139
3338
  case "superseded-read": {
3140
3339
  const r = ret as Extract<StepReturn, { type: "read-record" }>;
3141
- if (r.run.finished) return finishSupersededChild(clocked, p);
3340
+ if (r.run.finished) return finishSupersededChild(clocked, p, r.run);
3142
3341
  return {
3143
3342
  state: {
3144
3343
  ...clocked,
@@ -3503,10 +3702,21 @@ export function renderUnitReport(
3503
3702
  * after 2 review rounds: <url>`, `Held: <the human-gated row>`, `Stopped`,
3504
3703
  * `Round cap reached: <counts>` — and the verdict, the findings left below
3505
3704
  * the gate, the declined ones, the remaining-gate sentence, the level in
3506
- * force, the grant, the write-up pointer, the budget split, the re-issue
3507
- * prompt and a segment boundary are `verbose` asides; the full detail
3508
- * stays on the pull request, where the round routes put it. */
3705
+ * force, the grant, the write-up pointer, the budget split, the restart or
3706
+ * durable-wake action and a segment boundary are `verbose` asides; the full
3707
+ * detail stays on the pull request, where the round routes put it. */
3509
3708
  verbosity: Verbosity = "verbose",
3709
+ ): string {
3710
+ return renderUnitReportWithWake(s, facts, verbosity, false);
3711
+ }
3712
+
3713
+ /** The internal idle render is the only caller allowed to claim an indexed
3714
+ * wake will durably record a reply as the continuation action. */
3715
+ function renderUnitReportWithWake(
3716
+ s: UnitPipelineState,
3717
+ facts: MergeReadyFacts | undefined,
3718
+ verbosity: Verbosity,
3719
+ durableWake: boolean,
3510
3720
  ): string {
3511
3721
  const e = s.ending;
3512
3722
  if (!e) return "";
@@ -3514,12 +3724,20 @@ export function renderUnitReport(
3514
3724
  const rounds = `${e.reviewRounds} review round${e.reviewRounds === 1 ? "" : "s"}`;
3515
3725
  const prUrl = s.pr?.url;
3516
3726
  const prLine = prUrl ? ` PR: ${prUrl}` : "";
3517
- // The re-issue line keys on the instance's mark, never on who merges: a
3518
- // generated plan is re-issued with the request's own text, a seeded one by
3519
- // its plan path — and a seeded plan can be a person's merge too.
3520
- const continuation = s.input.generated
3521
- ? `The pipeline kept this unit's task and branch; the next reply in this thread continues it${prUrl ? ` from the open pull request (${prUrl})` : " from the branch head"}.`
3522
- : `The unit's dependents in this plan stay blocked; the next run of this plan recognizes the unit's branch and pull request.`;
3727
+ const nextAction = (checkpoint?: { branch: string; sha: string }): string => {
3728
+ if (durableWake)
3729
+ return `The unit remains live; a reply in this thread is recorded as its continuation action${prUrl ? ` from the open pull request (${prUrl})` : " from the saved branch state"}.`;
3730
+ const reconcile = checkpoint
3731
+ ? `verify \`${checkpoint.sha.slice(0, 7)}\` is on \`${checkpoint.branch}\`, then reconcile the remote branch and pull request if needed`
3732
+ : prUrl
3733
+ ? `verify the remote branch and pull request (${prUrl}) agree, then reconcile them if needed`
3734
+ : "verify the remote branch and reconcile any saved checkpoint if needed";
3735
+ const restart = s.input.generated
3736
+ ? `then start ship again with ${prUrl ?? "the original task"}`
3737
+ : "then start this plan again";
3738
+ const blocked = s.input.generated ? "" : "The unit's dependents in this plan stay blocked. ";
3739
+ return `${blocked}Next action: ${reconcile}, ${restart}.`;
3740
+ };
3523
3741
  const declined = [...(s.dispositionsByRound[e.reviewRounds - 1] ?? [])].filter((d) => d.disposition === "declined");
3524
3742
  const declinedLine = `Declined findings: ${declined.length > 0 ? declined.map((d) => `${d.findingId}${d.note ? ` — ${d.note}` : ""}`).join("; ") : "none"}`;
3525
3743
  const verdictLine = `Verdict: LGTM${s.lastVerdictSummary ? ` — ${s.lastVerdictSummary}` : ""}`;
@@ -3544,7 +3762,7 @@ export function renderUnitReport(
3544
3762
  const checkpointLine = (checkpoint: { branch: string; sha: string } | undefined) =>
3545
3763
  checkpoint === undefined
3546
3764
  ? undefined
3547
- : `The branch carries the interrupted work at \`${checkpoint.sha.slice(0, 7)}\` on \`${checkpoint.branch}\`; the next reply in this thread resumes from it instead of starting over.`;
3765
+ : `The run recorded \`${checkpoint.sha.slice(0, 7)}\` on \`${checkpoint.branch}\` as pushed, but Switchboard did not independently verify that commit on the remote branch or an open pull request.`;
3548
3766
  switch (e.kind) {
3549
3767
  case "merged":
3550
3768
  if (e.by === "other")
@@ -3605,9 +3823,9 @@ export function renderUnitReport(
3605
3823
  if (e.cause === "draft") {
3606
3824
  const link = e.pr !== undefined ? ` — ${e.pr.url}` : "";
3607
3825
  if (!shows(verbosity, "verbose")) return `⏸️ Held: draft — GitHub still marks the pull request as draft${link}`;
3608
- const draftContinuation = s.input.generated
3609
- ? `The pipeline kept the task, branch and pull request${e.pr !== undefined ? ` (${e.pr.url})` : ""}; after GitHub marks it ready, the next reply in this thread resumes at the review round.`
3610
- : `The unit stays held while GitHub marks the pull request as a draft; ${continuation.charAt(0).toLowerCase()}${continuation.slice(1)}`;
3826
+ const draftContinuation = `Next action: mark the pull request ready in GitHub, then ${nextAction()
3827
+ .replace(/^The unit's dependents in this plan stay blocked\. Next action: /, "")
3828
+ .replace(/^Next action: /, "")}`;
3611
3829
  return join([
3612
3830
  `⏸️ Held after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — the pull request is a draft, so nothing can merge and no fix round would change anything.`,
3613
3831
  draftContinuation,
@@ -3623,7 +3841,9 @@ export function renderUnitReport(
3623
3841
  return join([
3624
3842
  `⏸️ Held after ${rounds}: the coding child of round ${e.round.index} concluded its round blocked instead of opening a pull request — ${e.reason}.${prLine}`,
3625
3843
  writeUpPointer(s, e.round.kind, e.runId),
3626
- `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). The child's answer remains the thread's open question; the next reply is treated as its answer. ${continuation}`,
3844
+ durableWake
3845
+ ? `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). The child's answer remains the thread's open question. ${nextAction()}`
3846
+ : `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). The child's answer remains unresolved. ${nextAction()}`,
3627
3847
  ]);
3628
3848
  }
3629
3849
  // A human-gated row is a question to a person. The enclosing idle keeps
@@ -3651,8 +3871,8 @@ export function renderUnitReport(
3651
3871
  return join([
3652
3872
  `⚠️ The review approved ${e.pr.url} but the pipeline did not merge it: ${e.reason}. A person decides what becomes of the pull request.`,
3653
3873
  s.input.generated
3654
- ? continuation
3655
- : "The approved work remains on the branch. This is a bug: the pipeline has no automatic recovery for this refused merge, so the unit's dependents remain blocked.",
3874
+ ? `Next action: merge or close the pull request, then start ship again with ${e.pr.url} so the result is reconciled.`
3875
+ : "The approved work remains on the branch. This is a bug: the pipeline has no automatic recovery for this refused merge. Next action: merge or close the pull request, then start this plan again so its dependents can be reconciled.",
3656
3876
  ]);
3657
3877
  case "round_cap": {
3658
3878
  // The quiet copy counts the last review's open findings, only the
@@ -3671,7 +3891,7 @@ export function renderUnitReport(
3671
3891
  failedChecks.length > 0
3672
3892
  ? `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — the review of round ${e.reviewRounds} approved, but ${failedChecks.map((f) => `\`${f.id}\``).join(", ")} failed at the approved head and no fix round remains.${prLine}`
3673
3893
  : `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — no approval after ${rounds}.${prLine}`;
3674
- return join([headline, splitReport(s), continuation]);
3894
+ return join([headline, splitReport(s), nextAction()]);
3675
3895
  }
3676
3896
  case "wall_clock_cap":
3677
3897
  if (!shows(verbosity, "verbose")) return `🧢 Out of budget — no approval after ${rounds}.${prLine}`;
@@ -3679,13 +3899,13 @@ export function renderUnitReport(
3679
3899
  `🧢 Ship stopped at a cap: the remaining pipeline time (~${Math.max(0, Math.round(e.remainingMs / MIN))} min of the ${s.input.caps.maxMinutes}-minute budget) cannot hold another round${e.refused ? ` (the ${e.refused.round} round would get ${e.refused.minutes} min, under its floor of ${e.refused.floor})` : ""} — no approval after ${rounds}.${prLine}`,
3680
3900
  budgetSplitLine(e.spent, s.input.caps.maxMinutes),
3681
3901
  splitReport(s),
3682
- continuation,
3902
+ nextAction(),
3683
3903
  ]);
3684
3904
  case "review_pending":
3685
3905
  return join([
3686
3906
  `⏳ Review pending: the coding child shipped ${e.pr.url}${e.headSha !== undefined ? ` (head \`${e.headSha.slice(0, 7)}\`)` : ""} but the remaining pipeline time cannot hold the review round — the work stands, only the review is missing. The next pipeline starts at the review round while the pull request still heads at the child's own last push.`,
3687
3907
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
3688
- aside(continuation),
3908
+ aside(nextAction()),
3689
3909
  ]);
3690
3910
  case "stopped":
3691
3911
  if (!shows(verbosity, "verbose"))
@@ -3701,12 +3921,50 @@ export function renderUnitReport(
3701
3921
  e.postedReview
3702
3922
  ? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
3703
3923
  : undefined,
3704
- continuation,
3924
+ nextAction(e.checkpoint),
3705
3925
  ]);
3706
3926
  case "aborted":
3927
+ if (e.findingsStop !== undefined) {
3928
+ const observed = e.observedHead?.slice(0, 7);
3929
+ const branch = s.input.unit.branch;
3930
+ if (e.findingsStop === "incomplete_outputs")
3931
+ return join([
3932
+ `⚠️ Review did not restart: the completed findings work did not record every required result (${(e.missingOutputs ?? []).join("; ")}).`,
3933
+ observed !== undefined
3934
+ ? `Saved-work fact: The run ended at \`${observed}\`, but remote state was not checked; Switchboard cannot claim that commit is on the branch or pull request.`
3935
+ : "Saved-work fact: The run did not record a final commit, and remote state was not checked.",
3936
+ "Next action: a new findings run must record every disposition, the updated pull request description and its final commit before remote reconciliation.",
3937
+ ]);
3938
+ if (e.findingsStop === "unfinished")
3939
+ return join([
3940
+ "⚠️ Review did not restart: the findings work did not complete.",
3941
+ observed !== undefined
3942
+ ? `Saved-work fact: the stopped run recorded \`${observed}\`, but Switchboard did not verify it on the remote branch or pull request.`
3943
+ : "Saved-work fact: no completed commit was recorded, and remote state was not checked.",
3944
+ "Next action: start a new findings run; review can begin only after it completes every required result and Switchboard verifies its exact remote head.",
3945
+ ]);
3946
+ if (e.findingsStop === "missing_remote")
3947
+ return join([
3948
+ `⚠️ Review did not restart: The completed changes ended at \`${observed}\`, but no open pull request was found for \`${branch}\`.`,
3949
+ `Saved-work fact: The run recorded completed commit \`${observed}\`; Switchboard did not verify that commit on the remote branch or a pull request.`,
3950
+ "Next action: reconcile the branch and open pull request, then start ship again so it can verify the exact head before review.",
3951
+ ]);
3952
+ if (e.findingsStop === "head_mismatch")
3953
+ return join([
3954
+ `⚠️ Review did not restart: The completed changes ended at \`${observed}\`, but the pull request is at \`${e.remoteHead?.slice(0, 7)}\` (${s.pr?.url}).`,
3955
+ "Saved-work fact: the pull request's current commit is verified, but the completed changes are not verified there. No review ran on either commit.",
3956
+ "Next action: reconcile the pull request to the completed commit, then start ship again so it can verify that exact head before review.",
3957
+ ]);
3958
+ return join([
3959
+ `⚠️ Review did not restart: The pull request exists (${s.pr?.url}), but its exact head could not be verified.`,
3960
+ `Saved-work fact: the run recorded completed commit \`${observed}\`; Switchboard did not start review for \`${observed}\` and cannot claim the pull request contains it.`,
3961
+ "Next action: retry ship when the pull request state is readable so it can verify the exact head before review.",
3962
+ ]);
3963
+ }
3707
3964
  if (!shows(verbosity, "verbose")) return `⚠️ Aborted after ${rounds}: ${e.reason}${prLine}`;
3708
3965
  return join([
3709
3966
  e.reason,
3967
+ checkpointLine(e.checkpoint),
3710
3968
  writeUpPointer(
3711
3969
  s,
3712
3970
  e.round?.kind ?? "coding",
@@ -3714,7 +3972,7 @@ export function renderUnitReport(
3714
3972
  ),
3715
3973
  e.renewal !== undefined ? `🔁 Not renewed: ${e.renewal.line}.` : undefined,
3716
3974
  `⚠️ Ship aborted after ${rounds}.`,
3717
- continuation,
3975
+ nextAction(e.checkpoint),
3718
3976
  ]);
3719
3977
  case "continued":
3720
3978
  // A segment boundary is the runner continuing — an acknowledgement, verbose
@@ -3723,8 +3981,8 @@ export function renderUnitReport(
3723
3981
  if (!shows(verbosity, "verbose")) return "";
3724
3982
  return join([
3725
3983
  writeUpPointer(s, e.round.kind, e.runId),
3726
- s.input.idleDays && s.input.idleDays > 0
3727
- ? `🔁 The unit's budget ran out with the unit unfinished — ${e.line}. The next reply in this thread continues it; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`
3984
+ durableWake
3985
+ ? `🔁 The unit's budget ran out with the unit unfinished — ${e.line}. A reply in this thread is recorded as its continuation action; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`
3728
3986
  : `🔁 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` : ""}.`,
3729
3987
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
3730
3988
  ]);
@@ -3735,7 +3993,7 @@ export function renderUnitReport(
3735
3993
  `⚠️ The coding child of round ${e.round.index} (run ${e.runId}) died on a provider transient — a model-gateway 5xx, a cut stream or a gateway timeout past the harness's retry ladder — with nothing pushed, after the round was already re-run once for the same reason. The task itself was never the problem.`,
3736
3994
  writeUpPointer(s, e.round.kind, s.lastCodingRunId),
3737
3995
  `⚠️ This is a bug: ship ended after ${rounds} because its automatic provider retry was spent.`,
3738
- continuation,
3996
+ nextAction(),
3739
3997
  ]);
3740
3998
  case "no_verdict":
3741
3999
  if (!shows(verbosity, "verbose"))
@@ -3744,20 +4002,20 @@ export function renderUnitReport(
3744
4002
  `⚠️ Review round ${e.round.index} ended without a submitted verdict (budget, refusal, or stop) — ship never converts that into a request for changes, so no findings step ran.`,
3745
4003
  writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index]),
3746
4004
  `⚠️ Ship aborted after ${rounds}.`,
3747
- continuation,
4005
+ nextAction(),
3748
4006
  ]);
3749
4007
  case "interrupted":
3750
- return join([shipInterruptedNote(prUrl, e.cause, (s.input.idleDays ?? 0) > 0), checkpointLine(e.checkpoint)]);
4008
+ return join([shipInterruptedNote(prUrl, e.cause, durableWake), checkpointLine(e.checkpoint)]);
3751
4009
  case "idle_expired":
3752
4010
  return "⌛ Idle expired: no reply continued this unit before its idle window closed.";
3753
4011
  case "refused":
3754
4012
  return join([
3755
4013
  `🚫 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.`,
3756
- aside(continuation),
4014
+ aside(nextAction()),
3757
4015
  ]);
3758
4016
  case "idle":
3759
- // The old kind's sentence at this copy's level — the report is unchanged
3760
- // by the idle at every verbosity (record 0051).
3761
- return renderUnitReport({ ...s, ending: e.idled }, facts, verbosity);
4017
+ // Keep the old kind's outcome at this copy's level, but render the
4018
+ // indexed wake's durable reply action instead of a terminal restart.
4019
+ return renderUnitReportWithWake({ ...s, ending: e.idled }, facts, verbosity, true);
3762
4020
  }
3763
4021
  }
@@ -162,7 +162,7 @@ export function renderRenewal(
162
162
  ): string {
163
163
  const recourse = options.idle
164
164
  ? "the next reply in this thread continues the unit"
165
- : "the next reply in this thread continues the original task";
165
+ : "start ship again with the original task";
166
166
  const senders = options.senders?.length
167
167
  ? `, with ${options.senders.length} message${options.senders.length === 1 ? "" : "s"} from ${options.senders.join(", ")}`
168
168
  : "";