@coreplane/switchboard 1.260.4 → 1.260.6

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 (51) hide show
  1. package/dist/assets/deploy/cloudflare/package.json +2 -2
  2. package/dist/assets/deploy/cloudflare-docs/package.json +1 -1
  3. package/dist/assets/deploy/cloudflare-memory/package.json +2 -2
  4. package/dist/assets/deploy/cloudflare-memory/worker.ts +222 -11
  5. package/dist/assets/deploy/cloudflare-resident/package.json +2 -2
  6. package/dist/assets/deploy/cloudflare-resident/worker.ts +124 -40
  7. package/dist/assets/deploy/cloudflare-sandbox/package.json +2 -2
  8. package/dist/assets/package-lock.json +712 -97
  9. package/dist/assets/package.json +4 -4
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/core/budgets.ts +10 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +5 -0
  13. package/dist/assets/src/core/coordinator/driver.ts +155 -29
  14. package/dist/assets/src/core/pipelineStanding.ts +2 -0
  15. package/dist/assets/src/core/plane/decide.ts +19 -2
  16. package/dist/assets/src/core/provider.ts +380 -0
  17. package/dist/assets/src/core/refusal.ts +2 -1
  18. package/dist/assets/src/core/runEvents.ts +8 -0
  19. package/dist/assets/src/core/runLedger/decisions.ts +10 -1
  20. package/dist/assets/src/core/runLedger/types.ts +13 -1
  21. package/dist/assets/src/core/runRecord.ts +31 -0
  22. package/dist/assets/src/core/ship/contract.ts +2 -0
  23. package/dist/assets/src/core/ship/coordinator.ts +515 -30
  24. package/dist/assets/src/core/types.ts +6 -0
  25. package/dist/assets/src/execution/residentRebind.ts +15 -0
  26. package/dist/assets/src/execution/residentReuse.ts +23 -2
  27. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  28. package/dist/assets/web/dist/assets/{DeliveryPage-BaemWKW8.js → DeliveryPage-BUJckSuW.js} +1 -1
  29. package/dist/assets/web/dist/assets/{HomePage-fgKQHLaO.js → HomePage-CBQpcnaW.js} +1 -1
  30. package/dist/assets/web/dist/assets/{PendingTurnRow-vuWiDzvz.js → PendingTurnRow-DIC86uWA.js} +1 -1
  31. package/dist/assets/web/dist/assets/{PlanePage-DXMJf8FB.js → PlanePage-CfrbMh8t.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentDetailPage-DdC8yU_S.js → ResidentDetailPage-C-SgOUTl.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CSM-KODv.js → ResidentsIndexPage-CiDhvXok.js} +1 -1
  34. package/dist/assets/web/dist/assets/RunFoldRow-DZ8huwUU.js +1 -0
  35. package/dist/assets/web/dist/assets/{RunRoutePage-jo6NZxsP.js → RunRoutePage-Cp6wicYc.js} +3 -3
  36. package/dist/assets/web/dist/assets/{RunsIndexPage-Dx1Y4sSq.js → RunsIndexPage-BPXtfwwx.js} +1 -1
  37. package/dist/assets/web/dist/assets/{ScheduledPage-BuB_NZyR.js → ScheduledPage-DbvP48HI.js} +1 -1
  38. package/dist/assets/web/dist/assets/{SettingsPage-Cn96jB6Q.js → SettingsPage-B87lZaiN.js} +1 -1
  39. package/dist/assets/web/dist/assets/{SilentTurn-D2cJfpEq.js → SilentTurn-CPED_XCV.js} +1 -1
  40. package/dist/assets/web/dist/assets/{StatusDot-DDHUCPVJ.js → StatusDot-CFLn-DZ2.js} +1 -1
  41. package/dist/assets/web/dist/assets/{Tooltip-B4VIHkAI.js → Tooltip-Dx9YBVEx.js} +1 -1
  42. package/dist/assets/web/dist/assets/{UnitRoutePage-C-2DfjLI.js → UnitRoutePage-d4A2X_o7.js} +1 -1
  43. package/dist/assets/web/dist/assets/budgets-BQgcCyvb.js +1 -0
  44. package/dist/assets/web/dist/assets/{dist-Cf1CmCSK.js → dist-Cn51vWpw.js} +11 -11
  45. package/dist/assets/web/dist/assets/{indexRow-Dxg7C8s9.js → indexRow-BsWcHHVh.js} +1 -1
  46. package/dist/assets/web/dist/assets/{main-DxPqaFu9.js → main-DYwRHh0F.js} +2 -2
  47. package/dist/assets/web/dist/assets/{sseReplay-BzoeVxZB.js → sseReplay-DGRDRLua.js} +1 -1
  48. package/dist/cli.js +2550 -924
  49. package/package.json +4 -4
  50. package/dist/assets/web/dist/assets/RunFoldRow-Csik3wD6.js +0 -1
  51. package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.260.4",
