@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.
- package/dist/assets/config/config.example.yaml +44 -18
- package/dist/assets/deploy/cloudflare/preflight.mjs +21 -19
- package/dist/assets/deploy/cloudflare-memory/worker.ts +31 -0
- package/dist/assets/deploy/cloudflare-resident/drain.ts +109 -0
- package/dist/assets/deploy/cloudflare-resident/refresh.ts +5 -3
- package/dist/assets/deploy/cloudflare-resident/threadErr.ts +32 -3
- package/dist/assets/deploy/cloudflare-resident/worker.ts +235 -13
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +56 -9
- package/dist/assets/deploy/secrets.manifest.json +6 -0
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +4 -4
- package/dist/assets/src/core/budgets.ts +24 -0
- package/dist/assets/src/core/coordinator/contract.ts +4 -3
- package/dist/assets/src/core/coordinator/driver.ts +40 -7
- package/dist/assets/src/core/modelCard.ts +348 -0
- package/dist/assets/src/core/modelPricing.ts +14 -5
- package/dist/assets/src/core/modelRegistry.ts +51 -0
- package/dist/assets/src/core/provider.ts +103 -0
- package/dist/assets/src/core/refusal.ts +181 -0
- package/dist/assets/src/core/runEvents.ts +43 -0
- package/dist/assets/src/core/ship/coordinator.ts +80 -12
- package/dist/assets/src/core/ship/handoff.ts +54 -19
- package/dist/assets/src/core/trace/workerTrace.ts +9 -3
- package/dist/assets/src/core/types.ts +327 -0
- package/dist/assets/src/deploy/liveGate.ts +35 -0
- package/dist/assets/src/deploy/restart.ts +12 -11
- package/dist/assets/src/execution/residentRefresh.ts +26 -1
- package/dist/assets/src/execution/sandboxErrors.ts +122 -4
- package/dist/assets/web/dist/.vite/manifest.json +30 -30
- package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +1 -0
- package/dist/assets/web/dist/assets/{HomePage-DYxC0izY.js → HomePage-BpQRky8B.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-DLIpWYOc.js → ResidentDetailPage-BIUXyz6K.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-6LipuDjR.js → ResidentsIndexPage-BZymgSAb.js} +1 -1
- package/dist/assets/web/dist/assets/{RunFoldRow-V-iSy64e.js → RunFoldRow-3m4CPRI4.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-DUalB1u2.js → RunRoutePage-bgkjkA0p.js} +3 -3
- package/dist/assets/web/dist/assets/{RunsIndexPage-B9Ba1KdD.js → RunsIndexPage-8S944AzB.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-KdjLtD_7.js → ScheduledPage-8bBtG9y3.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-IT5l_NaL.js → SettingsPage-DQeNvfaV.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-DBHAl4Il.js → StatusDot-BPE5syBa.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-_LEjptLV.js → Tooltip-DkoeZfTs.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-BUzw--Ii.js +1 -0
- package/dist/assets/web/dist/assets/{dist-BcYPGOBL.js → dist-D11y9ZJ4.js} +1 -1
- package/dist/assets/web/dist/assets/{main-DUfSE0dj.js → main-B6LcgNM6.js} +2 -2
- package/dist/cli.js +2475 -1021
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/DeliveryPage-NP4g6bQd.js +0 -1
- 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
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
325
|
-
// silent control port keeps its name; anything
|
|
326
|
-
|
|
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
|
-
|
|
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
|
|
665
|
-
* is the refusal the route answers 404.
|
|
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).
|