@coreplane/switchboard 1.261.0 → 1.261.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 --------------------------------------------------------------------------------
@@ -507,6 +502,8 @@ export type Brief =
507
502
  kind: "findings";
508
503
  unit: string;
509
504
  pr: number;
505
+ /** The exact reviewed head the fix round may replace. */
506
+ headSha?: string;
510
507
  /** The review run normally supplies the findings. An adopted attempt
511
508
  * can recover them from the last posted verdict marker instead. */
512
509
  reviewRunId?: string;
@@ -704,7 +701,17 @@ export type PrCheck =
704
701
  commentId: string;
705
702
  };
706
703
  }
707
- | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string; mergedBy?: string }
704
+ | {
705
+ state: "merged";
706
+ prNumber: number;
707
+ url: string;
708
+ /** The pull request's last source head, distinct from `sha`, the commit
709
+ * the merge put on the base. Required for exact findings reconciliation. */
710
+ headSha?: string;
711
+ sha: string;
712
+ mergedAt: string;
713
+ mergedBy?: string;
714
+ }
708
715
  | { state: "closed"; prNumber: number; url: string; closedBy: string };
709
716
 
710
717
  /** Why a live child is being drained before the unit moves on. The pull
@@ -952,6 +959,15 @@ export type UnitEnding =
952
959
  round?: RoundRef;
953
960
  reviewRounds: number;
954
961
  finalReply?: string;
962
+ /** The mechanical WIP checkpoint recorded before this terminal abort. */
963
+ checkpoint?: { branch: string; sha: string };
964
+ /** A completed findings run could not safely advance to review. These
965
+ * causes render their own saved-work, failure and next-action facts,
966
+ * without the generic continuation promise. */
967
+ findingsStop?: "incomplete_outputs" | "unfinished" | "missing_remote" | "head_mismatch" | "remote_unreadable";
968
+ observedHead?: string;
969
+ remoteHead?: string;
970
+ missingOutputs?: string[];
955
971
  /** A round-0 end without a pull request that was judged for renewal and
956
972
  * refused: the decision and the card's sentence (decision 0046). */
957
973
  renewal?: { decision: Extract<RenewalDecision, { renew: false }>; line: string };
@@ -1000,8 +1016,8 @@ export type UnitEnding =
1000
1016
  /** The unit idles instead of ending (record 0051): with the resolved
1001
1017
  * `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
1002
1018
  * 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
1019
+ * `refused`) — in this ending: the old kind as `why`, its outcome report
1020
+ * with the terminal restart replaced by the durable wake action, and what a continuation needs (the renewals the grant
1005
1021
  * still holds — unspent: the wake spends one, this plan's fifth unit — the
1006
1022
  * head to continue from, the last coding child's run, the spend and its
1007
1023
  * handoff). `runId` is absent when the pipeline capped before any coding
@@ -1212,10 +1228,14 @@ type Phase =
1212
1228
  childPushed?: PushedHeadFact[];
1213
1229
  childLeaseStartedAt?: number;
1214
1230
  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. */
1231
+ /** Open or recover a pull request from the branch. A completed round-zero
1232
+ * child whose same-head salvage merely republished ready work needs this
1233
+ * path without being classified as dead. */
1218
1234
  recover?: true;
1235
+ /** A completed findings run supplied every typed result. The next step
1236
+ * must independently prove an open branch and pull request at exactly
1237
+ * `childHead`; neither a push record nor this flag is remote evidence. */
1238
+ findingsReady?: true;
1219
1239
  /** The coding child died (`failed` or `interrupted`) after it may have
1220
1240
  * pushed: the pr-check recovers a pushed branch by opening its pull
1221
1241
  * request; with nothing pushed the unit ends with the child's own reason. */
@@ -1448,6 +1468,7 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1448
1468
  kind: "findings",
1449
1469
  unit,
1450
1470
  pr,
1471
+ ...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
1451
1472
  ...(reviewRunId !== undefined ? { reviewRunId } : {}),
1452
1473
  ...(reviewRunId === undefined || (answers !== undefined && answers.length > 0) ? { findings: issued } : {}),
