@coreplane/switchboard 1.242.0 → 1.243.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 (59) hide show
  1. package/dist/assets/config/config.example.yaml +20 -1
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +86 -29
  3. package/dist/assets/deploy/cloudflare-resident/worker.ts +105 -13
  4. package/dist/assets/package-lock.json +3 -3
  5. package/dist/assets/package.json +1 -1
  6. package/dist/assets/project.json +3 -3
  7. package/dist/assets/source.json +3 -3
  8. package/dist/assets/src/agents/registry.ts +30 -34
  9. package/dist/assets/src/config/profile.ts +68 -3
  10. package/dist/assets/src/core/authz/policy.ts +4 -0
  11. package/dist/assets/src/core/budgets.ts +313 -0
  12. package/dist/assets/src/core/coordinator/driver.ts +0 -10
  13. package/dist/assets/src/core/costs.ts +9 -76
  14. package/dist/assets/src/core/modelPricing.ts +212 -0
  15. package/dist/assets/src/core/prDescriptionTypes.ts +29 -24
  16. package/dist/assets/src/core/reviewVerdict.ts +7 -0
  17. package/dist/assets/src/core/runEvents.ts +28 -4
  18. package/dist/assets/src/core/runFriction.ts +2 -1
  19. package/dist/assets/src/core/runRecord.ts +47 -0
  20. package/dist/assets/src/core/runUsage.ts +158 -47
  21. package/dist/assets/src/core/schedules.ts +3 -0
  22. package/dist/assets/src/core/ship/coordinator.ts +101 -84
  23. package/dist/assets/src/core/ship/handoff.ts +9 -0
  24. package/dist/assets/src/execution/bashTimeout.ts +8 -5
  25. package/dist/assets/src/execution/residentRefresh.ts +28 -3
  26. package/dist/assets/src/execution/residentSteps.ts +1 -1
  27. package/dist/assets/web/dist/.vite/manifest.json +67 -64
  28. package/dist/assets/web/dist/assets/CostsPage-CwXOmkeQ.js +2 -0
  29. package/dist/assets/web/dist/assets/HomePage-PRxjQiGG.js +2 -0
  30. package/dist/assets/web/dist/assets/PendingTurnRow-BT9RhFZ7.js +1 -0
  31. package/dist/assets/web/dist/assets/{ResidentDetailPage-DTMBgnIW.js → ResidentDetailPage-DACalNVF.js} +1 -1
  32. package/dist/assets/web/dist/assets/ResidentsIndexPage-SHPdu6uZ.js +1 -0
  33. package/dist/assets/web/dist/assets/RunFoldRow-0SdOmOr5.js +1 -0
  34. package/dist/assets/web/dist/assets/RunRoutePage-BaFS2p8I.js +9 -0
  35. package/dist/assets/web/dist/assets/RunsIndexPage-DYI-iALj.js +1 -0
  36. package/dist/assets/web/dist/assets/ScheduledPage-DJ8HiCPt.js +1 -0
  37. package/dist/assets/web/dist/assets/{SettingsPage-BIGio8Y0.js → SettingsPage-DLiN5IgY.js} +1 -1
  38. package/dist/assets/web/dist/assets/{StatusDot-ELoXHlFt.js → StatusDot-Dw0T1M-P.js} +1 -1
  39. package/dist/assets/web/dist/assets/{Tooltip-BoeFwYP2.js → Tooltip-BbLuIAiS.js} +1 -1
  40. package/dist/assets/web/dist/assets/UnitRoutePage-DicUG96U.js +1 -0
  41. package/dist/assets/web/dist/assets/{dist-BU5UivXC.js → dist-twkFmSUY.js} +1 -1
  42. package/dist/assets/web/dist/assets/format-BldUwl_R.js +1 -0
  43. package/dist/assets/web/dist/assets/indexRow-B_s5tKyq.js +1 -0
  44. package/dist/assets/web/dist/assets/{main-B2fX10aW.css → main-B4kEF3Sg.css} +1 -1
  45. package/dist/assets/web/dist/assets/{main-D4EA1g6n.js → main-d-w-tIKt.js} +2 -2
  46. package/dist/assets/web/dist/assets/{sseReplay-g7ml86LM.js → sseReplay-C9m_EB8J.js} +4 -4
  47. package/dist/cli.js +2758 -1445
  48. package/package.json +1 -1
  49. package/dist/assets/web/dist/assets/CostsPage-CtmKhhOF.js +0 -2
  50. package/dist/assets/web/dist/assets/HomePage-QtH1EwYF.js +0 -2
  51. package/dist/assets/web/dist/assets/PendingTurnRow-BZA_vQt3.js +0 -1
  52. package/dist/assets/web/dist/assets/ResidentsIndexPage-CaDvXhzJ.js +0 -1
  53. package/dist/assets/web/dist/assets/RunFoldRow-mgyLW0oV.js +0 -1
  54. package/dist/assets/web/dist/assets/RunRoutePage-DypJYMQa.js +0 -6
  55. package/dist/assets/web/dist/assets/RunsIndexPage-DYraPoWD.js +0 -1
  56. package/dist/assets/web/dist/assets/ScheduledPage-DXD2gLJk.js +0 -1
  57. package/dist/assets/web/dist/assets/UnitRoutePage-jhCrWW3i.js +0 -1
  58. package/dist/assets/web/dist/assets/indexRow-BD1VT8o8.js +0 -1
  59. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
