@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
@@ -7,7 +7,13 @@
7
7
  // bot answer becomes the machine's return, and what is left to the platform's
8
8
  // retry: a call that throws inside a step is the platform's to ask again under
9
9
  // the one policy (twelve times, two minutes apart, constant — long enough for a
10
- // bot deploy and its rollover), and the driver never catches it.
10
+ // bot deploy and its rollover), and the driver never catches it mid-step. A
11
+ // throw that escapes a unit's pipeline anyway — a stored answer the mappers
12
+ // cannot read, retries exhausted, the platform's own refusal — is told once as
13
+ // the unit's ending (kind `failed`, cause `step_threw`, the step and round and
14
+ // the throw's one line, issue 2100) before it is rethrown to fail the
15
+ // instance, so the unit's row never reads as the bare "no ending was
16
+ // recorded" seal.
11
17
  //
12
18
  // Every step's stored output is the bot's reply as the wire carried it — the
13
19
  // status and the text of a JSON object the bot stamped with its clock (`at`) —
@@ -33,7 +39,7 @@
33
39
  // plan's re-issue. A task string's ship branch waits for a person. Node-free:
34
40
  // the shim Worker imports this by relative path.
35
41
 
36
- import { DEFAULT_GRANT, GRANT_RENEWALS_MAX, IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
42
+ import { DAY_MS, DEFAULT_GRANT, GRANT_RENEWALS_MAX, IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
37
43
  import { DEFAULT_VERBOSITY, isVerbosity, type Verbosity } from "../verbosity.js";
38
44
  import {
39
45
  applyReturn,
@@ -68,8 +74,11 @@ import {
68
74
  childInterruptedEventType,
69
75
  childResumedEventType,
70
76
  isCoordinatorUnit,
77
+ isUnitWakeAnswer,
71
78
  runFinishedEventType,
79
+ unitNudgeEventType,
72
80
  type CoordinatorUnit,
81
+ type UnitWakeAnswer,
73
82
  } from "./contract.js";
74
83
 
75
84
  const MIN = 60_000;
@@ -102,6 +111,7 @@ export type CoordinatorStepRoute =
102
111
  | "pr-check"
103
112
  | "round"
104
113
  | "unit-end"
114
+ | "unit-wake"
105
115
  | "checks"
106
116
  | "merge"
107
117
  | "finish";
@@ -279,6 +289,11 @@ function readUnitStart(a: BotAnswer): { at: number } {
279
289
  return { at: a.body.at };
280
290
  }
281
291
 
292
+ function readWakeAnswer(a: BotAnswer): { answer: UnitWakeAnswer; at: number } {
293
+ if (a.body.ok !== true || !isUnitWakeAnswer(a.body.answer)) throw new UnreadableAnswer("unit-wake", a, "wake answer");
294
+ return { answer: a.body.answer, at: a.body.at };
295
+ }
296
+
282
297
  function branchReturn(step: string, a: BotAnswer): StepReturn {
283
298
  const { ok, reason, at } = a.body;
284
299
  if (ok === true) return { type: "branch", step, ok: true, at };
@@ -428,6 +443,9 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
428
443
  ...(Array.isArray(a.body.fixupCommits) && a.body.fixupCommits.every((s: unknown) => typeof s === "string")
429
444
  ? { fixupCommits: a.body.fixupCommits as string[] }
430
445
  : {}),
446
+ // The base's merge-queue rule beside the checks (issue 2011): the
447
+ // merge:person report says the person's merge is queued.
448
+ ...(typeof a.body.baseHasMergeQueue === "boolean" ? { baseHasMergeQueue: a.body.baseHasMergeQueue } : {}),
431
449
  },
432
450
  at,
433
451
  };
@@ -451,7 +469,7 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
451
469
  * ask's answer carries `retried` instead: whether the re-run was dispatched,
452
470
  * so the machine never waits on a head an undispatched re-run left unchanged. */
453
471
  function checksReturn(step: string, a: BotAnswer): StepReturn {
454
- const { ok, checks, draft, retried, at } = a.body;
472
+ const { ok, checks, draft, retried, refired, at } = a.body;
455
473
  if (ok !== true) throw new UnreadableAnswer("checks", a, "ok");
456
474
  return {
457
475
  type: "checks",
@@ -461,6 +479,7 @@ function checksReturn(step: string, a: BotAnswer): StepReturn {
461
479
  // unit for the ready event instead of merging or ending without a cause.
462
480
  ...(draft === true ? { draft: true } : {}),
463
481
  ...(typeof retried === "boolean" ? { retried } : {}),
482
+ ...(typeof refired === "boolean" ? { refired } : {}),
464
483
  at,
465
484
  };
466
485
  }
@@ -471,7 +490,10 @@ const isRoundChecks = (v: unknown): v is RoundChecks =>
471
490
  Array.isArray(v.pending) &&
472
491
  v.pending.every((n: unknown) => typeof n === "string") &&
473
492
  Array.isArray(v.failed) &&
474
- v.failed.every((f: unknown) => isRecord(f) && typeof f.name === "string" && typeof f.conclusion === "string");
493
+ v.failed.every((f: unknown) => isRecord(f) && typeof f.name === "string" && typeof f.conclusion === "string") &&
494
+ (v.required === undefined ||
495
+ (Array.isArray(v.required) && v.required.every((n: unknown) => typeof n === "string"))) &&
496
+ (v.expected === undefined || (Array.isArray(v.expected) && v.expected.every((n: unknown) => typeof n === "string")));
475
497
 
476
498
  function mergeReturn(step: string, a: BotAnswer): StepReturn {
477
499
  const { ok, outcome, by, sha, mergedAt, reason, at } = a.body;
@@ -652,8 +674,8 @@ async function perform(
652
674
  }
653
675
  case "checks":
654
676
  // The round's checks step (record 0055): the bot reads the check runs at
655
- // the reviewed head with the merge door's own reading — or, on a retry
656
- // ask, re-runs the named failed checks' jobs first.
677
+ // the reviewed head with the merge door's own reading — or performs one
678
+ // of the step's bounded recovery effects first.
657
679
  return checksReturn(
658
680
  action.step,
659
681
  answerOf(
@@ -664,6 +686,7 @@ async function perform(
664
686
  prNumber: action.prNumber,
665
687
  headSha: action.headSha,
666
688
  ...(action.retry !== undefined ? { retry: action.retry } : {}),
689
+ ...(action.refire === true ? { refire: true } : {}),
667
690
  }),
668
691
  ),
669
692
  ),
@@ -686,8 +709,70 @@ async function perform(
686
709
  }
687
710
  }
688
711
 
712
+ /** The throw's one line, for the step-threw ending's report (issue 2100). */
713
+ function oneLineOf(err: unknown): string {
714
+ const line = (err instanceof Error ? err.message : String(err)).split("\n")[0]!.trim();
715
+ if (line.length === 0) return "an error with no message";
716
+ return line.length > REASON_MAX ? `${line.slice(0, REASON_MAX)}…` : line;
717
+ }
718
+
719
+ /** Where the machine was when a step threw: the step's own name and, inside a
720
+ * round, the round it belonged to. */
721
+ interface StepAt {
722
+ step: string;
723
+ round?: { index: number; kind: string };
724
+ }
725
+
726
+ /** A step that throws inside the walk becomes the unit's ending (issue 2100):
727
+ * kind `failed`, cause `step_threw`, the step and round it was in and the
728
+ * throw's one line, posted in the user's words — best effort, before the
729
+ * throw is rethrown to fail the instance — so the unit's row carries a cause
730
+ * a person can read instead of the bare "no ending was recorded" seal (the
731
+ * walk-dies-before-unit-end path behind issues 2063 and 2100: run 21c50656
732
+ * died between round 2's review verdict and its outcome write). */
733
+ async function tellStepThrew(
734
+ step: StepRunner,
735
+ bot: CoordinatorBot,
736
+ prefix: string,
737
+ tag: { parentInstanceId: string; unit: string },
738
+ pr: { number: number; url: string } | undefined,
739
+ at: StepAt,
740
+ err: unknown,
741
+ ): Promise<void> {
742
+ const line = oneLineOf(err);
743
+ const where =
744
+ at.round === undefined
745
+ ? `at \`${at.step}\``
746
+ : at.round.kind === "review" && /\/review\/(read|checks)\b/.test(at.step)
747
+ ? `after round ${at.round.index}'s review verdict (\`${at.step}\`)`
748
+ : `in round ${at.round.index} (\`${at.step}\`)`;
749
+ const report =
750
+ `⚠️ The runner failed ${where}: ${line}\n\n` +
751
+ "Re-issue `agent:ship` in this thread to continue — a pull request already approved with green checks resumes at the checks step, never at a fresh coding round.";
752
+ const body = {
753
+ ...tag,
754
+ ending: {
755
+ kind: "failed",
756
+ cause: "step_threw",
757
+ step: at.step,
758
+ ...(at.round !== undefined ? { round: at.round.index } : {}),
759
+ report,
760
+ threadReport: report,
761
+ },
762
+ ...(pr !== undefined ? { pr } : {}),
763
+ };
764
+ try {
765
+ await step.do(`${prefix}/end/threw`, STEP_CONFIG, () => call(bot, "unit-end", body));
766
+ } catch {
767
+ // Best effort: the rethrow still fails the instance, and a bot that could
768
+ // not record the ending leaves the seal's line as before.
769
+ }
770
+ }
771
+
689
772
  /** One unit's pipeline: its start, then the machine's steps until it ends;
690
773
  * every round boundary and the ending told to the bot as they happen. */
774
+ type DrivenEnding = UnitEnding & { endedAt?: number };
775
+
691
776
  async function runUnit(
692
777
  step: StepRunner,
693
778
  bot: CoordinatorBot,
@@ -695,16 +780,28 @@ async function runUnit(
695
780
  node: PlanUnitNode,
696
781
  plan: PlanFacts,
697
782
  session?: LeaseSegmentProgress,
698
- ): Promise<UnitEnding> {
783
+ ): Promise<DrivenEnding> {
699
784
  const unit = node.id;
700
785
  // A renewal's segment names its steps under the segment (`U10/s2/…`), so
701
786
  // the Workflow's durable step cache never answers segment two with segment
702
787
  // one's results (decision 0046).
703
788
  const prefix = stepPrefixOf(unit, session);
704
789
  const tag = { parentInstanceId: instanceId, unit };
705
- const start = readUnitStart(
706
- answerOf("unit-start", await step.do(`${prefix}/start`, STEP_CONFIG, () => call(bot, "unit-start", tag))),
707
- );
790
+ // Where the machine is, tracked for the step-threw ending (issue 2100). The
791
+ // start belongs in the same net as the rest of the unit even though no
792
+ // pipeline state (and therefore no round) exists yet.
793
+ let last: StepAt = { step: `${prefix}/start` };
794
+ let start: ReturnType<typeof readUnitStart>;
795
+ try {
796
+ const startStep = `${prefix}/start`;
797
+ last = { step: startStep };
798
+ start = readUnitStart(
799
+ answerOf("unit-start", await step.do(startStep, STEP_CONFIG, () => call(bot, "unit-start", tag))),
800
+ );
801
+ } catch (err) {
802
+ await tellStepThrew(step, bot, prefix, tag, undefined, last, err);
803
+ throw err;
804
+ }
708
805
  // A resume at review (agent-ship item 10) rides the unit's row: the pull
709
806
  // request of ship's own the requester named opens the pipeline at its first
710
807
  // review round, with no pre-check, no branch and no round 0.
@@ -739,108 +836,139 @@ async function runUnit(
739
836
  start.at,
740
837
  );
741
838
  let notes = 0;
742
- for (;;) {
743
- const action = nextAction(state);
744
- if (action.type === "end") return action.ending;
745
- const transition = applyReturn(state, await perform(step, bot, instanceId, unit, action));
746
- state = transition.state;
747
- for (const note of transition.notes) {
748
- if (note.type === "round") {
749
- const body = {
750
- ...tag,
751
- index: note.index,
752
- agent: note.agent,
753
- outcome: note.outcome,
754
- ...(note.gate !== undefined ? { gate: note.gate } : {}),
755
- };
756
- await step.do(`${prefix}/note/${++notes}`, STEP_CONFIG, () => call(bot, "round", body));
757
- } else {
758
- // A merge_ready ending names the pull request as it is at the APPROVED
759
- // head (agent-ship item 9): one more pr-check reads the facts fresh —
760
- // auto-merge may have been switched on since the round's check, or it
761
- // may already have fired, in which case the answer is `merged` and the
762
- // report says so rather than naming a gate that has passed. An
763
- // unreadable answer just leaves the facts out.
764
- let endFacts: MergeReadyFacts | undefined;
765
- if (note.ending.kind === "merge_ready") {
766
- try {
767
- const check = prCheckReturn(
768
- `${prefix}/end/pr-facts`,
769
- answerOf(
770
- "pr-check",
771
- await step.do(`${prefix}/end/pr-facts`, STEP_CONFIG, () =>
772
- call(bot, "pr-check", { ...tag, checks: true }),
839
+ let endedAt: number | undefined;
840
+ try {
841
+ for (;;) {
842
+ const action = nextAction(state);
843
+ if (action.type === "end") return { ...action.ending, ...(endedAt !== undefined ? { endedAt } : {}) };
844
+ // Keep the current round across its non-round phases (for example merge)
845
+ // while replacing it whenever the pipeline names a new one.
846
+ last = {
847
+ step: action.step,
848
+ ...("round" in state.phase
849
+ ? { round: state.phase.round }
850
+ : last.round !== undefined
851
+ ? { round: last.round }
852
+ : {}),
853
+ };
854
+ const transition = applyReturn(state, await perform(step, bot, instanceId, unit, action));
855
+ state = transition.state;
856
+ for (const note of transition.notes) {
857
+ if (note.type === "round") {
858
+ const body = {
859
+ ...tag,
860
+ index: note.index,
861
+ agent: note.agent,
862
+ outcome: note.outcome,
863
+ ...(note.gate !== undefined ? { gate: note.gate } : {}),
864
+ };
865
+ const noteStep = `${prefix}/note/${++notes}`;
866
+ last = { step: noteStep, round: { index: note.index, kind: note.agent } };
867
+ await step.do(noteStep, STEP_CONFIG, () => call(bot, "round", body));
868
+ } else {
869
+ // A merge_ready ending names the pull request as it is at the APPROVED
870
+ // head (agent-ship item 9): one more pr-check reads the facts fresh —
871
+ // auto-merge may have been switched on since the round's check, or it
872
+ // may already have fired, in which case the answer is `merged` and the
873
+ // report says so rather than naming a gate that has passed. An
874
+ // unreadable answer just leaves the facts out.
875
+ let endFacts: MergeReadyFacts | undefined;
876
+ if (note.ending.kind === "merge_ready") {
877
+ try {
878
+ const factsStep = `${prefix}/end/pr-facts`;
879
+ last = { step: factsStep, ...(last.round !== undefined ? { round: last.round } : {}) };
880
+ const check = prCheckReturn(
881
+ factsStep,
882
+ answerOf(
883
+ "pr-check",
884
+ await step.do(factsStep, STEP_CONFIG, () => call(bot, "pr-check", { ...tag, checks: true })),
773
885
  ),
774
- ),
775
- );
776
- if (check.type === "pr-check" && check.pr.state === "merged")
777
- endFacts = { merged: { sha: check.pr.sha, mergedAt: check.pr.mergedAt } };
778
- else if (check.type === "pr-check" && check.pr.state === "open")
779
- endFacts = {
780
- ...(check.pr.autoMergeEnabled !== undefined ? { autoMergeEnabled: check.pr.autoMergeEnabled } : {}),
781
- // The checks at the approved head (record 0055): the report's
782
- // headline is a claim about them, never "merge-ready" over a red one.
783
- ...(check.pr.checks !== undefined ? { checks: check.pr.checks } : {}),
784
- // The ready state beside them (agent-ship item 9): a conflicting
785
- // head, or one carrying an unsquashed fix-up commit, is reported
786
- // approved-but-not-merge-ready, never "merge-ready".
787
- ...(check.pr.mergeableState !== undefined ? { mergeableState: check.pr.mergeableState } : {}),
788
- ...(check.pr.fixupCommits !== undefined ? { fixupCommits: check.pr.fixupCommits } : {}),
789
- };
790
- } catch {
791
- // the report simply omits the fact
886
+ );
887
+ if (check.type === "pr-check" && check.pr.state === "merged")
888
+ endFacts = { merged: { sha: check.pr.sha, mergedAt: check.pr.mergedAt } };
889
+ else if (check.type === "pr-check" && check.pr.state === "open")
890
+ endFacts = {
891
+ ...(check.pr.autoMergeEnabled !== undefined ? { autoMergeEnabled: check.pr.autoMergeEnabled } : {}),
892
+ // The checks at the approved head (record 0055): the report's
893
+ // headline is a claim about them, never "merge-ready" over a red one.
894
+ ...(check.pr.checks !== undefined ? { checks: check.pr.checks } : {}),
895
+ // The ready state beside them (agent-ship item 9): a conflicting
896
+ // head, or one carrying an unsquashed fix-up commit, is reported
897
+ // approved-but-not-merge-ready, never "merge-ready".
898
+ ...(check.pr.mergeableState !== undefined ? { mergeableState: check.pr.mergeableState } : {}),
899
+ ...(check.pr.fixupCommits !== undefined ? { fixupCommits: check.pr.fixupCommits } : {}),
900
+ // The base's merge-queue rule (issue 2011): the merge:person
901
+ // path names "queued" in its remaining-gate line.
902
+ ...(check.pr.baseHasMergeQueue !== undefined
903
+ ? { baseHasMergeQueue: check.pr.baseHasMergeQueue }
904
+ : {}),
905
+ };
906
+ } catch {
907
+ // the report simply omits the fact
908
+ }
792
909
  }
793
- }
794
- // The last coding child's run is named so the bot can put its handoff
795
- // — the deviations it recorded — on the unit's board issue beside the
796
- // ending (agent-ship item 14).
797
- const body = {
798
- ...tag,
799
- // Two copies (routing-and-config item 28): the full report for the
800
- // row and the board, and the thread's at the request's verbosity.
801
- ending: {
802
- kind: note.ending.kind,
803
- report: renderUnitReport(state, endFacts),
804
- threadReport: renderUnitReport(state, endFacts, state.input.verbosity ?? DEFAULT_VERBOSITY),
805
- // An idle ending carries its continuation facts (record 0051): the
806
- // bot writes them on the row's `idle` in place of an ending, with
807
- // the coding run id it already receives below.
808
- ...(note.ending.kind === "idle"
910
+ // The last coding child's run is named so the bot can put its handoff
911
+ // — the deviations it recorded — on the unit's board issue beside the
912
+ // ending (agent-ship item 14).
913
+ const body = {
914
+ ...tag,
915
+ // Two copies (routing-and-config item 28): the full report for the
916
+ // row and the board, and the thread's at the request's verbosity.
917
+ ending: {
918
+ kind: note.ending.kind,
919
+ report: renderUnitReport(state, endFacts),
920
+ threadReport: renderUnitReport(state, endFacts, state.input.verbosity ?? DEFAULT_VERBOSITY),
921
+ // An idle ending carries its continuation facts (record 0051): the
922
+ // bot writes them on the row's `idle` in place of an ending, with
923
+ // the coding run id it already receives below.
924
+ ...(note.ending.kind === "idle"
925
+ ? {
926
+ why: note.ending.why,
927
+ renewalsLeft: note.ending.renewalsLeft,
928
+ ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
929
+ spendUsd: note.ending.spendUsd,
930
+ ...(note.ending.handoff !== undefined ? { handoff: note.ending.handoff } : {}),
931
+ }
932
+ : {}),
933
+ },
934
+ ...(state.pr !== undefined ? { pr: state.pr } : {}),
935
+ // A review_pending ending names the child's own last push so the next
936
+ // attempt's pre-check can start at the review round (the row's lastPush)
937
+ // — an idled one the same, off the idle's `from` (record 0051).
938
+ ...(note.ending.kind === "review_pending" && note.ending.headSha !== undefined
939
+ ? { headSha: note.ending.headSha }
940
+ : note.ending.kind === "idle" && note.ending.why === "review_pending" && note.ending.from !== undefined
941
+ ? { headSha: note.ending.from }
942
+ : {}),
943
+ ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
944
+ // A continued ending is a segment's end, not the unit's: the bot
945
+ // writes the renewal as a row keyed by the next segment's index
946
+ // (decision 0046), so a runner reclaimed here never renews twice.
947
+ ...(note.ending.kind === "continued"
809
948
  ? {
810
- why: note.ending.why,
811
- renewalsLeft: note.ending.renewalsLeft,
812
- ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
813
- spendUsd: note.ending.spendUsd,
814
- ...(note.ending.handoff !== undefined ? { handoff: note.ending.handoff } : {}),
949
+ segment: {
950
+ index: note.ending.segment,
951
+ ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
952
+ runId: note.ending.runId,
953
+ },
815
954
  }
816
955
  : {}),
817
- },
818
- ...(state.pr !== undefined ? { pr: state.pr } : {}),
819
- // A review_pending ending names the child's own last push so the next
820
- // attempt's pre-check can start at the review round (the row's lastPush)
821
- // — an idled one the same, off the idle's `from` (record 0051).
822
- ...(note.ending.kind === "review_pending" && note.ending.headSha !== undefined
823
- ? { headSha: note.ending.headSha }
824
- : note.ending.kind === "idle" && note.ending.why === "review_pending" && note.ending.from !== undefined
825
- ? { headSha: note.ending.from }
826
- : {}),
827
- ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
828
- // A continued ending is a segment's end, not the unit's: the bot
829
- // writes the renewal as a row keyed by the next segment's index
830
- // (decision 0046), so a runner reclaimed here never renews twice.
831
- ...(note.ending.kind === "continued"
832
- ? {
833
- segment: {
834
- index: note.ending.segment,
835
- ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
836
- runId: note.ending.runId,
837
- },
838
- }
839
- : {}),
840
- };
841
- await step.do(`${prefix}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
956
+ };
957
+ const endStep = `${prefix}/end`;
958
+ last = { step: endStep, ...(last.round !== undefined ? { round: last.round } : {}) };
959
+ const endAnswer = answerOf(
960
+ "unit-end",
961
+ await step.do(endStep, STEP_CONFIG, () => call(bot, "unit-end", body)),
962
+ );
963
+ endedAt = endAnswer.body.at;
964
+ }
842
965
  }
843
966
  }
967
+ } catch (err) {
968
+ // The walk-dies-before-unit-end path (issue 2100): the throw becomes the
969
+ // unit's ending before it fails the instance, never the bare seal.
970
+ await tellStepThrew(step, bot, prefix, tag, state.pr, last, err);
971
+ throw err;
844
972
  }
845
973
  }
846
974
 
@@ -856,6 +984,24 @@ function blockedReport(unit: string, dep: string, depEnding: string): string {
856
984
  return `⛔ Blocked: ${unit} waits on ${dep}, which ended ${depEnding}${person ? " — a person's merge" : ""}. Re-issue the plan naming the remaining units once it is ${person ? "merged" : "resolved"}.`;
857
985
  }
858
986
 
987
+ /** End a unit that never entered `runUnit` under the same failure net. */
988
+ async function endUnrunUnit(
989
+ step: StepRunner,
990
+ bot: CoordinatorBot,
991
+ instanceId: string,
992
+ unit: string,
993
+ ending: { kind: "stopped" | "blocked"; report: string },
994
+ ): Promise<void> {
995
+ const tag = { parentInstanceId: instanceId, unit };
996
+ const endStep = `${unit}/end`;
997
+ try {
998
+ await step.do(endStep, STEP_CONFIG, () => call(bot, "unit-end", { ...tag, ending }));
999
+ } catch (err) {
1000
+ await tellStepThrew(step, bot, unit, tag, undefined, { step: endStep }, err);
1001
+ throw err;
1002
+ }
1003
+ }
1004
+
859
1005
  /** The graph as one plan answer carries it: the instance's unit rows, in the plan's order. */
860
1006
  function graphOf(plan: PlanFacts, instanceId: string): PlanGraph {
861
1007
  return {
@@ -900,6 +1046,106 @@ function rereadCursor(cursor: PlanCursor, fresh: PlanGraph): { cursor: PlanCurso
900
1046
  return { cursor: { order, status }, gone };
901
1047
  }
902
1048
 
1049
+ type IdleResult =
1050
+ | { kind: "segment"; session: LeaseSegmentProgress }
1051
+ | { kind: "ending"; ending: Extract<UnitEnding, { kind: "idle_expired" }> | { kind: "stopped" } };
1052
+
1053
+ /** Park one unit behind its indexed wait. A timeout closes the idle; a wake
1054
+ * answer is durable at the bot, so this loop may safely move to the next index
1055
+ * after a transport failure without deciding the same event twice. */
1056
+ async function waitOnIdle(
1057
+ step: StepRunner,
1058
+ bot: CoordinatorBot,
1059
+ instanceId: string,
1060
+ unit: string,
1061
+ prefix: string,
1062
+ idle: Extract<DrivenEnding, { kind: "idle" }>,
1063
+ idleDays: number,
1064
+ currentSession: LeaseSegmentProgress | undefined,
1065
+ ): Promise<IdleResult> {
1066
+ const deadline = (idle.endedAt ?? 0) + idleDays * DAY_MS;
1067
+ let now = idle.endedAt ?? 0;
1068
+ for (let index = 1; ; index += 1) {
1069
+ const waitId = `${prefix}/idle/${index}`;
1070
+ const timeout = Math.max(1, deadline - now);
1071
+ try {
1072
+ await step.waitForEvent(waitId, { type: unitNudgeEventType({ instanceId, unit }), timeout });
1073
+ } catch {
1074
+ const ending = { kind: "idle_expired" as const, reviewRounds: idle.reviewRounds };
1075
+ const endStep = `${waitId}/end`;
1076
+ await step.do(endStep, STEP_CONFIG, () =>
1077
+ call(bot, "unit-end", {
1078
+ parentInstanceId: instanceId,
1079
+ unit,
1080
+ ending: {
1081
+ kind: ending.kind,
1082
+ report: "⌛ Idle expired: no reply continued this unit before its idle window closed.",
1083
+ },
1084
+ }),
1085
+ );
1086
+ return { kind: "ending", ending };
1087
+ }
1088
+ let wake: { answer: UnitWakeAnswer; at: number };
1089
+ try {
1090
+ wake = readWakeAnswer(
1091
+ answerOf(
1092
+ "unit-wake",
1093
+ await step.do(`${waitId}/wake`, STEP_CONFIG, () =>
1094
+ call(bot, "unit-wake", { parentInstanceId: instanceId, unit, waitId }),
1095
+ ),
1096
+ ),
1097
+ );
1098
+ } catch {
1099
+ // The event remains unconsumed when the wake could not store an answer.
1100
+ // A stored answer is replayed by the next identity, so either way the
1101
+ // next indexed wait is the safe place to listen again.
1102
+ continue;
1103
+ }
1104
+ now = wake.at;
1105
+ const answer = wake.answer;
1106
+ if (answer.kind === "answered") continue;
1107
+ if (answer.kind === "expired") {
1108
+ const ending = { kind: "idle_expired" as const, reviewRounds: idle.reviewRounds };
1109
+ await step.do(`${waitId}/end`, STEP_CONFIG, () =>
1110
+ call(bot, "unit-end", {
1111
+ parentInstanceId: instanceId,
1112
+ unit,
1113
+ ending: {
1114
+ kind: ending.kind,
1115
+ report: "⌛ Idle expired: the unit reached its indexed wake limit.",
1116
+ },
1117
+ }),
1118
+ );
1119
+ return { kind: "ending", ending };
1120
+ }
1121
+ if (answer.kind === "stopped") {
1122
+ await step.do(`${waitId}/end`, STEP_CONFIG, () =>
1123
+ call(bot, "unit-end", {
1124
+ parentInstanceId: instanceId,
1125
+ unit,
1126
+ ending: { kind: "stopped", report: "⏹ Stopped: the idle unit was ended by an operator." },
1127
+ }),
1128
+ );
1129
+ return { kind: "ending", ending: { kind: "stopped" } };
1130
+ }
1131
+ return {
1132
+ kind: "segment",
1133
+ session: {
1134
+ segment: answer.index,
1135
+ renewalsSpent: answer.leaseMs !== undefined ? (currentSession?.renewalsSpent ?? 0) : answer.index - 1,
1136
+ spendUsd: answer.spendUsd,
1137
+ ...(answer.from !== undefined ? { continueFrom: answer.from } : {}),
1138
+ ...(answer.runId !== undefined ? { previousRunId: answer.runId } : {}),
1139
+ ...(answer.handoff !== undefined ? { previousHandoff: answer.handoff } : {}),
1140
+ ...(answer.texts.length > 0 ? { texts: answer.texts } : {}),
1141
+ ...(answer.leaseMs !== undefined
1142
+ ? { resume: { leaseMs: answer.leaseMs, attempt: (currentSession?.resume?.attempt ?? 0) + 1 } }
1143
+ : {}),
1144
+ },
1145
+ };
1146
+ }
1147
+ }
1148
+
903
1149
  /** The plan: its units one at a time in dependency order, then the endings of the units it never reached. */
904
1150
  async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string): Promise<PlanRunSummary> {
905
1151
  // The selection is read at every unit boundary (the orchestration-plane plan): the first read opens
@@ -926,8 +1172,7 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
926
1172
  if (plan.stopped) {
927
1173
  for (const id of cursor.order.filter((u) => endings[u] === undefined)) {
928
1174
  endings[id] = "stopped";
929
- const body = { parentInstanceId: instanceId, unit: id, ending: { kind: "stopped", report: stoppedReport(id) } };
930
- await step.do(`${id}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
1175
+ await endUnrunUnit(step, bot, instanceId, id, { kind: "stopped", report: stoppedReport(id) });
931
1176
  }
932
1177
  break;
933
1178
  }
@@ -935,21 +1180,45 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
935
1180
  if (next === undefined) break;
936
1181
  cursor = startUnit(graph, cursor, next);
937
1182
  const node = graph.units.find((u) => u.id === next)!;
938
- let ending = await runUnit(step, bot, instanceId, node, plan);
939
- // The lease continues while the grant renews (decision 0046): each
940
- // `continued` ending opens the next segment of the same unit — a fresh
941
- // pipeline under a fresh lease, from the recorded sha, briefed with the
942
- // previous segment's write-up — until the unit ends some other way.
943
- while (ending.kind === "continued") {
944
- const c = ending;
945
- ending = await runUnit(step, bot, instanceId, node, plan, {
946
- segment: c.segment,
947
- renewalsSpent: c.segment - 1,
948
- spendUsd: c.spendUsd,
949
- ...(c.from !== undefined ? { continueFrom: c.from } : {}),
950
- previousRunId: c.runId,
951
- ...(c.handoff !== undefined ? { previousHandoff: c.handoff } : {}),
952
- });
1183
+ let session: LeaseSegmentProgress | undefined;
1184
+ let ending: DrivenEnding | { kind: "stopped" } = await runUnit(step, bot, instanceId, node, plan);
1185
+ // A machine-renewed continuation opens at once. An idle continuation parks
1186
+ // the whole walk and opens only when the durable wake answer names the
1187
+ // segment (or settles as stopped/expired).
1188
+ for (;;) {
1189
+ if (ending.kind === "continued") {
1190
+ const c = ending;
1191
+ session = {
1192
+ segment: c.segment,
1193
+ renewalsSpent: c.segment - 1,
1194
+ spendUsd: c.spendUsd,
1195
+ ...(c.from !== undefined ? { continueFrom: c.from } : {}),
1196
+ previousRunId: c.runId,
1197
+ ...(c.handoff !== undefined ? { previousHandoff: c.handoff } : {}),
1198
+ };
1199
+ ending = await runUnit(step, bot, instanceId, node, plan, session);
1200
+ continue;
1201
+ }
1202
+ if (ending.kind === "idle") {
1203
+ const parked = await waitOnIdle(
1204
+ step,
1205
+ bot,
1206
+ instanceId,
1207
+ next,
1208
+ stepPrefixOf(next, session),
1209
+ ending,
1210
+ plan.idleDays,
1211
+ session,
1212
+ );
1213
+ if (parked.kind === "ending") {
1214
+ ending = parked.ending;
1215
+ break;
1216
+ }
1217
+ session = parked.session;
1218
+ ending = await runUnit(step, bot, instanceId, node, plan, session);
1219
+ continue;
1220
+ }
1221
+ break;
953
1222
  }
954
1223
  endings[next] = ending.kind;
955
1224
  // A unit is done for its dependents when the base carries its scope: the
@@ -975,12 +1244,10 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
975
1244
  for (const id of blocked) {
976
1245
  const node = graph.units.find((u) => u.id === id)!;
977
1246
  const dep = node.dependsOn.find((d) => cursor.status[d] === "failed" || cursor.status[d] === "blocked")!;
978
- const body = {
979
- parentInstanceId: instanceId,
980
- unit: id,
981
- ending: { kind: "blocked", report: blockedReport(id, dep, endings[dep]!) },
982
- };
983
- await step.do(`${id}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
1247
+ await endUnrunUnit(step, bot, instanceId, id, {
1248
+ kind: "blocked",
1249
+ report: blockedReport(id, dep, endings[dep]!),
1250
+ });
984
1251
  }
985
1252
  if (!plan.stopped && !cursorFinished(cursor))
986
1253
  throw new Error(`the plan's cursor did not finish: ${JSON.stringify(cursor.status)}`);