@coreplane/switchboard 1.267.2 → 1.268.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 (52) hide show
  1. package/dist/assets/deploy/cloudflare-memory/testDiagnostics.ts +44 -3
  2. package/dist/assets/deploy/cloudflare-memory/testDiagnosticsProtocol.ts +7 -0
  3. package/dist/assets/deploy/cloudflare-memory/testDiagnosticsReporter.ts +22 -0
  4. package/dist/assets/deploy/cloudflare-memory/testFetch.ts +21 -0
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +66 -1
  6. package/dist/assets/deploy/cloudflare-resident/imageReconcileState.ts +28 -0
  7. package/dist/assets/deploy/cloudflare-resident/runRegistration.ts +68 -0
  8. package/dist/assets/deploy/cloudflare-resident/shared.ts +2 -3
  9. package/dist/assets/deploy/cloudflare-resident/worker.ts +541 -192
  10. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +17 -2
  11. package/dist/assets/package-lock.json +3 -3
  12. package/dist/assets/package.json +1 -1
  13. package/dist/assets/source.json +3 -3
  14. package/dist/assets/src/agents/registry.ts +2 -2
  15. package/dist/assets/src/core/budgets.ts +6 -0
  16. package/dist/assets/src/core/coordinator/contract.ts +14 -8
  17. package/dist/assets/src/core/costs.ts +2 -0
  18. package/dist/assets/src/core/runEvents.ts +18 -1
  19. package/dist/assets/src/core/runLedger/types.ts +2 -0
  20. package/dist/assets/src/core/runRecord.ts +27 -1
  21. package/dist/assets/src/core/runUsage.ts +53 -0
  22. package/dist/assets/src/core/ship/coordinator.ts +4 -1
  23. package/dist/assets/src/core/shipSpend.ts +52 -0
  24. package/dist/assets/src/core/trace/attrs.ts +22 -0
  25. package/dist/assets/src/core/trace/tracer.ts +10 -2
  26. package/dist/assets/src/core/trace/types.ts +12 -1
  27. package/dist/assets/src/core/trace/workerTrace.ts +2 -2
  28. package/dist/assets/src/execution/residentPoolSpends.ts +9 -0
  29. package/dist/assets/src/execution/residentRecreateAdmission.ts +28 -0
  30. package/dist/assets/src/execution/seedPlan.ts +13 -4
  31. package/dist/assets/web/dist/.vite/manifest.json +31 -31
  32. package/dist/assets/web/dist/assets/CostsPage-6P9rJ7k_.js +1 -0
  33. package/dist/assets/web/dist/assets/{HomePage-BQmun9kS.js → HomePage-B4LGwXHO.js} +1 -1
  34. package/dist/assets/web/dist/assets/ResidentDetailPage-jpGfsR5n.js +1 -0
  35. package/dist/assets/web/dist/assets/ResidentsIndexPage-C4-5nG9M.js +1 -0
  36. package/dist/assets/web/dist/assets/{RunFoldRow-C1w-mwch.js → RunFoldRow-u4FTh57c.js} +1 -1
  37. package/dist/assets/web/dist/assets/{RunRoutePage-zWMHTzla.js → RunRoutePage-CxOKb7oC.js} +4 -4
  38. package/dist/assets/web/dist/assets/{RunsIndexPage-DF16D2Gg.js → RunsIndexPage-CULSlv_d.js} +1 -1
  39. package/dist/assets/web/dist/assets/{ScheduledPage-CyRs0fXN.js → ScheduledPage-CDjkZuqM.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-BRuMCHSj.js → SettingsPage-BMSomotV.js} +1 -1
  41. package/dist/assets/web/dist/assets/{StatusDot-BIAQN7iD.js → StatusDot-XxLkmRaK.js} +1 -1
  42. package/dist/assets/web/dist/assets/{Tooltip-CxiQ7luL.js → Tooltip-qB73RL4b.js} +1 -1
  43. package/dist/assets/web/dist/assets/{UnitRoutePage-BQB5mAAl.js → UnitRoutePage-DFRWAdOT.js} +1 -1
  44. package/dist/assets/web/dist/assets/{dist-I2OEmPqz.js → dist-BsLBAkQJ.js} +1 -1
  45. package/dist/assets/web/dist/assets/{main-DYwKyLY5.js → main-9ynpo780.js} +10 -10
  46. package/dist/assets/web/dist/assets/main-DSiJ9sHU.css +1 -0
  47. package/dist/cli.js +1178 -431
  48. package/package.json +1 -1
  49. package/dist/assets/web/dist/assets/CostsPage-RRggVvIx.js +0 -1
  50. package/dist/assets/web/dist/assets/ResidentDetailPage-CT6v9INM.js +0 -1
  51. package/dist/assets/web/dist/assets/ResidentsIndexPage-CWqPFf40.js +0 -1
  52. package/dist/assets/web/dist/assets/main-DPB0UO1-.css +0 -1
