@coreplane/switchboard 1.254.0 → 1.255.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/levels.ts +84 -0
- package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +202 -31
- 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 +21 -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 +49 -6
- package/dist/assets/src/core/costs.ts +39 -16
- package/dist/assets/src/core/pipelineStanding.ts +5 -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 +15 -2
- package/dist/assets/src/core/runEvents.ts +32 -13
- 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/coordinator.ts +327 -61
- 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 +59 -58
- 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-Be7jLLnU.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-D_RD6wLo.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-BLkSuCxo.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-ysJBY8xQ.js → RunRoutePage-CmWYGR36.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-DW-HHuZa.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-3aYsDf-q.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-DG-p5Xy1.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-CEnGlyAL.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-CiunVowT.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +1 -0
- package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
- package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-luhv3YSo.js} +1 -1
- package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
- package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-mAKx_zo9.js} +2 -2
- package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
- package/dist/cli.js +4154 -3061
- 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/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
|
@@ -58,6 +58,11 @@
|
|
|
58
58
|
// deploy/cloudflare/wrangler.template.jsonc). The class is deployed with the
|
|
59
59
|
// bot, which deploys after this Worker — so this binding lands one release
|
|
60
60
|
// after the class did, never in the same one.
|
|
61
|
+
// The bot Worker, for the plane's effect push (record 0064, "Where it
|
|
62
|
+
// lives"): committed effects POST to the bot's bearer-gated /plane/effects,
|
|
63
|
+
// which forwards them to the container. A push that fails is not retried by
|
|
64
|
+
// a timer — the effect rides the next heartbeat or reclaim-sweep answer.
|
|
65
|
+
"services": [{ "binding": "BOT", "service": "{{bot.script}}" }],
|
|
61
66
|
"workflows": [
|
|
62
67
|
{
|
|
63
68
|
"name": "{{bot.script}}-ship-coordinator",
|
|
@@ -22,8 +22,20 @@ FROM docker.io/cloudflare/sandbox:0.13.0-next.751.1
|
|
|
22
22
|
# installed here — matching the resident system prompts and the tests that pin
|
|
23
23
|
# that claim. No system credential.helper is configured; each worktree sets its
|
|
24
24
|
# own per-attach helper.
|
|
25
|
+
# The fallback user.email stays OFF the GitHub domain (a noreply-shaped
|
|
26
|
+
# address would render as a GitHub account it is not); the real pairs ride
|
|
27
|
+
# each /exec body's env. The system-level core.hooksPath replaces repo-local
|
|
28
|
+
# .git/hooks entirely — deliberate: an untrusted checkout's own hooks never
|
|
29
|
+
# run here (a repo-local core.hooksPath still wins, git's ordinary
|
|
30
|
+
# precedence) — docs/reference/specs/execution.md item 5.
|
|
25
31
|
RUN git config --system user.name "switchboard-resident" \
|
|
26
|
-
&& git config --system user.email "switchboard-resident@
|
|
32
|
+
&& git config --system user.email "switchboard-resident@switchboard.invalid" \
|
|
33
|
+
&& git config --system core.hooksPath /opt/switchboard/hooks
|
|
34
|
+
|
|
35
|
+
# The shared agent-trailer hook (deploy/hooks/prepare-commit-msg — this copy is
|
|
36
|
+
# byte-identical, held so by a test: the build context is this directory, so
|
|
37
|
+
# COPY cannot reach the canonical file).
|
|
38
|
+
COPY --chmod=0755 prepare-commit-msg /opt/switchboard/hooks/prepare-commit-msg
|
|
27
39
|
|
|
28
40
|
# The container is FOUR vCPUs shared by up to 16 threads (wrangler.jsonc).
|
|
29
41
|
# Node test runners size their worker pools from the CPU count the kernel
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The resident's level reports (record 0064; the orchestration plane's
|
|
2
|
+
// resident conditions) — the PURE half, kept free of the Sandbox SDK and DO
|
|
3
|
+
// storage so it runs under plain-Node vitest (levels.test.ts) like gc.ts,
|
|
4
|
+
// drain.ts and memoryGuard.ts. The Worker owns the readings (the user pool's
|
|
5
|
+
// allocation, the memory guard's last sample, its own incarnation) and feeds
|
|
6
|
+
// this module the numbers; every `/attach`, `/exec` and `/status` answer
|
|
7
|
+
// carries the document, and the bot forwards a change to the plane's
|
|
8
|
+
// `POST /plane/level`.
|
|
9
|
+
//
|
|
10
|
+
// Why an outbox and not a push: the resident never holds a credential for the
|
|
11
|
+
// state Worker, so it posts nothing itself — a crossing between calls is kept
|
|
12
|
+
// as a post and re-offered on every answer until a newer crossing of the same
|
|
13
|
+
// name supersedes it. The plane's level write is idempotent per (resident,
|
|
14
|
+
// name), so a post forwarded twice lands once.
|
|
15
|
+
|
|
16
|
+
import { MEMORY_SOFT_LIMIT_PCT } from "./memoryGuard.js";
|
|
17
|
+
|
|
18
|
+
/** Which side of its line a level reads: `above` closes the door (an exhausted
|
|
19
|
+
* pool, the gate's soft side), `below` opens it. */
|
|
20
|
+
export type LevelSide = "below" | "above";
|
|
21
|
+
|
|
22
|
+
/** One level post for the plane: the crossing (or a boot's re-statement) the
|
|
23
|
+
* bot forwards to `POST /plane/level`. */
|
|
24
|
+
export interface LevelPost {
|
|
25
|
+
/** `drain` is the registry's fleet-drain post; the residents post the other two. */
|
|
26
|
+
name: "seat" | "memory" | "drain";
|
|
27
|
+
side: LevelSide;
|
|
28
|
+
generation: string;
|
|
29
|
+
at: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The compact sides of one sample, persisted so the next sample can judge a
|
|
33
|
+
* crossing; `generation` names the incarnation the sample came from. */
|
|
34
|
+
export interface LevelSample {
|
|
35
|
+
seat: LevelSide;
|
|
36
|
+
memory: LevelSide;
|
|
37
|
+
generation: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The document every resident answer carries as `levels` (record 0064). */
|
|
41
|
+
export interface ResidentLevelsDoc {
|
|
42
|
+
seat: { side: LevelSide; used: number; total: number };
|
|
43
|
+
memory: { side: LevelSide; percent: number | null };
|
|
44
|
+
generation: string;
|
|
45
|
+
at: string;
|
|
46
|
+
/** The outbox: crossings not yet superseded, re-offered on every answer. */
|
|
47
|
+
posts: LevelPost[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The seat's side: the pool is a hard count, so `above` is exactly "no free
|
|
51
|
+
* user" — the next attach or op would be refused `user-pool-exhausted`. */
|
|
52
|
+
export function seatSide(used: number, total: number): LevelSide {
|
|
53
|
+
return used >= total ? "above" : "below";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The memory side is the gate's soft line (resident-repos item 70): at or
|
|
57
|
+
* past `MEMORY_SOFT_LIMIT_PCT` a NEW attach is refused, so that is where the
|
|
58
|
+
* plane's `memory` condition sits. No reading, or no cap, gates nothing. */
|
|
59
|
+
export function memorySide(percent: number | null): LevelSide {
|
|
60
|
+
return percent !== null && percent >= MEMORY_SOFT_LIMIT_PCT ? "above" : "below";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The posts one fresh sample owes the plane: each name whose side crossed
|
|
64
|
+
* since the previous sample — and, with no previous sample or one from
|
|
65
|
+
* another generation (a boot, a replaced runtime), both names re-stated
|
|
66
|
+
* (record 0064: "a boot re-states it"), whatever their sides. */
|
|
67
|
+
export function levelPosts(prev: LevelSample | null, next: LevelSample, at: string): LevelPost[] {
|
|
68
|
+
const restate = prev === null || prev.generation !== next.generation;
|
|
69
|
+
const posts: LevelPost[] = [];
|
|
70
|
+
if (restate || prev.seat !== next.seat)
|
|
71
|
+
posts.push({ name: "seat", side: next.seat, generation: next.generation, at });
|
|
72
|
+
if (restate || prev.memory !== next.memory)
|
|
73
|
+
posts.push({ name: "memory", side: next.memory, generation: next.generation, at });
|
|
74
|
+
return posts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Merge fresh posts into the outbox: a newer post of a name supersedes the
|
|
78
|
+
* older one — the plane only needs the current side, and the bot forwarding
|
|
79
|
+
* a superseded post would only be overwritten by the next. The outbox never
|
|
80
|
+
* grows past one post per name. */
|
|
81
|
+
export function mergeOutbox(outbox: LevelPost[], posts: LevelPost[]): LevelPost[] {
|
|
82
|
+
const superseded = new Set(posts.map((p) => p.name));
|
|
83
|
+
return [...outbox.filter((p) => !superseded.has(p.name)), ...posts];
|
|
84
|
+
}
|
|
@@ -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"
|
|
@@ -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 {
|
|
@@ -636,6 +646,12 @@ const DISK_KEY = "resident:disk";
|
|
|
636
646
|
* tick, persisted so the gauges (`/status`, `/residents`, the watchdog line)
|
|
637
647
|
* read storage only — the watchdog never touches the container. */
|
|
638
648
|
const MEMORY_KEY = "resident:memory";
|
|
649
|
+
/** The last level sample and the crossing outbox (record 0064; `levels.ts`). */
|
|
650
|
+
const LEVELS_LAST_KEY = "resident:levels:last";
|
|
651
|
+
const LEVELS_OUTBOX_KEY = "resident:levels:outbox";
|
|
652
|
+
/** The registry's drain post (record 0064): the fleet drain's set/cleared/expired,
|
|
653
|
+
* re-offered on every registry answer until superseded. */
|
|
654
|
+
const DRAIN_OUTBOX_KEY = "drain:outbox";
|
|
639
655
|
/** The lifecycle row (docs/reference/specs/resident-repos.md item 7): `workflow`,
|
|
640
656
|
* the one scheduler. Kept from the flagged rollout so `/status` and `/debug
|
|
641
657
|
* info` can say so; an `alarm` value a flip left behind reads `workflow`
|
|
@@ -1193,6 +1209,13 @@ interface ThreadBinding {
|
|
|
1193
1209
|
depsKey?: string;
|
|
1194
1210
|
}
|
|
1195
1211
|
|
|
1212
|
+
/** What one image reconcile decided (`reconcileImage`): the container was
|
|
1213
|
+
* stopped to restart on the current image (`restarted`), already runs it
|
|
1214
|
+
* (`current`), is not running so the next start uses it anyway (`inactive`),
|
|
1215
|
+
* or is busy — an operation, an attach or a registered run in flight — and
|
|
1216
|
+
* the restart is deferred to the next quiet check (`deferred`). */
|
|
1217
|
+
type ImageReconcileResult = "restarted" | "current" | "inactive" | "deferred";
|
|
1218
|
+
|
|
1196
1219
|
interface AttachOk {
|
|
1197
1220
|
workspace: string;
|
|
1198
1221
|
ref: string;
|
|
@@ -1535,15 +1558,56 @@ export class ResidentRegistryDO extends DurableObject<Env> {
|
|
|
1535
1558
|
}
|
|
1536
1559
|
|
|
1537
1560
|
/** Admin-only by construction (reached solely via POST /drain): replaces
|
|
1538
|
-
* whatever drain stood — a second deploy's drain extends the first's.
|
|
1561
|
+
* whatever drain stood — a second deploy's drain extends the first's. The
|
|
1562
|
+
* set posts `above` for the plane (record 0064) and arms ONE alarm at
|
|
1563
|
+
* `until`, so a drain nobody lifts posts its expiry itself. */
|
|
1539
1564
|
async setDrain(record: DrainRecord): Promise<DrainRecord> {
|
|
1540
1565
|
await this.ctx.storage.put(DRAIN_KEY, record);
|
|
1566
|
+
await this.pushDrainPost("above");
|
|
1567
|
+
await this.ctx.storage.setAlarm(Date.parse(record.until));
|
|
1541
1568
|
return record;
|
|
1542
1569
|
}
|
|
1543
1570
|
|
|
1544
|
-
/** Admin-only by construction (POST /undrain): true when a record was there.
|
|
1571
|
+
/** Admin-only by construction (POST /undrain): true when a record was there.
|
|
1572
|
+
* A cleared drain posts `below` — the plane's resident-drain window lifts. */
|
|
1545
1573
|
async clearDrain(): Promise<boolean> {
|
|
1546
|
-
|
|
1574
|
+
const had = await this.ctx.storage.delete(DRAIN_KEY);
|
|
1575
|
+
if (had) await this.pushDrainPost("below");
|
|
1576
|
+
return had;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** The one alarm, at the drain's `until` (record 0064): a drain past
|
|
1580
|
+
* its end is nothing (`liveDrain` already reads it so), and the expiry is
|
|
1581
|
+
* posted `below` like a clear — whoever forgot the drain, the plane's
|
|
1582
|
+
* window lifts. A drain replaced with a later `until` re-arms via setDrain. */
|
|
1583
|
+
async alarm(): Promise<void> {
|
|
1584
|
+
const now = systemClock();
|
|
1585
|
+
const stored = await this.ctx.storage.get(DRAIN_KEY);
|
|
1586
|
+
if (stored === undefined) return;
|
|
1587
|
+
if (liveDrain(stored, now) === null) {
|
|
1588
|
+
await this.ctx.storage.delete(DRAIN_KEY);
|
|
1589
|
+
await this.pushDrainPost("below");
|
|
1590
|
+
} else {
|
|
1591
|
+
// Replaced with a later end under an already-armed alarm: re-arm at it.
|
|
1592
|
+
await this.ctx.storage.setAlarm(Date.parse((stored as DrainRecord).until));
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/** The drain's plane post (name `drain`), superseding the last: the plane
|
|
1597
|
+
* only needs the current side, and the bot's forward is idempotent. */
|
|
1598
|
+
private async pushDrainPost(side: LevelSide): Promise<void> {
|
|
1599
|
+
await this.ctx.storage.put(DRAIN_OUTBOX_KEY, {
|
|
1600
|
+
name: "drain",
|
|
1601
|
+
side,
|
|
1602
|
+
generation: "",
|
|
1603
|
+
at: new Date(systemClock()).toISOString(),
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** The pending drain post, for the registry's answers (`/drain`, `/undrain`,
|
|
1608
|
+
* `/residents`) — the bot forwards it to `POST /plane/level`. */
|
|
1609
|
+
async getDrainOutbox(): Promise<LevelPost | null> {
|
|
1610
|
+
return ((await this.ctx.storage.get(DRAIN_OUTBOX_KEY)) as LevelPost | undefined) ?? null;
|
|
1547
1611
|
}
|
|
1548
1612
|
}
|
|
1549
1613
|
|
|
@@ -3366,7 +3430,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
3366
3430
|
// RUNNING container keeps the old one, so new Worker code can name pool
|
|
3367
3431
|
// users the image lacks. Reconcile here (every cycle, cheap) — see
|
|
3368
3432
|
// reconcileImage — so a rollout self-applies within one refresh.
|
|
3369
|
-
if (await this.reconcileImage("refresh")) {
|
|
3433
|
+
if ((await this.reconcileImage("refresh")) === "restarted") {
|
|
3370
3434
|
// Container stopping; it restarts on the new image in seconds. The
|
|
3371
3435
|
// engine's retry re-enters this step thirty seconds on and re-warms the
|
|
3372
3436
|
// resident within the minute, instead of the next bucket.
|
|
@@ -4592,6 +4656,37 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4592
4656
|
return this.memoryGuard.lastReading ?? (await this.ctx.storage.get<MemoryReading>(MEMORY_KEY)) ?? null;
|
|
4593
4657
|
}
|
|
4594
4658
|
|
|
4659
|
+
/** The resident's levels (record 0064; `levels.ts`): the seat (the
|
|
4660
|
+
* thread/op user pool, whose exhaustion is the `user-pool-exhausted`
|
|
4661
|
+
* refusal) and the memory line (the gate's soft side), stamped with this
|
|
4662
|
+
* incarnation — the container's boot id where one is memoized, else the
|
|
4663
|
+
* DO incarnation, so a boot or a replaced runtime reads as a new
|
|
4664
|
+
* generation and re-states both levels. A crossing lands in the outbox
|
|
4665
|
+
* and is re-offered on every answer until a newer crossing of the same
|
|
4666
|
+
* name supersedes it; the bot forwards posts to `POST /plane/level`.
|
|
4667
|
+
* Storage reads and one storage write only — never a container touch, so
|
|
4668
|
+
* every `/attach`, `/exec` and `/status` answer can carry the document. */
|
|
4669
|
+
async residentLevels(): Promise<ResidentLevelsDoc> {
|
|
4670
|
+
const at = new Date(systemClock()).toISOString();
|
|
4671
|
+
const all = await this.ctx.storage.list<ThreadBinding>({ prefix: THREAD_KEY_PREFIX });
|
|
4672
|
+
const used = new Set([...all.values()].filter((b) => !b.evicted && b.user).map((b) => b.user));
|
|
4673
|
+
for (const u of this.opUsersInUse) used.add(u);
|
|
4674
|
+
const reading = await this.memoryGauge();
|
|
4675
|
+
const generation = this.containerIdMemo ?? this.incarnation;
|
|
4676
|
+
const seat = { side: seatSide(used.size, THREAD_USERS.length), used: used.size, total: THREAD_USERS.length };
|
|
4677
|
+
const memory = { side: memorySide(reading?.percent ?? null), percent: reading?.percent ?? null };
|
|
4678
|
+
const next: LevelSample = { seat: seat.side, memory: memory.side, generation };
|
|
4679
|
+
const prev = (await this.ctx.storage.get<LevelSample>(LEVELS_LAST_KEY)) ?? null;
|
|
4680
|
+
const posts = levelPosts(prev, next, at);
|
|
4681
|
+
let outbox = (await this.ctx.storage.get<LevelPost[]>(LEVELS_OUTBOX_KEY)) ?? [];
|
|
4682
|
+
if (posts.length > 0) {
|
|
4683
|
+
outbox = mergeOutbox(outbox, posts);
|
|
4684
|
+
await this.ctx.storage.put(LEVELS_OUTBOX_KEY, outbox);
|
|
4685
|
+
await this.ctx.storage.put(LEVELS_LAST_KEY, next);
|
|
4686
|
+
}
|
|
4687
|
+
return { seat, memory, generation, at, posts: outbox };
|
|
4688
|
+
}
|
|
4689
|
+
|
|
4595
4690
|
/** The route gate (item 70): one fresh sample, then the pure verdict over
|
|
4596
4691
|
* the last reading. A refusal is the same 503 shape as `mirror-busy`, so
|
|
4597
4692
|
* the bot falls back or waits legibly — and a command already running is
|
|
@@ -4831,27 +4926,51 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4831
4926
|
/** Pool users live in the IMAGE (Dockerfile useradd loop) while THREAD_USERS
|
|
4832
4927
|
* lives in the Worker. After a deploy that grows the pool, a still-running
|
|
4833
4928
|
* 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
|
-
|
|
4929
|
+
* last pool user exists; if not and nothing is in flight — the in-memory
|
|
4930
|
+
* counters AND the durable run registrations (item 44): a harness run's
|
|
4931
|
+
* process lives in the container between the bot's operator calls, so a
|
|
4932
|
+
* restart decided on the op counters alone stops the container under a live
|
|
4933
|
+
* run — stop the container so it restarts on the current image (state is DO
|
|
4934
|
+
* storage + R2 — the disk is a cache). A deferred restart re-checks on
|
|
4935
|
+
* every later attach and refresh cycle until the resident is quiet; a
|
|
4936
|
+
* registration whose release never came defers it only until the clean-idle
|
|
4937
|
+
* sweep drains that registration. Answers `restarted` when a stop was
|
|
4938
|
+
* issued, else why not. */
|
|
4939
|
+
private async reconcileImage(where: string): Promise<ImageReconcileResult> {
|
|
4940
|
+
if (!(await this.isRuntimeActive().catch(() => false))) return "inactive";
|
|
4839
4941
|
const last = THREAD_USERS[THREAD_USERS.length - 1];
|
|
4840
4942
|
const probe = await this.run(["id", "-u", last]);
|
|
4841
|
-
if (probe.exitCode === 0) return
|
|
4943
|
+
if (probe.exitCode === 0) return "current";
|
|
4842
4944
|
const busy = this.inFlightCount();
|
|
4843
4945
|
if (busy > 0) {
|
|
4844
4946
|
console.log(
|
|
4845
4947
|
`image-stale (${where}): ${last} missing but ${busy} operation(s)/attach(es) in flight — deferring restart`,
|
|
4846
4948
|
);
|
|
4847
|
-
return
|
|
4949
|
+
return "deferred";
|
|
4950
|
+
}
|
|
4951
|
+
const registered = await this.registeredRunsBeyondOps();
|
|
4952
|
+
if (registered > 0) {
|
|
4953
|
+
console.log(
|
|
4954
|
+
`image-stale (${where}): ${last} missing but ${registered} run registration(s) live — deferring restart until the resident is quiet`,
|
|
4955
|
+
);
|
|
4956
|
+
return "deferred";
|
|
4848
4957
|
}
|
|
4849
4958
|
console.log(
|
|
4850
4959
|
`image-stale (${where}): ${last} missing in the running container — stopping so it restarts on the current image`,
|
|
4851
4960
|
);
|
|
4852
4961
|
this.swapIncarnation(); // deliberate incarnation swap
|
|
4853
4962
|
await this.stop().catch((err) => console.log(`image-stale: stop failed: ${errMsg(err)}`));
|
|
4854
|
-
return
|
|
4963
|
+
return "restarted";
|
|
4964
|
+
}
|
|
4965
|
+
|
|
4966
|
+
/** The deploy's reconcile, inside the drain window (item 69's order: the
|
|
4967
|
+
* runs in flight end, the swap lands, the containers reconcile, the fleet
|
|
4968
|
+
* reopens): `POST /reconcile` calls this on every resident after the Worker
|
|
4969
|
+
* deploy landed and BEFORE the drain is lifted, so a container that
|
|
4970
|
+
* predates the new image restarts while nothing can be admitted onto it —
|
|
4971
|
+
* never under the first run the reopened fleet admits. */
|
|
4972
|
+
async reconcileForDeploy(): Promise<{ result: ImageReconcileResult }> {
|
|
4973
|
+
return { result: await this.reconcileImage("deploy") };
|
|
4855
4974
|
}
|
|
4856
4975
|
|
|
4857
4976
|
// -- watchdog (the sparse cron; it re-arms nothing) --------------------------
|
|
@@ -5193,7 +5312,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5193
5312
|
const memory = await this.memoryGate("attach", registered);
|
|
5194
5313
|
if (memory) return memory;
|
|
5195
5314
|
const resourceId = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
|
|
5196
|
-
if (await this.reconcileImage("attach")) {
|
|
5315
|
+
if ((await this.reconcileImage("attach")) === "restarted") {
|
|
5197
5316
|
return {
|
|
5198
5317
|
error: "image-stale: the container predates the current pool and is restarting; retry shortly",
|
|
5199
5318
|
status: 503,
|
|
@@ -8459,6 +8578,7 @@ const ROUTES: Record<string, { scope: Scope; method: string }> = {
|
|
|
8459
8578
|
"/rebuild": { scope: "admin", method: "POST" },
|
|
8460
8579
|
"/drain": { scope: "drain", method: "POST" }, // close the fleet to new runs for a deploy (item 69; admin implied)
|
|
8461
8580
|
"/undrain": { scope: "drain", method: "POST" }, // reopen it
|
|
8581
|
+
"/reconcile": { scope: "drain", method: "POST" }, // reconcile every container onto the current image, inside the drain window
|
|
8462
8582
|
"/residents": { scope: "read", method: "GET" }, // admin implied; read-only bearer allowed
|
|
8463
8583
|
"/debug": { scope: "read", method: "POST" }, // per-op: READ_DEBUG_OPS for read scope, everything for admin
|
|
8464
8584
|
"/status": { scope: "operator", method: "GET" },
|
|
@@ -8563,6 +8683,8 @@ export default {
|
|
|
8563
8683
|
return await handleDrain(env, body);
|
|
8564
8684
|
case "/undrain":
|
|
8565
8685
|
return await handleUndrain(env);
|
|
8686
|
+
case "/reconcile":
|
|
8687
|
+
return await handleReconcile(env);
|
|
8566
8688
|
case "/residents":
|
|
8567
8689
|
return await handleResidents(env);
|
|
8568
8690
|
case "/debug": {
|
|
@@ -8993,7 +9115,7 @@ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Res
|
|
|
8993
9115
|
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
8994
9116
|
const record = await registryStub(env).setDrain(parsed.record);
|
|
8995
9117
|
console.log(`[drain] fleet closed to new runs by ${record.by} for ${record.reason}: until ${record.until}`);
|
|
8996
|
-
return json({ draining: record });
|
|
9118
|
+
return json({ draining: record, planeOutbox: await registryStub(env).getDrainOutbox() });
|
|
8997
9119
|
}
|
|
8998
9120
|
|
|
8999
9121
|
/** POST /undrain (admin): reopen the fleet. Idempotent — `cleared` says whether
|
|
@@ -9001,7 +9123,29 @@ async function handleDrain(env: Env, body: Record<string, unknown>): Promise<Res
|
|
|
9001
9123
|
async function handleUndrain(env: Env): Promise<Response> {
|
|
9002
9124
|
const cleared = await registryStub(env).clearDrain();
|
|
9003
9125
|
console.log(`[drain] fleet reopened (${cleared ? "a drain stood" : "no drain stood"})`);
|
|
9004
|
-
return json({ draining: null, cleared });
|
|
9126
|
+
return json({ draining: null, cleared, planeOutbox: await registryStub(env).getDrainOutbox() });
|
|
9127
|
+
}
|
|
9128
|
+
|
|
9129
|
+
/** POST /reconcile (drain scope): reconcile every resident's container onto
|
|
9130
|
+
* the current image — the deploy runner posts it after its Worker deploy
|
|
9131
|
+
* landed and BEFORE its `/undrain`, so a stale container restarts inside the
|
|
9132
|
+
* drain window (item 69's order) and never under a run the reopened fleet
|
|
9133
|
+
* admits. Each resident answers what its reconcile decided; a failing one
|
|
9134
|
+
* degrades to `error` without touching its neighbors, and a `deferred` or
|
|
9135
|
+
* failed one restarts on its own next quiet attach or refresh cycle. */
|
|
9136
|
+
async function handleReconcile(env: Env): Promise<Response> {
|
|
9137
|
+
const residents = await registryStub(env).list();
|
|
9138
|
+
const settled = await Promise.allSettled(
|
|
9139
|
+
residents.map((record) => residentStub(env, record.resource).reconcileForDeploy()),
|
|
9140
|
+
);
|
|
9141
|
+
const reconciled = residents.map((record, i) => {
|
|
9142
|
+
const s = settled[i];
|
|
9143
|
+
return s.status === "fulfilled"
|
|
9144
|
+
? { resource: record.resource, result: s.value.result }
|
|
9145
|
+
: { resource: record.resource, result: "error" as const, error: errMsg(s.reason) };
|
|
9146
|
+
});
|
|
9147
|
+
console.log(`[reconcile] deploy image reconcile: ${JSON.stringify(reconciled)}`);
|
|
9148
|
+
return json({ reconciled });
|
|
9005
9149
|
}
|
|
9006
9150
|
|
|
9007
9151
|
async function handleResidents(env: Env): Promise<Response> {
|
|
@@ -9060,13 +9204,16 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
|
|
|
9060
9204
|
// deploy gate reads /residents. The registry check rides in the same flight
|
|
9061
9205
|
// (its 404 is judged first, the probes' results discarded then).
|
|
9062
9206
|
const stub = residentStub(env, resource.resource);
|
|
9063
|
-
const [record, status, inFlight, refresh, snapshot, memory] = await Promise.all([
|
|
9207
|
+
const [record, status, inFlight, refresh, snapshot, memory, levels] = await Promise.all([
|
|
9064
9208
|
registryStub(env).getRecord(resource.resource),
|
|
9065
9209
|
stub.getStatus(),
|
|
9066
9210
|
stub.getInFlightCount(),
|
|
9067
9211
|
stub.getRefreshView(),
|
|
9068
9212
|
stub.snapshotHandle(),
|
|
9069
9213
|
stub.memoryGauge(),
|
|
9214
|
+
// The levels (record 0064): what the bot forwards to the plane; the
|
|
9215
|
+
// plane's `probe` effect is answered by exactly this read.
|
|
9216
|
+
stub.residentLevels().catch(() => null),
|
|
9070
9217
|
]);
|
|
9071
9218
|
if (!record) return json({ error: `${resource.resource} is not onboarded` }, 404);
|
|
9072
9219
|
// Item 7: which scheduler drives the refresh cycle and, on the Workflow
|
|
@@ -9082,9 +9229,23 @@ async function handleStatus(env: Env, url: URL): Promise<Response> {
|
|
|
9082
9229
|
// Item 70: the last memory reading, so the bot and the residents page can
|
|
9083
9230
|
// show what the resident's own gate is reading.
|
|
9084
9231
|
memory,
|
|
9232
|
+
// Record 0064, record 0064: the levels and the crossing outbox on every answer.
|
|
9233
|
+
...(levels ? { levels } : {}),
|
|
9085
9234
|
});
|
|
9086
9235
|
}
|
|
9087
9236
|
|
|
9237
|
+
/** Levels on every data-plane answer (record 0064): the document is read
|
|
9238
|
+
* after the route's own work so the sample reflects it; a failed sample never
|
|
9239
|
+
* fails the answer — the bot forwards nothing that call. */
|
|
9240
|
+
async function withLevels<T>(
|
|
9241
|
+
stub: ReturnType<typeof residentStub>,
|
|
9242
|
+
pending: Promise<T>,
|
|
9243
|
+
): Promise<{ result: T; levels: ResidentLevelsDoc | null }> {
|
|
9244
|
+
const result = await pending;
|
|
9245
|
+
const levels = await stub.residentLevels().catch(() => null);
|
|
9246
|
+
return { result, levels };
|
|
9247
|
+
}
|
|
9248
|
+
|
|
9088
9249
|
// -- thread data plane handlers -----------------------------------------------
|
|
9089
9250
|
|
|
9090
9251
|
/** Shared front half of the thread routes: validate resource + threadKey (P1:
|
|
@@ -9161,17 +9322,20 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
|
|
|
9161
9322
|
// response does (`fetch failed` a few minutes in). A refusal
|
|
9162
9323
|
// carries its `status` in the body; `ResidentExecutor.attach` reads it there.
|
|
9163
9324
|
return streamHeartbeatJson(
|
|
9164
|
-
|
|
9165
|
-
ctx.
|
|
9166
|
-
|
|
9167
|
-
|
|
9168
|
-
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9325
|
+
withLevels(
|
|
9326
|
+
ctx.stub,
|
|
9327
|
+
ctx.stub.attachThread(
|
|
9328
|
+
ctx.threadKey,
|
|
9329
|
+
refHint,
|
|
9330
|
+
readonly.readonly,
|
|
9331
|
+
want.sha,
|
|
9332
|
+
reuse.reuse,
|
|
9333
|
+
ctx.record,
|
|
9334
|
+
traceparent,
|
|
9335
|
+
reason,
|
|
9336
|
+
),
|
|
9173
9337
|
),
|
|
9174
|
-
(result) => result,
|
|
9338
|
+
({ result, levels }) => ({ ...(result as object), ...(levels ? { levels } : {}) }),
|
|
9175
9339
|
(err) => catchAllErr(err),
|
|
9176
9340
|
);
|
|
9177
9341
|
}
|
|
@@ -9226,7 +9390,9 @@ async function handleExec(env: Env, body: Record<string, unknown>, traceparent?:
|
|
|
9226
9390
|
// read from the body alone through the one validated reader the sandbox
|
|
9227
9391
|
// Worker uses, and handed to the exec's env option, never onto the command.
|
|
9228
9392
|
const execEnv = envFromRequest({ body });
|
|
9229
|
-
return streamThreadExec(
|
|
9393
|
+
return streamThreadExec(
|
|
9394
|
+
withLevels(ctx.stub, ctx.stub.execThread(ctx.threadKey, body.command, timeoutMs, traceparent, execEnv)),
|
|
9395
|
+
);
|
|
9230
9396
|
}
|
|
9231
9397
|
|
|
9232
9398
|
/** Stream one pending result with the thread-sandbox Worker's heartbeat
|
|
@@ -9277,13 +9443,18 @@ function streamHeartbeatJson<T>(
|
|
|
9277
9443
|
* `status`, its word and its `transient`,
|
|
9278
9444
|
* and the client reads it like the JSON routes' answer, never as a
|
|
9279
9445
|
* deterministic answer over HTTP 200. */
|
|
9280
|
-
function streamThreadExec(
|
|
9446
|
+
function streamThreadExec(
|
|
9447
|
+
pending: Promise<{ result: Awaited<ReturnType<ResidentDO["execThread"]>>; levels: ResidentLevelsDoc | null }>,
|
|
9448
|
+
): Response {
|
|
9281
9449
|
return streamHeartbeatJson(
|
|
9282
9450
|
pending,
|
|
9283
|
-
(result) =>
|
|
9284
|
-
"error" in result
|
|
9451
|
+
({ result, levels }) => ({
|
|
9452
|
+
...("error" in result
|
|
9285
9453
|
? execFailureDocument(result)
|
|
9286
|
-
: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, truncated: result.truncated },
|
|
9454
|
+
: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, truncated: result.truncated }),
|
|
9455
|
+
// Record 0064, record 0064: levels on every answer, the crossing outbox included.
|
|
9456
|
+
...(levels ? { levels } : {}),
|
|
9457
|
+
}),
|
|
9287
9458
|
(err) => execFailureDocument(threadRejectionErr(err, "/exec")),
|
|
9288
9459
|
);
|
|
9289
9460
|
}
|
|
@@ -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
|
},
|
|
@@ -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"
|
|
@@ -34,6 +34,18 @@
|
|
|
34
34
|
"optional": true,
|
|
35
35
|
"note": "Anthropic Admin API key (sk-ant-admin…) for the LLM-spend layer of GET /costs. Absent, /costs shows the Cloudflare side only."
|
|
36
36
|
},
|
|
37
|
+
{
|
|
38
|
+
"name": "OPENROUTER_MANAGEMENT_KEY",
|
|
39
|
+
"workers": ["bot"],
|
|
40
|
+
"optional": true,
|
|
41
|
+
"note": "OpenRouter management key for the openrouter biller's invoice tie-out on GET /costs (providers.openrouter.invoiceKeyEnv). Never the inference key. Absent, that biller shows no invoice."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "OPENAI_ADMIN_KEY",
|
|
45
|
+
"workers": ["bot"],
|
|
46
|
+
"optional": true,
|
|
47
|
+
"note": "OpenAI admin key for the openai biller's invoice tie-out on GET /costs (providers.openai.invoiceKeyEnv). Never the inference key. Absent, that biller shows no invoice."
|
|
48
|
+
},
|
|
37
49
|
{
|
|
38
50
|
"name": "BRAVE_SEARCH_API_KEY",
|
|
39
51
|
"workers": ["bot"],
|