@coreplane/switchboard 1.258.0 → 1.260.0

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 (47) hide show
  1. package/dist/assets/config/config.example.yaml +17 -25
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +178 -27
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +74 -52
  4. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +6 -3
  5. package/dist/assets/package-lock.json +3 -3
  6. package/dist/assets/package.json +1 -1
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +3 -5
  9. package/dist/assets/src/core/authz/policy.ts +5 -0
  10. package/dist/assets/src/core/budgets.ts +7 -0
  11. package/dist/assets/src/core/coordinator/contract.ts +93 -5
  12. package/dist/assets/src/core/coordinator/driver.ts +386 -124
  13. package/dist/assets/src/core/memory/types.ts +5 -0
  14. package/dist/assets/src/core/pipelineStanding.ts +11 -4
  15. package/dist/assets/src/core/plane/decide.ts +53 -12
  16. package/dist/assets/src/core/provider.ts +8 -3
  17. package/dist/assets/src/core/runEvents.ts +14 -15
  18. package/dist/assets/src/core/runRecord.ts +36 -3
  19. package/dist/assets/src/core/ship/coordinator.ts +58 -18
  20. package/dist/assets/src/core/ship/renewal.ts +21 -8
  21. package/dist/assets/src/core/types.ts +12 -1
  22. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  23. package/dist/assets/web/dist/assets/{DeliveryPage-DadntjSp.js → DeliveryPage-DvMrWUg7.js} +1 -1
  24. package/dist/assets/web/dist/assets/HomePage-CcGEJ4w0.js +1 -0
  25. package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-BzGVxDYs.js} +1 -1
  26. package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-Arj9cyd5.js} +1 -1
  27. package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-CBPPe4Sj.js} +1 -1
  28. package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-CEpXgGo7.js} +1 -1
  29. package/dist/assets/web/dist/assets/RunFoldRow-BJq7tnLr.js +1 -0
  30. package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-DWiny7LN.js} +4 -4
  31. package/dist/assets/web/dist/assets/RunsIndexPage-Cxufw5sV.js +1 -0
  32. package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-SBcuDdVg.js} +1 -1
  33. package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-DrD_vWdp.js} +1 -1
  34. package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-Bjl7EPEN.js} +1 -1
  35. package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-BqteQTav.js} +1 -1
  36. package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-KRqJXEPN.js} +1 -1
  37. package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-Cv28FV4I.js} +1 -1
  38. package/dist/assets/web/dist/assets/{budgets-BRLm6oID.js → budgets-DQXVllsm.js} +1 -1
  39. package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-CTASUSno.js} +1 -1
  40. package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-B1YBy_IL.js} +1 -1
  41. package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-eTAe9-hk.js} +2 -2
  42. package/dist/assets/web/dist/assets/{sseReplay-BjlKmQsY.js → sseReplay-BcsNbn2j.js} +1 -1
  43. package/dist/cli.js +8324 -7937
  44. package/package.json +1 -1
  45. package/dist/assets/web/dist/assets/HomePage-BEEpTnVT.js +0 -1
  46. package/dist/assets/web/dist/assets/RunFoldRow-OPe7Z0Cz.js +0 -1
  47. package/dist/assets/web/dist/assets/RunsIndexPage-0YAClYQh.js +0 -1
@@ -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
  };
@@ -686,8 +704,70 @@ async function perform(
686
704
  }
687
705
  }
688
706
 
