@coreplane/switchboard 1.247.0 → 1.249.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 (48) hide show
  1. package/dist/assets/config/config.example.yaml +43 -17
  2. package/dist/assets/deploy/cloudflare/preflight.mjs +21 -19
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +31 -0
  4. package/dist/assets/deploy/cloudflare-resident/drain.ts +109 -0
  5. package/dist/assets/deploy/cloudflare-resident/threadErr.ts +32 -3
  6. package/dist/assets/deploy/cloudflare-resident/worker.ts +174 -10
  7. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +56 -9
  8. package/dist/assets/deploy/secrets.manifest.json +6 -0
  9. package/dist/assets/package-lock.json +3 -3
  10. package/dist/assets/package.json +1 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/agents/registry.ts +11 -6
  13. package/dist/assets/src/core/budgets.ts +24 -0
  14. package/dist/assets/src/core/coordinator/contract.ts +4 -3
  15. package/dist/assets/src/core/coordinator/driver.ts +40 -7
  16. package/dist/assets/src/core/modelCard.ts +348 -0
  17. package/dist/assets/src/core/modelPricing.ts +14 -5
  18. package/dist/assets/src/core/modelRegistry.ts +51 -0
  19. package/dist/assets/src/core/provider.ts +103 -0
  20. package/dist/assets/src/core/refusal.ts +181 -0
  21. package/dist/assets/src/core/runEvents.ts +43 -0
  22. package/dist/assets/src/core/ship/contract.ts +11 -1
  23. package/dist/assets/src/core/ship/coordinator.ts +71 -11
  24. package/dist/assets/src/core/ship/handoff.ts +54 -19
  25. package/dist/assets/src/core/trace/workerTrace.ts +9 -3
  26. package/dist/assets/src/core/types.ts +327 -0
  27. package/dist/assets/src/deploy/liveGate.ts +35 -0
  28. package/dist/assets/src/deploy/restart.ts +12 -11
  29. package/dist/assets/src/execution/sandboxErrors.ts +114 -4
  30. package/dist/assets/web/dist/.vite/manifest.json +30 -30
  31. package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +1 -0
  32. package/dist/assets/web/dist/assets/{HomePage-DYxC0izY.js → HomePage-BpQRky8B.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ResidentDetailPage-DLIpWYOc.js → ResidentDetailPage-BIUXyz6K.js} +1 -1
  34. package/dist/assets/web/dist/assets/{ResidentsIndexPage-6LipuDjR.js → ResidentsIndexPage-BZymgSAb.js} +1 -1
  35. package/dist/assets/web/dist/assets/{RunFoldRow-V-iSy64e.js → RunFoldRow-3m4CPRI4.js} +1 -1
  36. package/dist/assets/web/dist/assets/{RunRoutePage-DUalB1u2.js → RunRoutePage-bgkjkA0p.js} +3 -3
  37. package/dist/assets/web/dist/assets/{RunsIndexPage-B9Ba1KdD.js → RunsIndexPage-8S944AzB.js} +1 -1
  38. package/dist/assets/web/dist/assets/{ScheduledPage-KdjLtD_7.js → ScheduledPage-8bBtG9y3.js} +1 -1
  39. package/dist/assets/web/dist/assets/{SettingsPage-IT5l_NaL.js → SettingsPage-DQeNvfaV.js} +1 -1
  40. package/dist/assets/web/dist/assets/{StatusDot-DBHAl4Il.js → StatusDot-BPE5syBa.js} +1 -1
  41. package/dist/assets/web/dist/assets/{Tooltip-_LEjptLV.js → Tooltip-DkoeZfTs.js} +1 -1
  42. package/dist/assets/web/dist/assets/UnitRoutePage-BUzw--Ii.js +1 -0
  43. package/dist/assets/web/dist/assets/{dist-BcYPGOBL.js → dist-D11y9ZJ4.js} +1 -1
  44. package/dist/assets/web/dist/assets/{main-DUfSE0dj.js → main-B6LcgNM6.js} +2 -2
  45. package/dist/cli.js +2827 -1117
  46. package/package.json +1 -1
  47. package/dist/assets/web/dist/assets/DeliveryPage-NP4g6bQd.js +0 -1
  48. package/dist/assets/web/dist/assets/UnitRoutePage-DPBsvGPR.js +0 -1
@@ -13,6 +13,7 @@
13
13
  //
14
14
  // Route surface (JSON in/out; every route below requires a bearer secret):
15
15
  // admin scope POST /onboard /offboard /reconfigure /rebuild /debug (all ops)
16
+ // drain scope POST /drain /undrain (admin implied) — the deploy's bearer, nothing else
16
17
  // read scope GET /residents POST /debug ops info|schedules|threads only (admin implied)
