@coreplane/switchboard 1.248.0 → 1.249.1

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 (49) hide show
  1. package/dist/assets/config/config.example.yaml +44 -18
  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/refresh.ts +5 -3
  6. package/dist/assets/deploy/cloudflare-resident/threadErr.ts +32 -3
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +235 -13
  8. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +56 -9
  9. package/dist/assets/deploy/secrets.manifest.json +6 -0
  10. package/dist/assets/package-lock.json +3 -3
  11. package/dist/assets/package.json +1 -1
  12. package/dist/assets/source.json +3 -3
  13. package/dist/assets/src/agents/registry.ts +4 -4
  14. package/dist/assets/src/core/budgets.ts +24 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +4 -3
  16. package/dist/assets/src/core/coordinator/driver.ts +40 -7
  17. package/dist/assets/src/core/modelCard.ts +348 -0
  18. package/dist/assets/src/core/modelPricing.ts +14 -5
  19. package/dist/assets/src/core/modelRegistry.ts +51 -0
  20. package/dist/assets/src/core/provider.ts +103 -0
  21. package/dist/assets/src/core/refusal.ts +181 -0
  22. package/dist/assets/src/core/runEvents.ts +43 -0
  23. package/dist/assets/src/core/ship/coordinator.ts +80 -12
  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/residentRefresh.ts +26 -1
  30. package/dist/assets/src/execution/sandboxErrors.ts +122 -4
  31. package/dist/assets/web/dist/.vite/manifest.json +30 -30
  32. package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +1 -0
  33. package/dist/assets/web/dist/assets/{HomePage-DYxC0izY.js → HomePage-BpQRky8B.js} +1 -1
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-DLIpWYOc.js → ResidentDetailPage-BIUXyz6K.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-6LipuDjR.js → ResidentsIndexPage-BZymgSAb.js} +1 -1
  36. package/dist/assets/web/dist/assets/{RunFoldRow-V-iSy64e.js → RunFoldRow-3m4CPRI4.js} +1 -1
  37. package/dist/assets/web/dist/assets/{RunRoutePage-DUalB1u2.js → RunRoutePage-bgkjkA0p.js} +3 -3
  38. package/dist/assets/web/dist/assets/{RunsIndexPage-B9Ba1KdD.js → RunsIndexPage-8S944AzB.js} +1 -1
  39. package/dist/assets/web/dist/assets/{ScheduledPage-KdjLtD_7.js → ScheduledPage-8bBtG9y3.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-IT5l_NaL.js → SettingsPage-DQeNvfaV.js} +1 -1
  41. package/dist/assets/web/dist/assets/{StatusDot-DBHAl4Il.js → StatusDot-BPE5syBa.js} +1 -1
  42. package/dist/assets/web/dist/assets/{Tooltip-_LEjptLV.js → Tooltip-DkoeZfTs.js} +1 -1
  43. package/dist/assets/web/dist/assets/UnitRoutePage-BUzw--Ii.js +1 -0
  44. package/dist/assets/web/dist/assets/{dist-BcYPGOBL.js → dist-D11y9ZJ4.js} +1 -1
  45. package/dist/assets/web/dist/assets/{main-DUfSE0dj.js → main-B6LcgNM6.js} +2 -2
  46. package/dist/cli.js +2475 -1021
  47. package/package.json +1 -1
  48. package/dist/assets/web/dist/assets/DeliveryPage-NP4g6bQd.js +0 -1
  49. 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,11 @@ 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 {
163
+ isRuntimeBusySignal,
164
+ RUNTIME_BUSY_REASON,
165
+ SandboxRuntimeBusyError,
166
+ } from "../../src/execution/sandboxErrors.js";
161
167
  import {
162
168
  decisivePull,
163
169
  effectiveLimits,
@@ -323,11 +329,13 @@ import {
323
329
  } from "../../src/execution/residentDepsStore.js";
324
330
  import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
325
331
  import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
332
+ import { drainRefusal, liveDrain, parseDrainRequest, type DrainRecord } from "./drain";
326
333
  import {
327
334
  ControlResetError,
328
335
  RuntimeReplacedError,
329
336
  controlResetErr,
330
337
  execFailureDocument,
338
+ runtimeBusyErr,
331
339
  runtimeReplacedErr,
332
340
  selfAndCauses,
333
341
  threadErrBuilders,
@@ -417,6 +425,10 @@ export interface Env {
417
425
  * (info, schedules, threads) — for dashboards and humans who need to look,
418
426
  * never to change anything. Unset = no read scope exists. */
419
427
  RESIDENT_READ_TOKEN?: string;
428
+ /** Optional drain-only bearer (item 69): POST /drain and /undrain, nothing
429
+ * else — what a release deploy holds so it can close the fleet without the
430
+ * admin bearer. Unset = only admin can drain. */
431
+ RESIDENT_DRAIN_TOKEN?: string;
420
432
  // GitHub App identity for minting installation tokens inside residents
421
433
  // (provisioned via `npm run secrets` from deploy/secrets.manifest.json; when
422
434
  // unset, clones/fetches run anonymously —
@@ -605,6 +617,12 @@ const LIFECYCLE_KEY = "resident:lifecycle";
605
617
  * resident, with the step it last reported and the cycle lease it holds, and
606
618
  * the last bucket the cron skipped (a live cycle, a duplicate id). */
607
619
  const REFRESH_INSTANCE_KEY = "resident:refreshInstance";
620
+ /** The settled state a cycle found before it wrote `refreshing` (item 68):
621
+ * what a cycle that yields to a busy container puts back. Rewritten by every
622
+ * cycle right before its `refreshing` write, so it always names the state
623
+ * under the current marker and nothing older. */
624
+ const REFRESHING_FROM_KEY = "resident:refreshingFrom";
625
+ type RefreshingFrom = ResidentStatus;
608
626
  /** A `du` over a multi-GB checkout plus every live tree is seconds warm, tens
609
627
  * of seconds on a cold page cache — the same class as a git network step. */
610
628
  const DU_TIMEOUT_MS = GIT_NETWORK_TIMEOUT_MS;
@@ -843,6 +861,18 @@ function isRuntimeUnreachable(err: unknown): boolean {
843
861
  for (const link of selfAndCauses(err)) if (isRuntimeUnreachableSignal(link)) return true;
844
862
  return false;
845
863
  }
864
+
865
+ /** Did the platform refuse the connect inside its own accept allowance
866
+ * (docs/reference/specs/resident-repos.md item 68; execution.md item 28)? The
867
+ * platform's own wording — a plain `Error`, the SDK hands it on unwrapped —
868
+ * anywhere in the cause chain; its words blame load, which the platform never
869
+ * measured and an idle container has disproved. Asked only of a spawn-phase
870
+ * error, after `isRuntimeReplacement`: such a container is neither replaced
871
+ * nor silent for good, and a command's own output never gets here. */
872
+ function isRuntimeBusy(err: unknown): boolean {
873
+ for (const link of selfAndCauses(err)) if (isRuntimeBusySignal(link)) return true;
874
+ return false;
875
+ }
846
876
  /** Trailing slice of one string for an error reason. Command RESULTS are not
847
877
  * described here — `describeStepFailure` owns that, because choosing between
848
878
  * the two streams is what lost a diagnosis (residentStepReport.ts). */
@@ -1347,6 +1377,11 @@ const registryKey = (resource: string) => `${REGISTRY_KEY_PREFIX}${resource}`;
1347
1377
  /** Registry-DO key for the admin test overrides (gc.ts `StoredTestOverrides`).
1348
1378
  * Deliberately OUTSIDE the `resident:` prefix so it never counts as a slot. */
1349
1379
  const TEST_OVERRIDES_KEY = "testOverrides";
1380
+ /** Registry-DO key for the fleet drain (drain.ts; docs/reference/specs/resident-repos.md
1381
+ * item 69). Outside the `resident:` prefix like the overrides, so it never
1382
+ * counts as a slot; it survives the isolate swap a deploy performs, which is
1383
+ * why the record carries its own end. */
1384
+ const DRAIN_KEY = "drain";
1350
1385
 
1351
1386
  type OnboardResult = { ok: true; record: ResidentRecord } | { ok: false; status: number; error: string };
1352
1387
 
@@ -1462,6 +1497,25 @@ export class ResidentRegistryDO extends DurableObject<Env> {
1462
1497
  async remove(resource: string): Promise<boolean> {
1463
1498
  return this.ctx.storage.delete(registryKey(resource));
1464
1499
  }
1500
+
1501
+ /** The stored drain record as it is — `liveDrain` (drain.ts) decides at the
1502
+ * caller's clock whether it is in force; the registry keeps no clock of its
1503
+ * own so an expired record is read the same by every route. */
1504
+ async getDrain(): Promise<unknown> {
1505
+ return (await this.ctx.storage.get(DRAIN_KEY)) ?? null;
1506
+ }
1507
+
1508
+ /** Admin-only by construction (reached solely via POST /drain): replaces
1509
+ * whatever drain stood — a second deploy's drain extends the first's. */
1510
+ async setDrain(record: DrainRecord): Promise<DrainRecord> {
1511
+ await this.ctx.storage.put(DRAIN_KEY, record);
1512
+ return record;
1513
+ }
1514
+
1515
+ /** Admin-only by construction (POST /undrain): true when a record was there. */
1516
+ async clearDrain(): Promise<boolean> {
1517
+ return this.ctx.storage.delete(DRAIN_KEY);
1518
+ }
1465
1519
  }
1466
1520
 
1467
1521
  // ---------------------------------------------------------------------------
@@ -2027,6 +2081,14 @@ export class ResidentDO extends Sandbox<Env> {
2027
2081
  // read as a replaced container.
2028
2082
  if (isControlReset(err)) throw new ControlResetError("spawn", err);
2029
2083
  if (!isRuntimeReplacement(err)) {
2084
+ // The container is running but did not accept the SDK's connect inside
2085
+ // the platform's own allowance (item 68): a command already running in
2086
+ // it has its cores. Nothing started, the worktree is as it was, and the
2087
+ // container accepts again in moments — the typed word, for the thread
2088
+ // routes to answer with the wait token; never counted as unreachable.
2089
+ if (isRuntimeBusy(err)) {
2090
+ throw new SandboxRuntimeBusyError({ containerId: this.ctx.id.toString(), cause: errMsg(err) });
2091
+ }
2030
2092
  // The control port never answered the SDK's connect (its 30 s abort,
2031
2093
  // raised inside the wake path): no process started and nothing about
2032
2094
  // the repository is known. Count it in storage — the ladder of item 64
@@ -3306,6 +3368,9 @@ export class ResidentDO extends Sandbox<Env> {
3306
3368
  }
3307
3369
  }
3308
3370
 
3371
+ // Item 68: remember what `refreshing` covers, so a cycle the busy container
3372
+ // turns away can put it back — the last snapshot never stopped serving.
3373
+ await this.ctx.storage.put(REFRESHING_FROM_KEY, (await this.getStatus()) satisfies RefreshingFrom);
3309
3374
  await this.setResidentState("refreshing");
3310
3375
  let sha: string;
3311
3376
  try {
@@ -3328,6 +3393,9 @@ export class ResidentDO extends Sandbox<Env> {
3328
3393
  // and die at git-setup). Name the disk instead: not
3329
3394
  // serviceable, and the recovery below can free it.
3330
3395
  const failure = await this.classifyFailure("fetch", message);
3396
+ // Item 68: the container did not accept the connect — a run's command
3397
+ // has its cores. Not GitHub, not the mirror: the instance step yields.
3398
+ if (failure.busy) throw err;
3331
3399
  if (failure.diskFull) {
3332
3400
  await this.setResidentState("degraded", failure.reason);
3333
3401
  await this.recoverFromDiskFull(failure.reason, selfInFlight);
@@ -3481,7 +3549,9 @@ export class ResidentDO extends Sandbox<Env> {
3481
3549
  * error can surface between steps — with the generic "refresh" step, whose
3482
3550
  * failure reason is the `refresh-failed: …` shape. A full disk is a third
3483
3551
  * class: `disk-full: …`, never serviceable, and the one failure the
3484
- * resident can act on itself (recoverFromDiskFull). */
3552
+ * resident can act on itself (recoverFromDiskFull). A container that did
3553
+ * not accept the connect (`runtime-busy`, item 68) is a fourth: `busy`,
3554
+ * and the cycle yields to the run that holds it. */
3485
3555
  private async classifyCycleError(err: unknown): Promise<RefreshFailure> {
3486
3556
  // The control port never answered (item 64): the count decides, and a disk
3487
3557
  // probe would only cost another 30 s abort against the same silent port.
@@ -3859,7 +3929,10 @@ export class ResidentDO extends Sandbox<Env> {
3859
3929
  * on purpose (`CycleRestartError`), is thrown to the engine, whose retry
3860
3930
  * re-enters the same idempotent method — the row stays `refreshing`, never
3861
3931
  * `degraded`, and a `refreshing` younger than the stale bound keeps the
3862
- * cron from creating a second instance meanwhile. A failure of the repo's
3932
+ * cron from creating a second instance meanwhile. A container that turned
3933
+ * the step's connect away (`runtime-busy`, item 68) ends the cycle as
3934
+ * `stopped` with nothing recorded — `yieldCycle` puts back the state the
3935
+ * cycle found. A failure of the repo's
3863
3936
  * own is recorded — `degraded` with the reason, the last snapshot still
3864
3937
  * serving — and answered `failed`, which ends the cycle; the next cron
3865
3938
  * firing starts the next one from that state. */
@@ -3911,6 +3984,21 @@ export class ResidentDO extends Sandbox<Env> {
3911
3984
  return { ...result, startedAt, trace: trace.steps() };
3912
3985
  }
3913
3986
  const failure = await this.classifyCycleError(err);
3987
+ if (failure.busy) {
3988
+ // Item 68: the container did not accept the cycle's connect — a run's
3989
+ // command has its cores. Nothing ran and nothing about the repository
3990
+ // is known: the cycle yields, the state it found goes back, and no
3991
+ // failure is recorded — not `degraded`, not a rung of item 67's ladder
3992
+ // (three cycles of it used to destroy the container under the run).
3993
+ // The next cron firing tries again; the engine is not asked to retry
3994
+ // into the same busy container.
3995
+ outcome = `yielded (${failure.reason})`;
3996
+ console.log(
3997
+ `refresh instance ${instance}: ${step} yielded — ${failure.reason.slice(0, 400)}; the next cycle retries`,
3998
+ );
3999
+ await this.yieldCycle(instance);
4000
+ return { status: "stopped", why: RUNTIME_BUSY_REASON, startedAt, trace: trace.steps() };
4001
+ }
3914
4002
  if (failure.interrupted) {
3915
4003
  outcome = `interrupted (${failure.reason}) — the engine retries`;
3916
4004
  console.log(`refresh instance ${instance}: ${step} interrupted — ${failure.reason.slice(0, 400)}; retrying`);
@@ -3932,6 +4020,27 @@ export class ResidentDO extends Sandbox<Env> {
3932
4020
  }
3933
4021
  }
3934
4022
 
4023
+ /** A cycle that met the busy container ends here (item 68): the lease it
4024
+ * holds goes back, and the `refreshing` it wrote is undone to the settled
4025
+ * state it found, so the row says what the last snapshot still is — warm,
4026
+ * or the degraded an earlier cycle earned — never a `degraded` of this
4027
+ * cycle's own. A `refreshing` this cycle did not write (a step past the
4028
+ * fetch found the marker of a cycle that died mid-flight) is left to the
4029
+ * watchdog, which normalizes it. */
4030
+ private async yieldCycle(instance: string): Promise<void> {
4031
+ await this.clearInstanceLease(instance);
4032
+ const status = await this.getStatus();
4033
+ if (status.state !== "refreshing") return;
4034
+ const from = await this.ctx.storage.get<RefreshingFrom>(REFRESHING_FROM_KEY);
4035
+ if (!from || (from.state !== "warm" && from.state !== "degraded")) {
4036
+ console.log(
4037
+ `refresh instance ${instance}: yielded from \`refreshing\` over ${from?.state ?? "no recorded state"}; left for the watchdog`,
4038
+ );
4039
+ return;
4040
+ }
4041
+ await this.setResidentState(from.state, from.reason);
4042
+ }
4043
+
3935
4044
  /** Step `fetch`: the gates, the cycle lease, the fetch and the plan. The
3936
4045
  * instance id is the cycle `fetchMirror` records, so a retry of this step
3937
4046
  * finds its fetch done. A rebuild's markers come off here, once per cycle,
@@ -4175,7 +4284,8 @@ export class ResidentDO extends Sandbox<Env> {
4175
4284
  * errno, so a full-disk attach reads like a lock bug without the probe. */
4176
4285
  private async classifyFailure(step: string, message: string): Promise<RefreshFailure> {
4177
4286
  const direct = classifyRefreshFailure({ step, message });
4178
- if (direct.diskFull || direct.interrupted) return direct;
4287
+ // A busy container (item 68) would only refuse the disk probe's exec too.
4288
+ if (direct.diskFull || direct.interrupted || direct.busy) return direct;
4179
4289
  return classifyRefreshFailure({ step, message, freeKiB: await this.freeKiB() });
4180
4290
  }
4181
4291
 
@@ -4861,6 +4971,20 @@ export class ResidentDO extends Sandbox<Env> {
4861
4971
  ): Promise<AttachOk | ThreadErr> {
4862
4972
  try {
4863
4973
  await this.ensureHydrated();
4974
+ // The fleet drain (item 69): a deploy is waiting for the runs in flight
4975
+ // to end, and a NEW run's attach is refused with the record the bot
4976
+ // waits on — a real 503 in the streamed document, read by the client as
4977
+ // `draining`, never as the platform's transient. A run already in flight
4978
+ // — registered from its attach to its release (item 44) — re-attaches
4979
+ // through: a rolled container, an evicted worktree, a resumed run are
4980
+ // the runs the drain waits FOR, and refusing them would hold the fleet
4981
+ // closed on the run it is closed for. Read before the image reconcile so
4982
+ // a refused attach never restarts a container.
4983
+ const drain = await this.fleetDrain();
4984
+ if (drain && !(await this.ctx.storage.get(runRegKey(threadKey)))) {
4985
+ const refusal: ThreadErr & { draining: DrainRecord } = drainRefusal(drain);
4986
+ return refusal;
4987
+ }
4864
4988
  const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
4865
4989
  if (await this.reconcileImage("attach")) {
4866
4990
  return {
@@ -4937,6 +5061,7 @@ export class ResidentDO extends Sandbox<Env> {
4937
5061
  state: s.state,
4938
5062
  stateReason: s.reason,
4939
5063
  reason: s.reason,
5064
+ cause: "system",
4940
5065
  };
4941
5066
  }
4942
5067
  // `resourceId` was read by the caller a moment ago (item 15 of the audit:
@@ -4952,7 +5077,12 @@ export class ResidentDO extends Sandbox<Env> {
4952
5077
  // Typed `reason` beside the words: the client reads the field — a refusal no
4953
5078
  // wait clears, unlike the restore window's 503s — never the sentence.
4954
5079
  if (!record || !facts)
4955
- return { error: "not-serviceable: registry record or repo facts missing", status: 503, reason: "unregistered" };
5080
+ return {
5081
+ error: "not-serviceable: registry record or repo facts missing",
5082
+ status: 503,
5083
+ reason: "unregistered",
5084
+ cause: "system",
5085
+ };
4956
5086
 
4957
5087
  // The binding's ref wins for the thread's whole life, with one exception
4958
5088
  // (item 16): a thread bound to the repo default for want of a named branch
@@ -4981,6 +5111,7 @@ export class ResidentDO extends Sandbox<Env> {
4981
5111
  status: 409,
4982
5112
  needs: "ref",
4983
5113
  defaultRef: facts.defaultRef,
5114
+ cause: "request",
4984
5115
  };
4985
5116
  }
4986
5117
  const worktreePath = prior?.worktreePath ?? (await threadWorktreePath(threadKey, ref));
@@ -5102,7 +5233,14 @@ export class ResidentDO extends Sandbox<Env> {
5102
5233
  } catch (err) {
5103
5234
  if (err instanceof MirrorBusyError) {
5104
5235
  const s = await this.getStatus();
5105
- return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
5236
+ return {
5237
+ error: errMsg(err),
5238
+ status: 503,
5239
+ state: s.state,
5240
+ stateReason: s.reason,
5241
+ reason: "mirror-busy",
5242
+ cause: "system",
5243
+ };
5106
5244
  }
5107
5245
  return catchAllErr(err, "attach-failed");
5108
5246
  }
@@ -5404,17 +5542,30 @@ export class ResidentDO extends Sandbox<Env> {
5404
5542
  return { error: `reuse-refused: ${err.why}`, status: 409, needs: "recreate" };
5405
5543
  if (err instanceof MirrorBusyError) {
5406
5544
  const s = await this.getStatus();
5407
- 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
+ };
5408
5553
  }
5409
5554
  if (err instanceof StepError && err.step === "unknown-ref") {
5410
- return { error: `unknown-ref: ${err.message}`, status: 400 };
5555
+ return { error: `unknown-ref: ${err.message}`, status: 400, cause: "request" };
5411
5556
  }
5412
5557
  if (err instanceof StepError && err.step === "stale-tip") {
5413
5558
  // Item 51: not a resident fault and not a caller fault — a fact about
5414
5559
  // the mirror at this instant. 409 with the state, so the bot's named
5415
5560
  // fallback runs cold at the commit it asked for.
5416
5561
  const s = await this.getStatus();
5417
- return { error: `stale-tip: ${err.message}`, status: 409, state: s.state, reason: "stale-tip" };
5562
+ return {
5563
+ error: `stale-tip: ${err.message}`,
5564
+ status: 409,
5565
+ state: s.state,
5566
+ reason: "stale-tip",
5567
+ cause: "system",
5568
+ };
5418
5569
  }
5419
5570
  return this.attachFailed(err);
5420
5571
  }
@@ -5449,7 +5600,14 @@ export class ResidentDO extends Sandbox<Env> {
5449
5600
  // worktree lock above, so the bot-side fallback can retry.
5450
5601
  if (err instanceof MirrorBusyError) {
5451
5602
  const s = await this.getStatus();
5452
- return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
5603
+ return {
5604
+ error: errMsg(err),
5605
+ status: 503,
5606
+ state: s.state,
5607
+ stateReason: s.reason,
5608
+ reason: "mirror-busy",
5609
+ cause: "system",
5610
+ };
5453
5611
  }
5454
5612
  return this.attachFailed(err);
5455
5613
  }
@@ -6293,6 +6451,7 @@ export class ResidentDO extends Sandbox<Env> {
6293
6451
  state: s.state,
6294
6452
  stateReason: s.reason,
6295
6453
  reason: s.reason,
6454
+ cause: "system",
6296
6455
  };
6297
6456
  }
6298
6457
  const binding = await this.ctx.storage.get<ThreadBinding>(threadBindingKey(threadKey));
@@ -6381,6 +6540,9 @@ export class ResidentDO extends Sandbox<Env> {
6381
6540
  // container is unchanged, so no `replacedExecAnswer` gate applies.
6382
6541
  if (err instanceof ControlResetError) return controlResetErr(err);
6383
6542
  if (err instanceof RuntimeReplacedError) return this.replacedExecAnswer(err);
6543
+ // A refused connect at the command's spawn (item 68): the wait token,
6544
+ // the command never started.
6545
+ if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
6384
6546
  throw err;
6385
6547
  }
6386
6548
  }
@@ -6461,6 +6623,7 @@ export class ResidentDO extends Sandbox<Env> {
6461
6623
  } catch (err) {
6462
6624
  if (err instanceof ControlResetError) return controlResetErr(err);
6463
6625
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6626
+ if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
6464
6627
  throw err;
6465
6628
  }
6466
6629
  if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
@@ -6510,6 +6673,7 @@ export class ResidentDO extends Sandbox<Env> {
6510
6673
  } catch (err) {
6511
6674
  if (err instanceof ControlResetError) return controlResetErr(err);
6512
6675
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6676
+ if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
6513
6677
  throw err;
6514
6678
  }
6515
6679
  }
@@ -6557,6 +6721,7 @@ export class ResidentDO extends Sandbox<Env> {
6557
6721
  } catch (err) {
6558
6722
  if (err instanceof ControlResetError) return controlResetErr(err);
6559
6723
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
6724
+ if (err instanceof SandboxRuntimeBusyError) return runtimeBusyErr(err);
6560
6725
  const step = err instanceof StepError ? ` at ${err.step}` : "";
6561
6726
  return { error: `write-failed${step}: ${errMsg(err)}`, status: 400 };
6562
6727
  }
@@ -6781,6 +6946,19 @@ export class ResidentDO extends Sandbox<Env> {
6781
6946
  const threadOps = [...this.threadOpsInFlight.values()].reduce((a, n) => a + n, 0);
6782
6947
  return threadOps + this.opUsersInUse.size + this.attachesInFlight;
6783
6948
  }
6949
+ /** The fleet drain in force (item 69), read from the registry at this clock;
6950
+ * a registry that cannot be read is NO drain: a run must never fail because
6951
+ * a flag could not be read, and the deploy's own preflight fails closed on
6952
+ * its side (an unknown fleet refuses the deploy), so the failure lands on
6953
+ * the deploy, never on the run. Said in the log. */
6954
+ private async fleetDrain(): Promise<DrainRecord | null> {
6955
+ try {
6956
+ return liveDrain(await this.registry().getDrain(), systemClock());
6957
+ } catch (err) {
6958
+ console.warn(`[drain] registry unreadable at attach — treating as no drain: ${errMsg(err)}`);
6959
+ return null;
6960
+ }
6961
+ }
6784
6962
  /** In-flight activity for the deploy preflight (GET /status, GET /residents).
6785
6963
  * The in-memory counters (a fresh isolate answers 0 for them — nothing of
6786
6964
  * THEIRS survived to be interrupted) plus the durable run registrations:
@@ -6928,6 +7106,7 @@ export class ResidentDO extends Sandbox<Env> {
6928
7106
  state: s.state,
6929
7107
  stateReason: s.reason,
6930
7108
  reason: s.reason,
7109
+ cause: "system",
6931
7110
  };
6932
7111
  }
6933
7112
  // One storage round trip for the two facts; the registry lookup stays (an
@@ -6939,7 +7118,12 @@ export class ResidentDO extends Sandbox<Env> {
6939
7118
  // Typed `reason` beside the words: the client reads the field — a refusal no
6940
7119
  // wait clears, unlike the restore window's 503s — never the sentence.
6941
7120
  if (!record || !facts)
6942
- return { error: "not-serviceable: registry record or repo facts missing", status: 503, reason: "unregistered" };
7121
+ return {
7122
+ error: "not-serviceable: registry record or repo facts missing",
7123
+ status: 503,
7124
+ reason: "unregistered",
7125
+ cause: "system",
7126
+ };
6943
7127
  const command = record.commands[op];
6944
7128
  if (!command) return { error: `op-unavailable: the command table has no "${op}" entry`, status: 400 };
6945
7129
 
@@ -7035,10 +7219,17 @@ export class ResidentDO extends Sandbox<Env> {
7035
7219
  } catch (err) {
7036
7220
  if (err instanceof MirrorBusyError) {
7037
7221
  const s = await this.getStatus();
7038
- return { error: errMsg(err), status: 503, state: s.state, stateReason: s.reason, reason: "mirror-busy" };
7222
+ return {
7223
+ error: errMsg(err),
7224
+ status: 503,
7225
+ state: s.state,
7226
+ stateReason: s.reason,
7227
+ reason: "mirror-busy",
7228
+ cause: "system",
7229
+ };
7039
7230
  }
7040
7231
  if (err instanceof StepError && err.step === "unknown-ref") {
7041
- return { error: `unknown-ref: ${err.message}`, status: 400 };
7232
+ return { error: `unknown-ref: ${err.message}`, status: 400, cause: "request" };
7042
7233
  }
7043
7234
  // A step that failed is named and deterministic; a throw no step named is
7044
7235
  // typed by the one builder every such 500 goes through.
@@ -7821,7 +8012,7 @@ function timingSafeEqual(a: string, b: string): boolean {
7821
8012
  return diff === 0;
7822
8013
  }
7823
8014
 
7824
- type Scope = "admin" | "operator" | "read";
8015
+ type Scope = "admin" | "operator" | "read" | "drain";
7825
8016
 
7826
8017
  /** Which token a bearer is, or null. Constant-time per comparison; fail closed
7827
8018
  * on unset/empty secrets. */
@@ -7830,6 +8021,7 @@ function tokenScope(env: Env, token: string | null): Scope | null {
7830
8021
  if (env.RESIDENT_ADMIN_TOKEN && timingSafeEqual(token, env.RESIDENT_ADMIN_TOKEN)) return "admin";
7831
8022
  if (env.RESIDENT_OPERATOR_TOKEN && timingSafeEqual(token, env.RESIDENT_OPERATOR_TOKEN)) return "operator";
7832
8023
  if (env.RESIDENT_READ_TOKEN && timingSafeEqual(token, env.RESIDENT_READ_TOKEN)) return "read";
8024
+ if (env.RESIDENT_DRAIN_TOKEN && timingSafeEqual(token, env.RESIDENT_DRAIN_TOKEN)) return "drain";
7833
8025
  return null;
7834
8026
  }
7835
8027
 
@@ -8009,6 +8201,8 @@ const ROUTES: Record<string, { scope: Scope; method: string }> = {
8009
8201
  "/offboard": { scope: "admin", method: "POST" },
8010
8202
  "/reconfigure": { scope: "admin", method: "POST" },
8011
8203
  "/rebuild": { scope: "admin", method: "POST" },
8204
+ "/drain": { scope: "drain", method: "POST" }, // close the fleet to new runs for a deploy (item 69; admin implied)
8205
+ "/undrain": { scope: "drain", method: "POST" }, // reopen it
8012
8206
  "/residents": { scope: "read", method: "GET" }, // admin implied; read-only bearer allowed
8013
8207
  "/debug": { scope: "read", method: "POST" }, // per-op: READ_DEBUG_OPS for read scope, everything for admin
8014
8208
  "/status": { scope: "operator", method: "GET" },
@@ -8109,6 +8303,10 @@ export default {
8109
8303
  return await handleReconfigure(env, body);
8110
8304
  case "/rebuild":
8111
8305
  return await handleRebuild(env, body);
8306
+ case "/drain":
8307
+ return await handleDrain(env, body);
8308
+ case "/undrain":
8309
+ return await handleUndrain(env);
8112
8310
  case "/residents":
8113
8311
  return await handleResidents(env);
8114
8312
  case "/debug": {
@@ -8239,6 +8437,7 @@ async function handleOnboard(env: Env, body: Record<string, unknown>): Promise<R
8239
8437
  `exact name (GitHub's token API answers the same 422 for both). An org admin adds it under the ` +
8240
8438
  `App's installation settings (Settings → GitHub Apps → Configure → Repository access), ` +
8241
8439
  `then retry (${errMsg(err)})`,
8440
+ cause: "policy",
8242
8441
  },
8243
8442
  403,
8244
8443
  );
@@ -8529,6 +8728,26 @@ async function handleRebuild(env: Env, body: Record<string, unknown>): Promise<R
8529
8728
  * targets a different DO, so they run concurrently; a failing one degrades
8530
8729
  * to {error} without touching its neighbors, and the response order follows
8531
8730
  * the registry list. */
8731
+ /** POST /drain (admin): close the fleet to new runs (docs/reference/specs/resident-repos.md
8732
+ * item 69). The record carries its own end (`until`), so a drain nobody lifts
8733
+ * ends by itself; a second drain replaces the first. Runs in flight are
8734
+ * untouched — the deploy that asked waits for them through `/residents`. */
8735
+ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Response> {
8736
+ const parsed = parseDrainRequest(body, systemClock());
8737
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
8738
+ const record = await registryStub(env).setDrain(parsed.record);
8739
+ console.log(`[drain] fleet closed to new runs by ${record.by} for ${record.reason}: until ${record.until}`);
8740
+ return json({ draining: record });
8741
+ }
8742
+
8743
+ /** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says whether
8744
+ * a drain stood. */
8745
+ async function handleUndrain(env: Env): Promise<Response> {
8746
+ const cleared = await registryStub(env).clearDrain();
8747
+ console.log(`[drain] fleet reopened (${cleared ? "a drain stood" : "no drain stood"})`);
8748
+ return json({ draining: null, cleared });
8749
+ }
8750
+
8532
8751
  async function handleResidents(env: Env): Promise<Response> {
8533
8752
  const residents = await registryStub(env).list();
8534
8753
  const settled = await Promise.allSettled(
@@ -8564,6 +8783,9 @@ async function handleResidents(env: Env): Promise<Response> {
8564
8783
  count: residents.length,
8565
8784
  inFlight,
8566
8785
  inFlightUnknown,
8786
+ // The drain in force, or null (item 69): the deploy runner and the
8787
+ // dashboard read it here; the attach route reads the same record.
8788
+ draining: liveDrain(await registryStub(env).getDrain(), systemClock()),
8567
8789
  residents: enriched,
8568
8790
  });
8569
8791
  }
@@ -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).