@coreplane/switchboard 1.254.1 → 1.255.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 (63) hide show
  1. package/dist/assets/.dockerignore +3 -0
  2. package/dist/assets/Dockerfile +12 -1
  3. package/dist/assets/config/config.example.yaml +6 -1
  4. package/dist/assets/deploy/cloudflare/worker.ts +39 -23
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +552 -5
  6. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +5 -0
  7. package/dist/assets/deploy/cloudflare-resident/Dockerfile +13 -1
  8. package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
  9. package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
  10. package/dist/assets/deploy/cloudflare-resident/worker.ts +202 -31
  11. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
  12. package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
  13. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
  14. package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
  15. package/dist/assets/deploy/secrets.manifest.json +12 -0
  16. package/dist/assets/package-lock.json +3 -3
  17. package/dist/assets/package.json +1 -1
  18. package/dist/assets/source.json +3 -3
  19. package/dist/assets/src/agents/registry.ts +21 -0
  20. package/dist/assets/src/core/budgets.ts +35 -2
  21. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  22. package/dist/assets/src/core/coordinator/driver.ts +49 -6
  23. package/dist/assets/src/core/costs.ts +39 -16
  24. package/dist/assets/src/core/pipelineStanding.ts +5 -0
  25. package/dist/assets/src/core/plane/decide.ts +389 -22
  26. package/dist/assets/src/core/refusal.ts +3 -0
  27. package/dist/assets/src/core/reviewVerdict.ts +15 -2
  28. package/dist/assets/src/core/runEvents.ts +31 -12
  29. package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
  30. package/dist/assets/src/core/runLedger/types.ts +3 -0
  31. package/dist/assets/src/core/runRecord.ts +24 -7
  32. package/dist/assets/src/core/ship/coordinator.ts +327 -61
  33. package/dist/assets/src/core/trace/workerTrace.ts +3 -0
  34. package/dist/assets/src/execution/sandboxErrors.ts +77 -6
  35. package/dist/assets/web/dist/.vite/manifest.json +59 -58
  36. package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
  37. package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
  38. package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-Be7jLLnU.js} +1 -1
  39. package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
  40. package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-D_RD6wLo.js} +1 -1
  42. package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-BLkSuCxo.js} +1 -1
  43. package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +1 -0
  44. package/dist/assets/web/dist/assets/{RunRoutePage-ysJBY8xQ.js → RunRoutePage-CmWYGR36.js} +4 -4
  45. package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-DW-HHuZa.js} +1 -1
  46. package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-3aYsDf-q.js} +1 -1
  47. package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-DG-p5Xy1.js} +1 -1
  48. package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-CEnGlyAL.js} +1 -1
  49. package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-CiunVowT.js} +1 -1
  50. package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +1 -0
  51. package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
  52. package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-luhv3YSo.js} +1 -1
  53. package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
  54. package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-mAKx_zo9.js} +2 -2
  55. package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
  56. package/dist/cli.js +6022 -4956
  57. package/package.json +1 -1
  58. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
  59. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
  60. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
  61. package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
  62. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
  63. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +0 -1
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.254.1",
3
+ "version": "1.255.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.254.1",
9
+ "version": "1.255.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.254.1",
20448
+ "version": "1.255.0",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.254.1",
3
+ "version": "1.255.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.254.1",
3
- "commit": "48a9a480354e503b8fea59dc926f94132cf79756",
4
- "builtAt": "2026-09-19T21:28:07.753Z"
2
+ "version": "1.255.0",
3
+ "commit": "b19caf8a347aca9f78503cac8525f3b3206ba2ff",
4
+ "builtAt": "2026-09-20T02:32:25.817Z"
5
5
  }