17
18
  // operator scope POST /attach /detach /exec /read /write /op GET /status (state, reason, inFlight)
18
19
  // unauthenticated GET /healthz (deploy wake ping; touches no DO)
@@ -158,6 +159,7 @@ import {
158
159
  } from "../../src/core/schedules.js";
159
160
  import type { ResidentLifecycleState } from "../../src/execution/residentState.js";
160
161
  import { RestoreWaiters } from "../../src/execution/restoreWaiters.js";
162
+ import { isRuntimeBusySignal, SandboxRuntimeBusyError } from "../../src/execution/sandboxErrors.js";
161
163
  import {
162
164
  decisivePull,
163
165
  effectiveLimits,
@@ -323,11 +325,13 @@ import {
323
325
  } from "../../src/execution/residentDepsStore.js";
324
326
  import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
325
327
  import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
328
+ import { drainRefusal, liveDrain, parseDrainRequest, type DrainRecord } from "./drain";
326
329
  import {
327
330
  ControlResetError,
328
331
  RuntimeReplacedError,
329
332
  controlResetErr,
330
333
  execFailureDocument,
334
+ runtimeBusyErr,
331
335
  runtimeReplacedErr,
332
336
  selfAndCauses,
333
337
  threadErrBuilders,
@@ -417,6 +421,10 @@ export interface Env {
417
421
  * (info, schedules, threads) — for dashboards and humans who need to look,
418
422
  * never to change anything. Unset = no read scope exists. */
419
423
  RESIDENT_READ_TOKEN?: string;
424
+ /** Optional drain-only bearer (item 69): POST /drain and /undrain, nothing
425
+ * else — what a release deploy holds so it can close the fleet without the
426
+ * admin bearer. Unset = only admin can drain. */
427
+ RESIDENT_DRAIN_TOKEN?: string;
420
428
  // GitHub App identity for minting installation tokens inside residents
421
429
  // (provisioned via `npm run secrets` from deploy/secrets.manifest.json; when
422
430
  // unset, clones/fetches run anonymously —
@@ -843,6 +851,18 @@ function isRuntimeUnreachable(err: unknown): boolean {
843
851
  for (const link of selfAndCauses(err)) if (isRuntimeUnreachableSignal(link)) return true;
844
852
  return false;
845
853
  }
854
+
855
+ /** Did the platform refuse the connect inside its own accept allowance
856
+ * (docs/reference/specs/resident-repos.md item 68; execution.md item 28)? The
857
+ * platform's own wording — a plain `Error`, the SDK hands it on unwrapped —
858
+ * anywhere in the cause chain; its words blame load, which the platform never
859
+ * measured and an idle container has disproved. Asked only of a spawn-phase
860
+ * error, after `isRuntimeReplacement`: such a container is neither replaced
861
+ * nor silent for good, and a command's own output never gets here. */
862
+ function isRuntimeBusy(err: unknown): boolean {
863
+ for (const link of selfAndCauses(err)) if (isRuntimeBusySignal(link)) return true;
864
+ return false;
865
+ }
846
866
  /** Trailing slice of one string for an error reason. Command RESULTS are not
847
867
  * described here — `describeStepFailure` owns that, because choosing between
848
868
  * the two streams is what lost a diagnosis (residentStepReport.ts). */
@@ -1347,6 +1367,11 @@ const registryKey = (resource: string) => `${REGISTRY_KEY_PREFIX}${resource}`;
1347
1367
  /** Registry-DO key for the admin test overrides (gc.ts `StoredTestOverrides`).
1348
1368
  * Deliberately OUTSIDE the `resident:` prefix so it never counts as a slot. */
1349
1369
  const TEST_OVERRIDES_KEY = "testOverrides";
1370
+ /** Registry-DO key for the fleet drain (drain.ts; docs/reference/specs/resident-repos.md
1371
+ * item 69). Outside the `resident:` prefix like the overrides, so it never
1372
+ * counts as a slot; it survives the isolate swap a deploy performs, which is
1373
+ * why the record carries its own end. */
1374
+ const DRAIN_KEY = "drain";
1350
1375
 
1351
1376
  type OnboardResult = { ok: true; record: ResidentRecord } | { ok: false; status: number; error: string };
1352
1377
 
@@ -1462,6 +1487,25 @@ export class ResidentRegistryDO extends DurableObject<Env> {
1462
1487
  async remove(resource: string): Promise<boolean> {
1463
1488
  return this.ctx.storage.delete(registryKey(resource));
1464
1489
  }
1490
+
1491
+ /** The stored drain record as it is — `liveDrain` (drain.ts) decides at the
1492
+ * caller's clock whether it is in force; the registry keeps no clock of its
1493
+ * own so an expired record is read the same by every route. */
1494
+ async getDrain(): Promise<unknown> {
1495
+ return (await this.ctx.storage.get(DRAIN_KEY)) ?? null;
1496
+ }
1497
+
1498
+ /** Admin-only by construction (reached solely via POST /drain): replaces
1499
+ * whatever drain stood — a second deploy's drain extends the first's. */
1500
+ async setDrain(record: DrainRecord): Promise<DrainRecord> {
1501
+ await this.ctx.storage.put(DRAIN_KEY, record);
1502
+ return record;
1503
+ }
1504
+
1505
+ /** Admin-only by construction (POST /undrain): true when a record was there. */
1506
+ async clearDrain(): Promise<boolean> {
1507
+ return this.ctx.storage.delete(DRAIN_KEY);
1508
+ }
1465
1509
  }
1466
1510
 
1467
1511
  // ---------------------------------------------------------------------------
@@ -2027,6 +2071,14 @@ export class ResidentDO extends Sandbox<Env> {
2027
2071
  // read as a replaced container.
2028
2072
  if (isControlReset(err)) throw new ControlResetError("spawn", err);
2029
2073
  if (!isRuntimeReplacement(err)) {
2074
+ // The container is running but did not accept the SDK's connect inside
2075
+ // the platform's own allowance (item 68): a command already running in
2076
+ // it has its cores. Nothing started, the worktree is as it was, and the
2077
+ // container accepts again in moments — the typed word, for the thread
2078
+ // routes to answer with the wait token; never counted as unreachable.
2079
+ if (isRuntimeBusy(err)) {
2080
+ throw new SandboxRuntimeBusyError({ containerId: this.ctx.id.toString(), cause: errMsg(err) });
2081
+ }
2030
2082
  // The control port never answered the SDK's connect (its 30 s abort,
2031
2083
  // raised inside the wake path): no process started and nothing about
2032
2084
  // the repository is known. Count it in storage — the ladder of item 64
@@ -4861,6 +4913,20 @@ export class ResidentDO extends Sandbox<Env> {
4861
4913
  ): Promise<AttachOk | ThreadErr> {
4862
4914
  try {
4863
4915
  await this.ensureHydrated();
4916
+ // The fleet drain (item 69): a deploy is waiting for the runs in flight
4917
+ // to end, and a NEW run's attach is refused with the record the bot
4918
+ // waits on — a real 503 in the streamed document, read by the client as
4919
+ // `draining`, never as the platform's transient. A run already in flight
4920
+ // — registered from its attach to its release (item 44) — re-attaches
4921
+ // through: a rolled container, an evicted worktree, a resumed run are
4922
+ // the runs the drain waits FOR, and refusing them would hold the fleet
4923
+ // closed on the run it is closed for. Read before the image reconcile so
4924
+ // a refused attach never restarts a container.
4925
+ const drain = await this.fleetDrain();
4926
+ if (drain && !(await this.ctx.storage.get(runRegKey(threadKey)))) {
4927
+ const refusal: ThreadErr & { draining: DrainRecord } = drainRefusal(drain);
4928
+ return refusal;
4929
+ }
4864
4930
  const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
4865
4931
  if (await this.reconcileImage("attach")) {
4866
4932
  return {
@@ -4937,6 +5003,7 @@ export class ResidentDO extends Sandbox<Env> {
4937
5003
  state: s.state,
4938
5004
  stateReason: s.reason,
4939
5005
  reason: s.reason,
5006
+ cause: "system",
4940
5007
  };
4941
5008
  }
4942
5009
  // `resourceId` was read by the caller a moment ago (item 15 of the audit:
@@ -4952,7 +5019,12 @@ export class ResidentDO extends Sandbox<Env> {
4952
5019
  // Typed `reason` beside the words: the client reads the field — a refusal no
4953
5020
  // wait clears, unlike the restore window's 503s — never the sentence.
4954
5021
  if (!record || !facts)
4955
- return { error: "not-serviceable: registry record or repo facts missing", status: 503, reason: "unregistered" };
5022
+ return {
5023
+ error: "not-serviceable: registry record or repo facts missing",
5024
+ status: 503,
5025
+ reason: "unregistered",
5026
+ cause: "system",
5027
+ };
4956
5028
 
4957
5029
  // The binding's ref wins for the thread's whole life, with one exception
4958
5030
  // (item 16): a thread bound to the repo default for want of a named branch
@@ -4981,6 +5053,7 @@ export class ResidentDO extends Sandbox<Env> {
4981
5053
  status: 409,
4982
5054
  needs: "ref",
4983
5055
  defaultRef: facts.defaultRef,
5056
+ cause: "request",
4984
5057
  };
4985
5058
  }
4986
5059
  const worktreePath = prior?.worktreePath ?? (await threadWorktreePath(threadKey, ref));
@@ -5102,7 +5175,14 @@ export class ResidentDO extends Sandbox<Env> {
5102
5175
  } catch (err) {
5103
5176
  if (err instanceof MirrorBusyError) {
5104
5177
  const s = await this.getStatus();
5105
- return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
5178
+ return {
5179
+ error: errMsg(err),
5180
+ status: 503,
5181
+ state: s.state,
5182
+ stateReason: s.reason,
5183
+ reason: "mirror-busy",
5184
+ cause: "system",
5185
+ };
5106
5186
  }
5107
5187
  return catchAllErr(err, "attach-failed");
5108
5188
  }
@@ -5404,17 +5484,30 @@ export class ResidentDO extends Sandbox<Env> {
5404
5484
  return { error: `reuse-refused: ${err.why}`, status: 409, needs: "recreate" };
5405
5485
  if (err instanceof MirrorBusyError) {
5406
5486
  const s = await this.getStatus();
5407
- return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
5487
+ return {
5488
+ error: errMsg(err),
5489
+ status: 503,
5490
+ state: s.state,
5491
+ stateReason: s.reason,
5492
+ reason: "mirror-busy",
5493
+ cause: "system",
5494
+ };
5408
5495
  }
5409
5496
  if (err instanceof StepError && err.step === "unknown-ref") {
5410
- return { error: `unknown-ref: ${err.message}`, status: 400 };
5497
+ return { error: `unknown-ref: ${err.message}`, status: 400, cause: "request" };
5411
5498
  }
5412
5499
  if (err instanceof StepError && err.step === "stale-tip") {
5413
5500
  // Item 51: not a resident fault and not a caller fault — a fact about
5414
5501
  // the mirror at this instant. 409 with the state, so the bot's named
5415
5502
  // fallback runs cold at the commit it asked for.
5416
5503
  const s = await this.getStatus();
5417
- return { error: `stale-tip: ${err.message}`, status: 409, state: s.state, reason: "stale-tip" };
5504
+ return {
5505
+ error: `stale-tip: ${err.message}`,
5506
+ status: 409,
5507
+ state: s.state,
5508
+ reason: "stale-tip",
5509
+ cause: "system",
5510
+ };
5418
5511
  }
5419
5512
  return this.attachFailed(err);
5420
5513
  }
@@ -5449,7 +5542,14 @@ export class ResidentDO extends Sandbox<Env> {
5449
5542
  // worktree lock above, so the bot-side fallback can retry.
5450
5543
  if (err instanceof MirrorBusyError) {
5451
5544
  const s = await this.getStatus();
5452
- return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
5545
+ return {
5546
+ error: errMsg(err),
5547
+ status: 503,
5548
+ state: s.state,
5549
+ stateReason: s.reason,
5550
+ reason: "mirror-busy",
5551
+ cause: "system",
5552
+ };
5453
5553
  }
5454
5554
  return this.attachFailed(err);
5455
5555
  }
@@ -6293,6 +6393,7 @@ export class ResidentDO extends Sandbox<Env> {
6293
6393
  state: s.state,
6294
6394
  stateReason: s.reason,
6295
6395
  reason: s.reason,
6396
+ cause: "system",
6296
6397
  };
6297
6398
  }
6298
6399
  const binding = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(threadKey));
@@ -6381,6 +6482,9 @@ export class ResidentDO extends Sandbox<Env> {
6381
6482
  // container is unchanged, so no `replacedExecAnswer` gate applies.
6382
6483
  if (err instanceof ControlResetError) return controlResetErr(err);
6383
6484
  if (err instanceof RuntimeReplacedError) return this.replacedExecAnswer(err);
6485
+ // A refused connect at the command's spawn (item 68): the wait token,
6486
+ // the command never started.
6487
+ if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
6384
6488
  throw err;
6385
6489
  }
6386
6490
  }
@@ -6461,6 +6565,7 @@ export class ResidentDO extends Sandbox<Env> {
6461
6565
  } catch (err) {
6462
6566
  if (err instanceof ControlResetError) return controlResetErr(err);
6463
6567
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6568
+ if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
6464
6569
  throw err;
6465
6570
  }
6466
6571
  if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
@@ -6510,6 +6615,7 @@ export class ResidentDO extends Sandbox<Env> {
6510
6615
  } catch (err) {
6511
6616
  if (err instanceof ControlResetError) return controlResetErr(err);
6512
6617
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6618
+ if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
6513
6619
  throw err;
6514
6620
  }
6515
6621
  }
@@ -6557,6 +6663,7 @@ export class ResidentDO extends Sandbox<Env> {
6557
6663
  } catch (err) {
6558
6664
  if (err instanceof ControlResetError) return controlResetErr(err);
6559
6665
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6666
+ if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
6560
6667
  const step = err instanceof StepError ? ` at ${err.step}` : "";
6561
6668
  return { error: `write-failed${step}: ${errMsg(err)}`, status: 400 };
6562
6669
  }
@@ -6781,6 +6888,19 @@ export class ResidentDO extends Sandbox<Env> {
6781
6888
  const threadOps = [...this.threadOpsInFlight.values()].reduce((a, n) => a + n, 0);
6782
6889
  return threadOps + this.opUsersInUse.size + this.attachesInFlight;
6783
6890
  }
6891
+ /** The fleet drain in force (item 69), read from the registry at this clock;
6892
+ * a registry that cannot be read is NO drain: a run must never fail because
6893
+ * a flag could not be read, and the deploy's own preflight fails closed on
6894
+ * its side (an unknown fleet refuses the deploy), so the failure lands on
6895
+ * the deploy, never on the run. Said in the log. */
6896
+ private async fleetDrain(): Promise<DrainRecord | null> {
6897
+ try {
6898
+ return liveDrain(await this.registry().getDrain(), systemClock());
6899
+ } catch (err) {
6900
+ console.warn(`[drain] registry unreadable at attach — treating as no drain: ${errMsg(err)}`);
6901
+ return null;
6902
+ }
6903
+ }
6784
6904
  /** In-flight activity for the deploy preflight (GET /status, GET /residents).
6785
6905
  * The in-memory counters (a fresh isolate answers 0 for them — nothing of
6786
6906
  * THEIRS survived to be interrupted) plus the durable run registrations:
@@ -6928,6 +7048,7 @@ export class ResidentDO extends Sandbox<Env> {
6928
7048
  state: s.state,
6929
7049
  stateReason: s.reason,
6930
7050
  reason: s.reason,
7051
+ cause: "system",
6931
7052
  };
6932
7053
  }
6933
7054
  // One storage round trip for the two facts; the registry lookup stays (an
@@ -6939,7 +7060,12 @@ export class ResidentDO extends Sandbox<Env> {
6939
7060
  // Typed `reason` beside the words: the client reads the field — a refusal no
6940
7061
  // wait clears, unlike the restore window's 503s — never the sentence.
6941
7062
  if (!record || !facts)
6942
- return { error: "not-serviceable: registry record or repo facts missing", status: 503, reason: "unregistered" };
7063
+ return {
7064
+ error: "not-serviceable: registry record or repo facts missing",
7065
+ status: 503,
7066
+ reason: "unregistered",
7067
+ cause: "system",
7068
+ };
6943
7069
  const command = record.commands[op];
6944
7070
  if (!command) return { error: `op-unavailable: the command table has no "${op}" entry`, status: 400 };
6945
7071
 
@@ -7035,10 +7161,17 @@ export class ResidentDO extends Sandbox<Env> {
7035
7161
  } catch (err) {
7036
7162
  if (err instanceof MirrorBusyError) {
7037
7163
  const s = await this.getStatus();
7038
- return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
7164
+ return {
7165
+ error: errMsg(err),
7166
+ status: 503,
7167
+ state: s.state,
7168
+ stateReason: s.reason,
7169
+ reason: "mirror-busy",
7170
+ cause: "system",
7171
+ };
7039
7172
  }
7040
7173
  if (err instanceof StepError && err.step === "unknown-ref") {
7041
- return { error: `unknown-ref: ${err.message}`, status: 400 };
7174
+ return { error: `unknown-ref: ${err.message}`, status: 400, cause: "request" };
7042
7175
  }
7043
7176
  // A step that failed is named and deterministic; a throw no step named is
7044
7177
  // typed by the one builder every such 500 goes through.
@@ -7821,7 +7954,7 @@ function timingSafeEqual(a: string, b: string): boolean {
7821
7954
  return diff === 0;
7822
7955
  }
7823
7956
 
7824
- type Scope = "admin" | "operator" | "read";
7957
+ type Scope = "admin" | "operator" | "read" | "drain";
7825
7958
 
7826
7959
  /** Which token a bearer is, or null. Constant-time per comparison; fail closed
7827
7960
  * on unset/empty secrets. */
@@ -7830,6 +7963,7 @@ function tokenScope(env: Env, token: string | null): Scope | null {
7830
7963
  if (env.RESIDENT_ADMIN_TOKEN && timingSafeEqual(token, env.RESIDENT_ADMIN_TOKEN)) return "admin";
7831
7964
  if (env.RESIDENT_OPERATOR_TOKEN && timingSafeEqual(token, env.RESIDENT_OPERATOR_TOKEN)) return "operator";
7832
7965
  if (env.RESIDENT_READ_TOKEN && timingSafeEqual(token, env.RESIDENT_READ_TOKEN)) return "read";
7966
+ if (env.RESIDENT_DRAIN_TOKEN && timingSafeEqual(token, env.RESIDENT_DRAIN_TOKEN)) return "drain";
7833
7967
  return null;
7834
7968
  }
7835
7969
 
@@ -8009,6 +8143,8 @@ const ROUTES: Record<string, { scope: Scope; method: string }> = {
8009
8143
  "/offboard": { scope: "admin", method: "POST" },
8010
8144
  "/reconfigure": { scope: "admin", method: "POST" },
8011
8145
  "/rebuild": { scope: "admin", method: "POST" },
8146
+ "/drain": { scope: "drain", method: "POST" }, // close the fleet to new runs for a deploy (item 69; admin implied)
8147
+ "/undrain": { scope: "drain", method: "POST" }, // reopen it
8012
8148
  "/residents": { scope: "read", method: "GET" }, // admin implied; read-only bearer allowed
8013
8149
  "/debug": { scope: "read", method: "POST" }, // per-op: READ_DEBUG_OPS for read scope, everything for admin
8014
8150
  "/status": { scope: "operator", method: "GET" },
@@ -8109,6 +8245,10 @@ export default {
8109
8245
  return await handleReconfigure(env, body);
8110
8246
  case "/rebuild":
8111
8247
  return await handleRebuild(env, body);
8248
+ case "/drain":
8249
+ return await handleDrain(env, body);
8250
+ case "/undrain":
8251
+ return await handleUndrain(env);
8112
8252
  case "/residents":
8113
8253
  return await handleResidents(env);
8114
8254
  case "/debug": {
@@ -8239,6 +8379,7 @@ async function handleOnboard(env: Env, body: Record<string, unknown>): Promise<R
8239
8379
  `exact name (GitHub's token API answers the same 422 for both). An org admin adds it under the ` +
8240
8380
  `App's installation settings (Settings → GitHub Apps → Configure → Repository access), ` +
8241
8381
  `then retry (${errMsg(err)})`,
8382
+ cause: "policy",
8242
8383
  },
8243
8384
  403,
8244
8385
  );
@@ -8529,6 +8670,26 @@ async function handleRebuild(env: Env, body: Record<string, unknown>): Promise<R
8529
8670
  * targets a different DO, so they run concurrently; a failing one degrades
8530
8671
  * to {error} without touching its neighbors, and the response order follows
8531
8672
  * the registry list. */
8673
+ /** POST /drain (admin): close the fleet to new runs (docs/reference/specs/resident-repos.md
8674
+ * item 69). The record carries its own end (`until`), so a drain nobody lifts
8675
+ * ends by itself; a second drain replaces the first. Runs in flight are
8676
+ * untouched — the deploy that asked waits for them through `/residents`. */
8677
+ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Response> {
8678
+ const parsed = parseDrainRequest(body, systemClock());
8679
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
8680
+ const record = await registryStub(env).setDrain(parsed.record);
8681
+ console.log(`[drain] fleet closed to new runs by ${record.by} for ${record.reason}: until ${record.until}`);
8682
+ return json({ draining: record });
8683
+ }
8684
+
8685
+ /** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says whether
8686
+ * a drain stood. */
8687
+ async function handleUndrain(env: Env): Promise<Response> {
8688
+ const cleared = await registryStub(env).clearDrain();
8689
+ console.log(`[drain] fleet reopened (${cleared ? "a drain stood" : "no drain stood"})`);
8690
+ return json({ draining: null, cleared });
8691
+ }
8692
+
8532
8693
  async function handleResidents(env: Env): Promise<Response> {
8533
8694
  const residents = await registryStub(env).list();
8534
8695
  const settled = await Promise.allSettled(
@@ -8564,6 +8725,9 @@ async function handleResidents(env: Env): Promise<Response> {
8564
8725
  count: residents.length,
8565
8726
  inFlight,
8566
8727
  inFlightUnknown,
8728
+ // The drain in force, or null (item 69): the deploy runner and the
8729
+ // dashboard read it here; the attach route reads the same record.
8730
+ draining: liveDrain(await registryStub(env).getDrain(), systemClock()),
8567
8731
  residents: enriched,
8568
8732
  });
8569
8733
  }
@@ -58,9 +58,14 @@ import {
58
58
  fleetBusyAnswer,
59
59
  fleetBusyExecAnswer,
60
60
  isFleetBusyError,
61
+ isRuntimeBusyError,
62
+ isRuntimeBusySignal,
61
63
  isRuntimeUnreachableError,
64
+ runtimeBusyAnswer,
65
+ runtimeBusyExecAnswer,
62
66
  runtimeUnreachableAnswer,
63
67
  runtimeUnreachableExecAnswer,
68
+ SandboxRuntimeBusyError,
64
69
  SandboxRuntimeUnreachableError,
65
70
  sandboxStartingAnswer,
66
71
  sandboxStartingExecAnswer,
@@ -182,6 +187,16 @@ function isRuntimeUnreachable(err: unknown): boolean {
182
187
  return false;
183
188
  }
184
189
 
190
+ /** Did the platform refuse the connect inside its own accept allowance
191
+ * (docs/reference/specs/execution.md item 28)? The platform's own wording,
192
+ * anywhere in the cause chain; a plain `Error`, so the wording is all there
193
+ * is — and its words blame load the platform never measured. Asked only of
194
+ * a failure met before a process was started. */
195
+ function isRuntimeBusy(err: unknown): boolean {
196
+ for (const link of selfAndCauses(err)) if (isRuntimeBusySignal(link)) return true;
197
+ return false;
198
+ }
199
+
185
200
  /** A finished command, as `/exec` answers it. `durationMs` is the command's
186
201
  * wall time in the sandbox (docs/reference/specs/tracing.md item 19). */
187
202
  export interface ExecAnswer {
@@ -194,7 +209,7 @@ export interface ExecAnswer {
194
209
  /** A command the sandbox never answered for, in the dual in-body shape
195
210
  * (docs/reference/specs/execution.md item 3): a new executor throws on `error`, an
196
211
  * older one still renders `exit 127: <stderr>`. `reason` names the machine
197
- * token when there is one (`fleet-busy`, `runtime-unreachable`). */
212
+ * token when there is one (`fleet-busy`, `runtime-busy`, `runtime-unreachable`). */
198
213
  export interface ExecFailure {
199
214
  error: string;
200
215
  reason?: string;
@@ -205,8 +220,8 @@ export interface ExecFailure {
205
220
 
206
221
  /** A file route's refusal, with the HTTP status the fetch handler answers and,
207
222
  * when the refusal is a named condition the executor waits on (`fleet-busy`,
208
- * `runtime-unreachable`), its machine token — the executor reads the token,
209
- * never the text, so a refusal without it is a dead sandbox to it. */
223
+ * `runtime-busy`, `runtime-unreachable`), its machine token — the executor
224
+ * reads the token, never the text, so a refusal without it is a dead sandbox to it. */
210
225
  interface FileRefusal {
211
226
  error: string;
212
227
  status: number;
@@ -321,9 +336,10 @@ export class SwitchboardSandbox extends Sandbox<Env> {
321
336
  (cause) => sandboxStartingExecAnswer(cause),
322
337
  );
323
338
  } catch (err) {
324
- // The warm-up's own failure, handed on by the gate: a full fleet or a
325
- // silent control port keeps its name; anything else propagates.
326
- return this.execFailure(err, startedAt);
339
+ // The warm-up's own failure, handed on by the gate: a full fleet, a
340
+ // refused connect or a silent control port keeps its name; anything
341
+ // else propagates. Nothing ran — the warm-up is a spawn.
342
+ return this.spawnFailure(err, startedAt);
327
343
  }
328
344
  });
329
345
  }
@@ -343,7 +359,10 @@ export class SwitchboardSandbox extends Sandbox<Env> {
343
359
  try {
344
360
  proc = await createExtensionProcessSandbox(this).exec(argv, { env: envVars, timeout: backstopMs });
345
361
  } catch (err) {
346
- return this.execFailure(err, startedAt);
362
+ // The process was never started: a refused connect is the wait token
363
+ // here and only here (item 28) — the executor re-sends, and
364
+ // nothing runs twice.
365
+ return this.spawnFailure(err, startedAt);
347
366
  }
348
367
  try {
349
368
  const out = await proc.output({
@@ -631,6 +650,20 @@ export class SwitchboardSandbox extends Sandbox<Env> {
631
650
  return { stdout: out.stdout, stderr: out.stderr, exitCode: out.timedOut ? 124 : out.exitCode };
632
651
  }
633
652
 
653
+ /** A failure met BEFORE a process was started — the spawn, or the gate's
654
+ * warm-up: a container that did not accept the connection is named with
655
+ * its wait token (docs/reference/specs/execution.md item 28), since
656
+ * nothing ran and the identical request is safe to re-send. Every other
657
+ * failure is classified as after a start (`execFailure`). A failure of a
658
+ * running command's output never comes here: the process exists, and a
659
+ * re-send would run it again. */
660
+ private spawnFailure(err: unknown, startedAt: number): ExecFailure {
661
+ if (!isFleetBusyError(err) && !isRuntimeReplacement(err) && isRuntimeBusy(err)) {
662
+ return runtimeBusyExecAnswer(this.runtimeBusy(thrownText(thrownShape(err))).message);
663
+ }
664
+ return this.execFailure(err, startedAt);
665
+ }
666
+
634
667
  /** The named failures, as `/exec` data; anything else is thrown as it came. */
635
668
  private execFailure(err: unknown, startedAt: number): ExecFailure {
636
669
  const raw = thrownText(thrownShape(err));
@@ -660,15 +693,25 @@ export class SwitchboardSandbox extends Sandbox<Env> {
660
693
  });
661
694
  }
662
695
 
696
+ /** The typed, named error for a container that did not accept the
697
+ * connection (item 28), with this container's id — thrown across the RPC
698
+ * boundary to the fetch handler on the file routes, matched by name. */
699
+ private runtimeBusy(cause: string): SandboxRuntimeBusyError {
700
+ return new SandboxRuntimeBusyError({ containerId: this.ctx.id.toString(), cause });
701
+ }
702
+
663
703
  /** A file operation with its runtime failures named for the fetch handler:
664
- * a silent control port becomes the typed error (item 9); a missing file
665
- * is the refusal the route answers 404. The SDK's other errors propagate. */
704
+ * a refused connect (item 28) and a silent control port (item 9) become
705
+ * the typed errors; a missing file is the refusal the route answers 404.
706
+ * The SDK's other errors propagate. A file operation that met either
707
+ * never reached the runtime, so the executor's re-send does nothing twice. */
666
708
  private async fileOp<T>(op: () => Promise<T>): Promise<T | FileRefusal> {
667
709
  try {
668
710
  return await op();
669
711
  } catch (err) {
670
712
  const shape = thrownShape(err);
671
713
  if (shape.name === "FileNotFoundError") return { error: `read-failed: ${thrownText(shape)}`, status: 404 };
714
+ if (!isRuntimeReplacement(err) && isRuntimeBusy(err)) throw this.runtimeBusy(thrownText(shape));
672
715
  if (!isRuntimeReplacement(err) && isRuntimeUnreachable(err)) throw this.runtimeUnreachable(thrownText(shape));
673
716
  throw err;
674
717
  }
@@ -884,6 +927,9 @@ export default {
884
927
  // op never reached a runtime, so a 503 the executor's transport retry
885
928
  // re-sends, with the named reason and the container in the text.
886
929
  if (isRuntimeUnreachableError(err)) return json(runtimeUnreachableAnswer(msg), 503);
930
+ // The container did not accept the connection (item 28): the file op
931
+ // never reached it either — a 503 with the wait token.
932
+ if (isRuntimeBusyError(err)) return json(runtimeBusyAnswer(msg), 503);
887
933
  // A full fleet (docs/reference/specs/execution.md item 14): no container
888
934
  // instance for this thread's Durable Object, so the file op never
889
935
  // started — re-sending is safe by construction. Named so the executor
@@ -972,6 +1018,7 @@ function streamExec(run: () => Promise<ExecAnswer | ExecFailure>, traceparent: s
972
1018
  const shape = thrownShape(err);
973
1019
  const raw = thrownText(shape);
974
1020
  if (isRuntimeUnreachableError(err)) return runtimeUnreachableExecAnswer(raw);
1021
+ if (isRuntimeBusyError(err)) return runtimeBusyExecAnswer(raw);
975
1022
  if (isFleetBusyError(err)) return fleetBusyExecAnswer(raw);
976
1023
  // A recycle the Durable Object did not catch by type: by name, or
977
1024
  // a recycle-shaped text minutes into the attempt (item 9).
@@ -81,6 +81,12 @@
81
81
  "workers": ["resident"],
82
82
  "note": "Read-only /residents + debug routes; what the resident deploy preflight needs (CI holds this one). Self-minted."
83
83
  },
84
+ {
85
+ "name": "RESIDENT_DRAIN_TOKEN",
86
+ "workers": ["resident"],
87
+ "optional": true,
88
+ "note": "Drain-only bearer: POST /drain and /undrain, nothing else (docs/reference/specs/resident-repos.md item 69). What the release deploy holds (CI) so the resident step can close the fleet to new runs and land once the runs in flight end, without the admin bearer. Self-minted; optional — unset, only admin can drain and the deploy waits without one."
89
+ },
84
90
  {
85
91
  "name": "R2_ACCESS_KEY_ID",
86
92
  "workers": ["resident", "sandbox"],
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.247.0",
3
+ "version": "1.249.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.247.0",
9
+ "version": "1.249.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.247.0",
20448
+ "version": "1.249.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.247.0",
3
+ "version": "1.249.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.247.0",
3
- "commit": "713b83c9db0b6f3f10ef6d2cc1a46714b9a23c3c",
4
- "builtAt": "2026-09-18T03:41:18.440Z"
2
+ "version": "1.249.0",
3
+ "commit": "1145eea8ffdd6a2bd1ae8f1b26a4919db2caf47d",
4
+ "builtAt": "2026-09-18T07:16:19.891Z"
5
5
  }