@coreplane/switchboard 1.266.1 → 1.267.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 (46) hide show
  1. package/dist/assets/deploy/cloudflare-memory/package.json +1 -1
  2. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +1 -1
  3. package/dist/assets/deploy/cloudflare-resident/gc.ts +1 -0
  4. package/dist/assets/deploy/cloudflare-resident/keyedAsyncLock.ts +20 -0
  5. package/dist/assets/deploy/cloudflare-resident/legacyCredentials.ts +57 -0
  6. package/dist/assets/deploy/cloudflare-resident/orphanThreadUsers.ts +14 -0
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +646 -204
  8. package/dist/assets/package-lock.json +4003 -4040
  9. package/dist/assets/package.json +2 -1
  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 +59 -1
  13. package/dist/assets/src/core/coordinator/driver.ts +34 -5
  14. package/dist/assets/src/core/runRecord.ts +38 -3
  15. package/dist/assets/src/core/ship/coordinator.ts +121 -39
  16. package/dist/assets/src/core/trace/attrs.ts +3 -0
  17. package/dist/assets/src/execution/githubMintScope.ts +49 -0
  18. package/dist/assets/src/execution/residentCredentials.ts +9 -37
  19. package/dist/assets/src/execution/residentDestroyGate.ts +12 -0
  20. package/dist/assets/src/execution/residentPoolSpends.ts +96 -0
  21. package/dist/assets/src/execution/residentRecreateAdmission.ts +41 -0
  22. package/dist/assets/src/execution/residentSteps.ts +1 -0
  23. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  24. package/dist/assets/web/dist/assets/{DeliveryPage-BUJckSuW.js → DeliveryPage-QyS6_Q0m.js} +1 -1
  25. package/dist/assets/web/dist/assets/{HomePage-BfpIrKDX.js → HomePage-Bm40r0wF.js} +1 -1
  26. package/dist/assets/web/dist/assets/{PendingTurnRow-C6hroa00.js → PendingTurnRow-BqY-EaZR.js} +1 -1
  27. package/dist/assets/web/dist/assets/{PlanePage-C2Cb1mkT.js → PlanePage-VmzjNLAz.js} +1 -1
  28. package/dist/assets/web/dist/assets/{ResidentDetailPage-Bd9_Q87l.js → ResidentDetailPage-hjX3tRCh.js} +1 -1
  29. package/dist/assets/web/dist/assets/{ResidentsIndexPage-C8eS7hxp.js → ResidentsIndexPage-BKGnlCEh.js} +1 -1
  30. package/dist/assets/web/dist/assets/{RunFoldRow-DrfFauYJ.js → RunFoldRow-BOWvSoIv.js} +1 -1
  31. package/dist/assets/web/dist/assets/{RunRoutePage-B_eXfNAF.js → RunRoutePage-D9ztf-uE.js} +3 -3
  32. package/dist/assets/web/dist/assets/{RunsIndexPage-BpQu36Dm.js → RunsIndexPage-DTYQbhtr.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ScheduledPage-DGHcjcSt.js → ScheduledPage-BAYR0tuv.js} +1 -1
  34. package/dist/assets/web/dist/assets/{SettingsPage-IfQ71QjN.js → SettingsPage-BlYBmCG9.js} +1 -1
  35. package/dist/assets/web/dist/assets/{SilentTurn-D9ON15a8.js → SilentTurn-CHfzRQW8.js} +1 -1
  36. package/dist/assets/web/dist/assets/{StatusDot-DyFjGg4V.js → StatusDot-BMkUFVE8.js} +1 -1
  37. package/dist/assets/web/dist/assets/{Tooltip-BvPkV4kZ.js → Tooltip-Dfot16IA.js} +1 -1
  38. package/dist/assets/web/dist/assets/{UnitRoutePage-BNe3nBIG.js → UnitRoutePage-BdZoT-4d.js} +1 -1
  39. package/dist/assets/web/dist/assets/budgets-CJHiTZmM.js +1 -0
  40. package/dist/assets/web/dist/assets/{dist-v3Py3_QR.js → dist-XRrc0hhm.js} +1 -1
  41. package/dist/assets/web/dist/assets/{indexRow-BsWcHHVh.js → indexRow-BGjjLnN5.js} +1 -1
  42. package/dist/assets/web/dist/assets/{main-ks3kBojV.js → main-B32N1vUh.js} +2 -2
  43. package/dist/assets/web/dist/assets/{sseReplay-DuALaBPq.js → sseReplay-BsNQPLmJ.js} +2 -2
  44. package/dist/cli.js +1708 -625
  45. package/package.json +1 -1
  46. package/dist/assets/web/dist/assets/budgets-BQgcCyvb.js +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.266.1",
3
+ "version": "1.267.1",
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",
@@ -102,6 +102,7 @@
102
102
  "@opencode/protocol": "2.0.3",
103
103
  "@opencode/schema": "2.0.3",
104
104
  "@types/node": "^24.0.0",
105
+ "@vue/compiler-sfc": "3.5.42",
105
106
  "effect": "4.0.0-rc.112",
106
107
  "eslint": "^10.10.0",
107
108
  "eslint-config-prettier": "^10.1.8",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.266.1",
3
- "commit": "2b840cc31a6aae35accff736dad1dc74a30c2db3",
4
- "builtAt": "2026-09-26T22:15:46.755Z"
2
+ "version": "1.267.1",
3
+ "commit": "6382de7f03c465e18b040fd93103afb1dc44d543",
4
+ "builtAt": "2026-09-28T23:55:40.901Z"
5
5
  }
@@ -64,6 +64,16 @@ export const QUESTION_TTL_MS = DAY_MS;
64
64
  * that FAILED is never cached — the next read asks again. */