@@ -47,6 +47,14 @@ export function machineNeedsRepo(machine: MachineClass): boolean {
47
47
  export const IDENTITIES = ["none", "read", "write"] as const;
48
48
  export type Identity = (typeof IDENTITIES)[number];
49
49
 
50
+ /** The model tiers a preset may run on (the one-door plan's tiers rule): the
51
+ * `fast` tier is the router's own model (`routing.model`), everything else is
52
+ * `strong`. Each preset declares its allowed set below (`AgentDef.tiers`);
53
+ * a parent choosing a child's model at spawn is held to the child preset's
54
+ * set, and escalation is a new run — a run's tier is fixed at dispatch. */
55
+ export const MODEL_TIERS = ["fast", "strong"] as const;
56
+ export type ModelTier = (typeof MODEL_TIERS)[number];
57
+
50
58
  /** The wall clocks live in `src/core/budgets.ts` (docs/decisions/0046): a
51
59
  * preset's ask, the turn cap derived from it and every allowance are rows
52
60
  * there, and this registry reads them. Re-exported for the readers that
@@ -89,6 +97,12 @@ export interface AgentDef {
89
97
  * worktree flag and the token the sandbox env and the `repo-cold` vet mint
90
98
  * read this, through the run's effective profile. */
91
99
  identity: Identity;
100
+ /** The model tiers this preset may run on (`MODEL_TIERS`): what a parent's
101
+ * spawn — and the operator's bind — may put in the child's request slot.
102
+ * A preset that writes code (`coding`, `ship`, `review`) never includes
103
+ * `fast`: a wrong approval or a wrong edit costs more than the tokens
104
+ * saved; `explore` and `research` read, and may run fast. */
105
+ tiers: readonly ModelTier[];
92
106
  /** Whether the request router (docs/reference/specs/routing-and-config.md
93
107
  * item 21) may pick this preset for a plain message. Absent means yes: the
94
108
  * router's table is rendered from this registry. `false` keeps a preset
@@ -664,6 +678,7 @@ const WORK_PRESETS = {
664
678
  identity: "none",
665
679
  maxTokens: 16000,
666
680
  ...loopBudget("general"),
681
+ tiers: ["fast", "strong"],
667
682
  },
668
683
  coding: {
669
684
  name: "coding",
@@ -678,6 +693,7 @@ const WORK_PRESETS = {
678
693
  // `config set channel efforts.coding=…`, or `effort:` per request).
679
694
  machine: "repo-resident",
680
695
  identity: "write", // pushes branches and opens pull requests
696
+ tiers: ["strong"], // code-writing never runs fast (the one-door plan's tiers rule)
681
697
  // Never routed: a plain write ask deserves the coding → review loop, so
682
698
  // the router's table offers `ship` in coding's seat — a routed ship runs
683
699
  // a generated one-unit plan whose merge is a person's, never the runner's.
@@ -693,6 +709,7 @@ const WORK_PRESETS = {
693
709
  toolset: "readonly",
694
710
  machine: "repo-resident",
695
711
  identity: "read", // a read-scoped token and a read-only worktree: it cannot post or push from inside
712
+ tiers: ["strong"], // a wrong finding costs a merge decision: reviews never run fast
696
713
  maxTokens: 64000,
697
714
  ...loopBudget("review"), // a safety net — typical reviews land in ~5 minutes
698
715
  effort: "medium", // fast turns; one big-context pass does the deep work
@@ -717,6 +734,7 @@ const WORK_PRESETS = {
717
734
  toolset: "full",
718
735
  machine: "repo-resident",
719
736
  identity: "write",
737
+ tiers: ["strong"], // the pipeline's children write and review code: never fast
720
738
  // Routable: a routed ship runs a generated one-unit plan whose merge is a
721
739
  // person's (`merge: person`) and never a seeded plan (the hand-off refuses
722
740
  // a routed `plan <path>.md` naming `agent:ship`), so a wrong route costs a
@@ -733,6 +751,7 @@ const WORK_PRESETS = {
733
751
  toolset: "web",
734
752
  machine: "none", // web I/O only; no workspace is provisioned
735
753
  identity: "none",
754
+ tiers: ["fast", "strong"], // reads and reports: may run fast
736
755
  maxTokens: 24000,
737
756
  ...loopBudget("research"),
738
757
  effort: "medium",
@@ -747,6 +766,7 @@ const WORK_PRESETS = {
747
766
  // review depends on: a two-hour job shares no container with anyone.
748
767
  machine: "repo-cold",
749
768
  identity: "read", // a read-scoped token: it can clone and read, never push — whatever the caller holds
769
+ tiers: ["fast", "strong"], // reads and reports: may run fast
750
770
  maxTokens: 64000,
751
771
  ...loopBudget("explore"),
752
772
  // No built-in effort: the deployment decides, as for coding.
@@ -761,6 +781,7 @@ export const AGENTS: Record<string, AgentDef> = {
761
781
  "Coordinates other runs: spawns child runs as the requester — each in a thread of its own, under their permissions — follows them, and reports. No workspace or shell.",
762
782
  system: conductorSystem(Object.values(WORK_PRESETS)),
763
783
  toolset: "conductor",
784
+ tiers: ["fast", "strong"], // reads and coordinates: may run fast
764
785
  // Nothing is provisioned and no credential minted: the run tools call the
765
786
  // dispatcher, the GitHub reads are REST in the bot process.
766
787
  machine: "none",
@@ -46,6 +46,20 @@ export const CONFIRMATION_TTL_MS = 10 * MINUTE_MS;
46
46
  * and only loses answers. People answer a question in minutes to hours. */
47
47
  export const QUESTION_TTL_MS = DAY_MS;
48
48
 
49
+ /** How long a resolved author binding is reused before the stored `{ login,
50
+ * id }` pair is re-read from GitHub (docs/decisions/0062;
51
+ * docs/reference/specs/authorization.md item 18). The commit identity pairs
52
+ * are resolved per EXEC (`gitIdentityEnvs`), and the pi harness's log polls,
53
+ * FIFO sends and file writes ride the same executor — without a cache a
54
+ * write run costs one `GET /user/<id>` per command, drawing down the
55
+ * installation's shared rate limit for a freshness nothing needs: the
56
+ * identity rewrite re-reads the binding fresh before the PR opens, so a
57
+ * rename is caught there whatever this window holds. Read by `bindingOf`
58
+ * (`src/execution/authorBinding.ts`) for the resolved pair and the rename
59
+ * refusal alike (one `[identity]` line per window, not one per exec); a read
60
+ * that FAILED is never cached — the next read asks again. */
61
+ export const AUTHOR_BINDING_TTL_MS = 15 * MINUTE_MS;
62
+
49
63
  /** How long a dispatch's FIRST attach to a resident — a fresh run's, or a
50
64
  * resumed run's re-attach to its recorded worktree — waits for the resident
51
65
  * to wake when the Worker typed its refusal as the platform's transient
@@ -172,6 +186,13 @@ export const FLOORS: Readonly<Record<RoundKind, number>> = {
172
186
  * when the remainder allows it. */
173
187
  export const MERGE_WAIT_ASK_MINUTES = 60;
174
188
 
189
+ /** The provider retry ladder (issue 1932): the backoff before each retry of a
190
+ * transient model-call failure — a gateway 5xx, a stream cut before
191
+ * `message_stop`, a gateway timeout. Three attempts with growing waits,
192
+ * charged to the run's lease; the waits stay small next to the run's minutes
193
+ * because the observed blips are edge transients of seconds. */
194
+ export const PROVIDER_RETRY_BACKOFFS_MS = [5_000, 15_000, 45_000] as const;
195
+
175
196
  /** A hosted ship parent's deadline margin past the pipeline's wall clock
176
197
  * (record 0060): the row's `state.hosting.until` is the hand-off time plus
177
198
  * the instance's `caps.maxMinutes` plus this hour, absorbing the runner's own
@@ -183,8 +204,11 @@ export const HOSTED_DEADLINE_MARGIN_MINUTES = 60;
183
204
  * and the pause before a busy spawn is asked again. */
184
205
  export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes: 5, busyRetryMinutes: 2 } as const;