3
+ "version": "1.260.6",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -89,11 +89,11 @@
89
89
  "@earendil-works/pi-ai": "0.85.1",
90
90
  "@slack/bolt": "^5.1.0",
91
91
  "aws4fetch": "^1.0.20",
92
- "e2b": "^2.46.1",
92
+ "e2b": "^2.49.1",
93
93
  "mdast-util-from-markdown": "^2.0.3",
94
94
  "undici": "^8.10.2",
95
- "yaml": "^2.7.0",
96
- "zod": "^4.5.4"
95
+ "yaml": "^2.9.1",
96
+ "zod": "^4.6.5"
97
97
  },
98
98
  "devDependencies": {
99
99
  "@earendil-works/pi-coding-agent": "0.85.1",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.260.4",
3
- "commit": "714dbd34ad03841fe421bfdc78648854b4c8dbc1",
4
- "builtAt": "2026-09-22T07:09:50.930Z"
2
+ "version": "1.260.6",
3
+ "commit": "903ae2855833637fbcc0c0a22d54e7268be75965",
4
+ "builtAt": "2026-09-23T04:57:07.101Z"
5
5
  }
@@ -150,6 +150,11 @@ export function intakeReceiptRetentionMs(catchUpWindowMs: number): number {
150
150
  * prompt one bounds the ratio the gate is judged by. */
151
151
  export const INTAKE_RECOVERY_WINDOW_MS = 10 * MINUTE_MS;
152
152
 
153
+ /** How long one process owns the right to post a stored intake-failure
154
+ * sentence. A rejected post releases immediately; this bound only recovers a
155
+ * process that died after the atomic claim and before it could finish it. */
156
+ export const INTAKE_DELIVERY_CLAIM_MS = 5 * MINUTE_MS;
157
+
153
158
  /** The presets that run the tool loop, and the one pipeline preset. */
154
159
  export const LOOP_PRESETS = [
155
160
  "general",
@@ -249,6 +254,11 @@ export const HOSTED_DEADLINE_MARGIN_MINUTES = 60;
249
254
  * and the pause before a busy spawn is asked again. */
250
255
  export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes: 5, busyRetryMinutes: 2 } as const;
251
256
 
257
+ /** A finish event and the run record's durable write are separate deliveries.
258
+ * After the runner receives its own event, it gives the record this short
259
+ * visibility bound before treating an opaque absence as permanent. */
260
+ export const SHIP_RECORD_VISIBILITY = { retries: 3, retryMs: secondsToMs(1) } as const;
261
+
252
262
  /** How long a restarting close gives its replacement dispatch to claim the
253
263
  * same run before a read with no successor treats the close as final
254
264
  * (run-history item 47a). One whole coordinator wait chunk lets an ordinary
@@ -489,6 +489,10 @@ export interface CoordinatorUnit {
489
489
  * — no pre-check, no branch, no round 0. A task string's row only; written by
490
490
  * the hand-off, read by the driver into the machine's input. */
491
491
  resume?: { pr: number; headSha?: string; url?: string };
492
+ /** The decision-record number reserved at admission for this unit. A unit
493
+ * that writes a record carries it through every attempt and briefs its child
494
+ * as `record: NNNN`; the child never scans the directory for a number. */
495
+ record?: string;
492
496
  /** The head the unit's own coding child last pushed, recorded when the unit
493
497
  * ended `review_pending` (the wall clock capped after the pull request was
494
498
  * opened or updated): the next attempt's pre-check starts at the review
@@ -659,6 +663,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
659
663
  if (r.issue !== undefined && !isFinite(r.issue)) return false;
660
664
  if (r.pr !== undefined && !isPr(r.pr)) return false;
661
665
  if (r.resume !== undefined && !isResume(r.resume)) return false;
666
+ if (r.record !== undefined && (typeof r.record !== "string" || !/^\d{4}$/.test(r.record))) return false;
662
667
  if (r.lastPush !== undefined && !isText(r.lastPush)) return false;
663
668
  if (r.segments !== undefined && (!Array.isArray(r.segments) || !r.segments.every(isSegment))) return false;
664
669
  if (r.idle !== undefined && !isUnitIdle(r.idle)) return false;
@@ -39,7 +39,15 @@
39
39
  // plan's re-issue. A task string's ship branch waits for a person. Node-free:
40
40
  // the shim Worker imports this by relative path.
41
41
 
42
- import { DAY_MS, DEFAULT_GRANT, GRANT_RENEWALS_MAX, IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
42
+ import {
43
+ DAY_MS,
44
+ DEFAULT_GRANT,
45
+ GRANT_RENEWALS_MAX,
46
+ IDLE_DAYS_MAX,
47
+ SHIP_RECORD_VISIBILITY,
48
+ type Grant,
49
+ type GrantSource,
50
+ } from "../budgets.js";
43
51
  import { isFindingShape, type Finding } from "../reviewVerdict.js";
44
52
  import { DEFAULT_VERBOSITY, isVerbosity, type Verbosity } from "../verbosity.js";
45
53
  import {
@@ -53,6 +61,7 @@ import {
53
61
  type UnitStatus,
54
62
  renderUnitReport,
55
63
  reenterApprovedRebase,
64
+ reconcileTerminalPr,
56
65
  settleUnit,
57
66
  startUnit,
58
67
  type ChildFacts,
@@ -110,6 +119,7 @@ export type CoordinatorStepRoute =
110
119
  | "branch"
111
120
  | "spawn"
112
121
  | "read-record"
122
+ | "steer"
113
123
  | "pr-check"
114
124
  | "round"
115
125
  | "unit-end"
@@ -334,11 +344,25 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
334
344
  // The hard stop's mark, as the bot's answer carries it (record 0060; issue
335
345
  // 1924): a finished child's unit ends stopped on it.
336
346
  const stopped = a.body.stopped === true ? { stopped: true as const } : {};
347
+ const pullRequest = isRecord(a.body.pullRequest)
348
+ ? prCheckReturn(step, {
349
+ status: a.status,
350
+ body: { ok: true, ...a.body.pullRequest, at: a.body.at },
351
+ }).pr
352
+ : undefined;
337
353
  // The interrupted child restarted from its request (issue 1903): the bot
338
354
  // answers the live successor's id, and the machine keeps the wait on it.
339
355
  const restarted = typeof a.body.restartedAs === "string" ? { restartedAs: a.body.restartedAs } : {};
340
356
  if (!run.finished)
341
- return { type: "read-record", step, run: { finished: false }, ...stopped, ...restarted, at: a.body.at };
357
+ return {
358
+ type: "read-record",
359
+ step,
360
+ run: { finished: false },
361
+ ...(pullRequest !== undefined ? { pullRequest } : {}),
362
+ ...stopped,
363
+ ...restarted,
364
+ at: a.body.at,
365
+ };
342
366
  if (typeof run.status !== "string") throw new UnreadableAnswer("read-record", a, "status");
343
367
  // The typed artifacts as the bot's record carries them — shape-checked where
344
368
  // they were written (the run record's validator), read here as they are.
@@ -367,6 +391,7 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
367
391
  type: "read-record",
368
392
  step,
369
393
  ...stopped,
394
+ ...(pullRequest !== undefined ? { pullRequest } : {}),
370
395
  run: {
371
396
  finished: true,
372
397
  status: run.status as Extract<ChildFacts, { finished: true }>["status"],
@@ -435,8 +460,8 @@ function entryHumanGate(v: unknown):
435
460
  };
436
461
  }
437
462
 
438
- function prCheckReturn(step: string, a: BotAnswer): StepReturn {
439
- const { ok, state, prNumber, url, headSha, sha, mergedAt, at } = a.body;
463
+ function prCheckReturn(step: string, a: BotAnswer): Extract<StepReturn, { type: "pr-check" }> {
464
+ const { ok, state, prNumber, url, headSha, sha, mergedAt, mergedBy, closedBy, at } = a.body;
440
465
  if (ok === true && state === "none") {
441
466
  const { unrecovered, aheadOfBase, prClosed } = a.body;
442
467
  return {
@@ -464,6 +489,7 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
464
489
  prNumber,
465
490
  url,
466
491
  ...(typeof headSha === "string" ? { headSha } : {}),
492
+ ...(typeof a.body.headBranchExists === "boolean" ? { headBranchExists: a.body.headBranchExists } : {}),
467
493
  // The entry facts (issue 1689): the branch's own tip and whether the
468
494
  // bot's approval stands at the head, read at the unit-start's pre-check
469
495
  // so a re-issued plan resumes at review or at the merge decision.
@@ -494,10 +520,36 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
494
520
  typeof sha === "string" &&
495
521
  typeof mergedAt === "string"
496
522
  )
497
- return { type: "pr-check", step, pr: { state: "merged", prNumber, url, sha, mergedAt }, at };
523
+ return {
524
+ type: "pr-check",
525
+ step,
526
+ pr: {
527
+ state: "merged",
528
+ prNumber,
529
+ url,
530
+ sha,
531
+ mergedAt,
532
+ ...(typeof mergedBy === "string" ? { mergedBy } : {}),
533
+ },
534
+ at,
535
+ };
536
+ if (
537
+ ok === true &&
538
+ state === "closed" &&
539
+ typeof prNumber === "number" &&
540
+ typeof url === "string" &&
541
+ typeof closedBy === "string"
542
+ )
543
+ return { type: "pr-check", step, pr: { state: "closed", prNumber, url, closedBy }, at };
498
544
  throw new UnreadableAnswer("pr-check", a, "state");
499
545
  }
500
546
 
547
+ function steerReturn(step: string, a: BotAnswer): Extract<StepReturn, { type: "steer" }> {
548
+ if (a.body.ok === true && (a.body.outcome === "steered" || a.body.outcome === "not_live"))
549
+ return { type: "steer", step, outcome: a.body.outcome, at: a.body.at };
550
+ throw new UnreadableAnswer("steer", a, "outcome");
551
+ }
552
+
501
553
  /** The round's checks read as the bot answered it (record 0055): the runs at
502
554
  * the reviewed head, or none when GitHub could not be read — the machine
503
555
  * treats an absent read as pending and asks again at the chunk's end. A retry
@@ -510,6 +562,14 @@ function checksReturn(step: string, a: BotAnswer): StepReturn {
510
562
  type: "checks",
511
563
  step,
512
564
  ...(isRoundChecks(checks) ? { checks } : {}),
565
+ ...(isRecord(a.body.pullRequest)
566
+ ? {
567
+ pullRequest: prCheckReturn(step, {
568
+ status: a.status,
569
+ body: { ok: true, ...a.body.pullRequest, at },
570
+ }).pr,
571
+ }
572
+ : {}),
513
573
  // The pull request is a draft (issue 2063): the machine's table holds the
514
574
  // unit for the ready event instead of merging or ending without a cause.
515
575
  ...(draft === true ? { draft: true } : {}),
@@ -531,11 +591,31 @@ const isRoundChecks = (v: unknown): v is RoundChecks =>
531
591
  (v.expected === undefined || (Array.isArray(v.expected) && v.expected.every((n: unknown) => typeof n === "string")));
532
592
 
533
593
  function mergeReturn(step: string, a: BotAnswer): StepReturn {
534
- const { ok, outcome, by, sha, mergedAt, reason, at } = a.body;
594
+ const { ok, outcome, by, sha, mergedAt, mergedBy, reason, at } = a.body;
535
595
  // The door found the pull request already merged after the approval: the
536
596
  // merge commit and the time ride the answer, and the unit ends `by: other`.
537
597
  if (ok === true && outcome === "merged" && by === "other" && typeof sha === "string" && typeof mergedAt === "string")
538
- return { type: "merge", step, outcome: "merged", by: "other", sha, mergedAt, at };
598
+ return {
599
+ type: "merge",
600
+ step,
601
+ outcome: "merged",
602
+ by: "other",
603
+ sha,
604
+ mergedAt,
605
+ ...(typeof mergedBy === "string" ? { mergedBy } : {}),
606
+ at,
607
+ };
608
+ if (ok === true && outcome === "recheck" && isRecord(a.body.pullRequest))
609
+ return {
610
+ type: "merge",
611
+ step,
612
+ outcome: "recheck",
613
+ pullRequest: prCheckReturn(step, {
614
+ status: a.status,
615
+ body: { ok: true, ...a.body.pullRequest, at },
616
+ }).pr,
617
+ at,
618
+ };
539
619
  if (ok === true && outcome === "merged" && typeof sha === "string")
540
620
  return { type: "merge", step, outcome: "merged", sha, at };
541
621
  if (
@@ -568,15 +648,47 @@ async function call(
568
648
  bot: CoordinatorBot,
569
649
  route: CoordinatorStepRoute,
570
650
  body: Record<string, unknown>,
651
+ acceptOpaqueNotFound = false,
571
652
  ): Promise<BotReply> {
572
653
  const reply = await bot.step(route, body);
573
654
  const read = readBotAnswer(reply.status, reply.text);
574
- if (!read.ok) throw new Error(`the bot did not answer ${route}: ${read.reason}`);
655
+ if (!read.ok) {
656
+ if (acceptOpaqueNotFound && isOpaqueNotFound(reply)) return reply;
657
+ throw new Error(`the bot did not answer ${route}: ${read.reason}`);
658
+ }
575
659
  const transient = transientRefusal(read.answer);
576
660
  if (transient !== undefined) throw new Error(transient);
577
661
  return reply;
578
662
  }
579
663
 
664
+ /** The read door's authorization mask: absent and denied are deliberately the
665
+ * same reply. The runner may pace that reply only from its own addressed
666
+ * finish event; parsing it here never changes what another caller sees. */
667
+ function isOpaqueNotFound(reply: BotReply): boolean {
668
+ if (reply.status !== 404) return false;
669
+ try {
670
+ const body = JSON.parse(reply.text) as { ok?: unknown; error?: unknown };
671
+ return body?.ok === false && body.error === "not_found";
672
+ } catch {
673
+ return false;
674
+ }
675
+ }
676
+
677
+ async function readRecordStep(
678
+ step: StepRunner,
679
+ bot: CoordinatorBot,
680
+ action: Extract<CoordinatorAction, { type: "read-record" }>,
681
+ body: Record<string, unknown>,
682
+ ): Promise<BotReply> {
683
+ for (let retry = 0; ; retry++) {
684
+ const name = retry === 0 ? action.step : `${action.step}/record-read/${retry}`;
685
+ const reply = await step.do(name, STEP_CONFIG, () => call(bot, "read-record", body, true));
686
+ if (!isOpaqueNotFound(reply) || action.finishedObserved !== true || retry >= SHIP_RECORD_VISIBILITY.retries)
687
+ return reply;
688
+ await step.sleep(`${action.step}/record-visible/${retry + 1}`, SHIP_RECORD_VISIBILITY.retryMs);
689
+ }
690
+ }
691
+
580
692
  /** The answer a stored reply carries — the reply passed `call` once, so it reads the same on replay. */
581
693
  function answerOf(route: CoordinatorStepRoute, reply: BotReply): BotAnswer {
582
694
  const read = readBotAnswer(reply.status, reply.text);
@@ -672,10 +784,17 @@ async function perform(
672
784
  return { type: "wait", step: action.step, outcome: await waitForRun(step, action) };
673
785
  case "read-record":
674
786
  return readRecordReturn(
787
+ action.step,
788
+ answerOf("read-record", await readRecordStep(step, bot, action, { ...tag, runId: action.runId })),
789
+ );
790
+ case "steer":
791
+ return steerReturn(
675
792
  action.step,
676
793
  answerOf(
677
- "read-record",
678
- await step.do(action.step, STEP_CONFIG, () => call(bot, "read-record", { ...tag, runId: action.runId })),
794
+ "steer",
795
+ await step.do(action.step, STEP_CONFIG, () =>
796
+ call(bot, "steer", { ...tag, runId: action.runId, reason: action.reason }),
797
+ ),
679
798
  ),
680
799
  );
681
800
  case "pr-check":
@@ -939,11 +1058,17 @@ async function runUnit(
939
1058
  factsStep,
940
1059
  answerOf(
941
1060
  "pr-check",
942
- await step.do(factsStep, STEP_CONFIG, () => call(bot, "pr-check", { ...tag, checks: true })),
1061
+ await step.do(factsStep, STEP_CONFIG, () =>
1062
+ call(bot, "pr-check", {
1063
+ ...tag,
1064
+ checks: true,
1065
+ ...(state.pr !== undefined ? { pr: state.pr.number } : {}),
1066
+ }),
1067
+ ),
943
1068
  ),
944
1069
  );
945
- if (check.type === "pr-check" && check.pr.state === "merged")
946
- endFacts = { merged: { sha: check.pr.sha, mergedAt: check.pr.mergedAt } };
1070
+ if (check.type === "pr-check" && (check.pr.state === "merged" || check.pr.state === "closed"))
1071
+ state = reconcileTerminalPr(state, check.pr);
947
1072
  else if (check.type === "pr-check" && check.pr.state === "open")
948
1073
  endFacts = {
949
1074
  ...(check.pr.autoMergeEnabled !== undefined ? { autoMergeEnabled: check.pr.autoMergeEnabled } : {}),
@@ -973,6 +1098,7 @@ async function runUnit(
973
1098
  state = reenterApprovedRebase(state);
974
1099
  continue pipeline;
975
1100
  }
1101
+ const ending = state.ending ?? note.ending;
976
1102
  // The last coding child's run is named so the bot can put its handoff
977
1103
  // — the deviations it recorded — on the unit's board issue beside the
978
1104
  // ending (agent-ship item 14).
@@ -981,20 +1107,20 @@ async function runUnit(
981
1107
  // Two copies (routing-and-config item 28): the full report for the
982
1108
  // row and the board, and the thread's at the request's verbosity.
983
1109
  ending: {
984
- kind: note.ending.kind,
1110
+ kind: ending.kind,
985
1111
  report: renderUnitReport(state, endFacts),
986
1112
  threadReport: renderUnitReport(state, endFacts, state.input.verbosity ?? DEFAULT_VERBOSITY),
987
1113
  // An idle ending carries its continuation facts (record 0051): the
988
1114
  // bot writes them on the row's `idle` in place of an ending, with
989
1115
  // the coding run id it already receives below.
990
- ...(note.ending.kind === "idle"
1116
+ ...(ending.kind === "idle"
991
1117
  ? {
992
- why: note.ending.why,
993
- renewalsLeft: note.ending.renewalsLeft,
994
- ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
995
- spendUsd: note.ending.spendUsd,
996
- ...(note.ending.handoff !== undefined ? { handoff: note.ending.handoff } : {}),
997
- ...(note.ending.humanGate !== undefined ? { humanGate: note.ending.humanGate } : {}),
1118
+ why: ending.why,
1119
+ renewalsLeft: ending.renewalsLeft,
1120
+ ...(ending.from !== undefined ? { from: ending.from } : {}),
1121
+ spendUsd: ending.spendUsd,
1122
+ ...(ending.handoff !== undefined ? { handoff: ending.handoff } : {}),
1123
+ ...(ending.humanGate !== undefined ? { humanGate: ending.humanGate } : {}),
998
1124
  }
999
1125
  : {}),
1000
1126
  },
@@ -1002,21 +1128,21 @@ async function runUnit(
1002
1128
  // A review_pending ending names the child's own last push so the next
1003
1129
  // attempt's pre-check can start at the review round (the row's lastPush)
1004
1130
  // — an idled one the same, off the idle's `from` (record 0051).
1005
- ...(note.ending.kind === "review_pending" && note.ending.headSha !== undefined
1006
- ? { headSha: note.ending.headSha }
1007
- : note.ending.kind === "idle" && note.ending.why === "review_pending" && note.ending.from !== undefined
1008
- ? { headSha: note.ending.from }
1131
+ ...(ending.kind === "review_pending" && ending.headSha !== undefined
1132
+ ? { headSha: ending.headSha }
1133
+ : ending.kind === "idle" && ending.why === "review_pending" && ending.from !== undefined
1134
+ ? { headSha: ending.from }
1009
1135
  : {}),
1010
1136
  ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
1011
1137
  // A continued ending is a segment's end, not the unit's: the bot
1012
1138
  // writes the renewal as a row keyed by the next segment's index
1013
1139
  // (decision 0046), so a runner reclaimed here never renews twice.
1014
- ...(note.ending.kind === "continued"
1140
+ ...(ending.kind === "continued"
1015
1141
  ? {
1016
1142
  segment: {
1017
- index: note.ending.segment,
1018
- ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
1019
- runId: note.ending.runId,
1143
+ index: ending.segment,
1144
+ ...(ending.from !== undefined ? { from: ending.from } : {}),
1145
+ runId: ending.runId,
1020
1146
  },
1021
1147
  }
1022
1148
  : {}),
@@ -116,6 +116,7 @@ export const ROUND_STAGE = {
116
116
  * issue 2100) — closes the unit the same way. */
117
117
  export const ENDING_STAGE = {
118
118
  merged: "merged",
119
+ closed: "ended",
119
120
  already_landed: "merged",
120
121
  merge_ready: "merge-ready",
121
122
  held: "ended",
@@ -144,6 +145,7 @@ export const ENDING_STAGE = {
144
145
  * 2100), so a new ending kind fails the build here. */
145
146
  export const ENDING_WORDS = {
146
147
  merged: "merged",
148
+ closed: "closed",
147
149
  already_landed: "merged",
148
150
  merge_ready: "merge-ready",
149
151
  held: "held",
@@ -10,6 +10,7 @@
10
10
  // literal where `clock:check` expects it.)
11
11
 
12
12
  import { PLANE, minutesToMs } from "../budgets.js";
13
+ import type { ProviderFailureCause } from "../provider.js";
13
14
  import type { PlaneFinding } from "./findings.js";
14
15
 
15
16
  // ---- endings and their causes (record 0064, "Endings and the watches") ------------------------
@@ -139,6 +140,9 @@ export interface PlaneLevelRow {
139
140
  side: "below" | "above";
140
141
  reportedAt: number;
141
142
  generation: string;
143
+ /** A provider row's typed down cause; absent on resident rows, provider-up,
144
+ * and reports from an older bot generation. */
145
+ cause?: ProviderFailureCause;
142
146
  }
143
147
 
144
148
  /** The side a resident's level reads for an observer that knows the current
@@ -300,7 +304,13 @@ export type PlaneEvent =
300
304
  /** A provider's level as the model proxy reported it: `up` on a relayed
301
305
  * success, `down` on a failure past its one retry. `up` re-issues every
302
306
  * turn held parked on the provider, once each. */
303
- | { kind: "provider_level"; at: number; provider: string; level: "up" | "down" }
307
+ | {
308
+ kind: "provider_level";
309
+ at: number;
310
+ provider: string;
311
+ level: "up" | "down";
312
+ cause?: ProviderFailureCause;
313
+ }
304
314
  /** A run parked on its provider (record 0064): the harness holds the turn,
305
315
  * the lease keeps counting, and the provider's next `up` steers it once. */
306
316
  | { kind: "park"; at: number; runId: string; provider: string }
@@ -692,7 +702,13 @@ function onHeartbeat(
692
702
  * then walks the queue for anything waiting on `provider_up`. */
693
703
  function onProviderLevel(
694
704
  state: PlaneState,
695
- event: { kind: "provider_level"; at: number; provider: string; level: "up" | "down" },
705
+ event: {
706
+ kind: "provider_level";
707
+ at: number;
708
+ provider: string;
709
+ level: "up" | "down";
710
+ cause?: ProviderFailureCause;
711
+ },
696
712
  ): PlaneDecision {
697
713
  const row: PlaneLevelRow = {
698
714
  resident: event.provider,
@@ -700,6 +716,7 @@ function onProviderLevel(
700
716
  side: event.level === "up" ? "below" : "above",
701
717
  reportedAt: event.at,
702
718
  generation: "",
719
+ ...(event.level === "down" && event.cause !== undefined ? { cause: event.cause } : {}),
703
720
  };
704
721
  const levels = [...state.levels.filter((l) => !(l.resident === event.provider && l.name === "provider")), row];
705
722
  const writes: PlaneWrite[] = [{ table: "plane_levels", op: "put", row }];