1453
1474
  ...(answers !== undefined && answers.length > 0 ? { answers } : {}),
@@ -1863,6 +1884,13 @@ function interruptedCheckpoint(
1863
1884
  return { branch: found.ref, sha: found.sha };
1864
1885
  }
1865
1886
 
1887
+ /** A full commit id for findings completion and remote reconciliation. A
1888
+ * prefix can identify a commit for display, but cannot prove exact equality. */
1889
+ function fullHead(value: unknown): string | undefined {
1890
+ const head = normalizeHead(value);
1891
+ return head?.length === 40 ? head : undefined;
1892
+ }
1893
+
1866
1894
  /** A coding run's confirmed end: round 0's child, or the run a findings step dispatched. */
1867
1895
  function settleCoding(
1868
1896
  s: UnitPipelineState,
@@ -1890,8 +1918,8 @@ function settleCoding(
1890
1918
  }
1891
1919
  : {}),
1892
1920
  };
1893
- const readySalvage = completedSameHeadSalvage(next, facts.pushed, facts);
1894
- const checkpoint = interruptedCheckpoint(next, facts.pushed, facts);
1921
+ const readySalvage = round.kind === "coding" && completedSameHeadSalvage(next, facts.pushed, facts);
1922
+ const checkpoint = interruptedCheckpoint(next, facts.pushed, round.kind === "findings" ? undefined : facts);
1895
1923
  const mode = stopMode(facts.status);
1896
1924
  if (mode !== undefined)
1897
1925
  return end(
@@ -1906,6 +1934,64 @@ function settleCoding(
1906
1934
  },
1907
1935
  [roundNote(round, "stopped")],
1908
1936
  );
1937
+ // Findings rounds have their own typed completion contract. They do not
1938
+ // require round zero's handoff, but they do require a completed run, one
1939
+ // matching disposition for every finding, an updated PR description and a
1940
+ // final observed head. Only then may a fresh remote read decide readiness.
1941
+ if (round.kind === "findings" && facts.status === "completed") {
1942
+ const issued = next.findingsByRound[round.index] ?? [];
1943
+ const matched = matchDispositions(issued, facts.dispositions ?? []).matched;
1944
+ const missingOutputs = issued
1945
+ .filter((finding) => !matched.some((disposition) => disposition.findingId === finding.id))
1946
+ .map((finding) => `missing disposition for ${finding.id}`);
1947
+ if (facts.description !== true) missingOutputs.push("missing updated pull request description");
1948
+ const observedHead = fullHead(facts.headSha);
1949
+ if (observedHead === undefined) missingOutputs.push("missing final observed commit");
1950
+ if (missingOutputs.length > 0)
1951
+ return end(
1952
+ next,
1953
+ {
1954
+ kind: "aborted",
1955
+ reason: "The completed findings work did not record every required result.",
1956
+ findingsStop: "incomplete_outputs",
1957
+ ...(observedHead !== undefined ? { observedHead } : {}),
1958
+ missingOutputs,
1959
+ round,
1960
+ reviewRounds: next.reviewRounds,
1961
+ },
1962
+ [roundNote(round, "aborted")],
1963
+ );
1964
+ return {
1965
+ state: {
1966
+ ...next,
1967
+ phase: {
1968
+ at: "pr-check",
1969
+ round,
1970
+ runId,
1971
+ childHead: observedHead,
1972
+ findingsReady: true,
1973
+ ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
1974
+ },
1975
+ },
1976
+ notes: [],
1977
+ };
1978
+ }
1979
+ // A findings run must be completed before any remote state can make it
1980
+ // review-ready. A failed run stays resumable even when its record says a
1981
+ // push was attempted; no remote read upgrades an unfinished result.
1982
+ if (round.kind === "findings")
1983
+ return end(
1984
+ next,
1985
+ {
1986
+ kind: "aborted",
1987
+ reason: "The findings work did not complete, so no review was started.",
1988
+ findingsStop: "unfinished",
1989
+ ...(fullHead(facts.headSha) !== undefined ? { observedHead: fullHead(facts.headSha) } : {}),
1990
+ round,
1991
+ reviewRounds: next.reviewRounds,
1992
+ },
1993
+ [roundNote(round, "aborted")],
1994
+ );
1909
1995
  // A mechanical WIP push means the child did not declare the work ready for