185
206
 
186
- /** The plane's table (docs/decisions/0064): how long a finished run stays on it. */
187
- export const PLANE = { recentMinutes: 60 } as const;
207
+ /** The plane's table (docs/decisions/0064): how long a finished run stays on
208
+ * it, a reservation's window, and the default re-ask cadence — how often a
209
+ * silent resident something waits on is probed (`plane.reaskMinutes`
210
+ * overrides it per deployment). */
211
+ export const PLANE = { recentMinutes: 60, reservationMinutes: 2, reaskMinutes: 2 } as const;
188
212
 
189
213
  /** The named amounts a lease holds back, in minutes. Each stands for a step
190
214
  * every run or round pays: `provision` is attach and restore before the
@@ -473,6 +497,15 @@ export const DEFAULT_GRANT: Grant = { renewals: 0 };
473
497
  * in one message before a plan should carry it. */
474
498
  export const GRANT_RENEWALS_MAX = 12;
475
499
 
500
+ // ---- the structured-answer seam (record 0067: a violation is re-asked) ----
501
+
502
+ /** The most re-asks one structured ask may make (`askStructured`,
503
+ * src/core/dispatch/structured.ts): a named violation is quoted back to the
504
+ * same model at most this many times; the next violation is the caller's
505
+ * declared floor. A count, not a duration — the caller's one timeout covers
506
+ * the whole loop. */
507
+ export const STRUCTURED_RETRIES_MAX = 2;
508
+
476
509
  // ---- the idle unit (record 0051: a pipeline idles instead of ending) ----
