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