@coreplane/switchboard 1.254.1 → 1.256.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 (66) 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/drain.ts +55 -1
  9. package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
  10. package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
  11. package/dist/assets/deploy/cloudflare-resident/worker.ts +358 -42
  12. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
  13. package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
  14. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
  15. package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
  16. package/dist/assets/deploy/secrets.manifest.json +12 -0
  17. package/dist/assets/package-lock.json +3 -3
  18. package/dist/assets/package.json +1 -1
  19. package/dist/assets/source.json +3 -3
  20. package/dist/assets/src/agents/registry.ts +52 -0
  21. package/dist/assets/src/core/budgets.ts +35 -2
  22. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  23. package/dist/assets/src/core/coordinator/driver.ts +59 -6
  24. package/dist/assets/src/core/costs.ts +39 -16
  25. package/dist/assets/src/core/pipelineStanding.ts +62 -0
  26. package/dist/assets/src/core/plane/decide.ts +389 -22
  27. package/dist/assets/src/core/refusal.ts +3 -0
  28. package/dist/assets/src/core/reviewVerdict.ts +64 -2
  29. package/dist/assets/src/core/runEvents.ts +31 -12
  30. package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
  31. package/dist/assets/src/core/runLedger/types.ts +3 -0
  32. package/dist/assets/src/core/runRecord.ts +24 -7
  33. package/dist/assets/src/core/ship/contract.ts +20 -30
  34. package/dist/assets/src/core/ship/coordinator.ts +519 -126
  35. package/dist/assets/src/core/trace/workerTrace.ts +3 -0
  36. package/dist/assets/src/execution/sandboxErrors.ts +77 -6
  37. package/dist/assets/web/dist/.vite/manifest.json +61 -60
  38. package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
  39. package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
  40. package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-BEiStFwW.js} +1 -1
  41. package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
  42. package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
  43. package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
  44. package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
  45. package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
  46. package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
  47. package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-JqVSDOok.js} +1 -1
  48. package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-Viim-bus.js} +1 -1
  49. package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-CYUy8McC.js} +1 -1
  50. package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-Dv6UMaPy.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-Bd-5Rypv.js} +1 -1
  52. package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
  53. package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
  54. package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-Nl3uaxrP.js} +1 -1
  55. package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
  56. package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-DRxWlffc.js} +2 -2
  57. package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
  58. package/dist/cli.js +4400 -3079
  59. package/package.json +1 -1
  60. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
  61. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
  62. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
  63. package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +0 -9
  64. package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
  65. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
  66. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +0 -1