707
+ /** The throw's one line, for the step-threw ending's report (issue 2100). */
708
+ function oneLineOf(err: unknown): string {
709
+ const line = (err instanceof Error ? err.message : String(err)).split("\n")[0]!.trim();
710
+ if (line.length === 0) return "an error with no message";
711
+ return line.length > REASON_MAX ? `${line.slice(0, REASON_MAX)}…` : line;
712
+ }
713
+
714
+ /** Where the machine was when a step threw: the step's own name and, inside a
715
+ * round, the round it belonged to. */
716
+ interface StepAt {
717
+ step: string;
718
+ round?: { index: number; kind: string };
719
+ }
720
+
721
+ /** A step that throws inside the walk becomes the unit's ending (issue 2100):
722
+ * kind `failed`, cause `step_threw`, the step and round it was in and the
723
+ * throw's one line, posted in the user's words — best effort, before the
724
+ * throw is rethrown to fail the instance — so the unit's row carries a cause
725
+ * a person can read instead of the bare "no ending was recorded" seal (the
726
+ * walk-dies-before-unit-end path behind issues 2063 and 2100: run 21c50656
727
+ * died between round 2's review verdict and its outcome write). */
728
+ async function tellStepThrew(
729
+ step: StepRunner,
730
+ bot: CoordinatorBot,
731
+ prefix: string,
732
+ tag: { parentInstanceId: string; unit: string },
733
+ pr: { number: number; url: string } | undefined,
734
+ at: StepAt,
735
+ err: unknown,
736
+ ): Promise<void> {
737
+ const line = oneLineOf(err);
738
+ const where =
739
+ at.round === undefined
740
+ ? `at \`${at.step}\``
741
+ : at.round.kind === "review" && /\/review\/(read|checks)\b/.test(at.step)
742
+ ? `after round ${at.round.index}'s review verdict (\`${at.step}\`)`
743
+ : `in round ${at.round.index} (\`${at.step}\`)`;
744
+ const report =
745
+ `⚠️ The runner failed ${where}: ${line}\n\n` +
746
+ "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.";
747
+ const body = {
748
+ ...tag,
749
+ ending: {
750
+ kind: "failed",
751
+ cause: "step_threw",
752
+ step: at.step,
753
+ ...(at.round !== undefined ? { round: at.round.index } : {}),
754
+ report,
755
+ threadReport: report,
756
+ },
757
+ ...(pr !== undefined ? { pr } : {}),
758
+ };
759
+ try {
760
+ await step.do(`${prefix}/end/threw`, STEP_CONFIG, () => call(bot, "unit-end", body));
761
+ } catch {
762
+ // Best effort: the rethrow still fails the instance, and a bot that could
763
+ // not record the ending leaves the seal's line as before.
764
+ }
765
+ }
766
+
689
767
  /** One unit's pipeline: its start, then the machine's steps until it ends;
690
768
  * every round boundary and the ending told to the bot as they happen. */
769
+ type DrivenEnding = UnitEnding & { endedAt?: number };
770
+
691
771
  async function runUnit(
692
772
  step: StepRunner,
693
773
  bot: CoordinatorBot,
@@ -695,16 +775,28 @@ async function runUnit(
695
775
  node: PlanUnitNode,
696
776
  plan: PlanFacts,
697
777
  session?: LeaseSegmentProgress,
698
- ): Promise<UnitEnding> {
778
+ ): Promise<DrivenEnding> {
699
779
  const unit = node.id;
700
780
  // A renewal's segment names its steps under the segment (`U10/s2/…`), so
701
781
  // the Workflow's durable step cache never answers segment two with segment
702
782
  // one's results (decision 0046).
703
783
  const prefix = stepPrefixOf(unit, session);
704
784
  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
- );
785
+ // Where the machine is, tracked for the step-threw ending (issue 2100). The
786
+ // start belongs in the same net as the rest of the unit even though no
787
+ // pipeline state (and therefore no round) exists yet.
788
+ let last: StepAt = { step: `${prefix}/start` };
789
+ let start: ReturnType<typeof readUnitStart>;
790
+ try {
791
+ const startStep = `${prefix}/start`;
792
+ last = { step: startStep };
793
+ start = readUnitStart(
794
+ answerOf("unit-start", await step.do(startStep, STEP_CONFIG, () => call(bot, "unit-start", tag))),
795
+ );
796
+ } catch (err) {
797
+ await tellStepThrew(step, bot, prefix, tag, undefined, last, err);
798
+ throw err;
799
+ }
708
800
  // A resume at review (agent-ship item 10) rides the unit's row: the pull
709
801
  // request of ship's own the requester named opens the pipeline at its first
710
802
  // review round, with no pre-check, no branch and no round 0.
@@ -739,108 +831,139 @@ async function runUnit(
739
831
  start.at,
740
832
  );
741
833
  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 }),