@@ -11,7 +11,11 @@ providers:
11
11
  anthropic:
12
12
  type: anthropic
13
13
  apiKeyEnv: ANTHROPIC_API_KEY
14
- # Any OpenAI-compatible endpoint works with the same adapter:
14
+ # OpenAI itself runs on the same compatible adapter: any preset's model may
15
+ # be openai/<model-id> anywhere a model is accepted. The adapter speaks Chat
16
+ # Completions, not the Responses API, sends no reasoning control (our effort
17
+ # tiers do not reach these models), and meters tokens without pricing them.
18
+ # A first-class OpenAI block is a plan's business, not this file's.
15
19
  openai:
16
20
  type: openai-compatible
17
21
  baseUrl: https://api.openai.com/v1
@@ -65,10 +69,19 @@ defaults:
65
69
  # clipped (the card and the run record say so); a preset whose identity or
66
70
  # class is above a cap is refused by name before any executor exists. Absent →
67
71
  # nothing is capped and every preset runs exactly its declared profile.
72
+ # A fourth field, `confirm`, is the front door's and caps no run: the first
73
+ # blast-radius class a command the router bound from prose is handed back at
74
+ # (`To run this: …`) instead of run — `write` (every write is handed back; a
75
+ # read or a repository's own test or build runs) or `destructive` (a write
76
+ # runs at once; only a destructive one is handed back). It intersects toward
77
+ # caution like the run axes — a channel or a user can only ask MORE than what
78
+ # is set here — and unset everywhere it is the built-in `write`. `exec` and
79
+ # `never` are refused by name.
68
80
  # boundary:
69
81
  # maxMinutes: 120
70
82
  # maxIdentity: write
71
83
  # machines: [none, blank, repo-cold, repo-resident]
84
+ # confirm: write
72
85
 
73
86
  # PR reviews and the reading diff (docs/reference/specs/reading-diff.md). Every
74
87
  # review records its full `git diff`; the ABRIDGED version (meat.dev, run on
@@ -436,6 +449,12 @@ workspaceDir: ./workspaces
436
449
  # r2Buckets: # optional: bucket name → label for buckets not named <worker>-cache
437
450
  # some-other-bucket: uploads
438
451
  # anthropicWorkspaceId: wrkspc_... # this app's Anthropic workspace
452
+ # prices: # optional: USD per million tokens by <provider>/<model>, the ref a run's
453
+ # openai/gpt-5: # spans name — for a model the Anthropic list does not know, or to
454
+ # input: 1.25 # override the list; all four kinds named (a provider with no
455
+ # output: 10 # cache-write charge says 0). A model neither knows is "unpriced",
456
+ # cacheRead: 0.125 # never $0, on the costs page and on a run's own page
457
+ # cacheWrite: 0
439
458
 
440
459
  # Delivery indicators: GET /delivery and `delivery report` (docs/reference/specs/delivery.md).
441
460
  # Optional. Needs a GitHub credential (the App triple or GH_TOKEN); read-only on GitHub.
