@coreplane/switchboard 1.266.0 → 1.267.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 (41) hide show
  1. package/dist/assets/deploy/cloudflare/worker.ts +7 -1
  2. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +5 -1
  3. package/dist/assets/deploy/cloudflare-memory/package.json +1 -1
  4. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +1 -1
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +48 -3
  6. package/dist/assets/deploy/cloudflare-resident/legacyCredentials.ts +43 -0
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +102 -141
  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/channels/githubDoorPaths.ts +36 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +32 -1
  13. package/dist/assets/src/core/coordinator/driver.ts +19 -3
  14. package/dist/assets/src/core/refusal.ts +1 -0
  15. package/dist/assets/src/core/runEvents.ts +1 -0
  16. package/dist/assets/src/core/runRecord.ts +47 -3
  17. package/dist/assets/src/core/ship/coordinator.ts +80 -36
  18. package/dist/assets/src/core/trace/attrs.ts +3 -0
  19. package/dist/assets/src/execution/residentReadonly.ts +9 -8
  20. package/dist/assets/src/execution/residentSteps.ts +2 -1
  21. package/dist/assets/src/execution/seedPlan.ts +2 -2
  22. package/dist/assets/web/dist/.vite/manifest.json +49 -49
  23. package/dist/assets/web/dist/assets/{HomePage-B2Y-Fa9V.js → HomePage-BfpIrKDX.js} +1 -1
  24. package/dist/assets/web/dist/assets/{PendingTurnRow-DIC86uWA.js → PendingTurnRow-C6hroa00.js} +1 -1
  25. package/dist/assets/web/dist/assets/{PlanePage-CfrbMh8t.js → PlanePage-C2Cb1mkT.js} +1 -1
  26. package/dist/assets/web/dist/assets/{ResidentDetailPage-DRNwx2--.js → ResidentDetailPage-Bd9_Q87l.js} +1 -1
  27. package/dist/assets/web/dist/assets/{ResidentsIndexPage-EHKMtX69.js → ResidentsIndexPage-C8eS7hxp.js} +1 -1
  28. package/dist/assets/web/dist/assets/{RunFoldRow-CAdpX4Gn.js → RunFoldRow-DrfFauYJ.js} +1 -1
  29. package/dist/assets/web/dist/assets/{RunRoutePage-DppLhu79.js → RunRoutePage-B_eXfNAF.js} +3 -3
  30. package/dist/assets/web/dist/assets/{RunsIndexPage-C7hx5O1X.js → RunsIndexPage-BpQu36Dm.js} +1 -1
  31. package/dist/assets/web/dist/assets/{ScheduledPage-Dk2kMnYT.js → ScheduledPage-DGHcjcSt.js} +1 -1
  32. package/dist/assets/web/dist/assets/{SettingsPage-DAf6R383.js → SettingsPage-IfQ71QjN.js} +1 -1
  33. package/dist/assets/web/dist/assets/{SilentTurn-CPED_XCV.js → SilentTurn-D9ON15a8.js} +1 -1
  34. package/dist/assets/web/dist/assets/{StatusDot-IkfsHGXg.js → StatusDot-DyFjGg4V.js} +1 -1
  35. package/dist/assets/web/dist/assets/{Tooltip-CmQVfAl2.js → Tooltip-BvPkV4kZ.js} +1 -1
  36. package/dist/assets/web/dist/assets/{UnitRoutePage-BjfuZVWr.js → UnitRoutePage-BNe3nBIG.js} +1 -1
  37. package/dist/assets/web/dist/assets/{dist-Dg6HTzpQ.js → dist-v3Py3_QR.js} +1 -1
  38. package/dist/assets/web/dist/assets/{main-Bk1L-bNk.js → main-ks3kBojV.js} +2 -2
  39. package/dist/assets/web/dist/assets/{sseReplay-DGRDRLua.js → sseReplay-DuALaBPq.js} +1 -1
  40. package/dist/cli.js +2629 -935
  41. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.266.0",
3
+ "version": "1.267.0",
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.0",
3
- "commit": "9b03a2a996929a0e6f8604a7ea830106b0f37e53",
4
- "builtAt": "2026-09-26T17:09:46.306Z"
2
+ "version": "1.267.0",
3
+ "commit": "594f06834184ea4ff5559b00a8fc265f33e3db64",
4
+ "builtAt": "2026-09-28T22:36:17.080Z"
5
5
  }