65
65
  export const AUTHOR_BINDING_TTL_MS = 15 * MINUTE_MS;
66
66
 
67
+ /** Maximum local lifetime of any Git receive-pack forward. The bounded
68
+ * request prevents a write connection outliving its run indefinitely after
69
+ * revocation. An aborted remote write has an unknown outcome; existing-PR
70
+ * writes retain their owned pending intent for authoritative reconciliation. */
71
+ export const GIT_RECEIVE_PACK_FORWARD_TIMEOUT_MS = 5 * MINUTE_MS;
72
+ /** The run's final record waits for that one forwarded report, plus a small
73
+ * durable-receipt margin, before sealing. If it still has no answer, the
74
+ * pending intent stays as an uncertain write and no retry is authorized. */
75
+ export const GIT_PUBLICATION_SETTLE_TIMEOUT_MS = GIT_RECEIVE_PACK_FORWARD_TIMEOUT_MS + 10 * SECOND_MS;
76
+
67
77
  /** How long a dispatch's FIRST attach to a resident — a fresh run's, or a
68
78
  * resumed run's re-attach to its recorded worktree — waits for the resident
69
79
  * to wake when the Worker typed its refusal as the platform's transient
@@ -493,11 +493,30 @@ export interface RoundGate {
493
493
  findings: string[];
494
494
  }
495
495
 
496
+ /** A later human review is evidence to investigate, never typed findings. */
497
+ export interface RecoveryReviewEvidence {
498
+ id: number;
499
+ reviewer: { login: string; id: number };
500
+ headSha: string;
501
+ submittedAt: number;
502
+ body: string;
503
+ }
504
+
505
+ /** The original grant and historically priced children carried through a recovery checkpoint. */
506
+ export interface RecoveryAccounting {
507
+ spendUsd: number;
508
+ children: { runId: string; key: string; usd: number }[];
509
+ grant: Grant;
510
+ renewalsSpent: number;
511
+ }
512
+
496
513
  /** One admitted continuation of an ended original unit. The claim replaces
497
514
  * the terminal interpretation before a child starts; its step remains under
498
515
  * the original instance/unit idempotency namespace. */