834
+ let endedAt: number | undefined;
835
+ try {
836
+ for (;;) {
837
+ const action = nextAction(state);
838
+ if (action.type === "end") return { ...action.ending, ...(endedAt !== undefined ? { endedAt } : {}) };
839
+ // Keep the current round across its non-round phases (for example merge)
840
+ // while replacing it whenever the pipeline names a new one.
841
+ last = {
842
+ step: action.step,
843
+ ...("round" in state.phase
844
+ ? { round: state.phase.round }
845
+ : last.round !== undefined
846
+ ? { round: last.round }
847
+ : {}),
848
+ };
849
+ const transition = applyReturn(state, await perform(step, bot, instanceId, unit, action));
850
+ state = transition.state;
851
+ for (const note of transition.notes) {
852
+ if (note.type === "round") {
853
+ const body = {
854
+ ...tag,
855
+ index: note.index,
856
+ agent: note.agent,
857
+ outcome: note.outcome,
858
+ ...(note.gate !== undefined ? { gate: note.gate } : {}),
859
+ };
860
+ const noteStep = `${prefix}/note/${++notes}`;
861
+ last = { step: noteStep, round: { index: note.index, kind: note.agent } };
862
+ await step.do(noteStep, STEP_CONFIG, () => call(bot, "round", body));
863
+ } else {
864
+ // A merge_ready ending names the pull request as it is at the APPROVED
865
+ // head (agent-ship item 9): one more pr-check reads the facts fresh —
866
+ // auto-merge may have been switched on since the round's check, or it
867
+ // may already have fired, in which case the answer is `merged` and the
868
+ // report says so rather than naming a gate that has passed. An
869
+ // unreadable answer just leaves the facts out.
870
+ let endFacts: MergeReadyFacts | undefined;
871
+ if (note.ending.kind === "merge_ready") {
872
+ try {
873
+ const factsStep = `${prefix}/end/pr-facts`;
874
+ last = { step: factsStep, ...(last.round !== undefined ? { round: last.round } : {}) };
875
+ const check = prCheckReturn(
876
+ factsStep,
877
+ answerOf(
878
+ "pr-check",
879
+ await step.do(factsStep, STEP_CONFIG, () => call(bot, "pr-check", { ...tag, checks: true })),
773
880
  ),
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
881
+ );
882
+ if (check.type === "pr-check" && check.pr.state === "merged")
883
+ endFacts = { merged: { sha: check.pr.sha, mergedAt: check.pr.mergedAt } };
884
+ else if (check.type === "pr-check" && check.pr.state === "open")
885
+ endFacts = {
886
+ ...(check.pr.autoMergeEnabled !== undefined ? { autoMergeEnabled: check.pr.autoMergeEnabled } : {}),
887
+ // The checks at the approved head (record 0055): the report's
888
+ // headline is a claim about them, never "merge-ready" over a red one.
889
+ ...(check.pr.checks !== undefined ? { checks: check.pr.checks } : {}),
890
+ // The ready state beside them (agent-ship item 9): a conflicting
891
+ // head, or one carrying an unsquashed fix-up commit, is reported
892
+ // approved-but-not-merge-ready, never "merge-ready".
893
+ ...(check.pr.mergeableState !== undefined ? { mergeableState: check.pr.mergeableState } : {}),
894
+ ...(check.pr.fixupCommits !== undefined ? { fixupCommits: check.pr.fixupCommits } : {}),
895
+ // The base's merge-queue rule (issue 2011): the merge:person
896
+ // path names "queued" in its remaining-gate line.
897
+ ...(check.pr.baseHasMergeQueue !== undefined
898
+ ? { baseHasMergeQueue: check.pr.baseHasMergeQueue }
899
+ : {}),
900
+ };
901
+ } catch {
902
+ // the report simply omits the fact
903
+ }
792
904
  }
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"
905
+ // The last coding child's run is named so the bot can put its handoff
906
+ // — the deviations it recorded — on the unit's board issue beside the
907
+ // ending (agent-ship item 14).
908
+ const body = {
909
+ ...tag,
910
+ // Two copies (routing-and-config item 28): the full report for the
911
+ // row and the board, and the thread's at the request's verbosity.
912
+ ending: {
913
+ kind: note.ending.kind,
914
+ report: renderUnitReport(state, endFacts),
915
+ threadReport: renderUnitReport(state, endFacts, state.input.verbosity ?? DEFAULT_VERBOSITY),
916
+ // An idle ending carries its continuation facts (record 0051): the
917
+ // bot writes them on the row's `idle` in place of an ending, with
918
+ // the coding run id it already receives below.
919
+ ...(note.ending.kind === "idle"
920
+ ? {
921
+ why: note.ending.why,
922
+ renewalsLeft: note.ending.renewalsLeft,
923
+ ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
924
+ spendUsd: note.ending.spendUsd,
925
+ ...(note.ending.handoff !== undefined ? { handoff: note.ending.handoff } : {}),
926
+ }
927
+ : {}),
928
+ },
929
+ ...(state.pr !== undefined ? { pr: state.pr } : {}),
930
+ // A review_pending ending names the child's own last push so the next
931
+ // attempt's pre-check can start at the review round (the row's lastPush)
932
+ // — an idled one the same, off the idle's `from` (record 0051).
933
+ ...(note.ending.kind === "review_pending" && note.ending.headSha !== undefined
934
+ ? { headSha: note.ending.headSha }
935
+ : note.ending.kind === "idle" && note.ending.why === "review_pending" && note.ending.from !== undefined
936
+ ? { headSha: note.ending.from }
937
+ : {}),
938
+ ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
939
+ // A continued ending is a segment's end, not the unit's: the bot
940
+ // writes the renewal as a row keyed by the next segment's index
941
+ // (decision 0046), so a runner reclaimed here never renews twice.
942
+ ...(note.ending.kind === "continued"
809
943
  ? {
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 } : {}),
944
+ segment: {
945
+ index: note.ending.segment,
946
+ ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
947
+ runId: note.ending.runId,
948
+ },
815
949
  }
816
950
  : {}),
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));
951
+ };
952
+ const endStep = `${prefix}/end`;
953
+ last = { step: endStep, ...(last.round !== undefined ? { round: last.round } : {}) };
954
+ const endAnswer = answerOf(
955
+ "unit-end",
956
+ await step.do(endStep, STEP_CONFIG, () => call(bot, "unit-end", body)),
957
+ );
958
+ endedAt = endAnswer.body.at;
959
+ }
842
960
  }