@@ -95,6 +95,7 @@ import {
95
95
  SEED_MARKER,
96
96
  SEED_RESTORE_MAX_MS,
97
97
  SEED_ABANDONED_RESTORE_WAIT_MS,
98
+ seedDoorRemote,
98
99
  seedFixupScript,
99
100
  seedMarkerText,
100
101
  type RestorePhases,
@@ -451,12 +452,25 @@ export class SwitchboardSandbox extends Sandbox<Env> {
451
452
  detail: `presigned R2 transfer needs ${transfer.missing.join(", ")}`,
452
453
  };
453
454
  }
455
+ let doorRemote: string;
456
+ try {
457
+ doorRemote = seedDoorRemote(envVars.GIT_DOOR_ORIGIN ?? "", seed.slug);
458
+ } catch {
459
+ return { seeded: false, reason: "seed-failed", detail: "Git door origin unavailable", step: "fixup" };
460
+ }
454
461
  // The marker names the seed this container carries — the handle and the
455
462
  // ref and head checked out: the same seed again is the run's second
456
463
  // request (a retry, a re-attach), and a restore over the live tree would
457
464
  // destroy the run's work; a seed naming another ref is a new seed.
458
465
  const marker = await this.runRoot(["cat", SEED_MARKER], 30_000);
459
466
  if (marker.exitCode === 0 && marker.stdout.trim() === seedMarkerText(seed)) {
467
+ const origin = await this.runRoot(
468
+ ["git", "-C", SEED_CHECKOUT_DIR, "remote", "set-url", "origin", doorRemote],
469
+ 30_000,
470
+ envVars,
471
+ );
472
+ if (origin.exitCode !== 0)
473
+ return { seeded: false, reason: "seed-failed", detail: "seed origin refresh failed", step: "fixup" };
460
474
  const head = await this.runRoot(["git", "-C", SEED_CHECKOUT_DIR, "rev-parse", "HEAD"], 30_000);
461
475
  return {
462
476
  seeded: true,
@@ -490,14 +504,15 @@ export class SwitchboardSandbox extends Sandbox<Env> {
490
504
  t = systemClock();
491
505
  const script = seedFixupScript({
492
506
  slug: seed.slug,
507
+ doorOrigin: envVars.GIT_DOOR_ORIGIN ?? "",
493
508
  ref: seed.ref,
494
509
  ...(seed.fetchRef ? { fetchRef: seed.fetchRef } : {}),
495
510
  ...(seed.fetchSha ? { fetchSha: seed.fetchSha } : {}),
496
511
  checkoutDir: SEED_CHECKOUT_DIR,
497
512
  ...(seed.depsBackupId ? { depsDir: SEED_DEPS_STAGING_DIR } : {}),
498
513
  });
499
- // The fix-up's fetch authenticates through the image's credential helper
500
- // with the exec env's GH_TOKEN — the same channel every command uses.
514
+ // The fix-up's fetch authenticates through the door-scoped helper using
515
+ // the run bearer in the command environment.
501
516
  const fix = await this.runRoot(["bash", "-c", script], SEED_FIXUP_TIMEOUT_MS, envVars);
502
517
  if (fix.exitCode !== 0) throw new Error(`fix-up exited ${fix.exitCode}: ${tail(fix.stderr || fix.stdout)}`);
503
518
  steps.fixup = systemClock() - t;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.267.2",
3
+ "version": "1.268.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.267.2",
9
+ "version": "1.268.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -21023,7 +21023,7 @@
21023
21023
  },
21024
21024
  "packages/switchboard": {
21025
21025
  "name": "@coreplane/switchboard",
21026
- "version": "1.267.2",
21026
+ "version": "1.268.0",
21027
21027
  "license": "Apache-2.0",
21028
21028
  "dependencies": {
21029
21029
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.267.2",
3
+ "version": "1.268.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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.267.2",
3
- "commit": "08956af1ac05f7f428a07f4e2190b68695623a62",
4
- "builtAt": "2026-09-29T01:42:48.486Z"
2
+ "version": "1.268.0",
3
+ "commit": "f1bc76a31780740930006bd492101666268596da-dirty",
4
+ "builtAt": "2026-09-29T08:06:43.354Z"
5
5
  }
@@ -306,7 +306,7 @@ SCOPE FIRST — a hard rule, at most 5 tool calls: identify the target repositor
306
306
  - Use \`gh search code\` / \`gh api\` for cross-repo lookups; clone at most ONE repo per task.
307
307
 
308
308
  Workflow for shipping a PR:
309
- 1. Clone the repo into the workspace if it's not already there (use gh or git; both are authenticated on this host). Orient with a few BATCHED commands (tree + the relevant files in one call), not file-by-file exploration.
309
+ 1. Clone the repo into the workspace if it is not already there: \`gh repo clone <owner/name>\` or \`git clone "$GIT_DOOR_REMOTE"\` for the bound repository. A private \`github.com\` clone URL cannot use the run bearer. Orient with a few BATCHED commands (tree + the relevant files in one call), not file-by-file exploration.
310
310
  2. Create a branch with a descriptive name.
311
311
  3. Implement the change. Match the surrounding code's style and conventions.
312
312
  4. Prove the change with the cheapest checks that can (CHECKS BY COST below): the linter and the tests nearest the files you touched, the documentation checks for a documentation change.
@@ -628,7 +628,7 @@ You cannot run commands, clone repositories, edit code, or review pull requests,
628
628
  // preset, not a directive.
629
629
  const EXPLORE_SYSTEM = `You are Switchboard's explore agent: a long, read-only investigation of a repository, answering a request from Slack.
630
630
 
631
- You work in a fresh sandbox with a shell (bash), read_file, and a read-scoped GitHub credential: git and gh are authenticated for reads, so clone the target repository into your workspace first (\`gh repo clone <owner/name>\` or \`git clone\`; check out the ref the request names), install what you need and run whatever the investigation calls for — builds, test suites, benchmarks, \`act\`. ${SANDBOX_TOOLCHAIN} You cannot push. Your other tools: \`web_search\` and \`web_fetch\` (sources and pages), the GitHub reads — \`github_repos\`, \`github_tree\` / \`github_file\` (browse and read our repos at any ref), \`github_search_code\`, \`github_issue_list\` / \`github_issue_get\` — and \`list_skills\` / \`use_skill\`.
631
+ You work in a fresh sandbox with a shell (bash), read_file, and a read-scoped GitHub credential: clone the bound repository first with \`gh repo clone <owner/name>\` or \`git clone "$GIT_DOOR_REMOTE"\`, then check out the ref the request names. A private \`github.com\` clone URL cannot use the run bearer. Install what you need and run whatever the investigation calls for — builds, test suites, benchmarks, \`act\`. ${SANDBOX_TOOLCHAIN} You cannot push. Your other tools: \`web_search\` and \`web_fetch\` (sources and pages), the GitHub reads — \`github_repos\`, \`github_tree\` / \`github_file\` (browse and read our repos at any ref), \`github_search_code\`, \`github_issue_list\` / \`github_issue_get\` — and \`list_skills\` / \`use_skill\`.
632
632
 
633
633
  THE DELIVERABLE IS A CLAIM TABLE. Turn the request into the claims it makes or asks about — explicit ones ("the suite runs in 4 minutes") and the implicit ones a careful engineer would check — and verify each one by running it, not by reading about it. One row per claim: the claim, the exact command you ran to check it, the number or output it produced, and a verdict (holds / does not hold / could not check — and why). Numbers over adjectives: measure a duration, count the failures, quote the version. Say what you did not get to.
634
634
 
@@ -21,6 +21,8 @@ export const SECOND_MS = 1_000;
21
21
  export const secondsToMs = (seconds: number): number => seconds * SECOND_MS;
22
22
 
23
23
  export const MINUTE_MS = 60_000;
24
+ /** Allow the run's ending to release its resident workspace after its budget ends. */
25
+ export const RUN_REGISTRATION_GRACE_MS = MINUTE_MS;
24
26
 
25
27
  /** Minutes → milliseconds, for a duration a request names in minutes (a drain's
26
28
  * length): the multiplication lives here so no other file holds it. */
@@ -253,6 +255,10 @@ export const PROVIDER_RETRY_BACKOFFS_MS = [5_000, 15_000, 45_000] as const;
253
255
  * model call itself remains bounded by the run's lease. */
254
256
  export const MODEL_STREAM_HEARTBEAT_MS = 15_000;
255
257
 
258
+ /** A residents page re-reads the registry while open so a deploy's drain and
259
+ * image reports advance even when no run can attach to trigger its feed. */
260
+ export const RESIDENT_LISTING_POLL_MS = 30_000;
261
+
256
262
  /** A hosted ship parent's deadline margin past the pipeline's wall clock
257
263
  * (record 0060): the row's `state.hosting.until` is the hand-off time plus
258
264
  * the instance's `caps.maxMinutes` plus this hour, absorbing the runner's own
@@ -307,12 +307,13 @@ export interface ExistingPrPublicationBinding {
307
307
  owner: { instanceId: string; unit: string };
308
308
  }
309
309
 
310
- /** What a coordinator's spawn stamps on the child's every row: the instance
311
- * the child belongs to and the key the spawn carried — and, for the child's
312
- * own post-step, the base its pull request targets. */
310
+ /** What a coordinator's spawn stamps on the child's every row: its instance,
311
+ * spawn key and admitted cost cap, plus the base for its PR post-step. */
313
312
  export interface CoordinatorTag {
314
313
  parentInstanceId: string;
315
314
  idempotencyKey: string;
315
+ /** The unit's original dollar limit, fixed at admission; absent means no cost cap. */
316
+ costCapUsd?: number;
316
317
  /** The unit's durable write branch, read from its row at child admission. */
317
318
  branch?: string;
318
319
  /** Workflow transport for a recovered child. Identity and idempotency remain
@@ -346,15 +347,20 @@ export interface CoordinatorTag {
346
347
  publication?: ExistingPrPublicationBinding;
347
348
  }
348
349
 
349
- /** The tag as the two flat record fields, or nothing — so a row, a summary
350
- * and a record spread the same thing and a run with no coordinator carries no
351
- * key. The base never rides here: rows and records keep the shape written
352
- * before it existed. */
350
+ /** The tag's identity and optional cap as flat record fields, or nothing — so
351
+ * a row, summary and record agree. The base never rides here. */
353
352
  export function coordinatorFields(tag: CoordinatorTag | undefined): {
354
353
  parentInstanceId?: string;
355
354
  idempotencyKey?: string;
355
+ costCapUsd?: number;
356
356
  } {
357
- return tag ? { parentInstanceId: tag.parentInstanceId, idempotencyKey: tag.idempotencyKey } : {};
357
+ return tag
358
+ ? {
359
+ parentInstanceId: tag.parentInstanceId,
360
+ idempotencyKey: tag.idempotencyKey,
361
+ ...(tag.costCapUsd !== undefined ? { costCapUsd: tag.costCapUsd } : {}),
362
+ }
363
+ : {};
358
364
  }
359
365
 
360
366
  /** The parent ship record: what the bot writes at an instance's creation and
@@ -516,6 +516,8 @@ export interface CostReport {
516
516
  /** Each biller's daily invoice tie-out (item 4d); absent when no provider block is known
517
517
  * (a report built without the wiring) or run history is off. */
518
518
  billers?: BillerTieOut[];
519
+ /** Ship children across this bot's runs, independent of the Cloudflare group. */
520
+ shipSpend?: import("./shipSpend.js").ShipSpendReport;
519
521
  }
520
522
 
521
523
  /** What the report carries beyond the priced rows: the account behind the
@@ -974,6 +974,7 @@ export type RunEvent =
974
974
  type: "coordinator_tag";
975
975
  parentInstanceId: string;
976
976
  unit?: string;
977
+ costCapUsd?: number;
977
978
  branch?: string;
978
979
  transportWorkflowId?: string;
979
980
  recovery?: import("./coordinator/contract.js").CoordinatorTag["recovery"];
@@ -1142,7 +1143,15 @@ export type RunEvent =
1142
1143
  * `model` is the ref the run uses, resolved from a model the person
1143
1144
  * named in plain words (the plain-words model unit) — applied at
1144
1145
  * directive precedence. Additive: unknown → no model. */
1145
- binds?: ReadonlyArray<{ line: string; reason: string; model?: string; repo?: string; confirmed?: true }>;
1146
+ binds?: ReadonlyArray<{
1147
+ line: string;
1148
+ reason: string;
1149
+ model?: string;
1150
+ repo?: string;
1151
+ /** The admissible fact behind the model's typed repo choice. */
1152
+ repoSource?: "request" | "attachment" | "thread" | "channel";
1153
+ confirmed?: true;
1154
+ }>;
1146
1155
  question?: string;
1147
1156
  /** A question's proposed line, redacted and cut like the receipt — what
1148
1157
  * the next turn's "yes" binds (`bindFromAnswer`). */
@@ -1160,6 +1169,14 @@ export type RunEvent =
1160
1169
  * boundary. Its refusal text is always the cause-owned renderer. */
1161
1170
  providerFailure?: ProviderFailureCause;
1162
1171
  attempts?: ReadonlyArray<{ outcome: "accepted" | "violation"; violation?: string }>;
1172
+ /** Snapshot of the factual repository context the operator received. */
1173
+ repoContext?: {
1174
+ organization: string;
1175
+ threadRepo?: string;
1176
+ channelRepo?: string;
1177
+ candidateStatus: "skipped" | "available" | "truncated" | "empty" | "unavailable";
1178
+ candidateCount: number;
1179
+ };
1163
1180
  intake?: { verdict: string; reason: string };
1164
1181
  latencyMs?: number;
1165
1182
  outputTokens?: number;
@@ -99,6 +99,8 @@ export interface LiveRunMeta {
99
99
  * still sends the parent its event and a retried spawn finds its run. */
100
100
  parentInstanceId?: string;
101
101
  idempotencyKey?: string;
102
+ /** The original Ship unit cap carried by the claim, available before its tag event is retained. */
103
+ costCapUsd?: number;
102
104
  /** Where the run's conversation started (item 52), so a reclaimed run's
103
105
  * record still says so: `parent` for a spawned child, `channel` otherwise. */
104
106
  seed?: RunSeed;
@@ -227,6 +227,8 @@ export interface RunRecord {
227
227
  * item 48), stored at the claim so a retried spawn finds its run. Present
228
228
  * exactly when `parentInstanceId` is — both or neither, never one alone. */
229
229
  idempotencyKey?: string;
230
+ /** The coordinator unit's original cost cap, carried by each child. */
231
+ costCapUsd?: number;
230
232
  /** The plan runner instance this run's hand-off created (record 0051 R2;
231
233
  * item 2): the last `ship_handoff` event, folded at the assembly like the
232
234
  * coordinator tag. Present only on a ship run whose hand-off succeeded;
@@ -484,7 +486,14 @@ export interface RunOperatorDecision {
484
486
  mode: "shadow" | "on";
485
487
  outcome: "binds" | "question" | "refusal" | "non_decision";
486
488
  reason: string;
487
- binds?: { line: string; reason: string; model?: string; repo?: string; confirmed?: true }[];
489
+ binds?: {
490
+ line: string;
491
+ reason: string;
492
+ model?: string;
493
+ repo?: string;
494
+ repoSource?: "request" | "attachment" | "thread" | "channel";
495
+ confirmed?: true;
496
+ }[];
488
497
  question?: string;
489
498
  /** A question's proposed line — what the next turn's "yes" binds. */
490
499
  proposal?: string;
@@ -497,6 +506,13 @@ export interface RunOperatorDecision {
497
506
  /** The structured seam's attempts (record 0067): what each answer violated,
498
507
  * or that it was accepted. */
499
508
  attempts?: { outcome: "accepted" | "violation"; violation?: string }[];
509
+ repoContext?: {
510
+ organization: string;
511
+ threadRepo?: string;
512
+ channelRepo?: string;
513
+ candidateStatus: "skipped" | "available" | "truncated" | "empty" | "unavailable";
514
+ candidateCount: number;
515
+ };
500
516
  intake?: { verdict: string; reason: string };
501
517
  latencyMs?: number;
502
518
  outputTokens?: number;
@@ -520,6 +536,7 @@ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecisi
520
536
  reason: b.reason,
521
537
  ...(b.model !== undefined ? { model: b.model } : {}),
522
538
  ...(b.repo !== undefined ? { repo: b.repo } : {}),
539
+ ...(b.repoSource !== undefined ? { repoSource: b.repoSource } : {}),
523
540
  ...(b.confirmed ? { confirmed: true as const } : {}),
524
541
  })),
525
542
  }
@@ -538,6 +555,7 @@ export function operatorOfEvents(events: readonly RunEvent[]): RunOperatorDecisi
538
555
  })),
539
556
  }
540
557
  : {}),
558
+ ...(e.repoContext ? { repoContext: { ...e.repoContext } } : {}),
541
559
  ...(e.intake ? { intake: { verdict: e.intake.verdict, reason: e.intake.reason } } : {}),
542
560
  ...(e.latencyMs !== undefined ? { latencyMs: e.latencyMs } : {}),
543
561
  ...(e.outputTokens !== undefined ? { outputTokens: e.outputTokens } : {}),
@@ -1179,6 +1197,14 @@ export function isRunRecord(v: unknown): v is RunRecord {
1179
1197
  (typeof r.idempotencyKey !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(r.idempotencyKey))
1180
1198
  )
1181
1199
  return false;
1200
+ if (
1201
+ r.costCapUsd !== undefined &&
1202
+ (r.parentInstanceId === undefined ||
1203
+ typeof r.costCapUsd !== "number" ||
1204
+ !Number.isFinite(r.costCapUsd) ||
1205
+ r.costCapUsd <= 0)
1206
+ )
1207
+ return false;
1182
1208
  // The instance a ship run's hand-off created (record 0051 R2; item 2).
1183
1209
  if (r.instanceId !== undefined && (typeof r.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(r.instanceId)))
1184
1210
  return false;
@@ -1,4 +1,5 @@
1
1
  import type { RunEvent } from "./runEvents.js";
2
+ import { unitOfIdempotencyKey } from "./coordinator/contract.js";
2
3
 
3
4
  // What a run cost in tokens, and who it belongs to — the data behind the cost
4
5
  // dimensions of the costs page (docs/reference/specs/costs.md items 10–10a) and
@@ -200,6 +201,9 @@ export interface UsageRun {
200
201
  userName?: string;
201
202
  /** A child run is billed to whoever started its parent (run-history item 46). */
202
203
  parentRunId?: string;
204
+ parentInstanceId?: string;
205
+ idempotencyKey?: string;
206
+ costCapUsd?: number;
203
207
  /** The thread the run ran in and the channel it belongs to (platform-namespaced, invariant 4). */
204
208
  threadKey: string;
205
209
  channelId: string;
@@ -258,6 +262,8 @@ export interface UsageRow {
258
262
 
259
263
  export interface RunUsageReport {
260
264
  rows: UsageRow[];
265
+ /** Individual Ship children retained for the unit-spend trend; old snapshots omit this. */
266
+ shipRuns?: ShipRunUsage[];
261
267
  /** Runs in range whose usage is not known yet (written before the field; backfill outstanding). */
262
268
  pending: number;
263
269
  /** The oldest finish the store still holds, so a page can bound its range to the data. */
@@ -265,6 +271,33 @@ export interface RunUsageReport {
265
271
  retentionDays: number;
266
272
  }
267
273
 
274
+ export interface ShipRunUsage {
275
+ id: string;
276
+ unitKey: string;
277
+ finishedAt: number;
278
+ costCapUsd?: number;
279
+ /** Missing while an older record is still being backfilled. */
280
+ usage?: RunUsage;
281
+ }
282
+
283
+ /** The unit identity comes from the coordinator's durable key, never a thread or branch guess. */
284
+ export function shipRunsOf(runs: readonly UsageRun[]): ShipRunUsage[] {
285
+ return runs.flatMap((run) => {
286
+ if (!run.parentInstanceId || !run.idempotencyKey) return [];
287
+ const unit = unitOfIdempotencyKey(run.idempotencyKey);
288
+ if (!unit) return [];
289
+ return [
290
+ {
291
+ id: run.id,
292
+ unitKey: `${run.parentInstanceId}:${unit}`,
293
+ finishedAt: run.finishedAt,
294
+ ...(run.costCapUsd !== undefined ? { costCapUsd: run.costCapUsd } : {}),
295
+ ...(run.usage !== undefined ? { usage: run.usage } : {}),
296
+ },
297
+ ];
298
+ });
299
+ }
300
+
268
301
  export const dayOf = (epochMs: number): string => new Date(epochMs).toISOString().slice(0, 10);
269
302
 
270
303
  const identityOf = (who: UsageIdentity): UsageIdentity => ({
@@ -334,6 +367,7 @@ export function reportOfUsageRows(rows: RunUsageRows): RunUsageReport {
334
367
  const { rows: cells, pending } = aggregateUsage(rows.runs, (id) => rows.parents[id]);
335
368
  return {
336
369
  rows: cells,
370
+ shipRuns: shipRunsOf(rows.runs),
337
371
  // The store counts what it could not price; the fold sees the same runs without `usage`.
338
372
  pending: Math.max(pending, rows.pending),
339
373
  ...(rows.earliestFinishedAt !== undefined ? { earliestFinishedAt: rows.earliestFinishedAt } : {}),
@@ -356,6 +390,10 @@ function isUsageRun(v: unknown): v is UsageRun {
356
390
  typeof r.channelId === "string" &&
357
391
  (r.agent === undefined || typeof r.agent === "string") &&
358
392
  (r.parentRunId === undefined || typeof r.parentRunId === "string") &&
393
+ (r.parentInstanceId === undefined || typeof r.parentInstanceId === "string") &&
394
+ (r.idempotencyKey === undefined || typeof r.idempotencyKey === "string") &&
395
+ (r.costCapUsd === undefined ||
396
+ (typeof r.costCapUsd === "number" && Number.isFinite(r.costCapUsd) && r.costCapUsd > 0)) &&
359
397
  typeof r.startedAt === "number" &&
360
398
  typeof r.finishedAt === "number" &&
361
399
  (r.usage === undefined || isRunUsage(r.usage))
@@ -382,6 +420,7 @@ export function isRunUsageReport(v: unknown): v is RunUsageReport {
382
420
  if (typeof v !== "object" || v === null) return false;
383
421
  const r = v as Record<string, unknown>;
384
422
  if (!Array.isArray(r.rows) || !hasReportTail(r)) return false;
423
+ if (r.shipRuns !== undefined && (!Array.isArray(r.shipRuns) || !r.shipRuns.every(isShipRunUsage))) return false;
385
424
  return r.rows.every(
386
425
  (row) =>
387
426
  isIdentity(row) &&
@@ -394,3 +433,17 @@ export function isRunUsageReport(v: unknown): v is RunUsageReport {
394
433
  isRunUsage((row as UsageRow).usage),
395
434
  );
396
435
  }
436
+
437
+ function isShipRunUsage(v: unknown): v is ShipRunUsage {
438
+ if (typeof v !== "object" || v === null) return false;
439
+ const r = v as ShipRunUsage;
440
+ return (
441
+ typeof r.id === "string" &&
442
+ typeof r.unitKey === "string" &&
443
+ typeof r.finishedAt === "number" &&
444
+ Number.isFinite(r.finishedAt) &&
445
+ (r.costCapUsd === undefined ||
446
+ (typeof r.costCapUsd === "number" && Number.isFinite(r.costCapUsd) && r.costCapUsd > 0)) &&
447
+ (r.usage === undefined || isRunUsage(r.usage))
448
+ );
449
+ }
@@ -3886,7 +3886,10 @@ function renderUnitReportWithWake(
3886
3886
  const spentCapAction = (): string | undefined => {
3887
3887
  const cap = s.input.grant?.costCapUsd;
3888
3888
  if (cap === undefined || s.spendUsd === null || s.spendUsd < cap) return undefined;
3889
- return `Next action: $${s.spendUsd.toFixed(2)} spent against the original $${cap.toFixed(2)} cost cap; this unit cannot start another child. Continue ${prUrl ? `the existing pull request (${prUrl})` : "the saved branch"} only under separate authorization, then request review of its exact head.`;
3889
+ const next = prUrl
3890
+ ? `If the pull request (${prUrl}) is still open, continue it only under separate authorization, then request review of its exact head. If it has merged, no recovery is needed.`
3891
+ : "If work remains on the saved branch, continue it only under separate authorization, then request review of its exact head.";
3892
+ return `Next action: $${s.spendUsd.toFixed(2)} spent against the original $${cap.toFixed(2)} cost cap; this unit cannot start another child. ${next}`;
3890
3893
  };
3891
3894
  const nextAction = (checkpoint?: { branch: string; sha: string }): string => {
3892
3895
  if (durableWake)
@@ -0,0 +1,52 @@
1
+ import type { DateRange } from "./costs.js";
2
+ import type { RunUsage, ShipRunUsage } from "./runUsage.js";
3
+
4
+ /** Ship child costs within one snapshot, grouped by their durable unit key. */
5
+ export interface ShipSpendReport {
6
+ range: DateRange;
7
+ units: Array<{
8
+ key: string;
9
+ /** The admitted unit cap; null on older runs or inconsistent records. */
10
+ capUsd: number | null;
11
+ /** Unknown after any child with unpriced usage. */
12
+ totalUsd: number | null;
13
+ runs: Array<{ id: string; finishedAt: number; usd: number | null; cumulativeUsd: number | null }>;
14
+ }>;
15
+ }
16
+
17
+ /** The cap gate sums recorded per-model dollars; missing dollars stay unknown, while no model turns cost zero. */
18
+ function recordedUsd(usage: RunUsage | undefined): number | null {
19
+ const models = Object.values(usage?.byModel ?? {});
20
+ if (!usage || !Number.isSafeInteger(usage.turns) || usage.turns < 0) return null;
21
+ if (usage.turns === 0 && models.length === 0) return 0;
22
+ if (
23
+ models.length === 0 ||
24
+ models.reduce((sum, model) => sum + model.turns, 0) !== usage.turns ||
25
+ models.some((model) => typeof model.usd !== "number" || !Number.isFinite(model.usd) || model.usd < 0)
26
+ )
27
+ return null;
28
+ return models.reduce((sum, model) => sum + model.usd!, 0);
29
+ }
30
+
31
+ export function buildShipSpendReport(runs: readonly ShipRunUsage[], range: DateRange): ShipSpendReport {
32
+ const groups = new Map<string, ShipRunUsage[]>();
33
+ for (const run of runs) {
34
+ const day = new Date(run.finishedAt).toISOString().slice(0, 10);
35
+ if (day < range.from || day > range.to) continue;
36
+ groups.set(run.unitKey, [...(groups.get(run.unitKey) ?? []), run]);
37
+ }
38
+ const units = [...groups].map(([key, children]) => {
39
+ const ordered = children.sort((a, b) => a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
40
+ const caps = new Set(ordered.map((r) => r.costCapUsd));
41
+ const capUsd = caps.size === 1 ? (ordered[0]?.costCapUsd ?? null) : null;
42
+ let cumulative: number | null = 0;
43
+ const points = ordered.map((r) => {
44
+ const usd = recordedUsd(r.usage);
45
+ cumulative = cumulative === null || usd === null ? null : cumulative + usd;
46
+ return { id: r.id, finishedAt: r.finishedAt, usd, cumulativeUsd: cumulative };
47
+ });
48
+ return { key, capUsd, totalUsd: cumulative, runs: points };
49
+ });
50
+ units.sort((a, b) => (b.runs.at(-1)?.finishedAt ?? 0) - (a.runs.at(-1)?.finishedAt ?? 0));
51
+ return { range, units };
52
+ }
@@ -45,6 +45,16 @@ export interface AttrDomain {
45
45
  refusal: string;
46
46
  /** The refusal's cause, from the one code→cause table in src/core/refusal.ts. */
47
47
  cause: "request" | "policy" | "system";
48
+ /** The operator's decision, separate from the request's final outcome. */
49
+ operatorOutcome: "binds" | "question" | "refusal" | "non_decision";
50
+ /** Provenance of an accepted typed repository; the slug stays on the run event. */
51
+ operatorRepoSource: "request" | "attachment" | "thread" | "channel";
52
+ operatorAttempts: number;
53
+ operatorRepoCatalog: "skipped" | "available" | "truncated" | "empty" | "unavailable";
54
+ operatorRepoCandidates: number;
55
+ /** Final target resolution, separate from the operator's proposed repo. */
56
+ repoResolution: "skipped" | "resolved" | "rejected" | "unverified" | "conflict" | "unresolved";
57
+ repoTargetRelation: "not_proposed" | "matched" | "overridden" | "unresolved";
48
58
  count: number;
49
59
  backend: Backend;
50
60
  // run.command
@@ -146,6 +156,11 @@ const IDENTIFIER_KEYS: ReadonlySet<SpanAttrKey> = new Set<SpanAttrKey>([
146
156
  "outcome",
147
157
  "refusal",
148
158
  "cause",
159
+ "operatorOutcome",
160
+ "operatorRepoSource",
161
+ "operatorRepoCatalog",
162
+ "repoResolution",
163
+ "repoTargetRelation",
149
164
  "command",
150
165
  "route",
151
166
  "host",
@@ -204,6 +219,13 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
204
219
  outcome: "string",
205
220
  refusal: "string",
206
221
  cause: "string",
222
+ operatorOutcome: "string",
223
+ operatorRepoSource: "string",
224
+ operatorAttempts: "number",
225
+ operatorRepoCatalog: "string",
226
+ operatorRepoCandidates: "number",
227
+ repoResolution: "string",
228
+ repoTargetRelation: "string",
207
229
  count: "number",
208
230
  backend: "string",
209
231
  command: "string",
@@ -99,6 +99,7 @@ interface SpanInit {
99
99
  sinks: SpanSink[];
100
100
  startedAt: number | undefined;
101
101
  attrs: SpanAttrs | undefined;
102
+ rootIdentity?: { name: string; runId?: string };
102
103
  }
103
104
 
104
105
  class SpanImpl implements Span {
@@ -109,6 +110,8 @@ class SpanImpl implements Span {
109
110
  ended = false;
110
111
  private readonly sinks: SpanSink[];
111
112
  private readonly rec: SpanRecord;
113
+ private readonly rootIdentity: { name: string; runId?: string };
114
+ private readonly isLocalRoot: boolean;
112
115
 
113
116
  constructor(
114
117
  private readonly shared: Shared,
@@ -118,6 +121,8 @@ class SpanImpl implements Span {
118
121
  this.parentId = init.parentId;
119
122
  this.name = sanitizeSpanName(init.name);
120
123
  this.sinks = init.sinks;
124
+ this.isLocalRoot = init.rootIdentity === undefined;
125
+ this.rootIdentity = init.rootIdentity ?? { name: this.name, runId: init.attrs?.runId };
121
126
  this.rec = {
122
127
  traceId: this.traceId,
123
128
  spanId: this.id,
@@ -164,6 +169,7 @@ class SpanImpl implements Span {
164
169
  sinks: this.sinks,
165
170
  startedAt: opts?.startedAt,
166
171
  attrs: opts?.attrs,
172
+ rootIdentity: this.rootIdentity,
167
173
  });
168
174
  child.emitStart();
169
175
  return child;
@@ -177,6 +183,7 @@ class SpanImpl implements Span {
177
183
  sinks: this.sinks,
178
184
  startedAt: opts.startedAt,
179
185
  attrs: opts.attrs,
186
+ rootIdentity: this.rootIdentity,
180
187
  });
181
188
  child.emitStart();
182
189
  child.endAt(Math.max(opts.startedAt, opts.endedAt), opts.status ?? "ok", opts.errorKind, opts.errorCode);
@@ -196,7 +203,7 @@ class SpanImpl implements Span {
196
203
  }
197
204
  for (const s of this.sinks) {
198
205
  try {
199
- s.onEnd(this.record());
206
+ s.onEnd(this.record(), this.rootIdentity);
200
207
  } catch (err) {
201
208
  this.shared.warn(
202
209
  `[trace] sink onEnd threw for ${this.name}: ${err instanceof Error ? err.message : String(err)}`,
@@ -215,7 +222,7 @@ class SpanImpl implements Span {
215
222
  this.rec.status = status ?? this.rec.status ?? "ok";
216
223
  for (const s of this.sinks) {
217
224
  try {
218
- s.onEnd(this.record());
225
+ s.onEnd(this.record(), this.rootIdentity);
219
226
  } catch (err) {
220
227
  this.shared.warn(
221
228
  `[trace] sink onEnd threw for ${this.name}: ${err instanceof Error ? err.message : String(err)}`,
@@ -239,6 +246,7 @@ class SpanImpl implements Span {
239
246
 
240
247
  setAttrs(attrs: SpanAttrs): void {
241
248
  (this.rec as { attrs: SpanAttrs }).attrs = { ...this.rec.attrs, ...attrs };
249
+ if (this.isLocalRoot) this.rootIdentity.runId = this.rec.attrs.runId;
242
250
  }
243
251
 
244
252
  record(): SpanRecord {
@@ -40,12 +40,23 @@ export interface SpanRecord {
40
40
  attrs: SpanAttrs;
41
41
  }
42
42
 
43
+ /** A live, read-only view of the local root's run identity. Shared by its
44
+ * descendants, including late children; changing the root's runId updates
45
+ * the view without changing any span record. No span, sink or payload is
46
+ * retained here, so a bounded sink can keep it after the root ends. */
47
+ export interface SpanRootIdentity {
48
+ readonly name: string;
49
+ readonly runId?: string;
50
+ }
51
+
43
52
  /** Observer of span starts and ends (Observer). Sinks are attached to a root
44
53
  * and inherited by its whole subtree; a throwing sink never reaches traced
45
54
  * code. */
46
55
  export interface SpanSink {
47
56
  onStart?(span: SpanRecord): void;
48
- onEnd(span: SpanRecord): void;
57
+ /** The tracer supplies root identity separately from the record snapshot;
58
+ * synthetic records supplied directly by a caller may omit it. */
59
+ onEnd(span: SpanRecord, root?: SpanRootIdentity): void;
49
60
  }
50
61
 
51
62
  /** The seam the no-gaps test enters through (Strategy): production is the
@@ -82,9 +82,9 @@ export function refusalFilter(sink: SpanSink): SpanSink {
82
82
  const refused = (status: unknown) => status === 401 || status === 403;
83
83
  return {
84
84
  ...(sink.onStart ? { onStart: (rec) => sink.onStart!(rec) } : {}),
85
- onEnd: (rec) => {
85
+ onEnd: (rec, root) => {
86
86
  if (refused(rec.attrs.httpStatus)) return;
87
- sink.onEnd(rec);
87
+ sink.onEnd(rec, root);
88
88
  },
89
89
  };
90
90
  }
@@ -5,6 +5,15 @@ export interface SpentPoolUser {
5
5
  owner: string;
6
6
  }
7
7
 
8
+ /** An owner may reclaim its own UID; only a different owner needs a fresh one. */
9
+ export function unavailablePoolUsers(spent: ReadonlyMap<string, string>, owner: string): Set<string> {
10
+ return new Set([...spent].filter(([, prior]) => prior !== owner).map(([user]) => user));
11
+ }
12
+
13
+ export function ownedPoolUsers(spent: ReadonlyMap<string, string>, owner: string): string[] {
14
+ return [...spent].filter(([, prior]) => prior === owner).map(([user]) => user);
15
+ }
16
+
8
17
  export function parseSpentPoolUsers(value: unknown, pool: readonly string[]): ReadonlyMap<string, string> | null {
9
18
  if (
10
19
  !Array.isArray(value) ||