499
516
  export interface OriginalUnitRecovery {
500
517
  kind: "findings" | "review";
518
+ externalReview?: RecoveryReviewEvidence;
519
+ accounting?: RecoveryAccounting;
501
520
  round: number;
502
521
  expectedHeadSha: string;
503
522
  remainingMs: number;
@@ -531,6 +550,8 @@ export interface OriginalUnitRecovery {
531
550
  }
532
551
 
533
552
  export interface OriginalUnitRecoveryReceipt {
553
+ externalReview?: RecoveryReviewEvidence;
554
+ accounting?: RecoveryAccounting;
534
555
  reviewRunId: string;
535
556
  workflowId: string;
536
557
  at: number;
@@ -697,7 +718,6 @@ const isRoundGate = (v: unknown): boolean =>
697
718
  isAddressSeverity(v.level) &&
698
719
  Array.isArray(v.findings) &&
699
720
  v.findings.every((f) => typeof f === "string");
700
-
701
721
  export const isHumanGatePending = (v: unknown): v is HumanGatePending =>
702
722
  isObject(v) &&
703
723
  isObject(v.pr) &&
@@ -758,6 +778,35 @@ export const isUnitWakeAnswer = (v: unknown): v is UnitWakeAnswer => {
758
778
  );
759
779
  };
760
780
 
781
+ const isPositiveId = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
782
+ const isDollars = (v: unknown): v is number => isFinite(v) && v >= 0;
783
+ const isRecoveryReview = (v: unknown): v is RecoveryReviewEvidence =>
784
+ isObject(v) &&
785
+ isPositiveId(v.id) &&
786
+ isObject(v.reviewer) &&
787
+ isText(v.reviewer.login) &&
788
+ isPositiveId(v.reviewer.id) &&
789
+ typeof v.headSha === "string" &&
790
+ /^[0-9a-f]{40}$/i.test(v.headSha) &&
791
+ isFinite(v.submittedAt) &&
792
+ typeof v.body === "string";
793
+ const isRecoveryAccounting = (v: unknown): v is RecoveryAccounting =>
794
+ isObject(v) &&
795
+ isDollars(v.spendUsd) &&
796
+ Array.isArray(v.children) &&
797
+ v.children.length > 0 &&
798
+ v.children.every((c) => isObject(c) && isText(c.runId) && isText(c.key) && isDollars(c.usd)) &&
799
+ new Set(v.children.map((c) => c.runId)).size === v.children.length &&
800
+ new Set(v.children.map((c) => c.key)).size === v.children.length &&
801
+ v.children.reduce((sum, c) => sum + c.usd, 0) === v.spendUsd &&
802
+ isObject(v.grant) &&
803
+ isDollars(v.grant.renewals) &&
804
+ Number.isSafeInteger(v.grant.renewals) &&
805
+ (v.grant.costCapUsd === undefined || (isDollars(v.grant.costCapUsd) && v.grant.costCapUsd > v.spendUsd)) &&
806
+ isDollars(v.renewalsSpent) &&
807
+ Number.isSafeInteger(v.renewalsSpent) &&
808
+ v.renewalsSpent <= v.grant.renewals;
809
+
761
810
  export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
762
811
  if (!isObject(v)) return false;
763
812
  const r = v;
@@ -811,6 +860,13 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
811
860
  typeof r.recovery.step === "string" &&
812
861
  STEP_NAME_PATTERN.test(r.recovery.step) &&
813
862
  isText(r.recovery.reviewRunId) &&
863
+ (r.recovery.accounting === undefined || isRecoveryAccounting(r.recovery.accounting)) &&
864
+ (r.recovery.externalReview === undefined ||
865
+ (isRecoveryReview(r.recovery.externalReview) &&
866
+ r.recovery.kind === "review" &&
867
+ isRecoveryAccounting(r.recovery.accounting) &&
868
+ r.recovery.findings === undefined &&
869
+ r.recovery.findingsRunId === undefined)) &&
814
870
  (r.recovery.findingsRunId === undefined || isText(r.recovery.findingsRunId)) &&
815
871
  (r.recovery.findingsKey === undefined || isText(r.recovery.findingsKey)) &&
816
872
  ((r.recovery.findingsRunId === undefined && r.recovery.findingsKey === undefined) ||
@@ -841,6 +897,8 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
841
897
  r.recoveryReceipt !== undefined &&
842
898
  (!isObject(r.recoveryReceipt) ||
843
899
  !isText(r.recoveryReceipt.reviewRunId) ||
900
+ (r.recoveryReceipt.externalReview !== undefined && !isRecoveryReview(r.recoveryReceipt.externalReview)) ||
901
+ (r.recoveryReceipt.accounting !== undefined && !isRecoveryAccounting(r.recoveryReceipt.accounting)) ||
844
902
  typeof r.recoveryReceipt.workflowId !== "string" ||
845
903
  !INSTANCE_ID_PATTERN.test(r.recoveryReceipt.workflowId) ||
846
904
  !isFinite(r.recoveryReceipt.at))
@@ -423,7 +423,8 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
423
423
  ...(typeof costUsd === "number" || costUsd === null ? { costUsd } : {}),
424
424
  ...(handoffLists !== undefined ? { handoffLists } : {}),
425
425
  // The failure by name (run-history item 57), shape-checked: a
426
- // `provider_transient` drives the round-0 re-run (agent-ship item 9).
426
+ // A provider transient or incomplete local model stream drives the
427
+ // round-0 re-run (agent-ship item 9).
427
428
  ...(isRecord(failure) && typeof failure.kind === "string" ? { failure: { kind: failure.kind } } : {}),
428
429
  // What ended an interrupted child (issue 1876): the ending's sentence
429
430
  // names the cause instead of claiming a bot restart for every one.
@@ -686,6 +687,18 @@ function isOpaqueNotFound(reply: BotReply): boolean {
686
687
  }
687
688
  }
688
689
 
690
+ /** A finished, addressed child whose final record is still being sealed.
691
+ * Unlike an opaque 404, this answer already establishes the child's identity. */
692
+ function isRecordPending(reply: BotReply): boolean {
693
+ if (reply.status !== 409) return false;
694
+ try {
695
+ const body = JSON.parse(reply.text) as { ok?: unknown; error?: unknown };
696
+ return body?.ok === false && body.error === "record_pending";
697
+ } catch {
698
+ return false;
699
+ }
700
+ }
701
+
689
702
  async function readRecordStep(
690
703
  step: StepRunner,
691
704
  bot: CoordinatorBot,
@@ -695,8 +708,9 @@ async function readRecordStep(
695
708
  for (let retry = 0; ; retry++) {
696
709
  const name = retry === 0 ? action.step : `${action.step}/record-read/${retry}`;
697
710
  const reply = await step.do(name, STEP_CONFIG, () => call(bot, "read-record", body, true));
698
- if (!isOpaqueNotFound(reply) || action.finishedObserved !== true || retry >= SHIP_RECORD_VISIBILITY.retries)
699
- return reply;
711
+ const pending = isRecordPending(reply);
712
+ const eventQualifiedMissing = action.finishedObserved === true && isOpaqueNotFound(reply);
713
+ if ((!pending && !eventQualifiedMissing) || retry >= SHIP_RECORD_VISIBILITY.retries) return reply;
700
714
  await step.sleep(`${action.step}/record-visible/${retry + 1}`, SHIP_RECORD_VISIBILITY.retryMs);
701
715
  }
702
716
  }
@@ -1043,7 +1057,15 @@ async function runUnit(
1043
1057
  addressSeveritySource: plan.addressSeveritySource,
1044
1058
  // Recovery spends only the lease already carried by the claim. It never
1045
1059
  // opens another segment or idles for a renewal in this checkpoint.
1046
- grant: row?.recovery !== undefined ? { renewals: 0 } : plan.grant,
1060
+ grant:
1061
+ row?.recovery === undefined
1062
+ ? plan.grant
1063
+ : (row.recovery.accounting?.grant ??
1064
+ // A missing capped carry must still stop before dispatch, while an
1065
+ // uncapped legacy checkpoint must not acquire plan renewals.
1066
+ (plan.grant?.costCapUsd !== undefined
1067
+ ? { renewals: 0, costCapUsd: plan.grant.costCapUsd }
1068
+ : { renewals: 0 })),
1047
1069
  grantSource: plan.grantSource,
1048
1070
  verbosity: plan.verbosity,
1049
1071
  idleDays: row?.recovery !== undefined ? 0 : plan.idleDays,
@@ -1054,7 +1076,13 @@ async function runUnit(
1054
1076
  ...(lastPush !== undefined ? { lastPush } : {}),
1055
1077
  ...(session !== undefined ? { session } : {}),
1056
1078
  ...(row?.recovery !== undefined
1057
- ? { recovery: { remainingMs: row.recovery.remainingMs, unitKey: `${instanceId}:${unit}` } }
1079
+ ? {
1080
+ recovery: {
1081
+ remainingMs: row.recovery.remainingMs,
1082
+ unitKey: `${instanceId}:${unit}`,
1083
+ ...(row.recovery.accounting !== undefined ? { renewalsSpent: row.recovery.accounting.renewalsSpent } : {}),
1084
+ },
1085
+ }
1058
1086
  : {}),
1059
1087
  };
1060
1088
  let state: UnitPipelineState =
@@ -1065,6 +1093,7 @@ async function runUnit(
1065
1093
  pr: row.pr!,
1066
1094
  expectedHeadSha: row.recovery.expectedHeadSha,
1067
1095
  reviewRunId: row.recovery.reviewRunId,
1096
+ ...(row.recovery.accounting !== undefined ? { spendUsd: row.recovery.accounting.spendUsd } : {}),
1068
1097
  ...(row.recovery.findingsRunId !== undefined ? { findingsRunId: row.recovery.findingsRunId } : {}),
1069
1098
  ...(row.recovery.findings !== undefined ? { findings: row.recovery.findings } : {}),
1070
1099
  })
@@ -279,6 +279,17 @@ export interface RunRecord {
279
279
  * last `pushed_head` event's sha winning — the fact a renewal reads to know
280
280
  * the run made progress. Present only on a run that pushed. */
281
281
  pushed?: PushedHead[];
282
+ /** A Git-door write whose remote outcome was still uncertain at seal. The
283
+ * live ledger row is deleted at finish, so recovery retains this exact
284
+ * ref transition until head reconciliation. A PR number exists only for a
285
+ * verified existing-PR publication. */
286
+ doorPublicationPending?: {
287
+ id: string;
288
+ repo: string;
289
+ pr?: number;
290
+ owner?: { instanceId: string; unit: string };
291
+ update: { ref: string; old: string; next: string };
292
+ };
282
293
  /** What the run cost in tokens, per model, summed from its `model.turn`
283
294
  * spans at finish (`usageOfEvents`; docs/reference/specs/costs.md, cost by user).
284
295
  * Every record written since carries it (zero turns included); one written
@@ -572,12 +583,15 @@ function isRunPullRequestShape(v: unknown): v is RunPullRequest {
572
583
  * explanation's words — so the session's next seed leaves the refused
573
584
  * request out of its tail (docs/reference/specs/session-log.md item 9).
574
585
  * `provider_transient`: the run's model call failed on a provider transient
575
- * (a gateway 5xx, a cut stream, a gateway timeout) with the harness's retry
586
+ * (a gateway 5xx or timeout) with the harness's retry
576
587
  * ladder spent — the ship runner reads it off the child's record to re-run a
577
588
  * round-0 child that pushed nothing instead of aborting the unit
578
- * (docs/reference/specs/agent-ship.md item 9, issue 1932). A failure without
589
+ * (docs/reference/specs/agent-ship.md item 9, issue 1932).
590
+ * `model_stream_incomplete`: pi's answer stream ended incomplete after its
591
+ * local retry lease, with no provider-down evidence; Ship grants the same
592
+ * one re-run when nothing was pushed. A failure without
579
593
  * a name here leaves the record without the field. */
580
- export const RUN_FAILURE_KINDS = ["policy_refusal", "provider_transient"] as const;
594
+ export const RUN_FAILURE_KINDS = ["policy_refusal", "provider_transient", "model_stream_incomplete"] as const;
581
595
  export type RunFailureKind = (typeof RUN_FAILURE_KINDS)[number];
582
596
  export interface RunFailure {
583
597
  kind: RunFailureKind;
@@ -1121,6 +1135,27 @@ export function isRunRecord(v: unknown): v is RunRecord {
1121
1135
  )
1122
1136
  )
1123
1137
  return false;
1138
+ if (r.doorPublicationPending !== undefined) {
1139
+ const pending = r.doorPublicationPending;
1140
+ if (typeof pending !== "object" || pending === null) return false;
1141
+ const { id, repo, pr, owner, update } = pending as Record<string, unknown>;
1142
+ if (
1143
+ typeof id !== "string" ||
1144
+ !id ||
1145
+ typeof repo !== "string" ||
1146
+ !repo ||
1147
+ (pr !== undefined && (!Number.isInteger(pr) || (pr as number) < 1))
1148
+ )
1149
+ return false;
1150
+ if (owner !== undefined && (typeof owner !== "object" || owner === null)) return false;
1151
+ if (typeof update !== "object" || update === null) return false;
1152
+ const o = owner as Record<string, unknown> | undefined;
1153
+ const u = update as Record<string, unknown>;
1154
+ if (o !== undefined && (typeof o.instanceId !== "string" || typeof o.unit !== "string")) return false;
1155
+ if (typeof u.ref !== "string" || !u.ref.startsWith("refs/heads/")) return false;
1156
+ if (typeof u.old !== "string" || !/^[0-9a-f]{40}$/.test(u.old)) return false;
1157
+ if (typeof u.next !== "string" || !/^[0-9a-f]{40}$/.test(u.next)) return false;
1158
+ }
1124
1159
  // A parent is named by a run id (item 46): the same shape as the record's own.
1125
1160
  if (r.parentRunId !== undefined && (typeof r.parentRunId !== "string" || !RUN_ID_PATTERN.test(r.parentRunId)))
1126
1161
  return false;
@@ -630,8 +630,8 @@ export type ChildFacts =
630
630
  costUsd?: number | null;
631
631
  handoffLists?: Handoff;
632
632
  /** The failure by name off a `failed` run's record (run-history item 57):
633
- * `provider_transient` marks a child a gateway 5xx, a cut stream or a
634
- * gateway timeout ended past the harness's retry ladder (issue 1932). */
633
+ * provider transport failure or an incomplete local model stream may
634
+ * earn one round-0 re-run when nothing was pushed (issue 1932). */
635
635
  failure?: { kind: string };
636
636
  /** What ended an `interrupted` child, off its record's own events (issue
637
637
  * 1876): the ending's sentence names it instead of claiming a bot
@@ -996,14 +996,14 @@ export type UnitEnding =
996
996
  }
997
997
  | { kind: "no_verdict"; round: RoundRef; reviewRounds: number; finalReply?: string }
998
998
  | { kind: "idle_expired"; reviewRounds: number }
999
- /** Round 0's coding child died on a provider transient — a model-gateway
1000
- * 5xx, a cut stream, a gateway timeout, past the harness's retry ladder —
999
+ /** Round 0's coding child died on a retryable model-call failure — a
1000
+ * provider transient or an incomplete local stream past its retry ladder —
1001
1001
  * with nothing pushed, TWICE: the first such death re-ran the round once
1002
1002
  * (the ledger row and the branch untouched, a re-run costs only minutes),
1003
1003
  * and the second is the ending (agent-ship item 9, issue 1932). Named
1004
1004
  * `transient` so it reads as a condition beside `checks_failed` and `held`
1005
1005
  * in the plane's table, never as the child failing on its task. */
1006
- | { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number }
1006
+ | { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number; streamIncomplete?: true }
1007
1007
  | {
1008
1008
  kind: "interrupted";
1009
1009
  round: RoundRef;
@@ -1182,7 +1182,7 @@ export interface UnitPipelineInput {
1182
1182
  /** A terminal original unit re-entered at its unchanged reviewed head. The
1183
1183
  * exact remaining lease is carried in milliseconds; the step prefix keeps
1184
1184
  * every new durable step under `<original instance>:<unit>/recovery/...`. */
1185
- recovery?: { remainingMs: number; unitKey: string };
1185
+ recovery?: { remainingMs: number; unitKey: string; renewalsSpent?: number };
1186
1186
  }
1187
1187
 
1188
1188
  type Phase =
@@ -1243,17 +1243,21 @@ type Phase =
1243
1243
  * must independently prove an open branch and pull request at exactly
1244
1244
  * `childHead`; neither a push record nor this flag is remote evidence. */
1245
1245
  findingsReady?: true;
1246
+ /** A capped recovery must verify this pushed head before ending, but
1247
+ * missing findings outputs still forbid review or merge-ready success. */
1248
+ findingsIncomplete?: string[];
1246
1249
  /** The coding child died (`failed` or `interrupted`) after it may have
1247
1250
  * pushed: the pr-check recovers a pushed branch by opening its pull
1248
1251
  * request; with nothing pushed the unit ends with the child's own reason. */
1249
1252
  dead?: "failed" | "interrupted";
1250
1253
  /** What ended the dead child (issue 1876), for the `interrupted` ending's sentence. */
1251
1254
  cause?: InterruptionCause;
1252
- /** The dead child's record names a provider transient (`failure:
1253
- * provider_transient`, issue 1932): with nothing pushed, round 0 is
1254
- * re-run once instead of the unit aborting; a second transient in the
1255
- * same round is the `transient` ending. */
1255
+ /** The dead child's record names a retryable model-call failure
1256
+ * (run-history item 57): with nothing pushed, round 0 is re-run once;
1257
+ * a second such failure is the `transient` ending. */
1256
1258
  transient?: true;
1259
+ /** The retry signal was a local incomplete stream, not provider-down. */
1260
+ streamIncomplete?: true;
1257
1261
  }
1258
1262
  /** `queued`: the door enqueued the pull request — the base takes changes
1259
1263
  * only through a merge queue (issue 2011) — so every later ask reads the
@@ -1417,6 +1421,7 @@ export function openRecoveredUnitPipeline(
1417
1421
  pr: PrRef;
1418
1422
  expectedHeadSha: string;
1419
1423
  reviewRunId: string;
1424
+ spendUsd?: number;
1420
1425
  findingsRunId?: string;
1421
1426
  findings?: Finding[];
1422
1427
  },
@@ -1435,12 +1440,10 @@ export function openRecoveredUnitPipeline(
1435
1440
  reviewRestarts: 0,
1436
1441
  pr: recovery.pr,
1437
1442
  lastReviewHead: recovery.expectedHeadSha,
1438
- // Legacy terminal rows do not carry a complete lifetime category split or
1439
- // cumulative dollars. Recovery therefore measures only this checkpoint;
1440
- // admission refuses a cost-capped row rather than resetting an enforced
1441
- // total, and reports label this split as checkpoint-local.
1443
+ // Elapsed categories remain checkpoint-local. Enforced cumulative dollars
1444
+ // come only from the admission claim's complete persisted child evidence.
1442
1445
  spentMs: { coding: 0, review: 0, waiting: 0 },
1443
- spendUsd: 0,
1446
+ spendUsd: recovery.spendUsd ?? (input.grant?.costCapUsd === undefined ? 0 : null),
1444
1447
  findingsByRound: recovery.kind === "findings" ? { [recovery.round]: recovery.findings ?? [] } : {},
1445
1448
  humanAnswersByRound: {},
1446
1449
  dispositionsByRound: {},
@@ -1648,7 +1651,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1648
1651
  return {
1649
1652
  type: "pr-check",
1650
1653
  step: `${roundStep(s, p.round)}/pr-check`,
1651
- ...(p.dead !== undefined || p.recover === true || p.findingsReady === true
1654
+ ...(p.dead !== undefined || p.recover === true || p.findingsReady === true || p.findingsIncomplete !== undefined
1652
1655
  ? { recover: { runId: p.runId } }
1653
1656
  : {}),
1654
1657
  // The adopted pull request rides the check so the bot can follow it
@@ -1747,7 +1750,10 @@ function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEndin
1747
1750
  const grant = s.input.grant ?? DEFAULT_GRANT;
1748
1751
  // Unspent: an idle spends no renewal — the wake's segment does (this plan's
1749
1752
  // fifth unit) — so the row says what the grant still holds.
1750
- const renewalsLeft = Math.max(0, grant.renewals - (s.input.session?.renewalsSpent ?? 0));
1753
+ const renewalsLeft = Math.max(
1754
+ 0,
1755
+ grant.renewals - (s.input.recovery?.renewalsSpent ?? s.input.session?.renewalsSpent ?? 0),
1756
+ );
1751
1757
  // The head to continue from: a segment's own; a review_pending's reviewed
1752
1758
  // head — the pull request's, known even when the coding record carried none,
1753
1759
  // so the re-issue's resume-at-review fact survives the idle; else the last
@@ -1832,7 +1838,10 @@ function parkHumanGate(
1832
1838
  kind: "idle",
1833
1839
  why: "held",
1834
1840
  idled: held,
1835
- renewalsLeft: Math.max(0, grant.renewals - (s.input.session?.renewalsSpent ?? 0)),
1841
+ renewalsLeft: Math.max(
1842
+ 0,
1843
+ grant.renewals - (s.input.recovery?.renewalsSpent ?? s.input.session?.renewalsSpent ?? 0),
1844
+ ),
1836
1845
  ...(s.lastReviewHead !== undefined ? { from: s.lastReviewHead } : {}),
1837
1846
  ...(s.lastCodingRunId !== undefined ? { runId: s.lastCodingRunId } : {}),
1838
1847
  spendUsd: s.spendUsd,
@@ -1878,10 +1887,30 @@ function capEnding(s: UnitPipelineState, round?: RoundRef, refused?: Carve): Uni
1878
1887
  };
1879
1888
  }
1880
1889
 
1890
+ /** Recovery cannot buy another child (or claim readiness) after the original
1891
+ * dollar cap is spent. Unknown cost is not zero and never resets that cap. */
1892
+ function recoveryCostEnding(s: UnitPipelineState): UnitEnding | undefined {
1893
+ const cap = s.input.grant?.costCapUsd;
1894
+ if (s.input.recovery === undefined || cap === undefined) return undefined;
1895
+ if (s.spendUsd !== null && Number.isFinite(s.spendUsd) && s.spendUsd >= 0 && s.spendUsd < cap) return undefined;
1896
+ return {
1897
+ kind: "aborted",
1898
+ reason:
1899
+ s.spendUsd === null
1900
+ ? `The original $${cap} cost cap cannot be proven: cumulative child spend is unknown.`
1901
+ : `The original $${cap} cost cap is exhausted; cumulative child spend is $${s.spendUsd}.`,
1902
+ reviewRounds: s.reviewRounds,
1903
+ };
1904
+ }
1905
+
1881
1906
  /** Start a round if its carve holds (agent-ship item 8): a round the
1882
1907
  * remainder cannot carve above its floor is not dispatched, and the unit ends
1883
1908
  * at the cap naming the round, what it would have got and the floor. */
1884
1909
  function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNote[] = []): Transition {
1910
+ if (s.input.recovery !== undefined && round.index > s.input.caps.maxRounds)
1911
+ return end(s, { kind: "round_cap", maxRounds: s.input.caps.maxRounds, reviewRounds: s.reviewRounds }, notes);
1912
+ const costEnding = recoveryCostEnding(s);
1913
+ if (costEnding !== undefined) return end(s, costEnding, notes);
1885
1914
  const carved = roundCarve(s, round);
1886
1915
  if (carved.kind === "refused") return end(s, capEnding(s, round, carved), notes);
1887
1916
  const reviewRounds = round.kind === "review" ? round.index : s.reviewRounds;
@@ -1976,6 +2005,11 @@ function settleCoding(
1976
2005
  }
1977
2006
  : {}),
1978
2007
  };
2008
+ // A completed findings child may have moved the head. Its verification
2009
+ // read reconciles that head before the cap ends the unit.
2010
+ const costEnding = recoveryCostEnding(next);
2011
+ if (costEnding !== undefined && !(round.kind === "findings" && facts.status === "completed"))
2012
+ return end(next, costEnding, [roundNote(round, "aborted")]);
1979
2013
  const readySalvage = round.kind === "coding" && completedSameHeadSalvage(next, facts.pushed, facts);
1980
2014
  const checkpoint = interruptedCheckpoint(next, facts.pushed, round.kind === "findings" ? undefined : facts);
1981
2015
  const mode = stopMode(facts.status);
@@ -2007,6 +2041,21 @@ function settleCoding(
2007
2041
  if (facts.description !== true) missingOutputs.push("missing updated pull request description");
2008
2042
  const observedHead = fullHead(facts.headSha);
2009
2043
  if (observedHead === undefined) missingOutputs.push("missing final observed commit");
2044
+ if (
2045
+ missingOutputs.length > 0 &&
2046
+ costEnding !== undefined &&
2047
+ observedHead !== undefined &&
2048
+ facts.pushed?.length === 1 &&
2049
+ facts.pushed[0]?.ref === next.input.unit.branch &&
2050
+ facts.pushed[0]?.sha === observedHead
2051
+ )
2052
+ return {
2053
+ state: {
2054
+ ...next,
2055
+ phase: { at: "pr-check", round, runId, childHead: observedHead, findingsIncomplete: missingOutputs },
2056
+ },
2057
+ notes: [],
2058
+ };
2010
2059
  if (missingOutputs.length > 0)
2011
2060
  return end(
2012
2061
  next,
@@ -2060,9 +2109,11 @@ function settleCoding(
2060
2109
  const cause =
2061
2110
  facts.failure?.kind === "provider_transient"
2062
2111
  ? "the model provider's transport retry budget was spent"
2063
- : facts.status === "failed"
2064
- ? "it failed before finishing"
2065
- : "it ended before its work was ready for review";
2112
+ : facts.failure?.kind === "model_stream_incomplete"
2113
+ ? "the model stream ended before a complete answer"
2114
+ : facts.status === "failed"
2115
+ ? "it failed before finishing"
2116
+ : "it ended before its work was ready for review";
2066
2117
  return end(
2067
2118
  next,
2068
2119
  {
@@ -2088,7 +2139,10 @@ function settleCoding(
2088
2139
  round,
2089
2140
  runId,
2090
2141
  dead: "failed",
2091
- ...(facts.failure?.kind === "provider_transient" ? { transient: true as const } : {}),
2142
+ ...(facts.failure?.kind === "provider_transient" || facts.failure?.kind === "model_stream_incomplete"
2143
+ ? { transient: true as const }
2144
+ : {}),
2145
+ ...(facts.failure?.kind === "model_stream_incomplete" ? { streamIncomplete: true as const } : {}),
2092
2146
  },
2093
2147
  },
2094
2148
  notes: [],
@@ -2117,10 +2171,11 @@ function settleReview(
2117
2171
  facts: Extract<ChildFacts, { finished: true }>,
2118
2172
  ): Transition {
2119
2173
  const mode = stopMode(facts.status);
2174
+ const charged = { ...s, spendUsd: addSpend(s.spendUsd, facts.costUsd) };
2120
2175
  if (!facts.verdict) {
2121
2176
  if (mode !== undefined)
2122
2177
  return end(
2123
- s,
2178
+ charged,
2124
2179
  {
2125
2180
  kind: "stopped",
2126
2181
  mode,
@@ -2131,7 +2186,7 @@ function settleReview(
2131
2186
  [roundNote(round, "stopped")],
2132
2187
  );
2133
2188
  return end(
2134
- s,
2189
+ charged,
2135
2190
  {
2136
2191
  kind: "no_verdict",
2137
2192
  round,
@@ -2143,13 +2198,14 @@ function settleReview(
2143
2198
  }
2144
2199
  const verdict = facts.verdict;
2145
2200
  const next: UnitPipelineState = {
2146
- ...s,
2147
- spendUsd: addSpend(s.spendUsd, facts.costUsd),
2201
+ ...charged,
2148
2202
  findingsByRound: { ...s.findingsByRound, [round.index]: verdict.findings },
2149
2203
  ...(facts.reviewHead !== undefined ? { lastReviewHead: facts.reviewHead } : {}),
2150
2204
  ...(verdict.summary !== undefined ? { lastVerdictSummary: verdict.summary } : {}),
2151
2205
  };
2152
2206
  const notes = [roundNote(round, verdict.verdict)];
2207
+ const costEnding = recoveryCostEnding(next);
2208
+ if (costEnding !== undefined) return end(next, costEnding, notes);
2153
2209
  if (verdict.verdict === "approve") {
2154
2210
  // Merge-ready stands on the POSTED approval: an approve whose post did
2155
2211
  // not land left no approving review on the pull request. The reason is
@@ -2619,7 +2675,7 @@ function finishSupersededChild(
2619
2675
  if (phase.round.kind === "findings") {
2620
2676
  const settled = settleCoding(s, phase.round, phase.runId, facts);
2621
2677
  const ready = settled.state.phase;
2622
- if (ready.at === "pr-check" && ready.findingsReady === true)
2678
+ if (ready.at === "pr-check" && (ready.findingsReady === true || ready.findingsIncomplete !== undefined))
2623
2679
  return settlePrCheck(settled.state, ready, supersession.pullRequest);
2624
2680
  return settled;
2625
2681
  }
@@ -2672,11 +2728,24 @@ export function reconcileTerminalPr(s: UnitPipelineState, pr: PrCheck): UnitPipe
2672
2728
  /** The pull request heading the branch after round 0 or a findings step. */
2673
2729
  function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-check" }>, pr: PrCheck): Transition {
2674
2730
  const { round } = phase;
2731
+ const endIncompleteFindings = (verified: UnitPipelineState): Transition => {
2732
+ const cap = recoveryCostEnding(verified);
2733
+ return end(
2734
+ verified,
2735
+ {
2736
+ kind: "aborted",
2737
+ reason: `${cap?.kind === "aborted" ? `${cap.reason} ` : ""}The completed findings work still lacks required results (${phase.findingsIncomplete?.join("; ")}); no review started.`,
2738
+ round,
2739
+ reviewRounds: verified.reviewRounds,
2740
+ },
2741
+ [roundNote(round, "aborted")],
2742
+ );
2743
+ };
2675
2744
  // A completed findings run is not ready because it says it pushed. It is
2676
2745
  // ready only when this independent read sees the adopted pull request at the
2677
2746
  // exact full observed source head (and, while open, its live branch). Every
2678
2747
  // other remote fact is a resumable stop with no review or merged success.
2679
- if (phase.findingsReady === true) {
2748
+ if (phase.findingsReady === true || phase.findingsIncomplete !== undefined) {
2680
2749
  const observedHead = fullHead(phase.childHead)!;
2681
2750
  const withPr = pr.state !== "none" ? { ...s, pr: { number: pr.prNumber, url: pr.url } } : s;
2682
2751
  if (pr.state === "merged") {
@@ -2708,6 +2777,8 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
2708
2777
  },
2709
2778
  [roundNote(round, "aborted")],
2710
2779
  );
2780
+ if (phase.findingsIncomplete !== undefined)
2781
+ return endIncompleteFindings({ ...withPr, lastReviewHead: remoteHead });
2711
2782
  return foundMerged(withPr, pr, [roundNote(round, "completed")]);
2712
2783
  }
2713
2784
  if (pr.state !== "open" || pr.headBranchExists === false)
@@ -2751,6 +2822,10 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
2751
2822
  },
2752
2823
  [roundNote(round, "aborted")],
2753
2824
  );
2825
+ const reconciled = { ...withPr, lastReviewHead: remoteHead };
2826
+ if (phase.findingsIncomplete !== undefined) return endIncompleteFindings(reconciled);
2827
+ const costEnding = recoveryCostEnding(reconciled);
2828
+ if (costEnding !== undefined) return end(reconciled, costEnding, [roundNote(round, "aborted")]);
2754
2829
  return roundOnOpenPr(withPr, phase, pr);
2755
2830
  }
2756
2831
  // The merge landed during the round: the child found nothing left to ship
@@ -2776,15 +2851,23 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
2776
2851
  [roundNote(round, "aborted")],
2777
2852
  );
2778
2853
  if (phase.dead === "failed") {
2779
- // A provider transient with nothing pushed is not the child's failure
2854
+ // A retryable model-call failure with nothing pushed is not the child's failure
2780
2855
  // (issue 1932): the ledger row and the branch are untouched, so round 0
2781
2856
  // is re-run once — a fresh attempt under fresh step names — and only a
2782
2857
  // second transient in the same round is the ending, named `transient`.
2783
2858
  if (phase.transient && round.kind === "coding" && pr.unrecovered === "no_commits") {
2784
2859
  if ((round.attempt ?? 1) < 2) return enterRound(s, { ...round, attempt: 2 }, [roundNote(round, "transient")]);
2785
- return end(s, { kind: "transient", round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
2786
- roundNote(round, "transient"),
2787
- ]);
2860
+ return end(
2861
+ s,
2862
+ {
2863
+ kind: "transient",
2864
+ round,
2865
+ runId: phase.runId,
2866
+ reviewRounds: s.reviewRounds,
2867
+ ...(phase.streamIncomplete ? { streamIncomplete: true as const } : {}),
2868
+ },
2869
+ [roundNote(round, "transient")],
2870
+ );
2788
2871
  }
2789
2872
  // The abort repeats the bot's reason for recovering nothing, and claims
2790
2873
  // no more than the answer carried.
@@ -3825,11 +3908,10 @@ function renderUnitReportWithWake(
3825
3908
  const level = s.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
3826
3909
  const levelLine = `Severity addressed: ${level} and above (set by ${s.input.addressSeveritySource ?? "org"}).`;
3827
3910
  // The grant as the instance carries it (decision 0046): spent of granted,
3828
- // the cap when one is set, and who granted it — zero of zero until a scope
3829
- // or a directive says otherwise, and nothing spends it before the renewal
3830
- // decision exists.
3911
+ // the cap when one is set, and who granted it. A capped recovery carries
3912
+ // proven spent renewals; an uncapped legacy checkpoint has zero capacity.
3831
3913
  const grant = s.input.grant ?? DEFAULT_GRANT;
3832
- const grantLine = `Renewals: 0 of ${grant.renewals} spent${grant.costCapUsd !== undefined ? `, cost cap $${grant.costCapUsd}` : ""} (granted by ${s.input.grantSource ?? "org"}).`;
3914
+ const grantLine = `Renewals: ${s.input.recovery?.renewalsSpent ?? 0} of ${grant.renewals} spent${grant.costCapUsd !== undefined ? `, cost cap $${grant.costCapUsd}` : ""} (granted by ${s.input.grantSource ?? "org"}).`;
3833
3915
  const lastFindings = s.findingsByRound[e.reviewRounds] ?? [];
3834
3916
  const skipped = lastFindings.filter((f) => !findingsAtOrAbove([f], level).length);
3835
3917
  const skippedLine =
@@ -4076,11 +4158,11 @@ function renderUnitReportWithWake(
4076
4158
  ]);
4077
4159
  case "transient":
4078
4160
  if (!shows(verbosity, "verbose"))
4079
- return `⚠️ Aborted after ${rounds}: this is a bug — the model provider failed twice and no automatic retry remains.${prLine}`;
4161
+ return `⚠️ Aborted after ${rounds}: this is a bug — the model call failed twice and no automatic retry remains.${prLine}`;
4080
4162
  return join([
4081
- `⚠️ The coding child of round ${e.round.index} (run ${e.runId}) died on a provider transient — a model-gateway 5xx, a cut stream or a gateway timeout past the harness's retry ladder — with nothing pushed, after the round was already re-run once for the same reason. The task itself was never the problem.`,
4163
+ `⚠️ The coding child of round ${e.round.index} (run ${e.runId}) ${e.streamIncomplete ? "ended because the model stream ended before a complete answer" : "died on a provider transient — a model-gateway 5xx or gateway timeout past the harness's retry ladder"} — with nothing pushed, after the round was already re-run once for a retryable model-call failure. The task itself was never the problem.`,
4082
4164
  writeUpPointer(s, e.round.kind, s.lastCodingRunId),
4083
- `⚠️ This is a bug: ship ended after ${rounds} because its automatic provider retry was spent.`,
4165
+ `⚠️ This is a bug: ship ended after ${rounds} because its automatic model-call retry was spent.`,
4084
4166
  nextAction(),
4085
4167
  ]);
4086
4168
  case "no_verdict":