843
961
  }
962
+ } catch (err) {
963
+ // The walk-dies-before-unit-end path (issue 2100): the throw becomes the
964
+ // unit's ending before it fails the instance, never the bare seal.
965
+ await tellStepThrew(step, bot, prefix, tag, state.pr, last, err);
966
+ throw err;
844
967
  }
845
968
  }
846
969
 
@@ -856,6 +979,24 @@ function blockedReport(unit: string, dep: string, depEnding: string): string {
856
979
  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
980
  }
858
981
 
982
+ /** End a unit that never entered `runUnit` under the same failure net. */
983
+ async function endUnrunUnit(
984
+ step: StepRunner,
985
+ bot: CoordinatorBot,
986
+ instanceId: string,
987
+ unit: string,
988
+ ending: { kind: "stopped" | "blocked"; report: string },
989
+ ): Promise<void> {
990
+ const tag = { parentInstanceId: instanceId, unit };
991
+ const endStep = `${unit}/end`;
992
+ try {
993
+ await step.do(endStep, STEP_CONFIG, () => call(bot, "unit-end", { ...tag, ending }));
994
+ } catch (err) {
995
+ await tellStepThrew(step, bot, unit, tag, undefined, { step: endStep }, err);
996
+ throw err;
997
+ }
998
+ }
999
+
859
1000
  /** The graph as one plan answer carries it: the instance's unit rows, in the plan's order. */