1910
1996
  // review, whatever terminal status its own answer produced. Keep the unit
1911
1997
  // branch as the resumption point and abort this attempt; an ordinary push
@@ -1921,7 +2007,8 @@ function settleCoding(
1921
2007
  next,
1922
2008
  {
1923
2009
  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.`,
2010
+ reason: `⚠️ The coding child ended because ${cause}.`,
2011
+ checkpoint,
1925
2012
  round,
1926
2013
  reviewRounds: next.reviewRounds,
1927
2014
  },
@@ -2457,9 +2544,27 @@ function childSupersession(
2457
2544
  return undefined;
2458
2545
  }
2459
2546
 
2460
- function finishSupersededChild(s: UnitPipelineState, phase: Extract<Phase, { at: "superseded-read" }>): Transition {
2547
+ function finishSupersededChild(
2548
+ s: UnitPipelineState,
2549
+ phase: Extract<Phase, { at: "superseded-read" }>,
2550
+ facts: Extract<ChildFacts, { finished: true }>,
2551
+ ): Transition {
2461
2552
  const supersession = phase.supersession;
2462
- if (supersession.reason === "merged") return foundMerged(s, supersession.pullRequest);
2553
+ if (supersession.reason === "merged") {
2554
+ // A findings child can observe a merge before its own record is read. Fold
2555
+ // that record through the same typed-output gate as an ordinary completion,
2556
+ // then reconcile the merged PR's source head with the completed child head.
2557
+ // Neither the earlier terminal observation nor steering the child proves
2558
+ // that its final commit reached the pull request before the merge.
2559
+ if (phase.round.kind === "findings") {
2560
+ const settled = settleCoding(s, phase.round, phase.runId, facts);
2561
+ const ready = settled.state.phase;
2562
+ if (ready.at === "pr-check" && ready.findingsReady === true)
2563
+ return settlePrCheck(settled.state, ready, supersession.pullRequest);
2564
+ return settled;
2565
+ }
2566
+ return foundMerged(s, supersession.pullRequest);
2567
+ }
2463
2568
  if (supersession.reason === "closed") return foundClosed(s, supersession.pullRequest);
2464
2569
  if (supersession.reason === "branch_deleted") return missingHeadBranch(s, supersession.pullRequest);
2465
2570
  return {
@@ -2507,6 +2612,87 @@ export function reconcileTerminalPr(s: UnitPipelineState, pr: PrCheck): UnitPipe
2507
2612
  /** The pull request heading the branch after round 0 or a findings step. */
2508
2613
  function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-check" }>, pr: PrCheck): Transition {
2509
2614
  const { round } = phase;
2615
+ // A completed findings run is not ready because it says it pushed. It is
2616
+ // ready only when this independent read sees the adopted pull request at the
2617
+ // exact full observed source head (and, while open, its live branch). Every
2618
+ // other remote fact is a resumable stop with no review or merged success.
2619
+ if (phase.findingsReady === true) {
2620
+ const observedHead = fullHead(phase.childHead)!;
2621
+ const withPr = pr.state !== "none" ? { ...s, pr: { number: pr.prNumber, url: pr.url } } : s;
2622
+ if (pr.state === "merged") {
2623
+ const remoteHead = fullHead(pr.headSha);
2624
+ if (remoteHead === undefined)
2625
+ return end(
2626
+ withPr,
2627
+ {
2628
+ kind: "aborted",
2629
+ reason: "The merged pull request's exact source head could not be verified.",
2630
+ findingsStop: "remote_unreadable",
2631
+ observedHead,
2632
+ round,
2633
+ reviewRounds: withPr.reviewRounds,
2634
+ },
2635
+ [roundNote(round, "aborted")],
2636
+ );
2637
+ if (remoteHead !== observedHead)
2638
+ return end(
2639
+ withPr,
2640
+ {
2641
+ kind: "aborted",
2642
+ reason: "The merged pull request does not hold the completed changes.",
2643
+ findingsStop: "head_mismatch",
2644
+ observedHead,
2645
+ remoteHead,
2646
+ round,
2647
+ reviewRounds: withPr.reviewRounds,
2648
+ },
2649
+ [roundNote(round, "aborted")],
2650
+ );
2651
+ return foundMerged(withPr, pr, [roundNote(round, "completed")]);
2652
+ }
2653
+ if (pr.state !== "open" || pr.headBranchExists === false)
2654
+ return end(
2655
+ withPr,
2656
+ {
2657
+ kind: "aborted",
2658
+ reason: "No open pull request and branch could be verified for the completed changes.",
2659
+ findingsStop: "missing_remote",
2660
+ observedHead,
2661
+ round,
2662
+ reviewRounds: withPr.reviewRounds,
2663
+ },
2664
+ [roundNote(round, "aborted")],
2665
+ );
2666
+ const remoteHead = fullHead(pr.headSha);
2667
+ if (pr.headBranchExists !== true || remoteHead === undefined)
2668
+ return end(
2669
+ withPr,
2670
+ {
2671
+ kind: "aborted",
2672
+ reason: "The open pull request's branch and exact head could not be verified.",
2673
+ findingsStop: "remote_unreadable",
2674
+ observedHead,
2675
+ round,
2676
+ reviewRounds: withPr.reviewRounds,
2677
+ },
2678
+ [roundNote(round, "aborted")],
2679
+ );
2680
+ if (remoteHead !== observedHead)
2681
+ return end(
2682
+ withPr,
2683
+ {
2684
+ kind: "aborted",
2685
+ reason: "The pull request does not hold the completed changes.",
2686
+ findingsStop: "head_mismatch",
2687
+ observedHead,
2688
+ remoteHead,
2689
+ round,
2690
+ reviewRounds: withPr.reviewRounds,
2691
+ },
2692
+ [roundNote(round, "aborted")],
2693
+ );
2694
+ return roundOnOpenPr(withPr, phase, pr);
2695
+ }
2510
2696
  // The merge landed during the round: the child found nothing left to ship
2511
2697
  // (or shipped into a pull request a person merged under it). The round
2512
2698
  // completed without a pull request of its own, and the unit is done.
@@ -3000,7 +3186,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
3000
3186
  supersession,
3001
3187
  finishedObserved: p.finishedObserved,
3002
3188
  };
3003
- return finishSupersededChild(clocked, terminalPhase);
3189
+ return finishSupersededChild(clocked, terminalPhase, r.run);
3004
3190
  }
3005
3191
  return {
3006
3192
  state: {
@@ -3066,10 +3252,26 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
3066
3252
  },
3067
3253
  [roundNote(p.round, "aborted")],
3068
3254
  );
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")
3255
+ // A findings run must complete before another review. An interrupted
3256
+ // one remains resumable even if it recorded an attempted push; round
3257
+ // zero keeps its established recovery check.
3258
+ if (p.round.kind === "findings")
3259
+ return end(
3260
+ clocked,
3261
+ {
3262
+ kind: "interrupted",
3263
+ round: p.round,
3264
+ runId: p.runId,
3265
+ reviewRounds: s.reviewRounds,
3266
+ ...(cause !== undefined ? { cause } : {}),
3267
+ },
3268
+ [roundNote(p.round, "aborted")],
3269
+ );
3270
+ // A dead round-zero coding child may have pushed normally before the
3271
+ // ledger closed it: the pr-check recovers that branch. A review child
3272
+ // has nothing on the branch to recover, so its interruption ends the
3273
+ // unit at once.
3274
+ if (p.round.kind === "coding")
3073
3275
  return {
3074
3276
  state: {
3075
3277
  ...clocked,
@@ -3138,7 +3340,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
3138
3340
  }
3139
3341
  case "superseded-read": {
3140
3342
  const r = ret as Extract<StepReturn, { type: "read-record" }>;
3141
- if (r.run.finished) return finishSupersededChild(clocked, p);
3343
+ if (r.run.finished) return finishSupersededChild(clocked, p, r.run);
3142
3344
  return {
3143
3345
  state: {
3144
3346
  ...clocked,
@@ -3503,10 +3705,21 @@ export function renderUnitReport(
3503
3705
  * after 2 review rounds: <url>`, `Held: <the human-gated row>`, `Stopped`,
3504
3706
  * `Round cap reached: <counts>` — and the verdict, the findings left below
3505
3707
  * 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. */
3708
+ * force, the grant, the write-up pointer, the budget split, the restart or
3709
+ * durable-wake action and a segment boundary are `verbose` asides; the full
3710
+ * detail stays on the pull request, where the round routes put it. */
3509
3711
  verbosity: Verbosity = "verbose",
3712
+ ): string {
3713
+ return renderUnitReportWithWake(s, facts, verbosity, false);
3714
+ }
3715
+
3716
+ /** The internal idle render is the only caller allowed to claim an indexed
3717
+ * wake will durably record a reply as the continuation action. */
3718
+ function renderUnitReportWithWake(
3719
+ s: UnitPipelineState,
3720
+ facts: MergeReadyFacts | undefined,
3721
+ verbosity: Verbosity,
3722
+ durableWake: boolean,
3510
3723
  ): string {
3511
3724
  const e = s.ending;
3512
3725
  if (!e) return "";
@@ -3514,12 +3727,20 @@ export function renderUnitReport(
3514
3727
  const rounds = `${e.reviewRounds} review round${e.reviewRounds === 1 ? "" : "s"}`;
3515
3728
  const prUrl = s.pr?.url;
3516
3729
  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.`;
3730
+ const nextAction = (checkpoint?: { branch: string; sha: string }): string => {
3731
+ if (durableWake)
3732
+ 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"}.`;
3733
+ const reconcile = checkpoint
3734
+ ? `verify \`${checkpoint.sha.slice(0, 7)}\` is on \`${checkpoint.branch}\`, then reconcile the remote branch and pull request if needed`
3735
+ : prUrl
3736
+ ? `verify the remote branch and pull request (${prUrl}) agree, then reconcile them if needed`
3737
+ : "verify the remote branch and reconcile any saved checkpoint if needed";
3738
+ const restart = s.input.generated
3739
+ ? `then start ship again with ${prUrl ?? "the original task"}`
3740
+ : "then start this plan again";
3741
+ const blocked = s.input.generated ? "" : "The unit's dependents in this plan stay blocked. ";
3742
+ return `${blocked}Next action: ${reconcile}, ${restart}.`;
3743
+ };
3523
3744
  const declined = [...(s.dispositionsByRound[e.reviewRounds - 1] ?? [])].filter((d) => d.disposition === "declined");
3524
3745
  const declinedLine = `Declined findings: ${declined.length > 0 ? declined.map((d) => `${d.findingId}${d.note ? ` — ${d.note}` : ""}`).join("; ") : "none"}`;
3525
3746
  const verdictLine = `Verdict: LGTM${s.lastVerdictSummary ? ` — ${s.lastVerdictSummary}` : ""}`;
@@ -3544,7 +3765,7 @@ export function renderUnitReport(
3544
3765
  const checkpointLine = (checkpoint: { branch: string; sha: string } | undefined) =>
3545
3766
  checkpoint === undefined
3546
3767
  ? 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.`;
3768
+ : `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
3769
  switch (e.kind) {
3549
3770
  case "merged":
3550
3771
  if (e.by === "other")
@@ -3605,9 +3826,9 @@ export function renderUnitReport(
3605
3826
  if (e.cause === "draft") {
3606
3827
  const link = e.pr !== undefined ? ` — ${e.pr.url}` : "";
3607
3828
  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)}`;
3829
+ const draftContinuation = `Next action: mark the pull request ready in GitHub, then ${nextAction()
3830
+ .replace(/^The unit's dependents in this plan stay blocked\. Next action: /, "")
3831
+ .replace(/^Next action: /, "")}`;
3611
3832
  return join([
3612
3833
  `⏸️ 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
3834
  draftContinuation,
@@ -3623,7 +3844,9 @@ export function renderUnitReport(
3623
3844
  return join([
3624
3845
  `⏸️ 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
3846
  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}`,
3847
+ durableWake
3848
+ ? `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()}`
3849
+ : `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
3850
  ]);
3628
3851
  }
3629
3852
  // A human-gated row is a question to a person. The enclosing idle keeps
@@ -3651,8 +3874,8 @@ export function renderUnitReport(
3651
3874
  return join([
3652
3875
  `⚠️ 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
3876
  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.",
3877
+ ? `Next action: merge or close the pull request, then start ship again with ${e.pr.url} so the result is reconciled.`
3878
+ : "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
3879
  ]);
3657
3880
  case "round_cap": {
3658
3881
  // The quiet copy counts the last review's open findings, only the
@@ -3671,7 +3894,7 @@ export function renderUnitReport(
3671
3894
  failedChecks.length > 0
3672
3895
  ? `🧢 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
3896
  : `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — no approval after ${rounds}.${prLine}`;
3674
- return join([headline, splitReport(s), continuation]);
3897
+ return join([headline, splitReport(s), nextAction()]);
3675
3898
  }
3676
3899
  case "wall_clock_cap":
3677
3900
  if (!shows(verbosity, "verbose")) return `🧢 Out of budget — no approval after ${rounds}.${prLine}`;
@@ -3679,13 +3902,13 @@ export function renderUnitReport(
3679
3902
  `🧢 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
3903
  budgetSplitLine(e.spent, s.input.caps.maxMinutes),
3681
3904
  splitReport(s),
3682
- continuation,
3905
+ nextAction(),
3683
3906
  ]);
3684
3907
  case "review_pending":
3685
3908
  return join([
3686
3909
  `⏳ 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
3910
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
3688
- aside(continuation),
3911
+ aside(nextAction()),
3689
3912
  ]);
3690
3913
  case "stopped":
3691
3914
  if (!shows(verbosity, "verbose"))
@@ -3701,12 +3924,50 @@ export function renderUnitReport(
3701
3924
  e.postedReview
3702
3925
  ? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
3703
3926
  : undefined,
3704
- continuation,
3927
+ nextAction(e.checkpoint),
3705
3928
  ]);
3706
3929
  case "aborted":
3930
+ if (e.findingsStop !== undefined) {
3931
+ const observed = e.observedHead?.slice(0, 7);
3932
+ const branch = s.input.unit.branch;
3933
+ if (e.findingsStop === "incomplete_outputs")
3934
+ return join([
3935
+ `⚠️ Review did not restart: the completed findings work did not record every required result (${(e.missingOutputs ?? []).join("; ")}).`,
3936
+ observed !== undefined
3937
+ ? `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.`
3938
+ : "Saved-work fact: The run did not record a final commit, and remote state was not checked.",
3939
+ "Next action: a new findings run must record every disposition, the updated pull request description and its final commit before remote reconciliation.",
3940
+ ]);
3941
+ if (e.findingsStop === "unfinished")
3942
+ return join([
3943
+ "⚠️ Review did not restart: the findings work did not complete.",
3944
+ observed !== undefined
3945
+ ? `Saved-work fact: the stopped run recorded \`${observed}\`, but Switchboard did not verify it on the remote branch or pull request.`
3946
+ : "Saved-work fact: no completed commit was recorded, and remote state was not checked.",
3947
+ "Next action: start a new findings run; review can begin only after it completes every required result and Switchboard verifies its exact remote head.",
3948
+ ]);
3949
+ if (e.findingsStop === "missing_remote")
3950
+ return join([
3951
+ `⚠️ Review did not restart: The completed changes ended at \`${observed}\`, but no open pull request was found for \`${branch}\`.`,
3952
+ `Saved-work fact: The run recorded completed commit \`${observed}\`; Switchboard did not verify that commit on the remote branch or a pull request.`,
3953
+ "Next action: reconcile the branch and open pull request, then start ship again so it can verify the exact head before review.",
3954
+ ]);
3955
+ if (e.findingsStop === "head_mismatch")
3956
+ return join([
3957
+ `⚠️ Review did not restart: The completed changes ended at \`${observed}\`, but the pull request is at \`${e.remoteHead?.slice(0, 7)}\` (${s.pr?.url}).`,
3958
+ "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.",
3959
+ "Next action: reconcile the pull request to the completed commit, then start ship again so it can verify that exact head before review.",
3960
+ ]);
3961
+ return join([
3962
+ `⚠️ Review did not restart: The pull request exists (${s.pr?.url}), but its exact head could not be verified.`,
3963
+ `Saved-work fact: the run recorded completed commit \`${observed}\`; Switchboard did not start review for \`${observed}\` and cannot claim the pull request contains it.`,
3964
+ "Next action: retry ship when the pull request state is readable so it can verify the exact head before review.",
3965
+ ]);
3966
+ }
3707
3967
  if (!shows(verbosity, "verbose")) return `⚠️ Aborted after ${rounds}: ${e.reason}${prLine}`;
3708
3968
  return join([
3709
3969
  e.reason,
3970
+ checkpointLine(e.checkpoint),
3710
3971
  writeUpPointer(
3711
3972
  s,
3712
3973
  e.round?.kind ?? "coding",
@@ -3714,7 +3975,7 @@ export function renderUnitReport(
3714
3975
  ),
3715
3976
  e.renewal !== undefined ? `🔁 Not renewed: ${e.renewal.line}.` : undefined,
3716
3977
  `⚠️ Ship aborted after ${rounds}.`,
3717
- continuation,
3978
+ nextAction(e.checkpoint),
3718
3979
  ]);
3719
3980
  case "continued":
3720
3981
  // A segment boundary is the runner continuing — an acknowledgement, verbose
@@ -3723,8 +3984,8 @@ export function renderUnitReport(
3723
3984
  if (!shows(verbosity, "verbose")) return "";
3724
3985
  return join([
3725
3986
  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` : ""}.`
3987
+ durableWake
3988
+ ? `🔁 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
3989
  : `🔁 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
3990
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
3730
3991
  ]);
@@ -3735,7 +3996,7 @@ export function renderUnitReport(
3735
3996
  `⚠️ 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
3997
  writeUpPointer(s, e.round.kind, s.lastCodingRunId),
3737
3998
  `⚠️ This is a bug: ship ended after ${rounds} because its automatic provider retry was spent.`,
3738
- continuation,
3999
+ nextAction(),
3739
4000
  ]);
3740
4001
  case "no_verdict":
3741
4002
  if (!shows(verbosity, "verbose"))
@@ -3744,20 +4005,20 @@ export function renderUnitReport(
3744
4005
  `⚠️ 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
4006
  writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index]),
3746
4007
  `⚠️ Ship aborted after ${rounds}.`,
3747
- continuation,
4008
+ nextAction(),
3748
4009
  ]);
3749
4010
  case "interrupted":
3750
- return join([shipInterruptedNote(prUrl, e.cause, (s.input.idleDays ?? 0) > 0), checkpointLine(e.checkpoint)]);
4011
+ return join([shipInterruptedNote(prUrl, e.cause, durableWake), checkpointLine(e.checkpoint)]);
3751
4012
  case "idle_expired":
3752
4013
  return "⌛ Idle expired: no reply continued this unit before its idle window closed.";
3753
4014
  case "refused":
3754
4015
  return join([
3755
4016
  `🚫 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),
4017
+ aside(nextAction()),
3757
4018
  ]);
3758
4019
  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);
4020
+ // Keep the old kind's outcome at this copy's level, but render the
4021
+ // indexed wake's durable reply action instead of a terminal restart.
4022
+ return renderUnitReportWithWake({ ...s, ending: e.idled }, facts, verbosity, true);
3762
4023
  }
3763
4024
  }
@@ -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
  : "";