@@ -25,6 +25,7 @@ import {
25
25
  isRunSession,
26
26
  isRunVisibilityFilter,
27
27
  normalizeStored,
28
+ pullRequestNumberOf,
28
29
  RETENTION_BOUNDS,
29
30
  RUN_EVENTS_DEFAULT_PAGE,
30
31
  RUN_EVENTS_MAX_PAGE,
@@ -59,11 +60,11 @@ import {
59
60
  } from "../../src/core/runLedger/sessionLog.ts";
60
61
  import type { RunEvent } from "../../src/core/runEvents.ts";
61
62
  import {
62
- aggregateUsageByUser,
63
63
  isRunUsage,
64
64
  usageOfEvents,
65
65
  type RunUsage,
66
- type RunUsageReport,
66
+ type RunUsageRows,
67
+ type UsageIdentity,
67
68
  type UsageRun,
68
69
  } from "../../src/core/runUsage.ts";
69
70
  import {
@@ -1265,13 +1266,15 @@ export class RunHistoryDO extends DurableObject<Env> {
1265
1266
  this.migrateRunsTable();
1266
1267
  // The indexes the visibility predicate's leaves walk (`channel_id IN`,
1267
1268
  // `channel_visibility IN`, `user_id =`), each ordered like the page; the
1268
- // session the sweep asks about; the parent a children listing filters on.
1269
+ // session the sweep asks about; the parent a children listing filters on;
1270
+ // the pull request a findings listing filters on.
1269
1271
  this.sql.exec(`
1270
1272
  CREATE INDEX IF NOT EXISTS runs_channel_finished ON runs(channel_id, finished_at DESC, run_id DESC);
1271
1273
  CREATE INDEX IF NOT EXISTS runs_visibility_finished ON runs(channel_visibility, finished_at DESC, run_id DESC);
1272
1274
  CREATE INDEX IF NOT EXISTS runs_user_finished ON runs(user_id, finished_at DESC, run_id DESC);
1273
1275
  CREATE INDEX IF NOT EXISTS runs_session ON runs(session_key);
1274
1276
  CREATE INDEX IF NOT EXISTS runs_parent ON runs(parent_run_id, finished_at DESC, run_id DESC);
1277
+ CREATE INDEX IF NOT EXISTS runs_pr ON runs(repo, pr_number, finished_at DESC, run_id DESC);
1275
1278
  `);
1276
1279
  // The sessions registry (session-log item 7): every session log a run of
1277
1280
  // this store claimed, with its thread — the sweep cannot enumerate the
@@ -1438,7 +1441,11 @@ export class RunHistoryDO extends DurableObject<Env> {
1438
1441
  * aggregate fills it from the run's stored events. The parent a child names
1439
1442
  * (run-history item 46), the column a children listing filters on: the
1440
1443
  * record already carries it in `summary_json`, so existing rows are filled
1441
- * from there once, and every later `put` writes it beside the row. */
1444
+ * from there once, and every later `put` writes it beside the row. The pull
1445
+ * request a run names (run-history item 58; `pullRequestNumberOf`), the
1446
+ * column a findings listing filters on: the same one-time fill from
1447
+ * `summary_json` — the coding post-step's `pr`, else a posted review's
1448
+ * target — and every later `put` writes it beside the row. */
1442
1449
  migrateRunsTable(): void {
1443
1450
  const columns = new Set(
1444
1451
  this.sql
@@ -1457,6 +1464,19 @@ export class RunHistoryDO extends DurableObject<Env> {
1457
1464
  WHERE json_type(summary_json, '$.parentRunId') = 'text'`,
1458
1465
  );
1459
1466
  }
1467
+ if (!columns.has("pr_number")) {
1468
+ this.sql.exec(`ALTER TABLE runs ADD COLUMN pr_number INTEGER`);
1469
+ // `pullRequestNumberOf` in SQL: the coding post-step's pull request, else
1470
+ // the one a posted review targeted (a skipped post has no target).
1471
+ this.sql.exec(
1472
+ `UPDATE runs SET pr_number = COALESCE(
1473
+ CASE WHEN json_type(summary_json, '$.pr.number') = 'integer' THEN json_extract(summary_json, '$.pr.number') END,
1474
+ CASE WHEN json_extract(summary_json, '$.reviewPost.posted') = 1
1475
+ AND json_type(summary_json, '$.reviewPost.target.number') = 'integer'
1476
+ THEN json_extract(summary_json, '$.reviewPost.target.number') END
1477
+ )`,
1478
+ );
1479
+ }
1460
1480
  }
1461
1481
 
1462
1482
  private liveByThread(threadKey: string): LiveRunRow | undefined {
@@ -1970,8 +1990,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1970
1990
  );
1971
1991
  this.sql.exec(
1972
1992
  `INSERT INTO runs (run_id, label, agent, model, channel_id, user_id, thread_key, channel_visibility, repo, started_at, finished_at, stored_at, status,
1973
- event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json, parent_run_id)
1974
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1993
+ event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json, parent_run_id, pr_number)
1994
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1975
1995
  ON CONFLICT(run_id) DO UPDATE SET
1976
1996
  label = excluded.label, agent = excluded.agent, model = excluded.model, channel_id = excluded.channel_id,
1977
1997
  user_id = excluded.user_id, thread_key = excluded.thread_key, channel_visibility = excluded.channel_visibility,
@@ -1981,7 +2001,8 @@ export class RunHistoryDO extends DurableObject<Env> {
1981
2001
  bytes = excluded.bytes, diagnosis_json = excluded.diagnosis_json, summary_json = excluded.summary_json,
1982
2002
  session_key = excluded.session_key,
1983
2003
  usage_json = COALESCE(excluded.usage_json, runs.usage_json),
1984
- parent_run_id = excluded.parent_run_id`,
2004
+ parent_run_id = excluded.parent_run_id,
2005
+ pr_number = excluded.pr_number`,
1985
2006
  stored.id,
1986
2007
  stored.label ?? null,
1987
2008
  stored.agent ?? null,
@@ -2004,6 +2025,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2004
2025
  stored.session?.key ?? null,
2005
2026
  stored.usage ? JSON.stringify(stored.usage) : null,
2006
2027
  stored.parentRunId ?? null,
2028
+ pullRequestNumberOf(stored) ?? null,
2007
2029
  );
2008
2030
  // The session's registry row learns its newest finish (session-log item
2009
2031
  // 7); a record that reaches the store without a claim (the plain put
@@ -2208,28 +2230,32 @@ export class RunHistoryDO extends DurableObject<Env> {
2208
2230
  }
2209
2231
 
2210
2232
  /**
2211
- * Who spent what, per UTC day (costs.md, cost by user), over the runs that
2212
- * finished in [sinceMs, untilMs) and are inside the retention window. A row
2213
- * written before `usage_json` existed is filled in here from its stored
2214
- * `model.turn` events — up to USAGE_BACKFILL_PER_CALL a call, the rest
2215
- * reported as `pending` — so the history heals as it is read, with no
2233
+ * What the runs that finished in [sinceMs, untilMs) and are inside the
2234
+ * retention window cost (costs.md items 10–10a; run-history item 56): one row
2235
+ * per run — its requester, thread, channel, agent and usage — plus the
2236
+ * identity of every parent a child names that is outside the batch, so the
2237
+ * bot bills the child without a second read. The arithmetic is the bot's; the
2238
+ * Worker only reads its rows. A row written before `usage_json` existed is
2239
+ * filled in here from its stored `model.turn` events — up to
2240
+ * USAGE_BACKFILL_PER_CALL a call, the rest answered without `usage` and
2241
+ * counted in `pending` — so the history heals as it is read, with no
2216
2242
  * operator step; a run without turns is written back as the zero usage so it
2217
- * is not re-read. A child is billed to its parent's requester; a parent
2218
- * outside the range is looked up by id.
2243
+ * is not re-read.
2219
2244
  */
2220
- usageByUser(sinceMs: number, untilMs: number): RunUsageReport {
2245
+ usage(sinceMs: number, untilMs: number): RunUsageRows {
2221
2246
  const now = systemClock();
2222
2247
  const { policy } = this.policyState();
2223
2248
  const cutoff = now - policy.retentionDays * 86_400_000;
2224
2249
  const rows = this.sql
2225
2250
  .exec<UsageRow>(
2226
- `SELECT run_id, user_id, started_at, finished_at, usage_json, summary_json FROM runs
2251
+ `SELECT run_id, user_id, agent, channel_id, thread_key, started_at, finished_at, usage_json, summary_json FROM runs
2227
2252
  WHERE finished_at >= ? AND finished_at < ? ORDER BY finished_at ASC, run_id ASC`,
2228
2253
  Math.max(sinceMs, cutoff),
2229
2254
  untilMs,
2230
2255
  )
2231
2256
  .toArray();
2232
2257
  let backfilled = 0;
2258
+ let pending = 0;
2233
2259
  const runs: UsageRun[] = rows.map((row) => {
2234
2260
  let usage = parseUsageJson(row.usage_json);
2235
2261
  if (usage === undefined && backfilled < USAGE_BACKFILL_PER_CALL) {
@@ -2237,31 +2263,38 @@ export class RunHistoryDO extends DurableObject<Env> {
2237
2263
  this.sql.exec(`UPDATE runs SET usage_json = ? WHERE run_id = ?`, JSON.stringify(usage), row.run_id);
2238
2264
  backfilled += 1;
2239
2265
  }
2266
+ if (usage === undefined) pending += 1;
2240
2267
  const who = identityOfSummary(row.summary_json);
2241
2268
  return {
2242
2269
  id: row.run_id,
2243
2270
  userId: row.user_id,
2244
2271
  ...(who.userName ? { userName: who.userName } : {}),
2245
2272
  ...(who.parentRunId ? { parentRunId: who.parentRunId } : {}),
2273
+ threadKey: row.thread_key,
2274
+ channelId: row.channel_id,
2275
+ ...(row.agent ? { agent: row.agent } : {}),
2246
2276
  startedAt: row.started_at,
2247
2277
  finishedAt: row.finished_at,
2248
2278
  ...(usage ? { usage } : {}),
2249
2279
  };
2250
2280
  });
2251
- const lookupParent = (id: string) => {
2281
+ // The parents a child names that are not in the batch: looked up once each, known or not.
2282
+ const inBatch = new Set(runs.map((r) => r.id));
2283
+ const parents: Record<string, UsageIdentity> = {};
2284
+ for (const id of new Set(runs.map((r) => r.parentRunId).filter((p): p is string => !!p && !inBatch.has(p)))) {
2252
2285
  const p = this.sql
2253
2286
  .exec<{ user_id: string; summary_json: string }>(`SELECT user_id, summary_json FROM runs WHERE run_id = ?`, id)
2254
2287
  .toArray()[0];
2255
- if (!p) return undefined;
2288
+ if (!p) continue;
2256
2289
  const who = identityOfSummary(p.summary_json);
2257
- return { userId: p.user_id, ...(who.userName ? { userName: who.userName } : {}) };
2258
- };
2259
- const { rows: out, pending } = aggregateUsageByUser(runs, lookupParent);
2290
+ parents[id] = { userId: p.user_id, ...(who.userName ? { userName: who.userName } : {}) };
2291
+ }
2260
2292
  const earliest = this.sql
2261
2293
  .exec<{ m: number | null }>(`SELECT MIN(finished_at) AS m FROM runs WHERE finished_at >= ?`, cutoff)
2262
2294
  .one().m;
2263
2295
  return {
2264
- rows: out,
2296
+ runs,
2297
+ parents,
2265
2298
  pending,
2266
2299
  ...(earliest !== null ? { earliestFinishedAt: earliest } : {}),
2267
2300
  retentionDays: policy.retentionDays,
@@ -2343,6 +2376,11 @@ export class RunHistoryDO extends DurableObject<Env> {
2343
2376
  where.push(`parent_run_id = ?`);
2344
2377
  params.push(q.parentRunId);
2345
2378
  }
2379
+ if (q.pr !== undefined) {
2380
+ // `namesPullRequest` in SQL: the row's repository and the number it names.
2381
+ where.push(`repo = ?`, `pr_number = ?`);
2382
+ params.push(q.pr.repo, q.pr.number);
2383
+ }
2346
2384
  if (q.visibleTo !== undefined && q.visibleTo.kind !== "all") where.push(visibilitySql(q.visibleTo, params));
2347
2385
  const select = `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE ${where.join(" AND ")} ORDER BY finished_at DESC, run_id DESC`;
2348
2386
  // `LIMIT` holds on the over-bound path too: the kept set is the newest
@@ -2423,6 +2461,9 @@ const USAGE_BACKFILL_PER_CALL = 200;
2423
2461
  type UsageRow = {
2424
2462
  run_id: string;
2425
2463
  user_id: string;
2464
+ agent: string | null;
2465
+ channel_id: string;
2466
+ thread_key: string;
2426
2467
  started_at: number;
2427
2468
  finished_at: number;
2428
2469
  usage_json: string | null;
@@ -2590,12 +2631,26 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
2590
2631
  if (!id.ok) return invalid("parentRunId must match ^[A-Za-z0-9_-]{1,64}$");
2591
2632
  query.parentRunId = id.value;
2592
2633
  }
2634
+ if (b.pr !== undefined) {
2635
+ const pr = b.pr as Record<string, unknown> | null;
2636
+ if (
2637
+ typeof pr !== "object" ||
2638
+ pr === null ||
2639
+ typeof pr.repo !== "string" ||
2640
+ !REPO_SLUG.test(pr.repo) ||
2641
+ typeof pr.number !== "number" ||
2642
+ !Number.isInteger(pr.number) ||
2643
+ pr.number < 1
2644
+ )
2645
+ return invalid("pr must be { repo: owner/name, number: a positive integer }");
2646
+ query.pr = { repo: pr.repo, number: pr.number };
2647
+ }
2593
2648
  if (b.visibleTo !== undefined) {
2594
2649
  // A malformed filter is a 400, never "all": the bot degrades to live rows
2595
2650
  // rather than the DO widening what an actor may see.
2596
2651
  if (!isRunVisibilityFilter(b.visibleTo)) return invalid("visibleTo must be a run visibility filter");
2597
- if (boundParameters(b.visibleTo) > DO_MAX_BOUND_PARAMETERS - RUN_LIST_BASE_PARAMETERS)
2598
- return invalid(`visibleTo names more than ${DO_MAX_BOUND_PARAMETERS - RUN_LIST_BASE_PARAMETERS} ids`);
2652
+ const headroom = DO_MAX_BOUND_PARAMETERS - RUN_LIST_BASE_PARAMETERS - (query.pr ? RUN_LIST_PR_PARAMETERS : 0);
2653
+ if (boundParameters(b.visibleTo) > headroom) return invalid(`visibleTo names more than ${headroom} ids`);
2599
2654
  query.visibleTo = b.visibleTo;
2600
2655
  }
2601
2656
  return { ok: true, value: { storeKey: key.value, query } };
@@ -2604,6 +2659,8 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
2604
2659
  /** Parameters the page query binds before any filter: the cursor pair (3) and the age floor (1),
2605
2660
  * plus `agent`, `channel`, `threadKey`, `parentRunId`, and the LIMIT at most — the headroom `visibleTo` must fit under. */
2606
2661
  const RUN_LIST_BASE_PARAMETERS = 9;
2662
+ /** The two more a `pr` filter binds (`repo`, `pr_number`), taken from the same headroom only when asked. */
2663
+ const RUN_LIST_PR_PARAMETERS = 2;
2607
2664
 
2608
2665
  /** How many `?` a filter binds (one per id, one per user). */
2609
2666
  function boundParameters(f: RunVisibilityFilter): number {
@@ -3800,13 +3857,13 @@ async function handleRuns(pathname: string, body: unknown, env: Env): Promise<Re
3800
3857
  );
3801
3858
  return json(result ?? { events: null });
3802
3859
  }
3803
- if (pathname === "/runs/usage-by-user") {
3860
+ if (pathname === "/runs/usage") {
3804
3861
  const parsed = parseRunUsageQuery(body);
3805
3862
  if (!parsed.ok) return json({ error: parsed.error }, 400);
3806
3863
  const { storeKey, sinceMs, untilMs } = parsed.value;
3807
- const report = await stub(storeKey).usageByUser(sinceMs, untilMs);
3808
- console.log(`[runs/usage-by-user] ${storeKey} -> ${report.rows.length} rows, ${report.pending} pending`);
3809
- return json(report);
3864
+ const rows = await stub(storeKey).usage(sinceMs, untilMs);
3865
+ console.log(`[runs/usage] ${storeKey} -> ${rows.runs.length} runs, ${rows.pending} pending`);
3866
+ return json(rows);
3810
3867
  }
3811
3868
  // /runs/delete
3812
3869
  const parsed = parseRunTarget(body);
@@ -3831,7 +3888,7 @@ const ROUTES = new Set([
3831
3888
  "/runs/summary",
3832
3889
  "/runs/list",
3833
3890
  "/runs/events",
3834
- "/runs/usage-by-user",
3891
+ "/runs/usage",
3835
3892
  "/runs/delete",
3836
3893
  ...LEDGER_ROUTES,
3837
3894
  ]);
@@ -179,6 +179,7 @@ import {
179
179
  isRuntimeUnreachableSignal,
180
180
  killStaleBuildProcessesCommand,
181
181
  planRefresh,
182
+ RUNTIME_MOVED_WORDING,
182
183
  RUNTIME_REPLACEMENT_WORDING,
183
184
  RUNTIME_UNREACHABLE_DOWN_AT,
184
185
  runtimeUnreachableReason,
@@ -766,9 +767,34 @@ function isRuntimeReplacement(err: unknown): boolean {
766
767
  return false;
767
768
  }
768
769
 
770
+ /** Whether the SDK's failure vouches that the runtime under the command MOVED
771
+ * — a new incarnation serves the thread, so the container the command was
772
+ * aimed at is gone whatever else the resident knows: the typed classes for
773
+ * a stale process handle and an inactive runtime identity, an interruption
774
+ * whose reason is a replaced runtime, the platform's superseded-isolate
775
+ * reset, and the SDK's wordings for the same (`RUNTIME_MOVED_WORDING`, on the
776
+ * error and its cause chain). NOT the words for a container that is merely
777
+ * down (`STOPPED_CONTAINER_WORDING`: "The container is not running", "Process
778
+ * supervisor is closed") and NOT a transport lost under the call
779
+ * (`RPCTransportError`): an asleep or starting container and a network blip
780
+ * answer those too, so on `/exec` they are the word only when the resident
781
+ * itself knows the container it held is gone (`replacedExecAnswer`). The
782
+ * resident's own steps keep the union (`isRuntimeReplacement`): a step
783
+ * interrupted either way is retried onto the container that comes back. */
784
+ function sdkVouchesRuntimeMoved(err: unknown): boolean {
785
+ if (err instanceof StaleProcessHandleError) return true;
786
+ if (err instanceof RuntimeIdentityInactiveError) return true;
787
+ if (err instanceof OperationInterruptedError && RUNTIME_REPLACED_REASONS.has(err.reason)) return true;
788
+ if (isDurableObjectCodeUpdateReset(err)) return true;
789
+ for (const link of selfAndCauses(err)) if (RUNTIME_MOVED_WORDING.test(errMsg(link))) return true;
790
+ return false;
791
+ }
792
+
769
793
  /** The named ThreadErr every thread route (exec/read/write) answers for a
770
794
  * runtime replacement, so the client can classify it (409 like the other
771
- * recoverable thread states; `reason` is the discriminator). */
795
+ * recoverable thread states; `reason` is the discriminator). On `/exec` the
796
+ * answer goes through `replacedExecAnswer` first: the word only when the SDK
797
+ * vouched the runtime moved or the resident knows the container is gone. */
772
798
  function runtimeReplacedErr(err: RuntimeReplacedError): ThreadErr {
773
799
  return { error: err.message, status: 409, reason: "runtime-replaced" };
774
800
  }
@@ -1592,6 +1618,52 @@ export class ResidentDO extends Sandbox<Env> {
1592
1618
  * survives, so hydration state is always probed from disk. */
1593
1619
  private hydration: Promise<void> | null = null;
1594
1620
 
1621
+ /** The container stop the platform delivered to `onStop` and no command has
1622
+ * answered since: the resident's own knowledge that the container it held
1623
+ * is gone (docs/reference/specs/resident-repos.md item 43). In memory on
1624
+ * purpose — a Durable Object that restarts learns the same fact from the
1625
+ * restore its next command's wake path starts (`restoring`), and a stop
1626
+ * recorded in storage would outlive the container that answers now. */
1627
+ private containerStop: { at: number; exitCode: number; reason: string } | undefined;
1628
+
1629
+ /** The platform's signal that the container stopped — the rollout's
1630
+ * `runtime_signal`, or an exit of its own — recorded before the SDK's
1631
+ * reconciliation runs, so an `/exec` that meets the stopped container from
1632
+ * here on answers `runtime-replaced` (`replacedExecAnswer`) rather than the
1633
+ * binding's bare "The container is not running". The Sandbox base declares
1634
+ * the hook without parameters; the Container base beneath it passes them. */
1635
+ async onStop(params?: { exitCode: number; reason: string }): Promise<void> {
1636
+ this.containerStop = { at: systemClock(), exitCode: params?.exitCode ?? 0, reason: params?.reason ?? "unknown" };
1637
+ console.log(
1638
+ `container: stopped (exit ${this.containerStop.exitCode}, ${this.containerStop.reason}) — the container this resident held is gone; /exec answers runtime-replaced until a command answers again`,
1639
+ );
1640
+ await super.onStop();
1641
+ }
1642
+
1643
+ /** Whether this resident knows the container it held is gone: it saw the
1644
+ * container stop and no command has answered since, or a restore is under
1645
+ * way (`restoring`, persisted before any restore work). Never the
1646
+ * platform's `running` flag — an asleep or starting container answers
1647
+ * false to it too, and an `/exec` that meets one must say what the SDK
1648
+ * said so the harness probes rather than relaunch into a container that
1649
+ * was never replaced. */
1650
+ private async knowsContainerGone(): Promise<boolean> {
1651
+ if (this.containerStop !== undefined) return true;
1652
+ return (await this.getStatus()).state === "restoring";
1653
+ }
1654
+
1655
+ /** The `/exec` answer for a command the SDK failed with a runtime
1656
+ * replacement (item 43; harness.md item 6): the word — `reason:
1657
+ * "runtime-replaced"`, the resident's sentence — when the SDK vouched the
1658
+ * runtime moved or the resident knows the container it held is gone;
1659
+ * otherwise what the SDK said, with no word and no `reason`, so the
1660
+ * harness's one more command decides. The command is never re-issued
1661
+ * either way: it may have started. */
1662
+ private async replacedExecAnswer(err: RuntimeReplacedError): Promise<ThreadErr> {
1663
+ if (sdkVouchesRuntimeMoved(err.cause) || (await this.knowsContainerGone())) return runtimeReplacedErr(err);
1664
+ return { error: errMsg(err.cause), status: 409 };
1665
+ }
1666
+
1595
1667
  /** The step trace of the request in flight (docs/reference/specs/tracing.md item 19):
1596
1668
  * `attachThread` and `runOp` each run inside their own collector, so the
1597
1669
  * commands `runOk` runs and the mirror-lock waits land on the answer that
@@ -1978,7 +2050,9 @@ export class ResidentDO extends Sandbox<Env> {
1978
2050
  proc = await createExtensionProcessSandbox(this).exec(argv as unknown as SandboxCommand, launch);
1979
2051
  }
1980
2052
  // The spawn is the proof the control port answers: a persisted count of
1981
- // unanswered connects ends here, whatever the command goes on to do.
2053
+ // unanswered connects ends here, whatever the command goes on to do — and
2054
+ // a container stop seen before it is history (`knowsContainerGone`).
2055
+ this.containerStop = undefined;
1982
2056
  await this.clearRuntimeUnreachable();
1983
2057
  try {
1984
2058
  const out = await proc.output({ encoding: "utf8", timeout: timeout + 30_000 });
@@ -6233,11 +6307,30 @@ export class ResidentDO extends Sandbox<Env> {
6233
6307
  return res;
6234
6308
  }
6235
6309
 
6310
+ /** The exec route's one gate for a runtime replacement (item 43): every
6311
+ * `RuntimeReplacedError` the route meets — the preflight's own probe of the
6312
+ * worktree, the command itself — is judged by `replacedExecAnswer`, so the
6313
+ * word reaches the harness only when the SDK vouched the runtime moved or
6314
+ * the resident knows the container it held is gone. */
6236
6315
  private async execThreadImpl(
6237
6316
  threadKey: string,
6238
6317
  command: string,
6239
6318
  timeoutMs: number,
6240
6319
  env?: Record<string, string>,
6320
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; truncated: boolean } | ThreadErr> {
6321
+ try {
6322
+ return await this.execThreadBody(threadKey, command, timeoutMs, env);
6323
+ } catch (err) {
6324
+ if (err instanceof RuntimeReplacedError) return this.replacedExecAnswer(err);
6325
+ throw err;
6326
+ }
6327
+ }
6328
+
6329
+ private async execThreadBody(
6330
+ threadKey: string,
6331
+ command: string,
6332
+ timeoutMs: number,
6333
+ env?: Record<string, string>,
6241
6334
  ): Promise<{ stdout: string; stderr: string; exitCode: number; truncated: boolean } | ThreadErr> {
6242
6335
  const pre = await this.threadPreflight(threadKey);
6243
6336
  if ("error" in pre) return pre;
@@ -6253,13 +6346,8 @@ export class ResidentDO extends Sandbox<Env> {
6253
6346
  : {}),
6254
6347
  } satisfies ThreadBinding);
6255
6348
 
6256
- let r: Awaited<ReturnType<ResidentDO["threadRun"]>>;
6257
- try {
6258
- r = await this.threadRunCapped(binding.user, binding.worktreePath, command, timeoutMs, EXEC_OUTPUT_CAP, env);
6259
- } catch (err) {
6260
- if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6261
- throw err;
6262
- }
6349
+ // A runtime replacement under the command propagates to `execThreadImpl`'s gate.
6350
+ const r = await this.threadRunCapped(binding.user, binding.worktreePath, command, timeoutMs, EXEC_OUTPUT_CAP, env);
6263
6351
  const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP || r.truncated === true;
6264
6352
  const notes: string[] = [];
6265
6353
  if (r.timedOut)
@@ -7411,13 +7499,17 @@ export class ResidentDO extends Sandbox<Env> {
7411
7499
  return { infraStreak: { ...row, rung: infraStreakRung(count) } };
7412
7500
  }
7413
7501
 
7414
- /** Fault injection for item 67: corrupt the mirror on disk (its objects
7415
- * removed), so the next cycle's `fetch` fails in a resident step. The
7416
- * snapshots in R2 are untouched — the ladder's recreate restores them.
7502
+ /** Fault injection for item 67: corrupt the mirror on disk so the next
7503
+ * cycle's `fetch` fails in a resident step. The mirror's `config` goes (its
7504
+ * `origin` remote with it: `'origin' does not appear to be a git
7505
+ * repository`, exit 128) — NOT its objects: the wake check (`readyStamp`)
7506
+ * tests `objects/`, and a missing directory is a disk to restore, which the
7507
+ * wake did before any step could fail the first time this was tried live.
7508
+ * The snapshots in R2 are untouched — the ladder's recreate restores them.
7417
7509
  * Test-only semantics; admin scope. */
7418
7510
  async debugBreakMirror(): Promise<{ broken: boolean; error?: string }> {
7419
7511
  try {
7420
- await this.runOk(["rm", "-rf", `${MIRROR_DIR}/objects`], "break-mirror");
7512
+ await this.runOk(["rm", "-f", `${MIRROR_DIR}/config`], "break-mirror");
7421
7513
  return { broken: true };
7422
7514
  } catch (err) {
7423
7515
  return { broken: false, error: errMsg(err) };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.242.0",
3
+ "version": "1.243.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.242.0",
9
+ "version": "1.243.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.242.0",
20448
+ "version": "1.243.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.242.0",
3
+ "version": "1.243.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",
@@ -168,11 +168,11 @@
168
168
  "when": "Part of `check:consistency`."
169
169
  },
170
170
  "clock:gen": {
171
- "does": "Regenerates the clock-read allowlist (`src/core/trace/clockAllowlist.json`) from the tree — empty since the ratchet reached zero; a result that is not `{}` names a new direct read.",
171
+ "does": "Regenerates both clock allowlists from the tree: wall-clock reads (empty) and duration literals outside `src/core/budgets.ts`.",
172
172
  "when": "Part of `fix`."
173
173
  },
174
174
  "clock:check": {
175
- "does": "No production file reads the wall clock directly: the allowlist is empty and the tree agrees.",
175
+ "does": "No production file reads the wall clock directly or gained a duration literal outside `src/core/budgets.ts`; both allowlists match the tree.",
176
176
  "when": "Part of `check:consistency`."
177
177
  },
178
178
  "docs:changed": {
@@ -212,7 +212,7 @@
212
212
  "when": "Part of `check:consistency`."
213
213
  },
214
214
  "load": {
215
- "does": "Load harness: `-- history|resident|sandbox|e2e|cards|provider|pi|route`.",
215
+ "does": "Load harness: `-- history|resident|sandbox|e2e|cards|provider|pi|route|door`.",
216
216
  "when": "Capacity receipts (docs/reference/specs/load-harness.md)."
217
217
  }
218
218
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.242.0",
3
- "commit": "c0dc26a1590c854d944c451829e9b21f6ce7d721",
4
- "builtAt": "2026-09-17T05:42:17.412Z"
2
+ "version": "1.243.0",
3
+ "commit": "abfb780fe3a276f303d13d597ad7c549ba8f49c0",
4
+ "builtAt": "2026-09-17T07:58:14.271Z"
5
5
  }