860
1001
  function graphOf(plan: PlanFacts, instanceId: string): PlanGraph {
861
1002
  return {
@@ -900,6 +1041,106 @@ function rereadCursor(cursor: PlanCursor, fresh: PlanGraph): { cursor: PlanCurso
900
1041
  return { cursor: { order, status }, gone };
901
1042
  }
902
1043
 
1044
+ type IdleResult =
1045
+ | { kind: "segment"; session: LeaseSegmentProgress }
1046
+ | { kind: "ending"; ending: Extract<UnitEnding, { kind: "idle_expired" }> | { kind: "stopped" } };
1047
+
1048
+ /** Park one unit behind its indexed wait. A timeout closes the idle; a wake
1049
+ * answer is durable at the bot, so this loop may safely move to the next index
1050
+ * after a transport failure without deciding the same event twice. */
1051
+ async function waitOnIdle(
1052
+ step: StepRunner,
1053
+ bot: CoordinatorBot,
1054
+ instanceId: string,
1055
+ unit: string,
1056
+ prefix: string,
1057
+ idle: Extract<DrivenEnding, { kind: "idle" }>,
1058
+ idleDays: number,
1059
+ currentSession: LeaseSegmentProgress | undefined,
1060
+ ): Promise<IdleResult> {
1061
+ const deadline = (idle.endedAt ?? 0) + idleDays * DAY_MS;
1062
+ let now = idle.endedAt ?? 0;
1063
+ for (let index = 1; ; index += 1) {
1064
+ const waitId = `${prefix}/idle/${index}`;
1065
+ const timeout = Math.max(1, deadline - now);
1066
+ try {
1067
+ await step.waitForEvent(waitId, { type: unitNudgeEventType({ instanceId, unit }), timeout });
1068
+ } catch {
1069
+ const ending = { kind: "idle_expired" as const, reviewRounds: idle.reviewRounds };
1070
+ const endStep = `${waitId}/end`;
1071
+ await step.do(endStep, STEP_CONFIG, () =>
1072
+ call(bot, "unit-end", {
1073
+ parentInstanceId: instanceId,
1074
+ unit,
1075
+ ending: {
1076
+ kind: ending.kind,
1077
+ report: "⌛ Idle expired: no reply continued this unit before its idle window closed.",
1078
+ },
1079
+ }),
1080
+ );
1081
+ return { kind: "ending", ending };
1082
+ }
1083
+ let wake: { answer: UnitWakeAnswer; at: number };
1084
+ try {
1085
+ wake = readWakeAnswer(
1086
+ answerOf(
1087
+ "unit-wake",
1088
+ await step.do(`${waitId}/wake`, STEP_CONFIG, () =>
1089
+ call(bot, "unit-wake", { parentInstanceId: instanceId, unit, waitId }),
1090
+ ),
1091
+ ),
1092
+ );
1093
+ } catch {
1094
+ // The event remains unconsumed when the wake could not store an answer.
1095
+ // A stored answer is replayed by the next identity, so either way the
1096
+ // next indexed wait is the safe place to listen again.
1097
+ continue;
1098
+ }
1099
+ now = wake.at;
1100
+ const answer = wake.answer;
1101
+ if (answer.kind === "answered") continue;
1102
+ if (answer.kind === "expired") {
1103
+ const ending = { kind: "idle_expired" as const, reviewRounds: idle.reviewRounds };
1104
+ await step.do(`${waitId}/end`, STEP_CONFIG, () =>
1105
+ call(bot, "unit-end", {
1106
+ parentInstanceId: instanceId,
1107
+ unit,
1108
+ ending: {
1109
+ kind: ending.kind,
1110
+ report: "⌛ Idle expired: the unit reached its indexed wake limit.",
1111
+ },
1112
+ }),
1113
+ );
1114
+ return { kind: "ending", ending };
1115
+ }
1116
+ if (answer.kind === "stopped") {
1117
+ await step.do(`${waitId}/end`, STEP_CONFIG, () =>
1118
+ call(bot, "unit-end", {
1119
+ parentInstanceId: instanceId,
1120
+ unit,
1121
+ ending: { kind: "stopped", report: "⏹ Stopped: the idle unit was ended by an operator." },
1122
+ }),
1123
+ );
1124
+ return { kind: "ending", ending: { kind: "stopped" } };
1125
+ }
1126
+ return {
1127
+ kind: "segment",
1128
+ session: {
1129
+ segment: answer.index,
1130
+ renewalsSpent: answer.leaseMs !== undefined ? (currentSession?.renewalsSpent ?? 0) : answer.index - 1,
1131
+ spendUsd: answer.spendUsd,
1132
+ ...(answer.from !== undefined ? { continueFrom: answer.from } : {}),
1133
+ ...(answer.runId !== undefined ? { previousRunId: answer.runId } : {}),
1134
+ ...(answer.handoff !== undefined ? { previousHandoff: answer.handoff } : {}),
1135
+ ...(answer.texts.length > 0 ? { texts: answer.texts } : {}),
1136
+ ...(answer.leaseMs !== undefined
1137
+ ? { resume: { leaseMs: answer.leaseMs, attempt: (currentSession?.resume?.attempt ?? 0) + 1 } }
1138
+ : {}),
1139
+ },
1140
+ };
1141
+ }
1142
+ }
1143
+
903
1144
  /** The plan: its units one at a time in dependency order, then the endings of the units it never reached. */
904
1145
  async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string): Promise<PlanRunSummary> {
905
1146
  // The selection is read at every unit boundary (the orchestration-plane plan): the first read opens
@@ -926,8 +1167,7 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
926
1167
  if (plan.stopped) {
927
1168
  for (const id of cursor.order.filter((u) => endings[u] === undefined)) {
928
1169
  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));
1170
+ await endUnrunUnit(step, bot, instanceId, id, { kind: "stopped", report: stoppedReport(id) });
931
1171
  }
932
1172
  break;
933
1173
  }