@@ -0,0 +1,36 @@
1
+ /** The public Git door surface, shared by the bot and its Cloudflare edge. */
2
+ export const GIT_DOOR_PATH =
3
+ /^\/(?:git\/)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/;
4
+
5
+ export function isGithubDoorPath(path: string): boolean {
6
+ return path === "/api/graphql" || path.startsWith("/api/v3/") || GIT_DOOR_PATH.test(path);
7
+ }
8
+
9
+ export function githubDoorMethodAllowed(url: URL, method: string): boolean {
10
+ const path = url.pathname;
11
+ if (path === "/api/graphql") return method === "POST";
12
+ if (path.startsWith("/api/v3/")) return method === "GET" || method === "HEAD";
13
+ const git = GIT_DOOR_PATH.exec(path);
14
+ if (!git) return false;
15
+ if (git[3] === "info/refs")
16
+ return method === "GET" && ["git-upload-pack", "git-receive-pack"].includes(url.searchParams.get("service") ?? "");
17
+ return method === "POST";
18
+ }
19
+
20
+ /** A configured git.* host exposes only the door. A door path on any other
21
+ * host, including workers.dev and the dashboard host, is never forwarded. */
22
+ export function githubDoorEdgeRoute(url: URL, method: string, configuredBase?: string): "door" | "other" | "refuse" {
23
+ const doorPath = isGithubDoorPath(url.pathname);
24
+ if (!configuredBase) return doorPath ? "refuse" : "other";
25
+ let base: URL;
26
+ try {
27
+ base = new URL(configuredBase);
28
+ } catch {
29
+ return "refuse";
30
+ }
31
+ if (base.protocol !== "https:" || base.pathname !== "/" || base.search || base.hash) return "refuse";
32
+ if (url.host === base.host) {
33
+ return url.protocol === "https:" && doorPath && githubDoorMethodAllowed(url, method) ? "door" : "refuse";
34
+ }
35
+ return doorPath ? "refuse" : "other";
36
+ }
@@ -313,6 +313,8 @@ export interface ExistingPrPublicationBinding {
313
313
  export interface CoordinatorTag {
314
314
  parentInstanceId: string;
315
315
  idempotencyKey: string;
316
+ /** The unit's durable write branch, read from its row at child admission. */
317
+ branch?: string;
316
318
  /** Workflow transport for a recovered child. Identity and idempotency remain
317
319
  * on `parentInstanceId`; only lifecycle wake-ups use this checkpoint id. */
318
320
  transportWorkflowId?: string;
@@ -491,11 +493,20 @@ export interface RoundGate {
491
493
  findings: string[];
492
494
  }
493
495
 
496
+ /** The original grant and historically priced children carried through a recovery checkpoint. */
497
+ export interface RecoveryAccounting {
498
+ spendUsd: number;
499
+ children: { runId: string; key: string; usd: number }[];
500
+ grant: Grant;
501
+ renewalsSpent: number;
502
+ }
503
+
494
504
  /** One admitted continuation of an ended original unit. The claim replaces
495
505
  * the terminal interpretation before a child starts; its step remains under
496
506
  * the original instance/unit idempotency namespace. */
497
507
  export interface OriginalUnitRecovery {
498
508
  kind: "findings" | "review";
509
+ accounting?: RecoveryAccounting;
499
510
  round: number;
500
511
  expectedHeadSha: string;
501
512
  remainingMs: number;
@@ -529,6 +540,7 @@ export interface OriginalUnitRecovery {
529
540
  }
530
541
 
531
542
  export interface OriginalUnitRecoveryReceipt {
543
+ accounting?: RecoveryAccounting;
532
544
  reviewRunId: string;
533
545
  workflowId: string;
534
546
  at: number;
@@ -695,7 +707,6 @@ const isRoundGate = (v: unknown): boolean =>
695
707
  isAddressSeverity(v.level) &&
696
708
  Array.isArray(v.findings) &&
697
709
  v.findings.every((f) => typeof f === "string");
698
-
699
710
  export const isHumanGatePending = (v: unknown): v is HumanGatePending =>
700
711
  isObject(v) &&
701
712
  isObject(v.pr) &&
@@ -756,6 +767,24 @@ export const isUnitWakeAnswer = (v: unknown): v is UnitWakeAnswer => {
756
767
  );
757
768
  };
758
769
 
770
+ const isDollars = (v: unknown): v is number => isFinite(v) && v >= 0;
771
+ const isRecoveryAccounting = (v: unknown): v is RecoveryAccounting =>
772
+ isObject(v) &&
773
+ isDollars(v.spendUsd) &&
774
+ Array.isArray(v.children) &&
775
+ v.children.length > 0 &&
776
+ v.children.every((c) => isObject(c) && isText(c.runId) && isText(c.key) && isDollars(c.usd)) &&
777
+ new Set(v.children.map((c) => c.runId)).size === v.children.length &&
778
+ new Set(v.children.map((c) => c.key)).size === v.children.length &&
779
+ v.children.reduce((sum, c) => sum + c.usd, 0) === v.spendUsd &&
780
+ isObject(v.grant) &&
781
+ isDollars(v.grant.renewals) &&
782
+ Number.isSafeInteger(v.grant.renewals) &&
783
+ (v.grant.costCapUsd === undefined || (isDollars(v.grant.costCapUsd) && v.grant.costCapUsd > v.spendUsd)) &&
784
+ isDollars(v.renewalsSpent) &&
785
+ Number.isSafeInteger(v.renewalsSpent) &&
786
+ v.renewalsSpent <= v.grant.renewals;
787
+
759
788
  export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
760
789
  if (!isObject(v)) return false;
761
790
  const r = v;
@@ -809,6 +838,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
809
838
  typeof r.recovery.step === "string" &&
810
839
  STEP_NAME_PATTERN.test(r.recovery.step) &&
811
840
  isText(r.recovery.reviewRunId) &&
841
+ (r.recovery.accounting === undefined || isRecoveryAccounting(r.recovery.accounting)) &&
812
842
  (r.recovery.findingsRunId === undefined || isText(r.recovery.findingsRunId)) &&
813
843
  (r.recovery.findingsKey === undefined || isText(r.recovery.findingsKey)) &&
814
844
  ((r.recovery.findingsRunId === undefined && r.recovery.findingsKey === undefined) ||
@@ -839,6 +869,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
839
869
  r.recoveryReceipt !== undefined &&
840
870
  (!isObject(r.recoveryReceipt) ||
841
871
  !isText(r.recoveryReceipt.reviewRunId) ||
872
+ (r.recoveryReceipt.accounting !== undefined && !isRecoveryAccounting(r.recoveryReceipt.accounting)) ||
842
873
  typeof r.recoveryReceipt.workflowId !== "string" ||
843
874
  !INSTANCE_ID_PATTERN.test(r.recoveryReceipt.workflowId) ||
844
875
  !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.
@@ -1043,7 +1044,15 @@ async function runUnit(
1043
1044
  addressSeveritySource: plan.addressSeveritySource,
1044
1045
  // Recovery spends only the lease already carried by the claim. It never
1045
1046
  // opens another segment or idles for a renewal in this checkpoint.
1046
- grant: row?.recovery !== undefined ? { renewals: 0 } : plan.grant,
1047
+ grant:
1048
+ row?.recovery === undefined
1049
+ ? plan.grant
1050
+ : (row.recovery.accounting?.grant ??
1051
+ // A missing capped carry must still stop before dispatch, while an
1052
+ // uncapped legacy checkpoint must not acquire plan renewals.
1053
+ (plan.grant?.costCapUsd !== undefined
1054
+ ? { renewals: 0, costCapUsd: plan.grant.costCapUsd }
1055
+ : { renewals: 0 })),
1047
1056
  grantSource: plan.grantSource,
1048
1057
  verbosity: plan.verbosity,
1049
1058
  idleDays: row?.recovery !== undefined ? 0 : plan.idleDays,
@@ -1054,7 +1063,13 @@ async function runUnit(
1054
1063
  ...(lastPush !== undefined ? { lastPush } : {}),
1055
1064
  ...(session !== undefined ? { session } : {}),
1056
1065
  ...(row?.recovery !== undefined
1057
- ? { recovery: { remainingMs: row.recovery.remainingMs, unitKey: `${instanceId}:${unit}` } }
1066
+ ? {
1067
+ recovery: {
1068
+ remainingMs: row.recovery.remainingMs,
1069
+ unitKey: `${instanceId}:${unit}`,
1070
+ ...(row.recovery.accounting !== undefined ? { renewalsSpent: row.recovery.accounting.renewalsSpent } : {}),
1071
+ },
1072
+ }
1058
1073
  : {}),
1059
1074
  };
1060
1075
  let state: UnitPipelineState =
@@ -1065,6 +1080,7 @@ async function runUnit(
1065
1080
  pr: row.pr!,
1066
1081
  expectedHeadSha: row.recovery.expectedHeadSha,
1067
1082
  reviewRunId: row.recovery.reviewRunId,
1083
+ ...(row.recovery.accounting !== undefined ? { spendUsd: row.recovery.accounting.spendUsd } : {}),
1068
1084
  ...(row.recovery.findingsRunId !== undefined ? { findingsRunId: row.recovery.findingsRunId } : {}),
1069
1085
  ...(row.recovery.findings !== undefined ? { findings: row.recovery.findings } : {}),
1070
1086
  })
@@ -64,6 +64,7 @@ const CAUSE_OF = {
64
64
  // one pipeline per thread (record 0060): the host key's claim answered thread-live
65
65
  ship_thread_live: "request",
66
66
  setup_failed: "system",
67
+ run_budget_exhausted: "system",
67
68
  publication_ownership_unknown: "system",
68
69
  // the click on a confirmation (confirm.ts)
69
70
  confirmation_used: "request",
@@ -974,6 +974,7 @@ export type RunEvent =
974
974
  type: "coordinator_tag";
975
975
  parentInstanceId: string;
976
976
  unit?: string;
977
+ branch?: string;
977
978
  transportWorkflowId?: string;
978
979
  recovery?: import("./coordinator/contract.js").CoordinatorTag["recovery"];
979
980
  base?: string;
@@ -572,12 +572,15 @@ function isRunPullRequestShape(v: unknown): v is RunPullRequest {
572
572
  * explanation's words — so the session's next seed leaves the refused
573
573
  * request out of its tail (docs/reference/specs/session-log.md item 9).
574
574
  * `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
575
+ * (a gateway 5xx or timeout) with the harness's retry
576
576
  * ladder spent — the ship runner reads it off the child's record to re-run a
577
577
  * 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
578
+ * (docs/reference/specs/agent-ship.md item 9, issue 1932).
579
+ * `model_stream_incomplete`: pi's answer stream ended incomplete after its
580
+ * local retry lease, with no provider-down evidence; Ship grants the same
581
+ * one re-run when nothing was pushed. A failure without
579
582
  * a name here leaves the record without the field. */
580
- export const RUN_FAILURE_KINDS = ["policy_refusal", "provider_transient"] as const;
583
+ export const RUN_FAILURE_KINDS = ["policy_refusal", "provider_transient", "model_stream_incomplete"] as const;
581
584
  export type RunFailureKind = (typeof RUN_FAILURE_KINDS)[number];
582
585
  export interface RunFailure {
583
586
  kind: RunFailureKind;
@@ -702,9 +705,50 @@ export function clampListLimit(limit: number | undefined): number {
702
705
  return Math.min(RUN_LIST_MAX_LIMIT, Math.max(1, Math.floor(limit)));
703
706
  }
704
707
 
708
+ /** The recovery audit must see either identity claim, including contradictory
709
+ * parents, and any activity in the original threads. Never AND these claims. */
710
+ export interface RecoveryEvidenceScope {
711
+ instanceId: string;
712
+ unit: string;
713
+ threadKeys: string[];
714
+ }
715
+
716
+ export function isRecoveryEvidenceScope(value: unknown): value is RecoveryEvidenceScope {
717
+ if (typeof value !== "object" || value === null) return false;
718
+ const scope = value as RecoveryEvidenceScope;
719
+ return (
720
+ typeof scope.instanceId === "string" &&
721
+ scope.instanceId.length > 0 &&
722
+ scope.instanceId.length <= 256 &&
723
+ typeof scope.unit === "string" &&
724
+ /^U[1-9][0-9]*$/.test(scope.unit) &&
725
+ scope.unit.length <= 64 &&
726
+ Array.isArray(scope.threadKeys) &&
727
+ scope.threadKeys.length > 0 &&
728
+ scope.threadKeys.length <= 2 &&
729
+ scope.threadKeys.every((key) => typeof key === "string" && key.length > 0 && key.length <= 256)
730
+ );
731
+ }
732
+
733
+ export function matchesRecoveryEvidence(
734
+ run: { parentInstanceId?: string; idempotencyKey?: string; threadKey?: string },
735
+ scope: RecoveryEvidenceScope,
736
+ ): boolean {
737
+ const unitKey = `${scope.instanceId}:${scope.unit}`;
738
+ return (
739
+ run.parentInstanceId === scope.instanceId ||
740
+ run.idempotencyKey === unitKey ||
741
+ run.idempotencyKey?.startsWith(`${unitKey}/`) === true ||
742
+ (run.threadKey !== undefined && scope.threadKeys.includes(run.threadKey))
743
+ );
744
+ }
745
+
705
746
  /** The `list` query — the same fields on the wire (`/runs/list`) and in the
706
747
  * `RunStore` interface. */
707
748
  export interface RunListOptions {
749
+ /** Internal admission audit: a complete bounded relevant set, never a UI page.
750
+ * A Worker must explicitly attest completeness; a full page is not evidence. */
751
+ recoveryEvidence?: RecoveryEvidenceScope;
708
752
  /** Rows to return: default `RUN_LIST_DEFAULT_LIMIT`, capped at `RUN_LIST_MAX_LIMIT`. */
709
753
  limit?: number;
710
754
  /** Cursor: only runs ordered after the last row seen — `finishedAt` strictly
@@ -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 =
@@ -1249,11 +1249,12 @@ type Phase =
1249
1249
  dead?: "failed" | "interrupted";
1250
1250
  /** What ended the dead child (issue 1876), for the `interrupted` ending's sentence. */
1251
1251
  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. */
1252
+ /** The dead child's record names a retryable model-call failure
1253
+ * (run-history item 57): with nothing pushed, round 0 is re-run once;
1254
+ * a second such failure is the `transient` ending. */
1256
1255
  transient?: true;
1256
+ /** The retry signal was a local incomplete stream, not provider-down. */
1257
+ streamIncomplete?: true;
1257
1258
  }
1258
1259
  /** `queued`: the door enqueued the pull request — the base takes changes
1259
1260
  * only through a merge queue (issue 2011) — so every later ask reads the
@@ -1417,6 +1418,7 @@ export function openRecoveredUnitPipeline(
1417
1418
  pr: PrRef;
1418
1419
  expectedHeadSha: string;
1419
1420
  reviewRunId: string;
1421
+ spendUsd?: number;
1420
1422
  findingsRunId?: string;
1421
1423
  findings?: Finding[];
1422
1424
  },
@@ -1435,12 +1437,10 @@ export function openRecoveredUnitPipeline(
1435
1437
  reviewRestarts: 0,
1436
1438
  pr: recovery.pr,
1437
1439
  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.
1440
+ // Elapsed categories remain checkpoint-local. Enforced cumulative dollars
1441
+ // come only from the admission claim's complete persisted child evidence.
1442
1442
  spentMs: { coding: 0, review: 0, waiting: 0 },
1443
- spendUsd: 0,
1443
+ spendUsd: recovery.spendUsd ?? (input.grant?.costCapUsd === undefined ? 0 : null),
1444
1444
  findingsByRound: recovery.kind === "findings" ? { [recovery.round]: recovery.findings ?? [] } : {},
1445
1445
  humanAnswersByRound: {},
1446
1446
  dispositionsByRound: {},
@@ -1747,7 +1747,10 @@ function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEndin
1747
1747
  const grant = s.input.grant ?? DEFAULT_GRANT;
1748
1748
  // Unspent: an idle spends no renewal — the wake's segment does (this plan's
1749
1749
  // fifth unit) — so the row says what the grant still holds.
1750
- const renewalsLeft = Math.max(0, grant.renewals - (s.input.session?.renewalsSpent ?? 0));
1750
+ const renewalsLeft = Math.max(
1751
+ 0,
1752
+ grant.renewals - (s.input.recovery?.renewalsSpent ?? s.input.session?.renewalsSpent ?? 0),
1753
+ );
1751
1754
  // The head to continue from: a segment's own; a review_pending's reviewed
1752
1755
  // head — the pull request's, known even when the coding record carried none,
1753
1756
  // so the re-issue's resume-at-review fact survives the idle; else the last
@@ -1832,7 +1835,10 @@ function parkHumanGate(
1832
1835
  kind: "idle",
1833
1836
  why: "held",
1834
1837
  idled: held,
1835
- renewalsLeft: Math.max(0, grant.renewals - (s.input.session?.renewalsSpent ?? 0)),
1838
+ renewalsLeft: Math.max(
1839
+ 0,
1840
+ grant.renewals - (s.input.recovery?.renewalsSpent ?? s.input.session?.renewalsSpent ?? 0),
1841
+ ),
1836
1842
  ...(s.lastReviewHead !== undefined ? { from: s.lastReviewHead } : {}),
1837
1843
  ...(s.lastCodingRunId !== undefined ? { runId: s.lastCodingRunId } : {}),
1838
1844
  spendUsd: s.spendUsd,
@@ -1878,10 +1884,29 @@ function capEnding(s: UnitPipelineState, round?: RoundRef, refused?: Carve): Uni
1878
1884
  };
1879
1885
  }
1880
1886
 
1887
+ /** A recovered unit keeps its original dollar cap across the checkpoint. */
1888
+ function recoveryCostEnding(s: UnitPipelineState): UnitEnding | undefined {
1889
+ const cap = s.input.grant?.costCapUsd;
1890
+ if (s.input.recovery === undefined || cap === undefined) return undefined;
1891
+ if (s.spendUsd !== null && Number.isFinite(s.spendUsd) && s.spendUsd >= 0 && s.spendUsd < cap) return undefined;
1892
+ return {
1893
+ kind: "aborted",
1894
+ reason:
1895
+ s.spendUsd === null
1896
+ ? `The original $${cap} cost cap cannot be proven: cumulative child spend is unknown.`
1897
+ : `The original $${cap} cost cap is exhausted; cumulative child spend is $${s.spendUsd}.`,
1898
+ reviewRounds: s.reviewRounds,
1899
+ };
1900
+ }
1901
+
1881
1902
  /** Start a round if its carve holds (agent-ship item 8): a round the
1882
1903
  * remainder cannot carve above its floor is not dispatched, and the unit ends
1883
1904
  * at the cap naming the round, what it would have got and the floor. */
1884
1905
  function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNote[] = []): Transition {
1906
+ if (s.input.recovery !== undefined && round.index > s.input.caps.maxRounds)
1907
+ return end(s, { kind: "round_cap", maxRounds: s.input.caps.maxRounds, reviewRounds: s.reviewRounds }, notes);
1908
+ const costEnding = recoveryCostEnding(s);
1909
+ if (costEnding !== undefined) return end(s, costEnding, notes);
1885
1910
  const carved = roundCarve(s, round);
1886
1911
  if (carved.kind === "refused") return end(s, capEnding(s, round, carved), notes);
1887
1912
  const reviewRounds = round.kind === "review" ? round.index : s.reviewRounds;
@@ -1976,6 +2001,11 @@ function settleCoding(
1976
2001
  }
1977
2002
  : {}),
1978
2003
  };
2004
+ // A completed findings child may have moved the head. Its verification
2005
+ // read reconciles that head before the cap ends the unit.
2006
+ const costEnding = recoveryCostEnding(next);
2007
+ if (costEnding !== undefined && !(round.kind === "findings" && facts.status === "completed"))
2008
+ return end(next, costEnding, [roundNote(round, "aborted")]);
1979
2009
  const readySalvage = round.kind === "coding" && completedSameHeadSalvage(next, facts.pushed, facts);
1980
2010
  const checkpoint = interruptedCheckpoint(next, facts.pushed, round.kind === "findings" ? undefined : facts);
1981
2011
  const mode = stopMode(facts.status);
@@ -2060,9 +2090,11 @@ function settleCoding(
2060
2090
  const cause =
2061
2091
  facts.failure?.kind === "provider_transient"
2062
2092
  ? "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";
2093
+ : facts.failure?.kind === "model_stream_incomplete"
2094
+ ? "the model stream ended before a complete answer"
2095
+ : facts.status === "failed"
2096
+ ? "it failed before finishing"
2097
+ : "it ended before its work was ready for review";
2066
2098
  return end(
2067
2099
  next,
2068
2100
  {
@@ -2088,7 +2120,10 @@ function settleCoding(
2088
2120
  round,
2089
2121
  runId,
2090
2122
  dead: "failed",
2091
- ...(facts.failure?.kind === "provider_transient" ? { transient: true as const } : {}),
2123
+ ...(facts.failure?.kind === "provider_transient" || facts.failure?.kind === "model_stream_incomplete"
2124
+ ? { transient: true as const }
2125
+ : {}),
2126
+ ...(facts.failure?.kind === "model_stream_incomplete" ? { streamIncomplete: true as const } : {}),
2092
2127
  },
2093
2128
  },
2094
2129
  notes: [],
@@ -2117,10 +2152,11 @@ function settleReview(
2117
2152
  facts: Extract<ChildFacts, { finished: true }>,
2118
2153
  ): Transition {
2119
2154
  const mode = stopMode(facts.status);
2155
+ const charged = { ...s, spendUsd: addSpend(s.spendUsd, facts.costUsd) };
2120
2156
  if (!facts.verdict) {
2121
2157
  if (mode !== undefined)
2122
2158
  return end(
2123
- s,
2159
+ charged,
2124
2160
  {
2125
2161
  kind: "stopped",
2126
2162
  mode,
@@ -2131,7 +2167,7 @@ function settleReview(
2131
2167
  [roundNote(round, "stopped")],
2132
2168
  );
2133
2169
  return end(
2134
- s,
2170
+ charged,
2135
2171
  {
2136
2172
  kind: "no_verdict",
2137
2173
  round,
@@ -2143,13 +2179,14 @@ function settleReview(
2143
2179
  }
2144
2180
  const verdict = facts.verdict;
2145
2181
  const next: UnitPipelineState = {
2146
- ...s,
2147
- spendUsd: addSpend(s.spendUsd, facts.costUsd),
2182
+ ...charged,
2148
2183
  findingsByRound: { ...s.findingsByRound, [round.index]: verdict.findings },
2149
2184
  ...(facts.reviewHead !== undefined ? { lastReviewHead: facts.reviewHead } : {}),
2150
2185
  ...(verdict.summary !== undefined ? { lastVerdictSummary: verdict.summary } : {}),
2151
2186
  };
2152
2187
  const notes = [roundNote(round, verdict.verdict)];
2188
+ const costEnding = recoveryCostEnding(next);
2189
+ if (costEnding !== undefined) return end(next, costEnding, notes);
2153
2190
  if (verdict.verdict === "approve") {
2154
2191
  // Merge-ready stands on the POSTED approval: an approve whose post did
2155
2192
  // not land left no approving review on the pull request. The reason is
@@ -2776,15 +2813,23 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
2776
2813
  [roundNote(round, "aborted")],
2777
2814
  );
2778
2815
  if (phase.dead === "failed") {
2779
- // A provider transient with nothing pushed is not the child's failure
2816
+ // A retryable model-call failure with nothing pushed is not the child's failure
2780
2817
  // (issue 1932): the ledger row and the branch are untouched, so round 0
2781
2818
  // is re-run once — a fresh attempt under fresh step names — and only a
2782
2819
  // second transient in the same round is the ending, named `transient`.
2783
2820
  if (phase.transient && round.kind === "coding" && pr.unrecovered === "no_commits") {
2784
2821
  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
- ]);
2822
+ return end(
2823
+ s,
2824
+ {
2825
+ kind: "transient",
2826
+ round,
2827
+ runId: phase.runId,
2828
+ reviewRounds: s.reviewRounds,
2829
+ ...(phase.streamIncomplete ? { streamIncomplete: true as const } : {}),
2830
+ },
2831
+ [roundNote(round, "transient")],
2832
+ );
2788
2833
  }
2789
2834
  // The abort repeats the bot's reason for recovering nothing, and claims
2790
2835
  // no more than the answer carried.
@@ -3825,11 +3870,10 @@ function renderUnitReportWithWake(
3825
3870
  const level = s.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
3826
3871
  const levelLine = `Severity addressed: ${level} and above (set by ${s.input.addressSeveritySource ?? "org"}).`;
3827
3872
  // 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.
3873
+ // the cap when one is set, and who granted it. A capped recovery carries
3874
+ // proven spent renewals; an uncapped legacy checkpoint has zero capacity.
3831
3875
  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"}).`;
3876
+ 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
3877
  const lastFindings = s.findingsByRound[e.reviewRounds] ?? [];
3834
3878
  const skipped = lastFindings.filter((f) => !findingsAtOrAbove([f], level).length);
3835
3879
  const skippedLine =
@@ -4076,11 +4120,11 @@ function renderUnitReportWithWake(
4076
4120
  ]);
4077
4121
  case "transient":
4078
4122
  if (!shows(verbosity, "verbose"))
4079
- return `⚠️ Aborted after ${rounds}: this is a bug — the model provider failed twice and no automatic retry remains.${prLine}`;
4123
+ return `⚠️ Aborted after ${rounds}: this is a bug — the model call failed twice and no automatic retry remains.${prLine}`;
4080
4124
  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.`,
4125
+ `⚠️ 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
4126
  writeUpPointer(s, e.round.kind, s.lastCodingRunId),
4083
- `⚠️ This is a bug: ship ended after ${rounds} because its automatic provider retry was spent.`,
4127
+ `⚠️ This is a bug: ship ended after ${rounds} because its automatic model-call retry was spent.`,
4084
4128
  nextAction(),
4085
4129
  ]);
4086
4130
  case "no_verdict":
@@ -25,6 +25,8 @@ export interface AttrDomain {
25
25
  // slack.receive
26
26
  caughtUp: boolean;
27
27
  files: number;
28
+ /** Files found on the canonical source message after event metadata is normalized. */
29
+ resolvedFiles: number;
28
30
  dedupe: "fresh" | "duplicate";
29
31
  /** How the requester was found (slack-channel.md item 13): the sender, the configured relay app's footer, or the app itself. */
30
32
  requester: "message" | "relay-footer" | "bot";
@@ -193,6 +195,7 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
193
195
  runId: "string",
194
196
  caughtUp: "boolean",
195
197
  files: "number",
198
+ resolvedFiles: "number",
196
199
  dedupe: "string",
197
200
  requester: "string",
198
201
  intake: "string",
@@ -15,9 +15,9 @@
15
15
  *
16
16
  * Mode is sticky per attach, not per thread: the binding records the mode
17
17
  * the tree was last built for, and an attach in the OTHER mode recreates the
18
- * tree (a credential-less, mirror-origin tree must never be handed to a
19
- * writable run, and a writable tree's credential file must never survive
20
- * into a read-only run). Recreating is the simple safe option — the only
18
+ * tree (a mirror-origin tree must never be handed to a writable run, and a
19
+ * writable tree's door origin must never survive into a read-only run).
20
+ * Recreating is the simple safe option — the only
21
21
  * state a tree carries between attaches is scratch files, and a mode switch
22
22
  * on one thread is rare (a thread is normally one agent for its whole life). */
23
23
 
@@ -37,12 +37,13 @@ export interface ReadonlyAttachPlan {
37
37
  /** True when the existing tree was built for the other mode and must be wiped
38
38
  * before reuse — evaluated before the ordinary dirty/stale checks. */
39
39
  modeSwitch: boolean;
40
- /** Mint a repo-scoped token for the tree and write the credential file (writable only). A read-only attach never puts a token in the tree; the mirror's own recovery fetch (root, outside the tree) may still mint one. */
40
+ /** Legacy mode projection. The Worker uses the Git door for writable trees;
41
+ * neither mode writes an App token into a thread tree. */
41
42
  credentialFile: boolean;
42
- /** Remove any credential file / helper config from the tree (read-only, every
43
- * attach — a reused tree may predate this rule). */
43
+ /** Read-only mode requires cleanup; the Worker also scrubs writable trees
44
+ * before attaching the Git door. */
44
45
  scrubCredentials: boolean;
45
- /** What the worktree's `origin` points at after clone. */
46
+ /** Legacy origin fallback; the Worker overrides writable origin with the door. */
46
47
  originUrl: string;
47
48
  }
48
49
 
@@ -51,7 +52,7 @@ export function planReadonlyAttach(input: {
51
52
  /** The thread's existing binding, if any. `evicted` trees are gone from disk
52
53
  * and are recreated anyway, so their recorded mode is irrelevant. */
53
54
  prior?: { readonly?: boolean; evicted?: boolean };
54
- /** `owner/name` of the repo — the writable origin. */
55
+ /** `owner/name` of the repo — used by the legacy origin fallback. */
55
56
  slug: string;
56
57
  /** The resident's bare mirror path — the read-only origin. Thread users are
57
58
  * denied traversal into it (root:worker1 750), so fetch/push fail while
@@ -51,6 +51,8 @@ export const RESIDENT_STEP_LABELS = {
51
51
  "worktree-chown": "setting the worktree owner",
52
52
  "op-chown": "setting the op tree's owner",
53
53
  "worktree-clean": "cleaning the worktree",
54
+ "thread-cred-file-scrub": "removing an old workspace credential",
55
+ "thread-cred-helper-scrub": "removing an old Git credential helper",
54
56
  "clean-workspace": "cleaning the workspace",
55
57
  "clean-before-restore": "cleaning before the restore",
56
58
  "break-mirror": "removing the mirror's config (fault injection)",
@@ -64,7 +66,6 @@ export const RESIDENT_STEP_LABELS = {
64
66
  "op-dir": "preparing a directory",
65
67
  "ops-dir": "preparing a directory",
66
68
  "stage-dir": "preparing a directory",
67
- stat: "checking a file",
68
69
  "boot-id": "reading the container's identity",
69
70
  touch: "touching a marker",
70
71
  nproc: "counting CPUs",
@@ -153,8 +153,8 @@ export function parseSeed(v: unknown): { ok: true; seed: SandboxSeed } | { ok: f
153
153
  * 3. the deps view, when one was restored, replaces whatever `node_modules`
154
154
  * the checkout carries (older snapshots still hold one);
155
155
  * 4. the thread's ref is fetched from origin (the credential is the exec
156
- * env's `GH_TOKEN`, through the image's `gh` credential helper — never a
157
- * token in this text) and checked out, at its resolved head when the
156
+ * run bearer through the Git door and per-command env — never a token
157
+ * in this text) and checked out, at its resolved head when the
158
158
  * fetch holds it, else at the fetched tip; without a thread ref the
159
159
  * checkout stays on the snapshot's branch;
160
160
  * 5. the head is printed last: the answer's `sha`. */