@@ -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 {
@@ -333,7 +343,15 @@ import {
333
343
  } from "../../src/execution/residentDepsStore.js";
334
344
  import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
335
345
  import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
336
- import { drainRefusal, liveDrain, parseDrainRequest, type DrainRecord } from "./drain";
346
+ import {
347
+ drainRefusal,
348
+ holdDrain,
349
+ liftDrain,
350
+ liveDrain,
351
+ parseDrainRequest,
352
+ reportImageCurrent,
353
+ type DrainRecord,
354
+ } from "./drain";
337
355
  import {
338
356
  CGROUP_READ_ARGV,
339
357
  MEMORY_PRESSURE_REASON,
@@ -636,6 +654,12 @@ const DISK_KEY = "resident:disk";
636
654
  * tick, persisted so the gauges (`/status`, `/residents`, the watchdog line)
637
655
  * read storage only — the watchdog never touches the container. */
638
656
  const MEMORY_KEY = "resident:memory";
657
+ /** The last level sample and the crossing outbox (record 0064; `levels.ts`). */
658
+ const LEVELS_LAST_KEY = "resident:levels:last";
659
+ const LEVELS_OUTBOX_KEY = "resident:levels:outbox";
660
+ /** The registry's drain post (record 0064): the fleet drain's set/cleared/expired,
661
+ * re-offered on every registry answer until superseded. */
662
+ const DRAIN_OUTBOX_KEY = "drain:outbox";
639
663
  /** The lifecycle row (docs/reference/specs/resident-repos.md item 7): `workflow`,
640
664
  * the one scheduler. Kept from the flagged rollout so `/status` and `/debug
641
665
  * info` can say so; an `alarm` value a flip left behind reads `workflow`
@@ -1193,6 +1217,13 @@ interface ThreadBinding {
1193
1217
  depsKey?: string;
1194
1218
  }
1195
1219
 
1220
+ /** What one image reconcile decided (`reconcileImage`): the container was
1221
+ * stopped to restart on the current image (`restarted`), already runs it
1222
+ * (`current`), is not running so the next start uses it anyway (`inactive`),
1223
+ * or is busy — an operation, an attach or a registered run in flight — and
1224
+ * the restart is deferred to the next quiet check (`deferred`). */
1225
+ type ImageReconcileResult = "restarted" | "current" | "inactive" | "deferred";
1226
+
1196
1227
  interface AttachOk {
1197
1228
  workspace: string;
1198
1229
  ref: string;
@@ -1411,6 +1442,10 @@ const TEST_OVERRIDES_KEY = "testOverrides";
1411
1442
  * counts as a slot; it survives the isolate swap a deploy performs, which is
1412
1443
  * why the record carries its own end. */
1413
1444
  const DRAIN_KEY = "drain";
1445
+ /** Resident-DO key (issue 1931): set when a deploy's reconcile could not
1446
+ * verify this resident's container on the new image; the next reconcile that
1447
+ * finds it current reports to the registry and clears it. */
1448
+ const IMAGE_REPORT_PENDING_KEY = "imageReportPending";
1414
1449
 
1415
1450
  type OnboardResult = { ok: true; record: ResidentRecord } | { ok: false; status: number; error: string };
1416
1451
 
@@ -1535,15 +1570,94 @@ export class ResidentRegistryDO extends DurableObject<Env> {
1535
1570
  }
1536
1571
 
1537
1572
  /** Admin-only by construction (reached solely via POST /drain): replaces
1538
- * whatever drain stood — a second deploy's drain extends the first's. */
1573
+ * whatever drain stood — a second deploy's drain extends the first's. The
1574
+ * set posts `above` for the plane (record 0064) and arms ONE alarm at
1575
+ * `until`, so a drain nobody lifts posts its expiry itself. */
1539
1576
  async setDrain(record: DrainRecord): Promise<DrainRecord> {
1540
1577
  await this.ctx.storage.put(DRAIN_KEY, record);
1578
+ await this.pushDrainPost("above");
1579
+ await this.ctx.storage.setAlarm(Date.parse(record.until));
1541
1580
  return record;
1542
1581
  }
1543
1582
 
1544
- /** Admin-only by construction (POST /undrain): true when a record was there. */
1545
- async clearDrain(): Promise<boolean> {
1546
- return this.ctx.storage.delete(DRAIN_KEY);
1583
+ /** Admin-only by construction (POST /undrain): `cleared` when a record was
1584
+ * there and was lifted — a cleared drain posts `below`, the plane's
1585
+ * resident-drain window lifts. With holds outstanding (issue 1931: a
1586
+ * container still to report the deploy's image) the fleet STAYS closed:
1587
+ * the record stands with `liftAsked` and the last container's report lifts
1588
+ * it (`reportContainerImageCurrent`); `until` remains the backstop. */
1589
+ async clearDrain(): Promise<{ cleared: boolean; held: string[] }> {
1590
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
1591
+ if (record !== null) {
1592
+ const lift = liftDrain(record);
1593
+ if (!lift.cleared) {
1594
+ await this.ctx.storage.put(DRAIN_KEY, lift.record);
1595
+ return { cleared: false, held: lift.record.holds ?? [] };
1596
+ }
1597
+ }
1598
+ const had = await this.ctx.storage.delete(DRAIN_KEY);
1599
+ if (had) await this.pushDrainPost("below");
1600
+ return { cleared: had, held: [] };
1601
+ }
1602
+
1603
+ /** The deploy's reconcile could not verify these residents' containers on
1604
+ * the new image (issue 1931): hold the drain for each — the fleet must not
1605
+ * reopen onto them until they report. No live drain, nothing to hold. */
1606
+ async holdDrainFor(resources: string[]): Promise<void> {
1607
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
1608
+ if (record === null || resources.length === 0) return;
1609
+ await this.ctx.storage.put(DRAIN_KEY, holdDrain(record, resources));
1610
+ }
1611
+
1612
+ /** One resident's word that its running container is on the deploy's image:
1613
+ * its hold drops, and when it was the last hold of a lift already asked the
1614
+ * drain lifts here — the reopen fires on the last container's report. */
1615
+ async reportContainerImageCurrent(resource: string): Promise<{ lifted: boolean }> {
1616
+ const record = liveDrain(await this.ctx.storage.get(DRAIN_KEY), systemClock());
1617
+ if (record === null) return { lifted: false };
1618
+ const report = reportImageCurrent(record, resource);
1619
+ if (report.record === null) {
1620
+ await this.ctx.storage.delete(DRAIN_KEY);
1621
+ await this.pushDrainPost("below");
1622
+ console.log(`[drain] fleet reopened — ${resource} was the last container to report the deploy's image`);
1623
+ return { lifted: true };
1624
+ }
1625
+ await this.ctx.storage.put(DRAIN_KEY, report.record);
1626
+ return { lifted: false };
1627
+ }
1628
+
1629
+ /** The one alarm, at the drain's `until` (record 0064): a drain past
1630
+ * its end is nothing (`liveDrain` already reads it so), and the expiry is
1631
+ * posted `below` like a clear — whoever forgot the drain, the plane's
1632
+ * window lifts. A drain replaced with a later `until` re-arms via setDrain. */
1633
+ async alarm(): Promise<void> {
1634
+ const now = systemClock();
1635
+ const stored = await this.ctx.storage.get(DRAIN_KEY);
1636
+ if (stored === undefined) return;
1637
+ if (liveDrain(stored, now) === null) {
1638
+ await this.ctx.storage.delete(DRAIN_KEY);
1639
+ await this.pushDrainPost("below");
1640
+ } else {
1641
+ // Replaced with a later end under an already-armed alarm: re-arm at it.
1642
+ await this.ctx.storage.setAlarm(Date.parse((stored as DrainRecord).until));
1643
+ }
1644
+ }
1645
+
1646
+ /** The drain's plane post (name `drain`), superseding the last: the plane
1647
+ * only needs the current side, and the bot's forward is idempotent. */
1648
+ private async pushDrainPost(side: LevelSide): Promise<void> {
1649
+ await this.ctx.storage.put(DRAIN_OUTBOX_KEY, {
1650
+ name: "drain",
1651
+ side,
1652
+ generation: "",
1653
+ at: new Date(systemClock()).toISOString(),
1654
+ });
1655
+ }
1656
+
1657
+ /** The pending drain post, for the registry's answers (`/drain`, `/undrain`,
1658
+ * `/residents`) — the bot forwards it to `POST /plane/level`. */
1659
+ async getDrainOutbox(): Promise<LevelPost | null> {
1660
+ return ((await this.ctx.storage.get(DRAIN_OUTBOX_KEY)) as LevelPost | undefined) ?? null;
1547
1661
  }
1548
1662
  }
1549
1663
 
@@ -3366,7 +3480,7 @@ export class ResidentDO extends Sandbox<Env> {
3366
3480
  // RUNNING container keeps the old one, so new Worker code can name pool
3367
3481
  // users the image lacks. Reconcile here (every cycle, cheap) — see
3368
3482
  // reconcileImage — so a rollout self-applies within one refresh.
3369
- if (await this.reconcileImage("refresh")) {
3483
+ if ((await this.reconcileImage("refresh")) === "restarted") {
3370
3484
  // Container stopping; it restarts on the new image in seconds. The
3371
3485
  // engine's retry re-enters this step thirty seconds on and re-warms the
3372
3486
  // resident within the minute, instead of the next bucket.
@@ -4592,6 +4706,37 @@ export class ResidentDO extends Sandbox<Env> {
4592
4706
  return this.memoryGuard.lastReading ?? (await this.ctx.storage.get<MemoryReading>(MEMORY_KEY)) ?? null;
4593
4707
  }
4594
4708
 
4709
+ /** The resident's levels (record 0064; `levels.ts`): the seat (the
4710
+ * thread/op user pool, whose exhaustion is the `user-pool-exhausted`
4711
+ * refusal) and the memory line (the gate's soft side), stamped with this
4712
+ * incarnation — the container's boot id where one is memoized, else the
4713
+ * DO incarnation, so a boot or a replaced runtime reads as a new
4714
+ * generation and re-states both levels. A crossing lands in the outbox
4715
+ * and is re-offered on every answer until a newer crossing of the same
4716
+ * name supersedes it; the bot forwards posts to `POST /plane/level`.
4717
+ * Storage reads and one storage write only — never a container touch, so
4718
+ * every `/attach`, `/exec` and `/status` answer can carry the document. */
4719
+ async residentLevels(): Promise<ResidentLevelsDoc> {
4720
+ const at = new Date(systemClock()).toISOString();
4721
+ const all = await this.ctx.storage.list<ThreadBinding>({ prefix: THREAD_KEY_PREFIX });
4722
+ const used = new Set([...all.values()].filter((b) => !b.evicted && b.user).map((b) => b.user));
4723
+ for (const u of this.opUsersInUse) used.add(u);
4724
+ const reading = await this.memoryGauge();
4725
+ const generation = this.containerIdMemo ?? this.incarnation;
4726
+ const seat = { side: seatSide(used.size, THREAD_USERS.length), used: used.size, total: THREAD_USERS.length };
4727
+ const memory = { side: memorySide(reading?.percent ?? null), percent: reading?.percent ?? null };
4728
+ const next: LevelSample = { seat: seat.side, memory: memory.side, generation };
4729
+ const prev = (await this.ctx.storage.get<LevelSample>(LEVELS_LAST_KEY)) ?? null;
4730
+ const posts = levelPosts(prev, next, at);
4731
+ let outbox = (await this.ctx.storage.get<LevelPost[]>(LEVELS_OUTBOX_KEY)) ?? [];
4732
+ if (posts.length > 0) {
4733
+ outbox = mergeOutbox(outbox, posts);
4734
+ await this.ctx.storage.put(LEVELS_OUTBOX_KEY, outbox);
4735
+ await this.ctx.storage.put(LEVELS_LAST_KEY, next);
4736
+ }
4737
+ return { seat, memory, generation, at, posts: outbox };
4738
+ }
4739
+
4595
4740
  /** The route gate (item 70): one fresh sample, then the pure verdict over
4596
4741
  * the last reading. A refusal is the same 503 shape as `mirror-busy`, so
4597
4742
  * the bot falls back or waits legibly — and a command already running is
@@ -4831,27 +4976,131 @@ export class ResidentDO extends Sandbox<Env> {
4831
4976
  /** Pool users live in the IMAGE (Dockerfile useradd loop) while THREAD_USERS
4832
4977
  * lives in the Worker. After a deploy that grows the pool, a still-running
4833
4978
  * 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;
4979
+ * last pool user exists; if not and nothing is in flight — the in-memory
4980
+ * counters AND the durable run registrations (item 44): a harness run's
4981
+ * process lives in the container between the bot's operator calls, so a
4982
+ * restart decided on the op counters alone stops the container under a live
4983
+ * run — stop the container so it restarts on the current image (state is DO
4984
+ * storage + R2 — the disk is a cache). A deferred restart re-checks on
4985
+ * every later attach and refresh cycle until the resident is quiet; a
4986
+ * registration whose release never came defers it only until the clean-idle
4987
+ * sweep drains that registration. Answers `restarted` when a stop was
4988
+ * issued, else why not. */
4989
+ private async reconcileImage(where: string, force = false): Promise<ImageReconcileResult> {
4990
+ if (!(await this.isRuntimeActive().catch(() => false))) {
4991
+ // An inactive container's next start is on the deployed image by
4992
+ // construction — and a held resident whose container idled out has no
4993
+ // other reporter left: new attaches are refused by the very drain the
4994
+ // hold keeps standing, so without this report the fleet stays closed
4995
+ // until the drain's `until` backstop (issue 1931).
4996
+ await this.reportPendingImageCurrent(where);
4997
+ return "inactive";
4998
+ }
4839
4999
  const last = THREAD_USERS[THREAD_USERS.length - 1];
4840
- const probe = await this.run(["id", "-u", last]);
4841
- if (probe.exitCode === 0) return false;
5000
+ // A pending report marker (issue 1931) means a deploy could not verify this
5001
+ // container on its image: the probe shortcut below is a POOL-USER check the
5002
+ // pre-deploy image passes when the pool did not change, so it can never
5003
+ // satisfy the marker — the container is treated as stale until it is cycled
5004
+ // post-deploy and the fresh start reported.
5005
+ const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
5006
+ if (!force && pending === undefined) {
5007
+ const probe = await this.run(["id", "-u", last]);
5008
+ if (probe.exitCode === 0) return "current";
5009
+ }
5010
+ const stale = force
5011
+ ? "the container predates the deploy"
5012
+ : pending !== undefined
5013
+ ? "a deploy's new-image report is pending, so the running container cannot be trusted current"
5014
+ : `${last} missing in the running container`;
4842
5015
  const busy = this.inFlightCount();
4843
5016
  if (busy > 0) {
5017
+ console.log(`image-stale (${where}): ${stale} but ${busy} operation(s)/attach(es) in flight — deferring restart`);
5018
+ return "deferred";
5019
+ }
5020
+ const registered = await this.registeredRunsBeyondOps();
5021
+ if (registered > 0) {
4844
5022
  console.log(
4845
- `image-stale (${where}): ${last} missing but ${busy} operation(s)/attach(es) in flight — deferring restart`,
5023
+ `image-stale (${where}): ${stale} but ${registered} run registration(s) live — deferring restart until the resident is quiet`,
4846
5024
  );
4847
- return false;
5025
+ return "deferred";
4848
5026
  }
4849
- console.log(
4850
- `image-stale (${where}): ${last} missing in the running container — stopping so it restarts on the current image`,
4851
- );
5027
+ console.log(`image-stale (${where}): ${stale} — stopping so it restarts on the current image`);
4852
5028
  this.swapIncarnation(); // deliberate incarnation swap
4853
5029
  await this.stop().catch((err) => console.log(`image-stale: stop failed: ${errMsg(err)}`));
4854
- return true;
5030
+ if (pending !== undefined) {
5031
+ // The post-deploy cycle just happened: probe the FRESH container — the
5032
+ // SDK boots it for this command on the image the deployed Worker pins,
5033
+ // so a fresh start answering after the deploy is the verified fact
5034
+ // (issue 1931), never the pool-user probe on the old process. A failed
5035
+ // probe keeps the marker: the next reconcile tries the cycle again.
5036
+ const fresh = await this.run(["id", "-u", last])
5037
+ .then((p) => p.exitCode === 0)
5038
+ .catch(() => false);
5039
+ if (fresh) await this.reportPendingImageCurrent(where);
5040
+ else
5041
+ console.log(`image-stale (${where}): the fresh container's probe failed — the new-image report stays pending`);
5042
+ }
5043
+ return "restarted";
5044
+ }
5045
+
5046
+ /** The report a held drain waits for (issue 1931): when the deploy's
5047
+ * reconcile could not verify this resident's fresh container on the new
5048
+ * image, a marker stays in storage and the registry holds the drain. The
5049
+ * fact the report stands on is a fresh container start AFTER the deploy —
5050
+ * the marker makes `reconcileImage` treat the container as stale until the
5051
+ * cycle happens, so this is called only behind that fresh start (or on an
5052
+ * inactive resident, whose next start is on the new image by construction).
5053
+ * The marker is deleted only after a successful report: a transient failure
5054
+ * keeps it for the next reconcile, and the drain's `until` is the backstop
5055
+ * for a report that never lands. */
5056
+ private async reportPendingImageCurrent(where: string): Promise<boolean> {
5057
+ const pending = await this.ctx.storage.get<{ resource: string }>(IMAGE_REPORT_PENDING_KEY);
5058
+ if (pending === undefined) return true;
5059
+ try {
5060
+ const { lifted } = await registryStub(this.env).reportContainerImageCurrent(pending.resource);
5061
+ await this.ctx.storage.delete(IMAGE_REPORT_PENDING_KEY);
5062
+ console.log(
5063
+ `[reconcile] ${pending.resource} reports the deploy's image (${where})${lifted ? " — drain lifted" : ""}`,
5064
+ );
5065
+ return true;
5066
+ } catch (err) {
5067
+ console.log(
5068
+ `[reconcile] ${pending.resource}: the new-image report failed (${where}): ${errMsg(err)} — retried on the next reconcile`,
5069
+ );
5070
+ return false;
5071
+ }
5072
+ }
5073
+
5074
+ /** The deploy's reconcile, inside the drain window (item 69's order: the
5075
+ * runs in flight end, the swap lands, the containers reconcile, the fleet
5076
+ * reopens): `POST /reconcile` calls this on every resident after the Worker
5077
+ * deploy landed and BEFORE the drain is lifted. The pool-user probe cannot
5078
+ * tell the pre-deploy image from the new one when the pool did not change,
5079
+ * and the platform replaces the container processes asynchronously after an
5080
+ * image-changing deploy (issue 1931) — so this path never trusts "current":
5081
+ * an active, quiet container is always cycled, and the fresh start —
5082
+ * necessarily on the deploy's image, which landed before this call — is
5083
+ * probed. `verified` true is the fact the reopen may stand on; anything
5084
+ * else (deferred, a failed fresh probe) leaves a hold on the drain, cleared
5085
+ * by this resident's later report (`reportPendingImageCurrent`), never by a
5086
+ * timer.
5087
+ *
5088
+ * The hold lands BEFORE the marker: the marker is what lets any concurrent
5089
+ * attach/refresh reconcile report, and a report that reaches the registry
5090
+ * before the hold is a no-op — the marker would be consumed and the hold
5091
+ * added afterwards would wait for a report nothing sends (until the drain's
5092
+ * `until`). Held first, every report finds its hold. */
5093
+ async reconcileForDeploy(resource: string): Promise<{ result: ImageReconcileResult; verified: boolean }> {
5094
+ await registryStub(this.env).holdDrainFor([resource]);
5095
+ await this.ctx.storage.put(IMAGE_REPORT_PENDING_KEY, { resource });
5096
+ const result = await this.reconcileImage("deploy", true);
5097
+ if (result === "deferred") return { result, verified: false };
5098
+ // Inactive: the inactive early-return reported (the next start is on the
5099
+ // new image by construction). Restarted: the cycle's fresh probe and
5100
+ // report ran inside reconcileImage. Either way the marker gone is the
5101
+ // verification.
5102
+ const verified = (await this.ctx.storage.get(IMAGE_REPORT_PENDING_KEY)) === undefined;
5103
+ return { result, verified };
4855
5104
  }
4856
5105
 
4857
5106
  // -- watchdog (the sparse cron; it re-arms nothing) --------------------------
@@ -5193,7 +5442,7 @@ export class ResidentDO extends Sandbox<Env> {
5193
5442
  const memory = await this.memoryGate("attach", registered);
5194
5443
  if (memory) return memory;
5195
5444
  const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
5196
- if (await this.reconcileImage("attach")) {
5445
+ if ((await this.reconcileImage("attach")) === "restarted") {
5197
5446
  return {
5198
5447
  error: "image-stale: the container predates the current pool and is restarting; retry shortly",
5199
5448
  status: 503,
@@ -8459,6 +8708,7 @@ const ROUTES: Record<string, { scope: Scope; method: string }> = {
8459
8708
  "/rebuild": { scope: "admin", method: "POST" },
8460
8709
  "/drain": { scope: "drain", method: "POST" }, // close the fleet to new runs for a deploy (item 69; admin implied)
8461
8710
  "/undrain": { scope: "drain", method: "POST" }, // reopen it
8711
+ "/reconcile": { scope: "drain", method: "POST" }, // reconcile every container onto the current image, inside the drain window
8462
8712
  "/residents": { scope: "read", method: "GET" }, // admin implied; read-only bearer allowed
8463
8713
  "/debug": { scope: "read", method: "POST" }, // per-op: READ_DEBUG_OPS for read scope, everything for admin
8464
8714
  "/status": { scope: "operator", method: "GET" },
@@ -8563,6 +8813,8 @@ export default {
8563
8813
  return await handleDrain(env, body);
8564
8814
  case "/undrain":
8565
8815
  return await handleUndrain(env);
8816
+ case "/reconcile":
8817
+ return await handleReconcile(env);
8566
8818
  case "/residents":
8567
8819
  return await handleResidents(env);
8568
8820
  case "/debug": {
@@ -8993,15 +9245,52 @@ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Res
8993
9245
  if (!parsed.ok) return json({ error: parsed.error }, 400);
8994
9246
  const record = await registryStub(env).setDrain(parsed.record);
8995
9247
  console.log(`[drain] fleet closed to new runs by ${record.by} for ${record.reason}: until ${record.until}`);
8996
- return json({ draining: record });
9248
+ return json({ draining: record, planeOutbox: await registryStub(env).getDrainOutbox() });
8997
9249
  }
8998
9250
 
8999
- /** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says whether
9000
- * a drain stood. */
9251
+ /** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says
9252
+ * whether a drain stood and was lifted. With containers still to report the
9253
+ * deploy's image (issue 1931) the fleet STAYS closed: `held` names them, the
9254
+ * record stands with the lift asked, and the last container's report reopens
9255
+ * the fleet — a fact, never a timer; the drain's `until` is the backstop. */
9001
9256
  async function handleUndrain(env: Env): Promise<Response> {
9002
- const cleared = await registryStub(env).clearDrain();
9003
- console.log(`[drain] fleet reopened (${cleared ? "a drain stood" : "no drain stood"})`);
9004
- return json({ draining: null, cleared });
9257
+ const lift = await registryStub(env).clearDrain();
9258
+ if (!lift.cleared && lift.held.length > 0) {
9259
+ console.log(`[drain] fleet stays closed — containers still to report the deploy's image: ${lift.held.join(", ")}`);
9260
+ const draining = liveDrain(await registryStub(env).getDrain(), systemClock());
9261
+ return json({ draining, cleared: false, held: lift.held, planeOutbox: await registryStub(env).getDrainOutbox() });
9262
+ }
9263
+ console.log(`[drain] fleet reopened (${lift.cleared ? "a drain stood" : "no drain stood"})`);
9264
+ return json({ draining: null, cleared: lift.cleared, planeOutbox: await registryStub(env).getDrainOutbox() });
9265
+ }
9266
+
9267
+ /** POST /reconcile (drain scope): reconcile every resident's container onto
9268
+ * the current image — the deploy runner posts it after its Worker deploy
9269
+ * landed and BEFORE its `/undrain`, so a stale container restarts inside the
9270
+ * drain window (item 69's order) and never under a run the reopened fleet
9271
+ * admits. Each resident answers what its reconcile decided and whether its
9272
+ * fresh container was VERIFIED on the deploy's image (issue 1931); one not
9273
+ * verified — deferred, a failed fresh probe, an error — leaves a hold on the
9274
+ * drain, so the fleet reopens only on that container's later report. */
9275
+ async function handleReconcile(env: Env): Promise<Response> {
9276
+ const residents = await registryStub(env).list();
9277
+ const settled = await Promise.allSettled(
9278
+ residents.map((record) => residentStub(env, record.resource).reconcileForDeploy(record.resource)),
9279
+ );
9280
+ const reconciled = residents.map((record, i) => {
9281
+ const s = settled[i];
9282
+ return s.status === "fulfilled"
9283
+ ? { resource: record.resource, result: s.value.result, verified: s.value.verified }
9284
+ : { resource: record.resource, result: "error" as const, verified: false, error: errMsg(s.reason) };
9285
+ });
9286
+ // Each resident held ITSELF before its marker (reconcileForDeploy), so no
9287
+ // report can outrun its hold; this pass is the backstop for a resident whose
9288
+ // call rejected before it could — holdDrain deduplicates, so re-holding a
9289
+ // deferred resident changes nothing.
9290
+ const unverified = reconciled.filter((r) => !r.verified).map((r) => r.resource);
9291
+ if (unverified.length > 0) await registryStub(env).holdDrainFor(unverified);
9292
+ console.log(`[reconcile] deploy image reconcile: ${JSON.stringify(reconciled)}`);
9293
+ return json({ reconciled });
9005
9294
  }
9006
9295
 
9007
9296
  async function handleResidents(env: Env): Promise<Response> {
@@ -9060,13 +9349,16 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
9060
9349
  // deploy gate reads /residents. The registry check rides in the same flight
9061
9350
  // (its 404 is judged first, the probes' results discarded then).
9062
9351
  const stub = residentStub(env, resource.resource);
9063
- const [record, status, inFlight, refresh, snapshot, memory] = await Promise.all([
9352
+ const [record, status, inFlight, refresh, snapshot, memory, levels] = await Promise.all([
9064
9353
  registryStub(env).getRecord(resource.resource),
9065
9354
  stub.getStatus(),
9066
9355
  stub.getInFlightCount(),
9067
9356
  stub.getRefreshView(),
9068
9357
  stub.snapshotHandle(),
9069
9358
  stub.memoryGauge(),
9359
+ // The levels (record 0064): what the bot forwards to the plane; the
9360
+ // plane's `probe` effect is answered by exactly this read.
9361
+ stub.residentLevels().catch(() => null),
9070
9362
  ]);
9071
9363
  if (!record) return json({ error: `${resource.resource} is not onboarded` }, 404);
9072
9364
  // Item 7: which scheduler drives the refresh cycle and, on the Workflow
@@ -9082,9 +9374,23 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
9082
9374
  // Item 70: the last memory reading, so the bot and the residents page can
9083
9375
  // show what the resident's own gate is reading.
9084
9376
  memory,
9377
+ // Record 0064, record 0064: the levels and the crossing outbox on every answer.
9378
+ ...(levels ? { levels } : {}),
9085
9379
  });
9086
9380
  }
9087
9381
 
9382
+ /** Levels on every data-plane answer (record 0064): the document is read
9383
+ * after the route's own work so the sample reflects it; a failed sample never
9384
+ * fails the answer — the bot forwards nothing that call. */
9385
+ async function withLevels<T>(
9386
+ stub: ReturnType<typeof residentStub>,
9387
+ pending: Promise<T>,
9388
+ ): Promise<{ result: T; levels: ResidentLevelsDoc | null }> {
9389
+ const result = await pending;
9390
+ const levels = await stub.residentLevels().catch(() => null);
9391
+ return { result, levels };
9392
+ }
9393
+
9088
9394
  // -- thread data plane handlers -----------------------------------------------
9089
9395
 
9090
9396
  /** Shared front half of the thread routes: validate resource + threadKey (P1:
@@ -9161,17 +9467,20 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
9161
9467
  // response does (`fetch failed` a few minutes in). A refusal
9162
9468
  // carries its `status` in the body; `ResidentExecutor.attach` reads it there.
9163
9469
  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,
9470
+ withLevels(
9471
+ ctx.stub,
9472
+ ctx.stub.attachThread(
9473
+ ctx.threadKey,
9474
+ refHint,
9475
+ readonly.readonly,
9476
+ want.sha,
9477
+ reuse.reuse,
9478
+ ctx.record,
9479
+ traceparent,
9480
+ reason,
9481
+ ),
9173
9482
  ),
9174
- (result) => result,
9483
+ ({ result, levels }) => ({ ...(result as object), ...(levels ? { levels } : {}) }),
9175
9484
  (err) => catchAllErr(err),
9176
9485
  );
9177
9486
  }
@@ -9226,7 +9535,9 @@ async function handleExec(env: Env, body: Record<string, unknown>, traceparent?:
9226
9535
  // read from the body alone through the one validated reader the sandbox
9227
9536
  // Worker uses, and handed to the exec's env option, never onto the command.
9228
9537
  const execEnv = envFromRequest({ body });
9229
- return streamThreadExec(ctx.stub.execThread(ctx.threadKey, body.command, timeoutMs, traceparent, execEnv));
9538
+ return streamThreadExec(
9539
+ withLevels(ctx.stub, ctx.stub.execThread(ctx.threadKey, body.command, timeoutMs, traceparent, execEnv)),
9540
+ );
9230
9541
  }
9231
9542
 
9232
9543
  /** Stream one pending result with the thread-sandbox Worker's heartbeat
@@ -9277,13 +9588,18 @@ function streamHeartbeatJson<T>(
9277
9588
  * `status`, its word and its `transient`,
9278
9589
  * and the client reads it like the JSON routes' answer, never as a
9279
9590
  * deterministic answer over HTTP 200. */
9280
- function streamThreadExec(pending: Promise<Awaited<ReturnType<ResidentDO["execThread"]>>>): Response {
9591
+ function streamThreadExec(
9592
+ pending: Promise<{ result: Awaited<ReturnType<ResidentDO["execThread"]>>; levels: ResidentLevelsDoc | null }>,
9593
+ ): Response {
9281
9594
  return streamHeartbeatJson(
9282
9595
  pending,
9283
- (result) =>
9284
- "error" in result
9596
+ ({ result, levels }) => ({
9597
+ ...("error" in result
9285
9598
  ? execFailureDocument(result)
9286
- : { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, truncated: result.truncated },
9599
+ : { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, truncated: result.truncated }),
9600
+ // Record 0064, record 0064: levels on every answer, the crossing outbox included.
9601
+ ...(levels ? { levels } : {}),
9602
+ }),
9287
9603
  (err) => execFailureDocument(threadRejectionErr(err, "/exec")),
9288
9604
  );
9289
9605
  }
@@ -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
  },