@@ -935,21 +1175,45 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
935
1175
  if (next === undefined) break;
936
1176
  cursor = startUnit(graph, cursor, next);
937
1177
  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
- });
1178
+ let session: LeaseSegmentProgress | undefined;
1179
+ let ending: DrivenEnding | { kind: "stopped" } = await runUnit(step, bot, instanceId, node, plan);
1180
+ // A machine-renewed continuation opens at once. An idle continuation parks
1181
+ // the whole walk and opens only when the durable wake answer names the
1182
+ // segment (or settles as stopped/expired).
1183
+ for (;;) {
1184
+ if (ending.kind === "continued") {
1185
+ const c = ending;
1186
+ session = {
1187
+ segment: c.segment,
1188
+ renewalsSpent: c.segment - 1,
1189
+ spendUsd: c.spendUsd,
1190
+ ...(c.from !== undefined ? { continueFrom: c.from } : {}),
1191
+ previousRunId: c.runId,
1192
+ ...(c.handoff !== undefined ? { previousHandoff: c.handoff } : {}),
1193
+ };
1194
+ ending = await runUnit(step, bot, instanceId, node, plan, session);
1195
+ continue;
1196
+ }
1197
+ if (ending.kind === "idle") {
1198
+ const parked = await waitOnIdle(
1199
+ step,
1200
+ bot,
1201
+ instanceId,
1202
+ next,
1203
+ stepPrefixOf(next, session),
1204
+ ending,
1205
+ plan.idleDays,
1206
+ session,
1207
+ );
1208
+ if (parked.kind === "ending") {
1209
+ ending = parked.ending;
1210
+ break;
1211
+ }
1212
+ session = parked.session;
1213
+ ending = await runUnit(step, bot, instanceId, node, plan, session);
1214
+ continue;
1215
+ }
1216
+ break;
953
1217
  }
954
1218
  endings[next] = ending.kind;
955
1219
  // A unit is done for its dependents when the base carries its scope: the
@@ -975,12 +1239,10 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
975
1239
  for (const id of blocked) {
976
1240
  const node = graph.units.find((u) => u.id === id)!;
977
1241
  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));
1242
+ await endUnrunUnit(step, bot, instanceId, id, {
1243
+ kind: "blocked",
1244
+ report: blockedReport(id, dep, endings[dep]!),
1245
+ });
984
1246
  }
985
1247
  if (!plan.stopped && !cursorFinished(cursor))
986
1248
  throw new Error(`the plan's cursor did not finish: ${JSON.stringify(cursor.status)}`);
@@ -9,6 +9,7 @@
9
9
  * live in thread history; duplicating them defeats the token win. Every record
10
10
  * keeps provenance (`sourceThreadKey`/`sourceRunId`) so a distilled fact can be
11
11
  * re-grounded in the run that produced it. */
12
+ import type { Effort } from "../../effort.js";
12
13
  import type { TraceOptions } from "../trace/types.js";
13
14
 
14
15
  export interface MemoryRecord {
@@ -170,6 +171,10 @@ export interface MemoryConfig {
170
171
  /** `<provider>/<model>` ref for the post-run reflection (write path) — a cheap
171
172
  * tier. Absent → the run's own resolved model. Never hardcoded (invariant 7). */
172
173
  model?: string;
174
+ /** How hard the extractor's model thinks, one of the five tiers; unset → the
175
+ * model's own default. Applied through the model card like a preset's
176
+ * effort (`turnEffort`, memory.md item 11). */
177
+ effort?: Effort;
173
178
  /** The durable store: the Memory Worker (deploy/cloudflare-memory/). Absent →
174
179
  * an in-process store that a restart loses (dev only; startup warns). */
175
180
  worker?: {