@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
@@ -58,6 +58,11 @@
58
58
  // deploy/cloudflare/wrangler.template.jsonc). The class is deployed with the
59
59
  // bot, which deploys after this Worker — so this binding lands one release
60
60
  // after the class did, never in the same one.
61
+ // The bot Worker, for the plane's effect push (record 0064, "Where it
62
+ // lives"): committed effects POST to the bot's bearer-gated /plane/effects,
63
+ // which forwards them to the container. A push that fails is not retried by
64
+ // a timer — the effect rides the next heartbeat or reclaim-sweep answer.
65
+ "services": [{ "binding": "BOT", "service": "{{bot.script}}" }],
61
66
  "workflows": [
62
67
  {
63
68
  "name": "{{bot.script}}-ship-coordinator",
@@ -22,8 +22,20 @@ FROM docker.io/cloudflare/sandbox:0.13.0-next.751.1
22
22
  # installed here — matching the resident system prompts and the tests that pin
23
23
  # that claim. No system credential.helper is configured; each worktree sets its
24
24
  # own per-attach helper.
25
+ # The fallback user.email stays OFF the GitHub domain (a noreply-shaped
26
+ # address would render as a GitHub account it is not); the real pairs ride
27
+ # each /exec body's env. The system-level core.hooksPath replaces repo-local
28
+ # .git/hooks entirely — deliberate: an untrusted checkout's own hooks never
29
+ # run here (a repo-local core.hooksPath still wins, git's ordinary
30
+ # precedence) — docs/reference/specs/execution.md item 5.
25
31
  RUN git config --system user.name "switchboard-resident" \
26
- && git config --system user.email "switchboard-resident@users.noreply.github.com"
32
+ && git config --system user.email "switchboard-resident@switchboard.invalid" \
33
+ && git config --system core.hooksPath /opt/switchboard/hooks
34
+
35
+ # The shared agent-trailer hook (deploy/hooks/prepare-commit-msg — this copy is
36
+ # byte-identical, held so by a test: the build context is this directory, so
37
+ # COPY cannot reach the canonical file).
38
+ COPY --chmod=0755 prepare-commit-msg /opt/switchboard/hooks/prepare-commit-msg
27
39
 
28
40
  # The container is FOUR vCPUs shared by up to 16 threads (wrangler.jsonc).
29
41
  # Node test runners size their worker pools from the CPU count the kernel
@@ -0,0 +1,84 @@
1
+ // The resident's level reports (record 0064; the orchestration plane's
2
+ // resident conditions) — the PURE half, kept free of the Sandbox SDK and DO
3
+ // storage so it runs under plain-Node vitest (levels.test.ts) like gc.ts,
4
+ // drain.ts and memoryGuard.ts. The Worker owns the readings (the user pool's
5
+ // allocation, the memory guard's last sample, its own incarnation) and feeds
6
+ // this module the numbers; every `/attach`, `/exec` and `/status` answer
7
+ // carries the document, and the bot forwards a change to the plane's
8
+ // `POST /plane/level`.
9
+ //
10
+ // Why an outbox and not a push: the resident never holds a credential for the
11
+ // state Worker, so it posts nothing itself — a crossing between calls is kept
12
+ // as a post and re-offered on every answer until a newer crossing of the same
13
+ // name supersedes it. The plane's level write is idempotent per (resident,
14
+ // name), so a post forwarded twice lands once.
15
+
16
+ import { MEMORY_SOFT_LIMIT_PCT } from "./memoryGuard.js";
17
+
18
+ /** Which side of its line a level reads: `above` closes the door (an exhausted
19
+ * pool, the gate's soft side), `below` opens it. */
20
+ export type LevelSide = "below" | "above";
21
+
22
+ /** One level post for the plane: the crossing (or a boot's re-statement) the
23
+ * bot forwards to `POST /plane/level`. */
24
+ export interface LevelPost {
25
+ /** `drain` is the registry's fleet-drain post; the residents post the other two. */
26
+ name: "seat" | "memory" | "drain";
27
+ side: LevelSide;
28
+ generation: string;
29
+ at: string;
30
+ }
31
+
32
+ /** The compact sides of one sample, persisted so the next sample can judge a
33
+ * crossing; `generation` names the incarnation the sample came from. */
34
+ export interface LevelSample {
35
+ seat: LevelSide;
36
+ memory: LevelSide;
37
+ generation: string;
38
+ }
39
+
40
+ /** The document every resident answer carries as `levels` (record 0064). */
41
+ export interface ResidentLevelsDoc {
42
+ seat: { side: LevelSide; used: number; total: number };
43
+ memory: { side: LevelSide; percent: number | null };
44
+ generation: string;
45
+ at: string;
46
+ /** The outbox: crossings not yet superseded, re-offered on every answer. */
47
+ posts: LevelPost[];
48
+ }
49
+
50
+ /** The seat's side: the pool is a hard count, so `above` is exactly "no free
51
+ * user" — the next attach or op would be refused `user-pool-exhausted`. */
52
+ export function seatSide(used: number, total: number): LevelSide {
53
+ return used >= total ? "above" : "below";
54
+ }
55
+
56
+ /** The memory side is the gate's soft line (resident-repos item 70): at or
57
+ * past `MEMORY_SOFT_LIMIT_PCT` a NEW attach is refused, so that is where the
58
+ * plane's `memory` condition sits. No reading, or no cap, gates nothing. */
59
+ export function memorySide(percent: number | null): LevelSide {
60
+ return percent !== null && percent >= MEMORY_SOFT_LIMIT_PCT ? "above" : "below";
61
+ }
62
+
63
+ /** The posts one fresh sample owes the plane: each name whose side crossed
64
+ * since the previous sample — and, with no previous sample or one from
65
+ * another generation (a boot, a replaced runtime), both names re-stated
66
+ * (record 0064: "a boot re-states it"), whatever their sides. */
67
+ export function levelPosts(prev: LevelSample | null, next: LevelSample, at: string): LevelPost[] {
68
+ const restate = prev === null || prev.generation !== next.generation;
69
+ const posts: LevelPost[] = [];
70
+ if (restate || prev.seat !== next.seat)
71
+ posts.push({ name: "seat", side: next.seat, generation: next.generation, at });
72
+ if (restate || prev.memory !== next.memory)
73
+ posts.push({ name: "memory", side: next.memory, generation: next.generation, at });
74
+ return posts;
75
+ }
76
+
77
+ /** Merge fresh posts into the outbox: a newer post of a name supersedes the
78
+ * older one — the plane only needs the current side, and the bot forwarding
79
+ * a superseded post would only be overwritten by the next. The outbox never
80
+ * grows past one post per name. */
81
+ export function mergeOutbox(outbox: LevelPost[], posts: LevelPost[]): LevelPost[] {
82
+ const superseded = new Set(posts.map((p) => p.name));
83
+ return [...outbox.filter((p) => !superseded.has(p.name)), ...posts];
84
+ }
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ # The agent trailer (docs/reference/specs/execution.md): every commit made in a
3
+ # Switchboard image carries `Co-Authored-By: <bot pair>`, so the agent's hand
4
+ # stays visible even when the author is the requester. The pair is read from
5
+ # GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL at commit time — the bot fills them
6
+ # per exec — so the image stays installation-agnostic; without them the
7
+ # image's own git identity stands (its fallback address is off the GitHub
8
+ # domain, so it never renders as a GitHub account). Idempotent: a message that
9
+ # already carries this exact trailer is left unchanged; a foreign
10
+ # Co-Authored-By does not stop it (the identity rewrite scrubs those).
11
+ set -e
12
+ msg="$1"
13
+ name="${GIT_COMMITTER_NAME:-$(git config user.name || true)}"
14
+ email="${GIT_COMMITTER_EMAIL:-$(git config user.email || true)}"
15
+ [ -n "$name" ] && [ -n "$email" ] || exit 0
16
+ git interpret-trailers --in-place --if-exists addIfDifferent \
17
+ --trailer "Co-Authored-By: $name <$email>" "$msg"
@@ -85,6 +85,16 @@ import { DurableObject } from "cloudflare:workers";
85
85
  import { BASH_TIMEOUT_MAX_MS, clampBashTimeout } from "../../src/execution/bashTimeout.js";
86
86
  import { selectBindingsToPurge } from "../../src/execution/bindingPurge.js";
87
87
  import { busyAfterKillReason, planForceDetach } from "../../src/execution/residentDetach.js";
88
+ import {
89
+ levelPosts,
90
+ memorySide,
91
+ mergeOutbox,
92
+ seatSide,
93
+ type LevelPost,
94
+ type LevelSample,
95
+ type LevelSide,
96
+ type ResidentLevelsDoc,
97
+ } from "./levels.js";
88
98
  import { parseReadonly, planReadonlyAttach } from "../../src/execution/residentReadonly.js";
89
99
  import { decideWorktree, parseReuse, type WorktreeFacts } from "../../src/execution/residentReuse.js";
90
100
  import {
@@ -636,6 +646,12 @@ const DISK_KEY = "resident:disk";
636
646
  * tick, persisted so the gauges (`/status`, `/residents`, the watchdog line)
637
647
  * read storage only — the watchdog never touches the container. */
638
648
  const MEMORY_KEY = "resident:memory";
649
+ /** The last level sample and the crossing outbox (record 0064; `levels.ts`). */
650
+ const LEVELS_LAST_KEY = "resident:levels:last";
651
+ const LEVELS_OUTBOX_KEY = "resident:levels:outbox";
652
+ /** The registry's drain post (record 0064): the fleet drain's set/cleared/expired,
653
+ * re-offered on every registry answer until superseded. */
654
+ const DRAIN_OUTBOX_KEY = "drain:outbox";
639
655
  /** The lifecycle row (docs/reference/specs/resident-repos.md item 7): `workflow`,
640
656
  * the one scheduler. Kept from the flagged rollout so `/status` and `/debug
641
657
  * info` can say so; an `alarm` value a flip left behind reads `workflow`
@@ -1193,6 +1209,13 @@ interface ThreadBinding {
1193
1209
  depsKey?: string;
1194
1210
  }
1195
1211
 
1212
+ /** What one image reconcile decided (`reconcileImage`): the container was
1213
+ * stopped to restart on the current image (`restarted`), already runs it
1214
+ * (`current`), is not running so the next start uses it anyway (`inactive`),
1215
+ * or is busy — an operation, an attach or a registered run in flight — and
1216
+ * the restart is deferred to the next quiet check (`deferred`). */
1217
+ type ImageReconcileResult = "restarted" | "current" | "inactive" | "deferred";
1218
+
1196
1219
  interface AttachOk {
1197
1220
  workspace: string;
1198
1221
  ref: string;
@@ -1535,15 +1558,56 @@ export class ResidentRegistryDO extends DurableObject<Env> {
1535
1558
  }
1536
1559
 
1537
1560
  /** Admin-only by construction (reached solely via POST /drain): replaces
1538
- * whatever drain stood — a second deploy's drain extends the first's. */
1561
+ * whatever drain stood — a second deploy's drain extends the first's. The
1562
+ * set posts `above` for the plane (record 0064) and arms ONE alarm at
1563
+ * `until`, so a drain nobody lifts posts its expiry itself. */
1539
1564
  async setDrain(record: DrainRecord): Promise<DrainRecord> {
1540
1565
  await this.ctx.storage.put(DRAIN_KEY, record);
1566
+ await this.pushDrainPost("above");
1567
+ await this.ctx.storage.setAlarm(Date.parse(record.until));
1541
1568
  return record;
1542
1569
  }
1543
1570
 
1544
- /** Admin-only by construction (POST /undrain): true when a record was there. */
1571
+ /** Admin-only by construction (POST /undrain): true when a record was there.
1572
+ * A cleared drain posts `below` — the plane's resident-drain window lifts. */
1545
1573
  async clearDrain(): Promise<boolean> {
1546
- return this.ctx.storage.delete(DRAIN_KEY);
1574
+ const had = await this.ctx.storage.delete(DRAIN_KEY);
1575
+ if (had) await this.pushDrainPost("below");
1576
+ return had;
1577
+ }
1578
+
1579
+ /** The one alarm, at the drain's `until` (record 0064): a drain past
1580
+ * its end is nothing (`liveDrain` already reads it so), and the expiry is
1581
+ * posted `below` like a clear — whoever forgot the drain, the plane's
1582
+ * window lifts. A drain replaced with a later `until` re-arms via setDrain. */
1583
+ async alarm(): Promise<void> {
1584
+ const now = systemClock();
1585
+ const stored = await this.ctx.storage.get(DRAIN_KEY);
1586
+ if (stored === undefined) return;
1587
+ if (liveDrain(stored, now) === null) {
1588
+ await this.ctx.storage.delete(DRAIN_KEY);
1589
+ await this.pushDrainPost("below");
1590
+ } else {
1591
+ // Replaced with a later end under an already-armed alarm: re-arm at it.
1592
+ await this.ctx.storage.setAlarm(Date.parse((stored as DrainRecord).until));
1593
+ }
1594
+ }
1595
+
1596
+ /** The drain's plane post (name `drain`), superseding the last: the plane
1597
+ * only needs the current side, and the bot's forward is idempotent. */
1598
+ private async pushDrainPost(side: LevelSide): Promise<void> {
1599
+ await this.ctx.storage.put(DRAIN_OUTBOX_KEY, {
1600
+ name: "drain",
1601
+ side,
1602
+ generation: "",
1603
+ at: new Date(systemClock()).toISOString(),
1604
+ });
1605
+ }
1606
+
1607
+ /** The pending drain post, for the registry's answers (`/drain`, `/undrain`,
1608
+ * `/residents`) — the bot forwards it to `POST /plane/level`. */
1609
+ async getDrainOutbox(): Promise<LevelPost | null> {
1610
+ return ((await this.ctx.storage.get(DRAIN_OUTBOX_KEY)) as LevelPost | undefined) ?? null;
1547
1611
  }
1548
1612
  }
1549
1613
 
@@ -3366,7 +3430,7 @@ export class ResidentDO extends Sandbox<Env> {
3366
3430
  // RUNNING container keeps the old one, so new Worker code can name pool
3367
3431
  // users the image lacks. Reconcile here (every cycle, cheap) — see
3368
3432
  // reconcileImage — so a rollout self-applies within one refresh.
3369
- if (await this.reconcileImage("refresh")) {
3433
+ if ((await this.reconcileImage("refresh")) === "restarted") {
3370
3434
  // Container stopping; it restarts on the new image in seconds. The
3371
3435
  // engine's retry re-enters this step thirty seconds on and re-warms the
3372
3436
  // resident within the minute, instead of the next bucket.
@@ -4592,6 +4656,37 @@ export class ResidentDO extends Sandbox<Env> {
4592
4656
  return this.memoryGuard.lastReading ?? (await this.ctx.storage.get<MemoryReading>(MEMORY_KEY)) ?? null;
4593
4657
  }
4594
4658
 
4659
+ /** The resident's levels (record 0064; `levels.ts`): the seat (the
4660
+ * thread/op user pool, whose exhaustion is the `user-pool-exhausted`
4661
+ * refusal) and the memory line (the gate's soft side), stamped with this
4662
+ * incarnation — the container's boot id where one is memoized, else the
4663
+ * DO incarnation, so a boot or a replaced runtime reads as a new
4664
+ * generation and re-states both levels. A crossing lands in the outbox
4665
+ * and is re-offered on every answer until a newer crossing of the same
4666
+ * name supersedes it; the bot forwards posts to `POST /plane/level`.
4667
+ * Storage reads and one storage write only — never a container touch, so
4668
+ * every `/attach`, `/exec` and `/status` answer can carry the document. */
4669
+ async residentLevels(): Promise<ResidentLevelsDoc> {
4670
+ const at = new Date(systemClock()).toISOString();
4671
+ const all = await this.ctx.storage.list<ThreadBinding>({ prefix: THREAD_KEY_PREFIX });
4672
+ const used = new Set([...all.values()].filter((b) => !b.evicted && b.user).map((b) => b.user));
4673
+ for (const u of this.opUsersInUse) used.add(u);
4674
+ const reading = await this.memoryGauge();
4675
+ const generation = this.containerIdMemo ?? this.incarnation;
4676
+ const seat = { side: seatSide(used.size, THREAD_USERS.length), used: used.size, total: THREAD_USERS.length };
4677
+ const memory = { side: memorySide(reading?.percent ?? null), percent: reading?.percent ?? null };
4678
+ const next: LevelSample = { seat: seat.side, memory: memory.side, generation };
4679
+ const prev = (await this.ctx.storage.get<LevelSample>(LEVELS_LAST_KEY)) ?? null;
4680
+ const posts = levelPosts(prev, next, at);
4681
+ let outbox = (await this.ctx.storage.get<LevelPost[]>(LEVELS_OUTBOX_KEY)) ?? [];
4682
+ if (posts.length > 0) {
4683
+ outbox = mergeOutbox(outbox, posts);
4684
+ await this.ctx.storage.put(LEVELS_OUTBOX_KEY, outbox);
4685
+ await this.ctx.storage.put(LEVELS_LAST_KEY, next);
4686
+ }
4687
+ return { seat, memory, generation, at, posts: outbox };
4688
+ }
4689
+
4595
4690
  /** The route gate (item 70): one fresh sample, then the pure verdict over
4596
4691
  * the last reading. A refusal is the same 503 shape as `mirror-busy`, so
4597
4692
  * the bot falls back or waits legibly — and a command already running is
@@ -4831,27 +4926,51 @@ export class ResidentDO extends Sandbox<Env> {
4831
4926
  /** Pool users live in the IMAGE (Dockerfile useradd loop) while THREAD_USERS
4832
4927
  * lives in the Worker. After a deploy that grows the pool, a still-running
4833
4928
  * container lacks the new users and `install -o workerN` fails. Check the
4834
- * last pool user exists; if not and nothing is in flight, stop the container
4835
- * so it restarts on the current image (state is DO storage + R2 — the
4836
- * disk is a cache). Returns true when a stop was issued. */
4837
- private async reconcileImage(where: string): Promise<boolean> {
4838
- if (!(await this.isRuntimeActive().catch(() => false))) return false;
4929
+ * last pool user exists; if not and nothing is in flight — the in-memory
4930
+ * counters AND the durable run registrations (item 44): a harness run's
4931
+ * process lives in the container between the bot's operator calls, so a
4932
+ * restart decided on the op counters alone stops the container under a live
4933
+ * run — stop the container so it restarts on the current image (state is DO
4934
+ * storage + R2 — the disk is a cache). A deferred restart re-checks on
4935
+ * every later attach and refresh cycle until the resident is quiet; a
4936
+ * registration whose release never came defers it only until the clean-idle
4937
+ * sweep drains that registration. Answers `restarted` when a stop was
4938
+ * issued, else why not. */
4939
+ private async reconcileImage(where: string): Promise<ImageReconcileResult> {
4940
+ if (!(await this.isRuntimeActive().catch(() => false))) return "inactive";
4839
4941
  const last = THREAD_USERS[THREAD_USERS.length - 1];
4840
4942
  const probe = await this.run(["id", "-u", last]);
4841
- if (probe.exitCode === 0) return false;
4943
+ if (probe.exitCode === 0) return "current";
4842
4944
  const busy = this.inFlightCount();
4843
4945
  if (busy > 0) {
4844
4946
  console.log(
4845
4947
  `image-stale (${where}): ${last} missing but ${busy} operation(s)/attach(es) in flight — deferring restart`,
4846
4948
  );
4847
- return false;
4949
+ return "deferred";
4950
+ }
4951
+ const registered = await this.registeredRunsBeyondOps();
4952
+ if (registered > 0) {
4953
+ console.log(
4954
+ `image-stale (${where}): ${last} missing but ${registered} run registration(s) live — deferring restart until the resident is quiet`,
4955
+ );
4956
+ return "deferred";
4848
4957
  }
4849
4958
  console.log(
4850
4959
  `image-stale (${where}): ${last} missing in the running container — stopping so it restarts on the current image`,
4851
4960
  );
4852
4961
  this.swapIncarnation(); // deliberate incarnation swap
4853
4962
  await this.stop().catch((err) => console.log(`image-stale: stop failed: ${errMsg(err)}`));
4854
- return true;
4963
+ return "restarted";
4964
+ }
4965
+
4966
+ /** The deploy's reconcile, inside the drain window (item 69's order: the
4967
+ * runs in flight end, the swap lands, the containers reconcile, the fleet
4968
+ * reopens): `POST /reconcile` calls this on every resident after the Worker
4969
+ * deploy landed and BEFORE the drain is lifted, so a container that
4970
+ * predates the new image restarts while nothing can be admitted onto it —
4971
+ * never under the first run the reopened fleet admits. */
4972
+ async reconcileForDeploy(): Promise<{ result: ImageReconcileResult }> {
4973
+ return { result: await this.reconcileImage("deploy") };
4855
4974
  }
4856
4975
 
4857
4976
  // -- watchdog (the sparse cron; it re-arms nothing) --------------------------
@@ -5193,7 +5312,7 @@ export class ResidentDO extends Sandbox<Env> {
5193
5312
  const memory = await this.memoryGate("attach", registered);
5194
5313
  if (memory) return memory;
5195
5314
  const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
5196
- if (await this.reconcileImage("attach")) {
5315
+ if ((await this.reconcileImage("attach")) === "restarted") {
5197
5316
  return {
5198
5317
  error: "image-stale: the container predates the current pool and is restarting; retry shortly",
5199
5318
  status: 503,
@@ -8459,6 +8578,7 @@ const ROUTES: Record<string, { scope: Scope; method: string }> = {
8459
8578
  "/rebuild": { scope: "admin", method: "POST" },
8460
8579
  "/drain": { scope: "drain", method: "POST" }, // close the fleet to new runs for a deploy (item 69; admin implied)
8461
8580
  "/undrain": { scope: "drain", method: "POST" }, // reopen it
8581
+ "/reconcile": { scope: "drain", method: "POST" }, // reconcile every container onto the current image, inside the drain window
8462
8582
  "/residents": { scope: "read", method: "GET" }, // admin implied; read-only bearer allowed
8463
8583
  "/debug": { scope: "read", method: "POST" }, // per-op: READ_DEBUG_OPS for read scope, everything for admin
8464
8584
  "/status": { scope: "operator", method: "GET" },
@@ -8563,6 +8683,8 @@ export default {
8563
8683
  return await handleDrain(env, body);
8564
8684
  case "/undrain":
8565
8685
  return await handleUndrain(env);
8686
+ case "/reconcile":
8687
+ return await handleReconcile(env);
8566
8688
  case "/residents":
8567
8689
  return await handleResidents(env);
8568
8690
  case "/debug": {
@@ -8993,7 +9115,7 @@ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Res
8993
9115
  if (!parsed.ok) return json({ error: parsed.error }, 400);
8994
9116
  const record = await registryStub(env).setDrain(parsed.record);
8995
9117
  console.log(`[drain] fleet closed to new runs by ${record.by} for ${record.reason}: until ${record.until}`);
8996
- return json({ draining: record });
9118
+ return json({ draining: record, planeOutbox: await registryStub(env).getDrainOutbox() });
8997
9119
  }
8998
9120
 
8999
9121
  /** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says whether
@@ -9001,7 +9123,29 @@ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Res
9001
9123
  async function handleUndrain(env: Env): Promise<Response> {
9002
9124
  const cleared = await registryStub(env).clearDrain();
9003
9125
  console.log(`[drain] fleet reopened (${cleared ? "a drain stood" : "no drain stood"})`);
9004
- return json({ draining: null, cleared });
9126
+ return json({ draining: null, cleared, planeOutbox: await registryStub(env).getDrainOutbox() });
9127
+ }
9128
+
9129
+ /** POST /reconcile (drain scope): reconcile every resident's container onto
9130
+ * the current image — the deploy runner posts it after its Worker deploy
9131
+ * landed and BEFORE its `/undrain`, so a stale container restarts inside the
9132
+ * drain window (item 69's order) and never under a run the reopened fleet
9133
+ * admits. Each resident answers what its reconcile decided; a failing one
9134
+ * degrades to `error` without touching its neighbors, and a `deferred` or
9135
+ * failed one restarts on its own next quiet attach or refresh cycle. */
9136
+ async function handleReconcile(env: Env): Promise<Response> {
9137
+ const residents = await registryStub(env).list();
9138
+ const settled = await Promise.allSettled(
9139
+ residents.map((record) => residentStub(env, record.resource).reconcileForDeploy()),
9140
+ );
9141
+ const reconciled = residents.map((record, i) => {
9142
+ const s = settled[i];
9143
+ return s.status === "fulfilled"
9144
+ ? { resource: record.resource, result: s.value.result }
9145
+ : { resource: record.resource, result: "error" as const, error: errMsg(s.reason) };
9146
+ });
9147
+ console.log(`[reconcile] deploy image reconcile: ${JSON.stringify(reconciled)}`);
9148
+ return json({ reconciled });
9005
9149
  }
9006
9150
 
9007
9151
  async function handleResidents(env: Env): Promise<Response> {
@@ -9060,13 +9204,16 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
9060
9204
  // deploy gate reads /residents. The registry check rides in the same flight
9061
9205
  // (its 404 is judged first, the probes' results discarded then).
9062
9206
  const stub = residentStub(env, resource.resource);
9063
- const [record, status, inFlight, refresh, snapshot, memory] = await Promise.all([
9207
+ const [record, status, inFlight, refresh, snapshot, memory, levels] = await Promise.all([
9064
9208
  registryStub(env).getRecord(resource.resource),
9065
9209
  stub.getStatus(),
9066
9210
  stub.getInFlightCount(),
9067
9211
  stub.getRefreshView(),
9068
9212
  stub.snapshotHandle(),
9069
9213
  stub.memoryGauge(),
9214
+ // The levels (record 0064): what the bot forwards to the plane; the
9215
+ // plane's `probe` effect is answered by exactly this read.
9216
+ stub.residentLevels().catch(() => null),
9070
9217
  ]);
9071
9218
  if (!record) return json({ error: `${resource.resource} is not onboarded` }, 404);
9072
9219
  // Item 7: which scheduler drives the refresh cycle and, on the Workflow
@@ -9082,9 +9229,23 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
9082
9229
  // Item 70: the last memory reading, so the bot and the residents page can
9083
9230
  // show what the resident's own gate is reading.
9084
9231
  memory,
9232
+ // Record 0064, record 0064: the levels and the crossing outbox on every answer.
9233
+ ...(levels ? { levels } : {}),
9085
9234
  });
9086
9235
  }
9087
9236
 
9237
+ /** Levels on every data-plane answer (record 0064): the document is read
9238
+ * after the route's own work so the sample reflects it; a failed sample never
9239
+ * fails the answer — the bot forwards nothing that call. */
9240
+ async function withLevels<T>(
9241
+ stub: ReturnType<typeof residentStub>,
9242
+ pending: Promise<T>,
9243
+ ): Promise<{ result: T; levels: ResidentLevelsDoc | null }> {
9244
+ const result = await pending;
9245
+ const levels = await stub.residentLevels().catch(() => null);
9246
+ return { result, levels };
9247
+ }
9248
+
9088
9249
  // -- thread data plane handlers -----------------------------------------------
9089
9250
 
9090
9251
  /** Shared front half of the thread routes: validate resource + threadKey (P1:
@@ -9161,17 +9322,20 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
9161
9322
  // response does (`fetch failed` a few minutes in). A refusal
9162
9323
  // carries its `status` in the body; `ResidentExecutor.attach` reads it there.
9163
9324
  return streamHeartbeatJson(
9164
- ctx.stub.attachThread(
9165
- ctx.threadKey,
9166
- refHint,
9167
- readonly.readonly,
9168
- want.sha,
9169
- reuse.reuse,
9170
- ctx.record,
9171
- traceparent,
9172
- reason,
9325
+ withLevels(
9326
+ ctx.stub,
9327
+ ctx.stub.attachThread(
9328
+ ctx.threadKey,
9329
+ refHint,
9330
+ readonly.readonly,
9331
+ want.sha,
9332
+ reuse.reuse,
9333
+ ctx.record,
9334
+ traceparent,
9335
+ reason,
9336
+ ),
9173
9337
  ),
9174
- (result) => result,
9338
+ ({ result, levels }) => ({ ...(result as object), ...(levels ? { levels } : {}) }),
9175
9339
  (err) => catchAllErr(err),
9176
9340
  );
9177
9341
  }
@@ -9226,7 +9390,9 @@ async function handleExec(env: Env, body: Record<string, unknown>, traceparent?:
9226
9390
  // read from the body alone through the one validated reader the sandbox
9227
9391
  // Worker uses, and handed to the exec's env option, never onto the command.
9228
9392
  const execEnv = envFromRequest({ body });
9229
- return streamThreadExec(ctx.stub.execThread(ctx.threadKey, body.command, timeoutMs, traceparent, execEnv));
9393
+ return streamThreadExec(
9394
+ withLevels(ctx.stub, ctx.stub.execThread(ctx.threadKey, body.command, timeoutMs, traceparent, execEnv)),
9395
+ );
9230
9396
  }
9231
9397
 
9232
9398
  /** Stream one pending result with the thread-sandbox Worker's heartbeat
@@ -9277,13 +9443,18 @@ function streamHeartbeatJson<T>(
9277
9443
  * `status`, its word and its `transient`,
9278
9444
  * and the client reads it like the JSON routes' answer, never as a
9279
9445
  * deterministic answer over HTTP 200. */
9280
- function streamThreadExec(pending: Promise<Awaited<ReturnType<ResidentDO["execThread"]>>>): Response {
9446
+ function streamThreadExec(
9447
+ pending: Promise<{ result: Awaited<ReturnType<ResidentDO["execThread"]>>; levels: ResidentLevelsDoc | null }>,
9448
+ ): Response {
9281
9449
  return streamHeartbeatJson(
9282
9450
  pending,
9283
- (result) =>
9284
- "error" in result
9451
+ ({ result, levels }) => ({
9452
+ ...("error" in result
9285
9453
  ? execFailureDocument(result)
9286
- : { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, truncated: result.truncated },
9454
+ : { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, truncated: result.truncated }),
9455
+ // Record 0064, record 0064: levels on every answer, the crossing outbox included.
9456
+ ...(levels ? { levels } : {}),
9457
+ }),
9287
9458
  (err) => execFailureDocument(threadRejectionErr(err, "/exec")),
9288
9459
  );
9289
9460
  }
@@ -61,7 +61,17 @@ RUN apt-get update \
61
61
  && rm -rf /var/lib/apt/lists/* \
62
62
  && git config --system credential.helper '!gh auth git-credential' \
63
63
  && git config --system user.name "switchboard-bot" \
64
- && git config --system user.email "switchboard-bot@users.noreply.github.com"
64
+ && git config --system user.email "switchboard-bot@switchboard.invalid" \
65
+ && git config --system core.hooksPath /opt/switchboard/hooks
66
+
67
+ # The shared agent-trailer hook (deploy/hooks/prepare-commit-msg — this copy is
68
+ # byte-identical, held so by a test: the build context is this directory, so
69
+ # COPY cannot reach the canonical file). The fallback user.email above stays
70
+ # OFF the GitHub domain; the real pairs ride the per-exec environment. The
71
+ # system-level core.hooksPath above replaces repo-local .git/hooks entirely —
72
+ # deliberate: an untrusted checkout's own hooks never run here (a repo-local
73
+ # core.hooksPath still wins) — docs/reference/specs/execution.md item 5.
74
+ COPY --chmod=0755 prepare-commit-msg /opt/switchboard/hooks/prepare-commit-msg
65
75
 
66
76
  # `docker` resolves to this wrapper (/usr/local/bin precedes /usr/bin on PATH):
67
77
  # it starts dockerd in its own session on first use — a plain background
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ # The agent trailer (docs/reference/specs/execution.md): every commit made in a
3
+ # Switchboard image carries `Co-Authored-By: <bot pair>`, so the agent's hand
4
+ # stays visible even when the author is the requester. The pair is read from
5
+ # GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL at commit time — the bot fills them
6
+ # per exec — so the image stays installation-agnostic; without them the
7
+ # image's own git identity stands (its fallback address is off the GitHub
8
+ # domain, so it never renders as a GitHub account). Idempotent: a message that
9
+ # already carries this exact trailer is left unchanged; a foreign
10
+ # Co-Authored-By does not stop it (the identity rewrite scrubs those).
11
+ set -e
12
+ msg="$1"
13
+ name="${GIT_COMMITTER_NAME:-$(git config user.name || true)}"
14
+ email="${GIT_COMMITTER_EMAIL:-$(git config user.email || true)}"
15
+ [ -n "$name" ] && [ -n "$email" ] || exit 0
16
+ git interpret-trailers --in-place --if-exists addIfDifferent \
17
+ --trailer "Co-Authored-By: $name <$email>" "$msg"
@@ -57,6 +57,7 @@ import {
57
57
  import {
58
58
  fleetBusyAnswer,
59
59
  fleetBusyExecAnswer,
60
+ fleetBusyRefusedLine,
60
61
  isFleetBusyError,
61
62
  isRuntimeBusyError,
62
63
  isRuntimeBusySignal,
@@ -213,6 +214,9 @@ export interface ExecAnswer {
213
214
  export interface ExecFailure {
214
215
  error: string;
215
216
  reason?: string;
217
+ /** The thread's Durable Object id, on a `fleet-busy` answer alone — the bot's
218
+ * ending log names which object the platform refused an instance to. */
219
+ containerId?: string;
216
220
  stdout: "";
217
221
  stderr: string;
218
222
  exitCode: 127;
@@ -668,8 +672,15 @@ export class SwitchboardSandbox extends Sandbox<Env> {
668
672
  private execFailure(err: unknown, startedAt: number): ExecFailure {
669
673
  const raw = thrownText(thrownShape(err));
670
674
  // A full fleet (docs/reference/specs/execution.md item 14): no container
671
- // instance for this thread, so nothing started — the executor waits.
672
- if (isFleetBusyError(err)) return fleetBusyExecAnswer(raw);
675
+ // instance for this thread, so nothing started — the executor waits. One
676
+ // queryable line per refusal, so a log sweep after a capacity incident
677
+ // can count them without reading cards; the answer carries this object's
678
+ // id so the bot's ending log can name which object the fleet refused.
679
+ if (isFleetBusyError(err)) {
680
+ const container = this.ctx.id.toString();
681
+ console.log(fleetBusyRefusedLine({ thread: this.ctx.id.name ?? container, container, refusal: raw }));
682
+ return fleetBusyExecAnswer(raw, container);
683
+ }
673
684
  // The runtime changed under the command (item 9): the process, if it
674
685
  // started, is gone with its output. Certain — the SDK said so by type.
675
686
  if (isRuntimeReplacement(err)) {
@@ -934,8 +945,14 @@ export default {
934
945
  // instance for this thread's Durable Object, so the file op never
935
946
  // started — re-sending is safe by construction. Named so the executor
936
947
  // waits instead of reading it as a dead sandbox; 503 because that is
937
- // what it is.
938
- if (isFleetBusyError(err)) return json(fleetBusyAnswer(msg), 503);
948
+ // what it is. One queryable line per refusal (the same event as the
949
+ // exec path's), the object's id computed from the thread key it is
950
+ // named by, since the error crossed the RPC boundary without it.
951
+ if (isFleetBusyError(err)) {
952
+ const container = env.Sandbox.idFromName(threadKey).toString();
953
+ console.log(fleetBusyRefusedLine({ thread: threadKey, container, refusal: msg, route: url.pathname }));
954
+ return json(fleetBusyAnswer(msg, container), 503);
955
+ }
939
956
  return json({ error: msg }, 500);
940
957
  }
941
958
  },
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ # The agent trailer (docs/reference/specs/execution.md): every commit made in a
3
+ # Switchboard image carries `Co-Authored-By: <bot pair>`, so the agent's hand
4
+ # stays visible even when the author is the requester. The pair is read from
5
+ # GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL at commit time — the bot fills them
6
+ # per exec — so the image stays installation-agnostic; without them the
7
+ # image's own git identity stands (its fallback address is off the GitHub
8
+ # domain, so it never renders as a GitHub account). Idempotent: a message that
9
+ # already carries this exact trailer is left unchanged; a foreign
10
+ # Co-Authored-By does not stop it (the identity rewrite scrubs those).
11
+ set -e
12
+ msg="$1"
13
+ name="${GIT_COMMITTER_NAME:-$(git config user.name || true)}"
14
+ email="${GIT_COMMITTER_EMAIL:-$(git config user.email || true)}"
15
+ [ -n "$name" ] && [ -n "$email" ] || exit 0
16
+ git interpret-trailers --in-place --if-exists addIfDifferent \
17
+ --trailer "Co-Authored-By: $name <$email>" "$msg"
@@ -34,6 +34,18 @@
34
34
  "optional": true,
35
35
  "note": "Anthropic Admin API key (sk-ant-admin…) for the LLM-spend layer of GET /costs. Absent, /costs shows the Cloudflare side only."
36
36
  },
37
+ {
38
+ "name": "OPENROUTER_MANAGEMENT_KEY",
39
+ "workers": ["bot"],
40
+ "optional": true,
41
+ "note": "OpenRouter management key for the openrouter biller's invoice tie-out on GET /costs (providers.openrouter.invoiceKeyEnv). Never the inference key. Absent, that biller shows no invoice."
42
+ },
43
+ {
44
+ "name": "OPENAI_ADMIN_KEY",
45
+ "workers": ["bot"],
46
+ "optional": true,
47
+ "note": "OpenAI admin key for the openai biller's invoice tie-out on GET /costs (providers.openai.invoiceKeyEnv). Never the inference key. Absent, that biller shows no invoice."
48
+ },
37
49
  {
38
50
  "name": "BRAVE_SEARCH_API_KEY",
39
51
  "workers": ["bot"],