477
510
 
478
511
  /** `ship.idleDays`' default: zero — today's endings byte for byte. It moves to
@@ -347,6 +347,11 @@ export interface CoordinatorInstance {
347
347
  * attempt ended reruns the units not merged under `plan-<plan-id>-<attempt>`);
348
348
  * absent for the first. */
349
349
  attempt?: number;
350
+ /** The hard stop's mark (record 0060; issue 1924): written when the hosted
351
+ * parent is sealed, read by the runner before every unit start and before
352
+ * every child spawn — it honours the mark by ending the remaining units
353
+ * `stopped` and running nothing more. */
354
+ stop?: { at: number };
350
355
  }
351
356
 
352
357
  export interface UnitSegment {
@@ -491,6 +496,7 @@ export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
491
496
  if (!isOptionalText(r.runId) || !isOptionalText(r.label)) return false;
492
497
  if (r.verbosity !== undefined && !isVerbosity(r.verbosity)) return false;
493
498
  if (r.attempt !== undefined && !(Number.isInteger(r.attempt) && (r.attempt as number) >= 2)) return false;
499
+ if (r.stop !== undefined && !(isObject(r.stop) && isFinite(r.stop.at))) return false;
494
500
  return true;
495
501
  }
496
502
 
@@ -209,6 +209,9 @@ interface PlanFacts {
209
209
  generated: boolean;
210
210
  /** The runs page base the bot answered: the report links a child's write-up to its run page with it. */
211
211
  runPageBase?: string;
212
+ /** The hard stop's mark (record 0060; issue 1924): the hosted parent was
213
+ * sealed, so the walk ends the remaining units stopped and runs nothing more. */
214
+ stopped: boolean;
212
215
  repo: string;
213
216
  base: string;
214
217
  caps: ShipCaps;
@@ -263,6 +266,7 @@ function readPlan(a: BotAnswer): PlanFacts {
263
266
  idleDays: readIdleDays(b.idleDays),
264
267
  generated: b.generated === true,
265
268
  ...(typeof b.runPageBase === "string" && b.runPageBase.length > 0 ? { runPageBase: b.runPageBase } : {}),
269
+ stopped: b.stopped === true,
266
270
  repo: b.repo,
267
271
  base: b.base,
268
272
  caps: { maxRounds: b.caps.maxRounds, maxMinutes: b.caps.maxMinutes },
@@ -288,6 +292,9 @@ function spawnReturn(step: string, a: BotAnswer): StepReturn {
288
292
  return { type: "spawn", step, outcome: alreadySpawned === true ? "alreadySpawned" : "spawned", runId, at };
289
293
  if (a.status === 409 && error === "busy")
290
294
  return { type: "spawn", step, outcome: "busy", ...(typeof runId === "string" ? { runId } : {}), at };
295
+ // The hosted parent's hard stop (record 0060; issue 1924): the spawn is
296
+ // refused over the instance row's stop mark, and the unit ends stopped.
297
+ if (a.status === 409 && error === "stopped") return { type: "spawn", step, outcome: "stopped", at };
291
298
  if (a.status === 403 && typeof error === "string")
292
299
  return {
293
300
  type: "spawn",
@@ -306,7 +313,10 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
306
313
  const run = a.body.run;
307
314
  if (a.body.ok !== true || !isRecord(run) || typeof run.finished !== "boolean")
308
315
  throw new UnreadableAnswer("read-record", a, "run");
309
- if (!run.finished) return { type: "read-record", step, run: { finished: false }, at: a.body.at };
316
+ // The hard stop's mark, as the bot's answer carries it (record 0060; issue
317
+ // 1924): a finished child's unit ends stopped on it.
318
+ const stopped = a.body.stopped === true ? { stopped: true as const } : {};
319
+ if (!run.finished) return { type: "read-record", step, run: { finished: false }, ...stopped, at: a.body.at };
310
320
  if (typeof run.status !== "string") throw new UnreadableAnswer("read-record", a, "status");
311
321
  // The typed artifacts as the bot's record carries them — shape-checked where
312
322
  // they were written (the run record's validator), read here as they are.
@@ -326,10 +336,12 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
326
336
  leaseStartedAt,
327
337
  costUsd,
328
338
  handoffLists,
339
+ failure,
329
340
  } = facts;
330
341
  return {
331
342
  type: "read-record",
332
343
  step,
344
+ ...stopped,
333
345
  run: {
334
346
  finished: true,
335
347
  status: run.status as Extract<ChildFacts, { finished: true }>["status"],
@@ -348,6 +360,9 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
348
360
  ...(typeof leaseStartedAt === "number" ? { leaseStartedAt } : {}),
349
361
  ...(typeof costUsd === "number" || costUsd === null ? { costUsd } : {}),
350
362
  ...(handoffLists !== undefined ? { handoffLists } : {}),
363
+ // The failure by name (run-history item 57), shape-checked: a
364
+ // `provider_transient` drives the round-0 re-run (agent-ship item 9).
365
+ ...(isRecord(failure) && typeof failure.kind === "string" ? { failure: { kind: failure.kind } } : {}),
351
366
  },
352
367
  at: a.body.at,
353
368
  };
@@ -453,7 +468,11 @@ function mergeReturn(step: string, a: BotAnswer): StepReturn {
453
468
  return { type: "merge", step, outcome: "merged", by: "other", sha, mergedAt, at };
454
469
  if (ok === true && outcome === "merged" && typeof sha === "string")
455
470
  return { type: "merge", step, outcome: "merged", sha, at };
456
- if (ok === true && (outcome === "pending" || outcome === "refused") && typeof reason === "string")
471
+ if (
472
+ ok === true &&
473
+ (outcome === "pending" || outcome === "refused" || outcome === "enqueued" || outcome === "removed") &&
474
+ typeof reason === "string"
475
+ )
457
476
  return { type: "merge", step, outcome, reason, at };
458
477
  throw new UnreadableAnswer("merge", a, "outcome");
459
478
  }
@@ -642,7 +661,12 @@ async function perform(
642
661
  answerOf(
643
662
  "merge",
644
663
  await step.do(action.step, STEP_CONFIG, () =>
645
- call(bot, "merge", { ...tag, prNumber: action.prNumber, headSha: action.headSha }),
664
+ call(bot, "merge", {
665
+ ...tag,
666
+ prNumber: action.prNumber,
667
+ headSha: action.headSha,
668
+ ...(action.queued === true ? { queued: true } : {}),
669
+ }),
646
670
  ),
647
671
  ),
648
672
  );
@@ -807,6 +831,11 @@ async function runUnit(
807
831
  }
808
832
  }
809
833
 
834
+ /** The report of a unit the hard stop ended before it ran (record 0060; issue 1924). */
835
+ function stoppedReport(unit: string): string {
836
+ return `⏹ Stopped: the pipeline's hosted parent was hard-stopped, so ${unit} was ended without running. Re-issue the plan naming the remaining units to run them.`;
837
+ }
838
+
810
839
  function blockedReport(unit: string, dep: string, depEnding: string): string {
811
840
  if (depEnding === "blocked")
812
841
  return `⛔ Blocked: ${unit} waits on ${dep}, which is blocked itself. Re-issue the plan naming the remaining units once it is resolved.`;
@@ -877,6 +906,18 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
877
906
  let cursor = openPlanCursor(graph);
878
907
  const endings: Record<string, string> = {};
879
908
  for (;;) {
909
+ // The hard stop's mark (record 0060; issue 1924), read before every unit
910
+ // start: the walk ends every unit not yet ended `stopped` — the rows say
911
+ // why they never ran — and starts nothing more. A stop that lands once
912
+ // every unit has ended changes nothing: there is nothing left to end.
913
+ if (plan.stopped) {
914
+ for (const id of cursor.order.filter((u) => endings[u] === undefined)) {
915
+ endings[id] = "stopped";
916
+ const body = { parentInstanceId: instanceId, unit: id, ending: { kind: "stopped", report: stoppedReport(id) } };
917
+ await step.do(`${id}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
918
+ }
919
+ break;
920
+ }
880
921
  const [next] = readyUnits(graph, cursor);
881
922
  if (next === undefined) break;
882
923
  cursor = startUnit(graph, cursor, next);
@@ -914,8 +955,9 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
914
955
  // Blocked units, in the plan's order: each told its own ending, so the rows
915
956
  // and the summary say why it never ran. Every blocked unit's ending is known
916
957
  // before any report is rendered — a plan may list a dependent before the
917
- // dependency that blocks it.
918
- const blocked = cursor.order.filter((id) => cursor.status[id] === "blocked");
958
+ // dependency that blocks it. A stopped walk skips this: every unended unit
959
+ // was already ended `stopped` above, and its cursor never finishes.
960
+ const blocked = plan.stopped ? [] : cursor.order.filter((id) => cursor.status[id] === "blocked");
919
961
  for (const id of blocked) endings[id] = "blocked";
920
962
  for (const id of blocked) {
921
963
  const node = graph.units.find((u) => u.id === id)!;
@@ -927,7 +969,8 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
927
969
  };
928
970
  await step.do(`${id}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
929
971
  }
930
- if (!cursorFinished(cursor)) throw new Error(`the plan's cursor did not finish: ${JSON.stringify(cursor.status)}`);
972
+ if (!plan.stopped && !cursorFinished(cursor))
973
+ throw new Error(`the plan's cursor did not finish: ${JSON.stringify(cursor.status)}`);
931
974
  return {
932
975
  instance: instanceId,
933
976
  ...(plan.planId !== undefined ? { planId: plan.planId } : {}),
@@ -762,12 +762,19 @@ export interface BillerTieOutDay {
762
762
  invoiceUsd?: number;
763
763
  invoiceFeeUsd?: number;
764
764
  invoiceByokUsd?: number;
765
- /** The summed `model.turn` dollars whose ref names this block. */
765
+ /** The summed `model.turn` dollars whose ref names this block — the meter's
766
+ * charged figure from each turn's own `usd` (the provider's, the operator's
767
+ * or the registry's price at the time), never the price table's repricing
768
+ * the dimension views show (`buildCostsByReport`). */
766
769
  attributedUsd: number;
767
770
  /** The summed BYOK fees — the half an aggregator's `usage` ties against. */
768
771
  attributedFeeUsd?: number;
769
772
  /** `attributedUsd − attributedFeeUsd` — the half `byok_usage_inference` ties against. */
770
773
  attributedUpstreamUsd?: number;
774
+ /** The day's tokens whose turns carried no `usd` (null or absent) — spend the
775
+ * attributed side could not count, so a gap against the invoice is explained
776
+ * rather than silent. Absent when every turn was priced. */
777
+ unpricedTokens?: number;
771
778
  }
772
779
 
773
780
  /** One biller's daily tie-out over the range. */
@@ -784,15 +791,20 @@ const billerOfRef = (ref: string): string | undefined => (ref.includes("/") ? pa
784
791
 
785
792
  /**
786
793
  * Pure: each biller's invoice compared with the summed `model.turn` rows whose
787
- * ref names that block, per UTC day of the range. An aggregator's invoice
788
- * halves (`feeUsd`, `byokUsd`) sit beside the summed fees and the remainder. A
789
- * model whose `usd` is null or absent (unpriced, or from before the meter row)
790
- * contributes nothing to `attributedUsd` — an understated side is honest, an
791
- * invented one is not, so a day the source returned no row for keeps its
794
+ * ref names that block, per UTC day of the range. The attributed side is the
795
+ * meter's charged figure — each turn's own `usd`, never the price table's
796
+ * repricing. An aggregator's invoice halves (`feeUsd`, `byokUsd`) sit beside
797
+ * the summed fees and the remainder. A model whose `usd` is null or absent
798
+ * (unpriced, or from before the meter row) contributes nothing to
799
+ * `attributedUsd` — its tokens are counted as the day's `unpricedTokens`
800
+ * instead, so the understated side is visible: honest, never invented — and
801
+ * a day the source returned no row for keeps its
792
802
  * invoice side absent (the page renders —): the source holds closed days only,
793
803
  * and a trailing uninvoiced day beside real spend is not a $0 invoice. A day
794
- * with neither an invoice row nor a nonzero attributed figure is dropped; a
795
- * biller without a source (`invoice: false`) ties out against nothing.
804
+ * with no invoice row, no nonzero attributed figure and no unpriced tokens is
805
+ * dropped — but unpriced spend alone keeps its day, so the token count renders
806
+ * instead of vanishing; a biller without a source (`invoice: false`) ties out
807
+ * against nothing.
796
808
  */
797
809
  export function buildBillerTieOuts(
798
810
  billers: ReadonlyArray<{ name: string; invoice: boolean }>,
@@ -801,16 +813,22 @@ export function buildBillerTieOuts(
801
813
  range: DateRange,
802
814
  ): BillerTieOut[] {
803
815
  return billers.map(({ name, invoice }) => {
804
- const attributed = new Map<string, { usd: number; fee: number; hasFee: boolean }>();
816
+ const attributed = new Map<string, { usd: number; fee: number; hasFee: boolean; unpricedTokens: number }>();
805
817
  for (const cell of cells) {
806
818
  if (cell.day < range.from || cell.day > range.to) continue;
807
819
  for (const [ref, m] of Object.entries(cell.usage.byModel)) {
808
820
  if (billerOfRef(ref) !== name) continue;
809
- const day = attributed.get(cell.day) ?? { usd: 0, fee: 0, hasFee: false };
810
- if (typeof m.usd === "number") day.usd += m.usd;
811
- if (m.feeUsd !== undefined) {
812
- day.fee += m.feeUsd;
813
- day.hasFee = true;
821
+ const day = attributed.get(cell.day) ?? { usd: 0, fee: 0, hasFee: false, unpricedTokens: 0 };
822
+ // An unpriced model's feeUsd is skipped with its usd: adding the fee
823
+ // alone would drive attributedUpstreamUsd (usd − fee) negative.
824
+ if (typeof m.usd === "number") {
825
+ day.usd += m.usd;
826
+ if (m.feeUsd !== undefined) {
827
+ day.fee += m.feeUsd;
828
+ day.hasFee = true;
829
+ }
830
+ } else {
831
+ day.unpricedTokens += m.inputTokens + m.outputTokens + m.cacheReadTokens + m.cacheWriteTokens;
814
832
  }
815
833
  attributed.set(cell.day, day);
816
834
  }
@@ -820,7 +838,7 @@ export function buildBillerTieOuts(
820
838
  for (const d of invoices[name] ?? []) if (d.date >= range.from && d.date <= range.to) invoiced.set(d.date, d);
821
839
  const dates = [...new Set([...attributed.keys(), ...invoiced.keys()])].sort().filter((date) => {
822
840
  const a = attributed.get(date);
823
- return invoiced.has(date) || (a !== undefined && (a.usd !== 0 || a.hasFee));
841
+ return invoiced.has(date) || (a !== undefined && (a.usd !== 0 || a.hasFee || a.unpricedTokens > 0));
824
842
  });
825
843
  const days: BillerTieOutDay[] = dates.map((date) => {
826
844
  const a = attributed.get(date);
@@ -832,6 +850,7 @@ export function buildBillerTieOuts(
832
850
  ...(inv?.byokUsd !== undefined ? { invoiceByokUsd: inv.byokUsd } : {}),
833
851
  attributedUsd: a?.usd ?? 0,
834
852
  ...(a?.hasFee ? { attributedFeeUsd: a.fee, attributedUpstreamUsd: a.usd - a.fee } : {}),
853
+ ...(a !== undefined && a.unpricedTokens > 0 ? { unpricedTokens: a.unpricedTokens } : {}),
835
854
  };
836
855
  });
837
856
  return {
@@ -922,7 +941,11 @@ export class OpenAICostsSource implements LlmInvoiceSource {
922
941
  next_page?: string | null;
923
942
  };
924
943
  for (const bucket of body.data ?? []) {
925
- const date = new Date(num(bucket.start_time) * 1000).toISOString().slice(0, 10);
944
+ // A bucket without a finite start_time is skipped, never dated to epoch 0;
945
+ // out-of-range buckets are dropped like the OpenRouter source's rows.
946
+ if (typeof bucket.start_time !== "number" || !Number.isFinite(bucket.start_time)) continue;
947
+ const date = new Date(bucket.start_time * 1000).toISOString().slice(0, 10);
948
+ if (date < range.from || date > range.to) continue;
926
949
  for (const r of bucket.results ?? []) {
927
950
  const currency = str(r.amount?.currency).toLowerCase();
928
951
  if (currency !== "usd") throw new Error(`openai organization costs: unexpected currency ${currency || "?"}`);
@@ -94,6 +94,9 @@ export const ROUND_STAGE = {
94
94
  request_changes: "fix",
95
95
  no_verdict: "hold",
96
96
  checks_failed: "fix",
97
+ transient: "hold",
98
+ enqueued: "approved",
99
+ dequeued: "fix",
97
100
  aborted: "hold",
98
101
  stopped: "hold",
99
102
  continued: "continue",
@@ -111,12 +114,14 @@ export const ENDING_STAGE = {
111
114
  merged: "merged",
112
115
  already_landed: "merged",
113
116
  merge_ready: "merge-ready",
117
+ held: "ended",
114
118
  merge_refused: "ended",
115
119
  round_cap: "ended",
116
120
  wall_clock_cap: "ended",
117
121
  review_pending: "ended",
118
122
  stopped: "ended",
119
123
  aborted: "ended",
124
+ transient: "ended",
120
125
  continued: "hold",
121
126
  no_verdict: "ended",
122
127
  interrupted: "ended",