@sema-agent/server 7.93.5 → 7.93.7
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/USAGE.md +8 -0
- package/dist/bake-runner/main.d.ts +28 -1
- package/dist/bake-runner/main.js +122 -38
- package/dist/bake-runner/runner.d.ts +85 -7
- package/dist/bake-runner/runner.js +116 -27
- package/dist/config-catalog.js +1 -1
- package/dist/hooks/hook-runner.js +5 -1
- package/dist/observability/fail-open.d.ts +16 -0
- package/dist/observability/fail-open.js +16 -0
- package/dist/plugins/background-agent-store-sql.js +36 -16
- package/dist/plugins/checkpoint-store-sql.d.ts +5 -1
- package/dist/plugins/checkpoint-store-sql.js +111 -66
- package/dist/plugins/host-platform.d.ts +66 -4
- package/dist/plugins/host-platform.js +14 -3
- package/dist/plugins/leader-run-store-sql.js +16 -6
- package/dist/plugins/remote-env-host.js +9 -21
- package/dist/plugins/run-store-sql.d.ts +1 -1
- package/dist/plugins/run-store-sql.js +41 -10
- package/dist/plugins/session-policy-store-sql.js +5 -1
- package/dist/plugins/sql-json-column.d.ts +49 -0
- package/dist/plugins/sql-json-column.js +10 -0
- package/dist/plugins/workflow-run-store-sql.js +14 -4
- package/package.json +1 -1
package/USAGE.md
CHANGED
|
@@ -842,6 +842,14 @@ ENGINE_ORPHAN_LINGER_MS=2000 # 可选,默认 2s:成孤儿之后还要静多久
|
|
|
842
842
|
引擎进程消失 2.0s、`LOCK` 归还 1.96s。最后那一段「停机收尾」本身还有自己的闸:`LOCK` 归还在收尾链
|
|
843
843
|
尾部的 `backend.close()`,正常亚秒级,但卡死的连接池会被 `hardShutdown` 的 **15s 终局闸**兜住 ⇒ 最坏
|
|
844
844
|
上界是「≤10s + 收尾闸」。自动重起的壳请按「撞锁就退避重试」写,别把 10s 当硬保证。
|
|
845
|
+
- 🔴 **`DRAIN_GRACE_MS` 的生效值分车道 —— 别照抄出厂默认**:出厂 `600000`(10 min)是**服务器部署**的
|
|
846
|
+
取值(半夜一次滚动重启,宁可等长一点也不要砍掉一条在跑的 turn)。而**壳自 spawn 的引擎会被壳注入更短
|
|
847
|
+
的窗**:sema-cli 在装子进程 env 时把它填成一个分钟级的值(2026-09-22 亲读壳侧实现:`60000`;那是**壳的**
|
|
848
|
+
常量,以你手上那版壳为准 —— 单机形下这个窗直接等于"退出后多久不能重开",见上一条的撞锁退避),
|
|
849
|
+
**用户/运维显式设的值原样尊重、壳不覆盖**。⇒ 排壳形的关机/重开预算时,以**进程实际
|
|
850
|
+
生效值**为准:`GET /v1/config/catalog` 里 `DRAIN_GRACE_MS` 那一行的 `effectiveValue` 就是本进程真在用的
|
|
851
|
+
数(它读的是活 config,不是这份文档里的默认值),启动期的夹取/警告行同源。本文档其余各处谈这条排空腿
|
|
852
|
+
时都不带数字,数字只有这一处 —— 照抄默认值去算壳形预算会差一个数量级。
|
|
845
853
|
- **自退留痕 `<数据根>/exit-last.json`**:自退在 **drain 开始那一刻**就把判据写进数据根(`orphan` /
|
|
846
854
|
`reason` / `idleForMs` / `attached` / `inflight` / `lingerMs` + `version` / `at` / `pid`)。壳的 exit
|
|
847
855
|
watch 往往在流断之后才跑,只看退出码分不清"它自己退的"与"它崩了";读到这个文件就能把这个 pid 归入
|
|
@@ -10,16 +10,43 @@ interface RunnerEnv {
|
|
|
10
10
|
heartbeatMs: number;
|
|
11
11
|
idlePollMs: number;
|
|
12
12
|
minFreeGb: number;
|
|
13
|
+
shutdownGraceMs: number;
|
|
13
14
|
}
|
|
14
15
|
declare function loadEnv(): RunnerEnv;
|
|
15
16
|
/** The real image-api HTTP client. Authenticates claim/heartbeat with the runner bearer; ingest additionally
|
|
16
17
|
* carries the per-bake ingest secret header (so image-api can reject a stale/rogue runner — §P2.4e). */
|
|
17
18
|
declare function makeApiClient(env: RunnerEnv, log: BakeRunnerLogger): ImageApiClient;
|
|
19
|
+
/**
|
|
20
|
+
* S-567 —— **bake-runner 自己的在飞进程组簿**。
|
|
21
|
+
*
|
|
22
|
+
* 为什么不复用 host 前台簿(`src/plugins/host-platform.ts` 的 `adoptHostForegroundChild`)—— **属主语义不同**
|
|
23
|
+
* (车LD §3(a) 判定):那本簿的契约逐字是「被一个**在飞 turn** 拥有的 host lane 前台 exec」,收割姿势是
|
|
24
|
+
* 无条件整组硬杀、不等宽限;而 build 组有自己的属主(一条 30–45min 的租约 + cancel/deadline 梯子 +
|
|
25
|
+
* image-api 的 reaper)。两者合进一本簿 = 把两条不同的属主语义压成一个词,规则集变大。而且 bake-runner
|
|
26
|
+
* 是**另一个进程**,`boot/exit-boundary.ts` 那只装配器的三条腿有两条在这里无对象。
|
|
27
|
+
*
|
|
28
|
+
* 本簿的契约就一句:**这个进程 spawn 出来、现在还活着的进程组领袖 pgid**。让它承重的是
|
|
29
|
+
* {@link killLiveGroupsSync} —— 挂在 bake-runner 自己的 `process.once("exit")` 上的**同步**收割,盖住信号腿
|
|
30
|
+
* 之外的那一族:崩溃退 / `main().catch(… process.exit(1))` / 梯子的 ④。(SIGKILL 对 `exit` 不触发 ——
|
|
31
|
+
* 这是内核语义,任何写法都救不了;关机梯子的意义恰恰是让运维不再被逼到 `kill -9`。)
|
|
32
|
+
*/
|
|
33
|
+
declare const liveGroupPgids: Set<number>;
|
|
34
|
+
declare function killLiveGroupsSync(): void;
|
|
18
35
|
/** The real child spawner: `setsid`-style detached process GROUP so a cancel/deadline kill signals the whole tree
|
|
19
36
|
* (channel-close / a plain kill of the immediate child does NOT stop a detached build — §P2.11). */
|
|
20
37
|
declare function makeSpawner(): ChildSpawner;
|
|
38
|
+
/**
|
|
39
|
+
* 有界、整组击杀、进簿的 `sh -c` —— **本进程唯一的 host exec 面**(C5)。
|
|
40
|
+
*
|
|
41
|
+
* 语义与被它取代的 `promisify(exec)` 对齐:非零退出/信号 ⇒ reject(调用方的 try/catch 与重试环照旧成立),
|
|
42
|
+
* 成功 ⇒ `{ stdout }`。多出来的两件:① `timeoutMs` 到点整组 SIGKILL 并 reject;② 它的组在 {@link liveGroupPgids}
|
|
43
|
+
* 里,于是关机梯子的 ④(`process.exit`)经退出边界收得到它 —— 旧形下一只挂死的 `docker push` 是看不见也杀不死的。
|
|
44
|
+
*/
|
|
45
|
+
export declare function execGroup(cmd: string, timeoutMs: number): Promise<{
|
|
46
|
+
stdout: string;
|
|
47
|
+
}>;
|
|
21
48
|
/** The real host ops: fs manifest read, df /data, recipe checkout, and the thin docker-push big-blob retry. */
|
|
22
49
|
declare function makeHostOps(env: RunnerEnv): HostOps;
|
|
23
50
|
declare function main(): Promise<void>;
|
|
24
|
-
export { main, loadEnv, makeApiClient, makeSpawner, makeHostOps };
|
|
51
|
+
export { main, loadEnv, makeApiClient, makeSpawner, makeHostOps, liveGroupPgids, killLiveGroupsSync };
|
|
25
52
|
//# sourceMappingURL=main.d.ts.map
|
package/dist/bake-runner/main.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { spawn
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { promisify } from "node:util";
|
|
4
3
|
import { createLogger } from "../observability/logger.js";
|
|
5
4
|
import { parseNumOrFailNonNegative } from "../config.js";
|
|
6
5
|
import { shellQuote as shellSafe } from "../plugins/remote-shell.js";
|
|
7
6
|
import { BakeRunner, } from "./runner.js";
|
|
8
|
-
const exec = promisify(execCb);
|
|
9
7
|
const BAKE_CLAIM_INGEST_FETCH_TIMEOUT_MS = 30_000;
|
|
10
8
|
const BAKE_HEARTBEAT_FETCH_TIMEOUT_MS = 10_000;
|
|
9
|
+
const BAKE_HOST_PROBE_TIMEOUT_MS = 10_000;
|
|
10
|
+
const BAKE_RECIPE_CHECKOUT_TIMEOUT_MS = 600_000;
|
|
11
|
+
const BAKE_DOCKER_PUSH_TIMEOUT_MS = 1_800_000;
|
|
12
|
+
const BAKE_HOST_EXEC_OUTPUT_CAP = 1024 * 1024;
|
|
11
13
|
function loadEnv() {
|
|
12
14
|
const need = (k) => {
|
|
13
15
|
const v = process.env[k];
|
|
@@ -26,6 +28,7 @@ function loadEnv() {
|
|
|
26
28
|
heartbeatMs: parseNumOrFailNonNegative("BAKE_HEARTBEAT_MS", process.env.BAKE_HEARTBEAT_MS || "30000"),
|
|
27
29
|
idlePollMs: parseNumOrFailNonNegative("BAKE_IDLE_POLL_MS", process.env.BAKE_IDLE_POLL_MS || "5000"),
|
|
28
30
|
minFreeGb: parseNumOrFailNonNegative("IMAGE_BAKE_MIN_FREE_GB", process.env.IMAGE_BAKE_MIN_FREE_GB || "20"),
|
|
31
|
+
shutdownGraceMs: parseNumOrFailNonNegative("BAKE_SHUTDOWN_GRACE_MS", process.env.BAKE_SHUTDOWN_GRACE_MS || "5000"),
|
|
29
32
|
};
|
|
30
33
|
}
|
|
31
34
|
function makeApiClient(env, log) {
|
|
@@ -90,34 +93,104 @@ function makeApiClient(env, log) {
|
|
|
90
93
|
},
|
|
91
94
|
};
|
|
92
95
|
}
|
|
93
|
-
|
|
96
|
+
const liveGroupPgids = new Set();
|
|
97
|
+
function signalGroupQuietly(pgid, signal) {
|
|
98
|
+
if (pgid <= 0)
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
process.kill(-pgid, signal);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function pruneEmptyGroups() {
|
|
107
|
+
for (const pgid of [...liveGroupPgids]) {
|
|
108
|
+
if (!groupHasMembers(pgid))
|
|
109
|
+
liveGroupPgids.delete(pgid);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function killLiveGroupsSync() {
|
|
113
|
+
for (const pgid of liveGroupPgids)
|
|
114
|
+
signalGroupQuietly(pgid, "SIGKILL");
|
|
115
|
+
liveGroupPgids.clear();
|
|
116
|
+
}
|
|
117
|
+
function spawnGroupChild(opts) {
|
|
118
|
+
const child = spawn(opts.argv[0], opts.argv.slice(1), {
|
|
119
|
+
cwd: opts.cwd,
|
|
120
|
+
detached: true,
|
|
121
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
122
|
+
env: process.env,
|
|
123
|
+
});
|
|
124
|
+
const pgid = child.pid ?? -1;
|
|
125
|
+
pruneEmptyGroups();
|
|
126
|
+
if (pgid > 0)
|
|
127
|
+
liveGroupPgids.add(pgid);
|
|
128
|
+
bufferLines(child.stdout, opts.onStdoutLine);
|
|
129
|
+
bufferLines(child.stderr, opts.onStderrLine);
|
|
130
|
+
const exited = new Promise((resolve) => {
|
|
131
|
+
child.on("exit", (code, signal) => {
|
|
132
|
+
pruneEmptyGroups();
|
|
133
|
+
resolve({ code, signal });
|
|
134
|
+
});
|
|
135
|
+
child.on("error", () => {
|
|
136
|
+
liveGroupPgids.delete(pgid);
|
|
137
|
+
resolve({ code: 127, signal: null });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
94
140
|
return {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
env: process.env,
|
|
101
|
-
});
|
|
102
|
-
const pgid = child.pid ?? -1;
|
|
103
|
-
bufferLines(child.stdout, opts.onStdoutLine);
|
|
104
|
-
bufferLines(child.stderr, opts.onStderrLine);
|
|
105
|
-
const exited = new Promise((resolve) => {
|
|
106
|
-
child.on("exit", (code, signal) => resolve({ code, signal }));
|
|
107
|
-
child.on("error", () => resolve({ code: 127, signal: null }));
|
|
108
|
-
});
|
|
109
|
-
const handle = {
|
|
110
|
-
pgid,
|
|
111
|
-
exited,
|
|
112
|
-
killGroup(signal) {
|
|
113
|
-
if (pgid > 0)
|
|
114
|
-
process.kill(-pgid, signal);
|
|
115
|
-
},
|
|
116
|
-
};
|
|
117
|
-
return handle;
|
|
141
|
+
pgid,
|
|
142
|
+
exited,
|
|
143
|
+
killGroup(signal) {
|
|
144
|
+
if (pgid > 0)
|
|
145
|
+
process.kill(-pgid, signal);
|
|
118
146
|
},
|
|
147
|
+
groupAlive: () => groupHasMembers(pgid),
|
|
119
148
|
};
|
|
120
149
|
}
|
|
150
|
+
function groupHasMembers(pgid) {
|
|
151
|
+
if (pgid <= 0)
|
|
152
|
+
return false;
|
|
153
|
+
try {
|
|
154
|
+
process.kill(-pgid, 0);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
return err.code === "EPERM";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function makeSpawner() {
|
|
162
|
+
return { spawn: (opts) => spawnGroupChild(opts) };
|
|
163
|
+
}
|
|
164
|
+
export async function execGroup(cmd, timeoutMs) {
|
|
165
|
+
let stdout = "";
|
|
166
|
+
let stderr = "";
|
|
167
|
+
const append = (buf, line) => (buf.length >= BAKE_HOST_EXEC_OUTPUT_CAP ? buf : `${buf}${line}\n`);
|
|
168
|
+
const child = spawnGroupChild({
|
|
169
|
+
argv: ["/bin/sh", "-c", cmd],
|
|
170
|
+
onStdoutLine: (l) => {
|
|
171
|
+
stdout = append(stdout, l);
|
|
172
|
+
},
|
|
173
|
+
onStderrLine: (l) => {
|
|
174
|
+
stderr = append(stderr, l);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
let timedOut = false;
|
|
178
|
+
const timer = setTimeout(() => {
|
|
179
|
+
timedOut = true;
|
|
180
|
+
signalGroupQuietly(child.pgid, "SIGKILL");
|
|
181
|
+
}, timeoutMs);
|
|
182
|
+
try {
|
|
183
|
+
const { code, signal } = await child.exited;
|
|
184
|
+
if (timedOut)
|
|
185
|
+
throw new Error(`host exec timed out after ${timeoutMs}ms (group killed): ${cmd.slice(0, 120)}`);
|
|
186
|
+
if (code !== 0)
|
|
187
|
+
throw new Error(`host exec exited ${code ?? signal}: ${cmd.slice(0, 120)}${stderr.trim() ? ` — ${stderr.trim().slice(-400)}` : ""}`);
|
|
188
|
+
return { stdout };
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
121
194
|
function bufferLines(stream, onLine) {
|
|
122
195
|
if (!stream)
|
|
123
196
|
return;
|
|
@@ -143,18 +216,18 @@ function makeHostOps(env) {
|
|
|
143
216
|
return JSON.parse(await readFile(path, "utf8"));
|
|
144
217
|
},
|
|
145
218
|
async dfData() {
|
|
146
|
-
const { stdout } = await
|
|
219
|
+
const { stdout } = await execGroup(`df -P -BG ${shellSafe(env.dataDir)}`, BAKE_HOST_PROBE_TIMEOUT_MS);
|
|
147
220
|
return stdout;
|
|
148
221
|
},
|
|
149
222
|
async checkoutRecipe(ref) {
|
|
150
223
|
if (ref && ref !== "HEAD") {
|
|
151
224
|
const dir = shellSafe(env.recipeDir);
|
|
152
225
|
try {
|
|
153
|
-
await
|
|
154
|
-
`git -C ${dir} checkout --quiet --detach FETCH_HEAD
|
|
226
|
+
await execGroup(`git -C ${dir} fetch --depth 1 origin ${shellSafe(ref)} && ` +
|
|
227
|
+
`git -C ${dir} checkout --quiet --detach FETCH_HEAD`, BAKE_RECIPE_CHECKOUT_TIMEOUT_MS);
|
|
155
228
|
}
|
|
156
229
|
catch {
|
|
157
|
-
await
|
|
230
|
+
await execGroup(`git -C ${dir} fetch origin && git -C ${dir} checkout --quiet --detach ${shellSafe(ref)}`, BAKE_RECIPE_CHECKOUT_TIMEOUT_MS);
|
|
158
231
|
}
|
|
159
232
|
}
|
|
160
233
|
return env.recipeDir;
|
|
@@ -162,8 +235,8 @@ function makeHostOps(env) {
|
|
|
162
235
|
async dockerPushRetry(tag, attempts) {
|
|
163
236
|
for (let i = 0; i < attempts; i++) {
|
|
164
237
|
try {
|
|
165
|
-
await
|
|
166
|
-
const { stdout } = await
|
|
238
|
+
await execGroup(`docker push ${shellSafe(tag)}`, BAKE_DOCKER_PUSH_TIMEOUT_MS);
|
|
239
|
+
const { stdout } = await execGroup(`docker image inspect ${shellSafe(tag)} --format '{{index .RepoDigests 0}}'`, BAKE_HOST_PROBE_TIMEOUT_MS);
|
|
167
240
|
const repoDigest = stdout.trim();
|
|
168
241
|
const digest = repoDigest.includes("@") ? repoDigest.slice(repoDigest.indexOf("@") + 1) : null;
|
|
169
242
|
return { ok: true, digest };
|
|
@@ -204,13 +277,24 @@ async function main() {
|
|
|
204
277
|
heartbeatMs: env.heartbeatMs,
|
|
205
278
|
idlePollMs: env.idlePollMs,
|
|
206
279
|
minFreeGb: env.minFreeGb,
|
|
280
|
+
shutdownGraceMs: env.shutdownGraceMs,
|
|
207
281
|
});
|
|
208
|
-
|
|
282
|
+
process.once("exit", killLiveGroupsSync);
|
|
283
|
+
let laddering = false;
|
|
284
|
+
const onSignal = (sig, signo) => {
|
|
285
|
+
if (laddering) {
|
|
286
|
+
log.warn("bake_runner_stop_forced", { sig });
|
|
287
|
+
process.exit(128 + signo);
|
|
288
|
+
}
|
|
289
|
+
laddering = true;
|
|
209
290
|
log.info("bake_runner_stop", { sig });
|
|
210
|
-
runner
|
|
291
|
+
void runner
|
|
292
|
+
.shutdown(sig)
|
|
293
|
+
.catch((err) => log.error("bake_runner_shutdown_error", { sig, err: String(err) }))
|
|
294
|
+
.finally(() => process.exit(128 + signo));
|
|
211
295
|
};
|
|
212
|
-
process.on("SIGTERM", () =>
|
|
213
|
-
process.on("SIGINT", () =>
|
|
296
|
+
process.on("SIGTERM", () => onSignal("SIGTERM", 15));
|
|
297
|
+
process.on("SIGINT", () => onSignal("SIGINT", 2));
|
|
214
298
|
await runner.loop();
|
|
215
299
|
}
|
|
216
300
|
const invokedDirectly = process.argv[1] !== undefined && /bake-runner[/\\]main\.(ts|js)$/.test(process.argv[1]);
|
|
@@ -220,5 +304,5 @@ if (invokedDirectly) {
|
|
|
220
304
|
process.exit(1);
|
|
221
305
|
});
|
|
222
306
|
}
|
|
223
|
-
export { main, loadEnv, makeApiClient, makeSpawner, makeHostOps };
|
|
307
|
+
export { main, loadEnv, makeApiClient, makeSpawner, makeHostOps, liveGroupPgids, killLiveGroupsSync };
|
|
224
308
|
//# sourceMappingURL=main.js.map
|
|
@@ -31,6 +31,10 @@ export interface ChildHandle {
|
|
|
31
31
|
}>;
|
|
32
32
|
/** Send a signal to the process GROUP (SIGTERM then SIGKILL on the cancel/deadline path). */
|
|
33
33
|
killGroup(signal: NodeJS.Signals): void;
|
|
34
|
+
/** Does the process GROUP still have ANY member? (`kill(-pgid, 0)`.) 🔴 S-567 codex ①: this — not {@link exited} — is
|
|
35
|
+
* the group's lifetime. `exited` resolves when the LEADER exits; a leader that dies on SIGTERM can leave a
|
|
36
|
+
* TERM-immune descendant in the same group, and treating leader-exit as "the group is gone" lets it escape. */
|
|
37
|
+
groupAlive(): boolean;
|
|
34
38
|
}
|
|
35
39
|
/** Spawn `build.sh <argv> --json` detached in its own process group; stdout/stderr are delivered LINE-BY-LINE via
|
|
36
40
|
* the callbacks (the impl splits on newlines). The runner feeds ONLY stdout lines through the JSON parser; stderr
|
|
@@ -96,22 +100,75 @@ export interface BakeRunnerOpts {
|
|
|
96
100
|
/** Minimum free GiB on /data at claim; below this the bake fails fast (507-class) rather than dying mid-build and
|
|
97
101
|
* leaving a half-layer (k3s + docker containerd both on /data — a fill triggers kubelet GC eviction — §P2.7). */
|
|
98
102
|
minFreeGb?: number;
|
|
103
|
+
/** S-567 —— **一只注定要死的 build 进程组的「体面赴死窗」**(ms,`BAKE_SHUTDOWN_GRACE_MS`,默认 5000)。
|
|
104
|
+
*
|
|
105
|
+
* 两条路读同一个数,因为它们问的是**同一个问题**:
|
|
106
|
+
* · cancel / deadline 击杀({@link BakeRunner.runOne} 的 supervisor):SIGTERM → 本窗 → SIGKILL;
|
|
107
|
+
* · 关机梯子({@link BakeRunner.shutdown}):本窗同时是**整条梯子的总预算** —— 第②步(软杀→硬杀)花掉多少,
|
|
108
|
+
* 第③步(等 `runOne` 把终局 done 送出去)就只剩多少。于是对外只有一句话的契约:
|
|
109
|
+
* **收到 SIGTERM/SIGINT 后,runner 在约 `BAKE_SHUTDOWN_GRACE_MS` 内退出**。
|
|
110
|
+
*
|
|
111
|
+
* `0` = 不给窗,SIGTERM 之后立刻 SIGKILL(且梯子第③步没有预算 —— 终局 done 交给 image-api 的 lease reaper
|
|
112
|
+
* 兜底:reaper 记的是**失败**,不是把已完成的工作找回来)。 */
|
|
113
|
+
shutdownGraceMs?: number;
|
|
99
114
|
}
|
|
100
115
|
export declare class BakeRunner {
|
|
101
116
|
private readonly o;
|
|
102
117
|
private readonly heartbeatMs;
|
|
103
118
|
private readonly idlePollMs;
|
|
104
119
|
private readonly minFreeGb;
|
|
105
|
-
private
|
|
120
|
+
private readonly shutdownGraceMs;
|
|
121
|
+
/** ① of the ladder — the claim gate. Read at the loop head AND immediately above `spawner.spawn` (see `runOne`). */
|
|
122
|
+
private stopping;
|
|
123
|
+
/** The ladder itself, memoized ⇒ idempotent: N signals, one ladder, one awaitable. */
|
|
124
|
+
private ladder;
|
|
125
|
+
private inFlight;
|
|
126
|
+
/** The in-flight `runOne` (terminal fallback included) — what the ladder's ③ awaits so the bake lands. */
|
|
127
|
+
private inFlightRun;
|
|
128
|
+
/**
|
|
129
|
+
* 🔴 **在飞组杀梯子的属主**(修复验证轮)。三条路(cancel / deadline / 关机)起的梯子全登记在这里,
|
|
130
|
+
* 因为每一只都以一只 `clock.sleep` 作为升级的承重点 —— 而梯子的 ④ 是 `process.exit`,退出会把一切
|
|
131
|
+
* 未到点的定时器**取消**。只把关机自己发起的那只 await 住是不够的:取消 / deadline 发完 SIGTERM
|
|
132
|
+
* 正等升级时,领袖一退 `runOne` 就收车、`inFlight` 被清,关机于是看到「没有在飞的 build」立刻收敛,
|
|
133
|
+
* 那只待发的 SIGKILL 就死在退出上。这是 C2 同类,且是**本批引入的回归**:v7.93.5 的关机根本不退(只置
|
|
134
|
+
* flag),那只定时器总会到点。现在「在飞的组杀」有了属主,关机在预算内等它们走完。
|
|
135
|
+
*/
|
|
136
|
+
private readonly liveKills;
|
|
106
137
|
constructor(opts: BakeRunnerOpts);
|
|
107
|
-
/**
|
|
108
|
-
|
|
138
|
+
/**
|
|
139
|
+
* S-567 —— **关机梯子**:幂等、可 await、四步,一处承重。
|
|
140
|
+
*
|
|
141
|
+
* ── 病(车LD §2.3,28 次真进程×两套运行时、零反例)───────────────────────────────
|
|
142
|
+
* 旧 `stop()` 只置一个 flag,而 `loop()` 此刻正 `await this.runOne(...)`、`runOne` 正 `await child.exited`
|
|
143
|
+
* ⇒ 进程挂到在飞构建自然结束(30–45min 量级)。实测:SIGINT/SIGTERM 后 t+6s runner 与 build 组
|
|
144
|
+
* **全 alive**。运维看到的是「Ctrl-C 按了没反应」,于是只剩 `kill -9` —— 而 SIGKILL 恰好是**唯一留孤儿**
|
|
145
|
+
* 的那一形(build 组 `ppid=1`)。两只什么也不做的处理器,把人**引到了**病的入口。
|
|
146
|
+
*
|
|
147
|
+
* ── 梯子四步(判点坐标写在各步旁)────────────────────────────────────────
|
|
148
|
+
* ① **停 claim**:`stopping = true`。在飞的那一次 claim 跟完,不再取下一条。
|
|
149
|
+
* ② **在飞 build 组:软杀 → 宽限 → 硬杀**({@link killGroupLadder},与 cancel/deadline 同一只梯子)。
|
|
150
|
+
* ③ **等 `runOne` 落地**:终局 `done{CANCELLED}` 送达 image-api(不当成功、也不当失败重试风暴 ——
|
|
151
|
+
* CANCELLED 在 image-api 侧落成 coarse `failed` + errorCode `cancelled`,是既有闭集里的词,本批不新增词)。
|
|
152
|
+
* ④ **退**:`process.exit(128+signo)` —— 这一步在 `main.ts`(进程面归入口文件,本文件不碰 `process`)。
|
|
153
|
+
*
|
|
154
|
+
* 幂等 = 记住梯子本人(不是一个 bool):第二个信号 await 到**同一只** promise,梯子只跑一遍。
|
|
155
|
+
* 「第二个信号 = 直接硬退」那一臂在 `main.ts`(它才有 `process.exit`)。
|
|
156
|
+
*/
|
|
157
|
+
shutdown(reason: string): Promise<void>;
|
|
158
|
+
private runLadder;
|
|
159
|
+
/**
|
|
160
|
+
* 起一只组杀梯子并**登记**它 —— cancel / deadline / 关机 三条路的**唯一**入口(一处承重)。
|
|
161
|
+
* 调用方可以 await 它(supervisor 就这么做),也可以不 await(关机的 ②)—— 不论哪种,
|
|
162
|
+
* 关机的 ③ 都能在 {@link liveKills} 里找到它并把它等完。
|
|
163
|
+
*/
|
|
164
|
+
private startKill;
|
|
109
165
|
/** The long-poll loop: claim → run → release → poll next. Single-flight is enforced DB-side (the claim CAS), so a
|
|
110
|
-
* second runner replica simply never wins a claim. Runs until
|
|
166
|
+
* second runner replica simply never wins a claim. Runs until {@link shutdown} flips the claim gate. */
|
|
111
167
|
loop(): Promise<void>;
|
|
112
168
|
/** Run a single CLAIMED bake end-to-end. Owns the lineOrd counter, the heartbeat/cancel/deadline timers, the
|
|
113
169
|
* child process group, the stdout→ingest pump, and the terminal done synthesis. */
|
|
114
170
|
runOne(bake: ClaimedBake): Promise<void>;
|
|
171
|
+
private runOneInner;
|
|
115
172
|
/** Forward build.sh's own `done` line — but FIRST read the manifest BODY off disk and attach it so image-api can
|
|
116
173
|
* auto-register (build.sh's done carries only a build-host path, NOT the body — §P2.10). A manifest read failure is
|
|
117
174
|
* non-fatal: the done still forwards (the digest is on the line); image-api then registers indexId:null partial.
|
|
@@ -122,9 +179,30 @@ export declare class BakeRunner {
|
|
|
122
179
|
* with the recovered post-push digest (no manifest on disk — build.sh dies before the manifest write — so image-
|
|
123
180
|
* api registers the built-and-pushed-but-unregistered partial, which the P3 reconciliation tick finalizes). */
|
|
124
181
|
private forwardDone;
|
|
125
|
-
/**
|
|
126
|
-
*
|
|
127
|
-
|
|
182
|
+
/**
|
|
183
|
+
* 组杀**梯子**:SIGTERM → 最多 `budgetMs` 等组死 → SIGKILL。**可 await,且在调用方的 await 链里跑完**。
|
|
184
|
+
*
|
|
185
|
+
* 🔴 为什么不能再挂定时器(车LD/codex C2,亲核旧码):旧写法把 SIGKILL 升级挂在
|
|
186
|
+
* `void clock.sleep(5_000).then(...)` 上并立即返回 void。cancel/deadline 那条路上它碰巧能走到(进程还活着),
|
|
187
|
+
* 而关机路上 `process.exit()` 一到就把那只定时器**取消**了 ⇒ 一只 `trap '' TERM` 的后代照旧存活,
|
|
188
|
+
* 而读数是「已收割」。改成可 await 之后,升级在**退出之前**就已经发出去了。
|
|
189
|
+
*
|
|
190
|
+
* 两条路共用同一只梯子(cancel/deadline 与关机)—— 它们问的是同一个问题:“一只注定要死的组,
|
|
191
|
+
* 给它多久体面赴死”。`budgetMs <= 0` = 不给窗,SIGTERM 后立刻 SIGKILL。
|
|
192
|
+
*
|
|
193
|
+
* 射程如实记(本仓任何改法都够不着):build.sh 的 `docker build` 把真正的构建提交给**宿主 dockerd**
|
|
194
|
+
* (挂载的 `/var/run/docker.sock`,在 pod cgroup 之外)—— 组杀收的是 docker **客户端**,宿主侧的构建
|
|
195
|
+
* 不受本进程信号支配(既有射程,非本批新开)。一次 SIGKILL 死在 docker build 中间会留悬挂层;
|
|
196
|
+
* dup-check + lockhash 保证重烤不被毒化,悬挂层本身由 docker 自己的 prune / 运维回收。
|
|
197
|
+
* (旧注声称“一条启动期的工作目录清扫会把它们收回去”—— **假补偿**:全 `src/` 无此实现,build.sh 侧也只有
|
|
198
|
+
* per-job workdir + `mktemp -d` 回退,没有任何启动期清扫 —— 车LD §3(3) 亲核,S-567 删句:过期的“已被兜住”
|
|
199
|
+
* 声明比欠账更危险。真要做清扫,那是带自己设计的另一台车。)
|
|
200
|
+
*/
|
|
201
|
+
private killGroupLadder;
|
|
202
|
+
private signalGroup;
|
|
203
|
+
/** `p` 赢了 ⇒ true;`budgetMs` 先到(或预算已空)⇒ false。`p` 的异常不往外抛(梯子只关心「落地了没」)。 */
|
|
204
|
+
private raceBudget;
|
|
205
|
+
private remainingMs;
|
|
128
206
|
private stopSupervisor;
|
|
129
207
|
/** Pre-flight free-GiB read; null (fail-open) on a df parse/exec miss — a df hiccup must not block a bake (the
|
|
130
208
|
* reaper + a mid-build fill guard still protect the host). */
|
|
@@ -3,24 +3,68 @@ import { lineToFrame, dockerBuildTickFrame, synthDoneFrame, exitDoneFrame, deadl
|
|
|
3
3
|
const DEFAULT_HEARTBEAT_MS = 30_000;
|
|
4
4
|
const DEFAULT_IDLE_POLL_MS = 5_000;
|
|
5
5
|
const DEFAULT_MIN_FREE_GB = 20;
|
|
6
|
+
const DEFAULT_SHUTDOWN_GRACE_MS = 5_000;
|
|
7
|
+
const GROUP_REAP_POLL_MS = 50;
|
|
6
8
|
const TERMINAL_FALLBACK_ORD = 2_000_000_000;
|
|
7
9
|
export class BakeRunner {
|
|
8
10
|
o;
|
|
9
11
|
heartbeatMs;
|
|
10
12
|
idlePollMs;
|
|
11
13
|
minFreeGb;
|
|
12
|
-
|
|
14
|
+
shutdownGraceMs;
|
|
15
|
+
stopping = false;
|
|
16
|
+
ladder = null;
|
|
17
|
+
inFlight = null;
|
|
18
|
+
inFlightRun = null;
|
|
19
|
+
liveKills = [];
|
|
13
20
|
constructor(opts) {
|
|
14
21
|
this.o = opts;
|
|
15
22
|
this.heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
16
23
|
this.idlePollMs = opts.idlePollMs ?? DEFAULT_IDLE_POLL_MS;
|
|
17
24
|
this.minFreeGb = opts.minFreeGb ?? DEFAULT_MIN_FREE_GB;
|
|
25
|
+
this.shutdownGraceMs = opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS;
|
|
18
26
|
}
|
|
19
|
-
|
|
20
|
-
this.
|
|
27
|
+
shutdown(reason) {
|
|
28
|
+
this.ladder ??= this.runLadder(reason);
|
|
29
|
+
return this.ladder;
|
|
30
|
+
}
|
|
31
|
+
async runLadder(reason) {
|
|
32
|
+
const deadline = this.o.clock.now() + this.shutdownGraceMs;
|
|
33
|
+
this.stopping = true;
|
|
34
|
+
this.o.log.info("bake_runner_shutdown", { reason, graceMs: this.shutdownGraceMs, hasInFlight: this.inFlight !== null });
|
|
35
|
+
const inFlight = this.inFlight;
|
|
36
|
+
if (inFlight !== null && inFlight.killed === null) {
|
|
37
|
+
inFlight.killed = { kind: "cancelled", error: `bake aborted: bake-runner shutting down (${reason})` };
|
|
38
|
+
if (inFlight.child !== null)
|
|
39
|
+
this.startKill(inFlight.child, this.remainingMs(deadline));
|
|
40
|
+
}
|
|
41
|
+
const pending = [...this.liveKills];
|
|
42
|
+
if (this.inFlightRun !== null)
|
|
43
|
+
pending.push(this.inFlightRun);
|
|
44
|
+
if (pending.length > 0) {
|
|
45
|
+
const landed = await this.raceBudget(Promise.allSettled(pending).then(() => true), this.remainingMs(deadline));
|
|
46
|
+
if (!landed) {
|
|
47
|
+
this.o.log.warn("bake_shutdown_budget_exhausted", {
|
|
48
|
+
reason,
|
|
49
|
+
graceMs: this.shutdownGraceMs,
|
|
50
|
+
liveKills: this.liveKills.length,
|
|
51
|
+
runPending: this.inFlightRun !== null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
startKill(child, budgetMs) {
|
|
57
|
+
const p = this.killGroupLadder(child, budgetMs);
|
|
58
|
+
this.liveKills.push(p);
|
|
59
|
+
void p.finally(() => {
|
|
60
|
+
const i = this.liveKills.indexOf(p);
|
|
61
|
+
if (i >= 0)
|
|
62
|
+
this.liveKills.splice(i, 1);
|
|
63
|
+
});
|
|
64
|
+
return p;
|
|
21
65
|
}
|
|
22
66
|
async loop() {
|
|
23
|
-
while (!this.
|
|
67
|
+
while (!this.stopping) {
|
|
24
68
|
let claimed = null;
|
|
25
69
|
try {
|
|
26
70
|
claimed = await this.o.api.claimNext();
|
|
@@ -29,21 +73,43 @@ export class BakeRunner {
|
|
|
29
73
|
this.o.log.warn("bake_claim_error", { err: String(err) });
|
|
30
74
|
}
|
|
31
75
|
if (!claimed) {
|
|
76
|
+
if (this.stopping)
|
|
77
|
+
return;
|
|
32
78
|
await this.o.clock.sleep(this.idlePollMs);
|
|
33
79
|
continue;
|
|
34
80
|
}
|
|
81
|
+
const bake = claimed;
|
|
82
|
+
const run = (async () => {
|
|
83
|
+
try {
|
|
84
|
+
await this.runOne(bake);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
this.o.log.error("bake_run_error", { bakeId: bake.bakeId, err: String(err) });
|
|
88
|
+
await this.safeIngest(bake, exitDoneFrame(TERMINAL_FALLBACK_ORD, 1, String(err)));
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
this.inFlightRun = run;
|
|
35
92
|
try {
|
|
36
|
-
await
|
|
93
|
+
await run;
|
|
37
94
|
}
|
|
38
|
-
|
|
39
|
-
this.
|
|
40
|
-
await this.safeIngest(claimed, exitDoneFrame(TERMINAL_FALLBACK_ORD, 1, String(err)));
|
|
95
|
+
finally {
|
|
96
|
+
this.inFlightRun = null;
|
|
41
97
|
}
|
|
42
98
|
}
|
|
43
99
|
}
|
|
44
100
|
async runOne(bake) {
|
|
45
101
|
const ord = { n: 0 };
|
|
46
102
|
this.o.log.info("bake_claimed", { bakeId: bake.bakeId, push: bake.push, dryRun: bake.dryRun, logs: bake.logs });
|
|
103
|
+
const inFlight = { child: null, killed: null };
|
|
104
|
+
this.inFlight = inFlight;
|
|
105
|
+
try {
|
|
106
|
+
await this.runOneInner(bake, ord, inFlight);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
this.inFlight = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async runOneInner(bake, ord, inFlight) {
|
|
47
113
|
let pump = Promise.resolve();
|
|
48
114
|
const enqueueIngest = (frame) => {
|
|
49
115
|
pump = pump.then(() => this.safeIngest(bake, frame));
|
|
@@ -77,6 +143,11 @@ export class BakeRunner {
|
|
|
77
143
|
let sawDone = false;
|
|
78
144
|
const buildShAbs = resolvePath(cwd, this.o.buildShPath);
|
|
79
145
|
const buildArgv = [buildShAbs, ...bake.argv, "--json"];
|
|
146
|
+
if (this.stopping) {
|
|
147
|
+
this.o.log.info("bake_shutdown_before_spawn", { bakeId: bake.bakeId });
|
|
148
|
+
await this.safeIngest(bake, synthDoneFrame(nextOrd(ord), "cancelled", { error: "bake aborted: bake-runner shut down before the build started" }));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
80
151
|
const child = this.o.spawner.spawn({
|
|
81
152
|
argv: buildArgv,
|
|
82
153
|
cwd,
|
|
@@ -100,20 +171,20 @@ export class BakeRunner {
|
|
|
100
171
|
stderrTail.push(raw);
|
|
101
172
|
},
|
|
102
173
|
});
|
|
174
|
+
inFlight.child = child;
|
|
103
175
|
const deadlineMs = deadlineMsForProfile(bake.profile ?? "custom", bake.bands ?? null);
|
|
104
176
|
const startedAt = this.o.clock.now();
|
|
105
|
-
let killedFor = null;
|
|
106
177
|
let childExited = false;
|
|
107
178
|
const supervisor = (async () => {
|
|
108
179
|
while (true) {
|
|
109
180
|
await this.o.clock.sleep(this.heartbeatMs);
|
|
110
|
-
if (childExited ||
|
|
181
|
+
if (childExited || inFlight.killed)
|
|
111
182
|
return;
|
|
112
183
|
const elapsed = this.o.clock.now() - startedAt;
|
|
113
184
|
if (elapsed >= deadlineMs) {
|
|
114
|
-
|
|
185
|
+
inFlight.killed = { kind: "timeout" };
|
|
115
186
|
this.o.log.warn("bake_deadline_kill", { bakeId: bake.bakeId, elapsedMs: elapsed, deadlineMs });
|
|
116
|
-
this.
|
|
187
|
+
await this.startKill(child, this.shutdownGraceMs);
|
|
117
188
|
return;
|
|
118
189
|
}
|
|
119
190
|
let ack;
|
|
@@ -125,15 +196,15 @@ export class BakeRunner {
|
|
|
125
196
|
continue;
|
|
126
197
|
}
|
|
127
198
|
if (ack.cancelRequested) {
|
|
128
|
-
|
|
199
|
+
inFlight.killed = { kind: "cancelled" };
|
|
129
200
|
this.o.log.info("bake_cancel_kill", { bakeId: bake.bakeId });
|
|
130
|
-
this.
|
|
201
|
+
await this.startKill(child, this.shutdownGraceMs);
|
|
131
202
|
return;
|
|
132
203
|
}
|
|
133
204
|
if (ack.leaseValid === false) {
|
|
134
|
-
|
|
205
|
+
inFlight.killed = { kind: "timeout", error: "bake-runner lost its lease (reaped as stale) — build killed" };
|
|
135
206
|
this.o.log.warn("bake_lease_lost", { bakeId: bake.bakeId });
|
|
136
|
-
this.
|
|
207
|
+
await this.startKill(child, this.shutdownGraceMs);
|
|
137
208
|
return;
|
|
138
209
|
}
|
|
139
210
|
enqueueIngest(dockerBuildTickFrame(nextOrd(ord), Math.floor(elapsed / 1000)));
|
|
@@ -150,8 +221,9 @@ export class BakeRunner {
|
|
|
150
221
|
childExited = true;
|
|
151
222
|
this.stopSupervisor(supervisor);
|
|
152
223
|
await pump.catch(() => undefined);
|
|
153
|
-
|
|
154
|
-
|
|
224
|
+
const killed = inFlight.killed;
|
|
225
|
+
if (killed) {
|
|
226
|
+
await this.safeIngest(bake, synthDoneFrame(nextOrd(ord), killed.kind, killed.error === undefined ? undefined : { error: killed.error }));
|
|
155
227
|
return;
|
|
156
228
|
}
|
|
157
229
|
if (sawDone) {
|
|
@@ -193,19 +265,36 @@ export class BakeRunner {
|
|
|
193
265
|
}
|
|
194
266
|
await this.safeIngest(bake, frame);
|
|
195
267
|
}
|
|
196
|
-
|
|
268
|
+
async killGroupLadder(child, budgetMs) {
|
|
269
|
+
this.signalGroup(child, "SIGTERM");
|
|
270
|
+
const deadline = this.o.clock.now() + budgetMs;
|
|
271
|
+
while (child.groupAlive()) {
|
|
272
|
+
const left = deadline - this.o.clock.now();
|
|
273
|
+
if (left <= 0) {
|
|
274
|
+
this.signalGroup(child, "SIGKILL");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
await this.o.clock.sleep(Math.min(left, GROUP_REAP_POLL_MS));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
signalGroup(child, signal) {
|
|
197
281
|
try {
|
|
198
|
-
child.killGroup(
|
|
282
|
+
child.killGroup(signal);
|
|
199
283
|
}
|
|
200
284
|
catch {
|
|
201
285
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
286
|
+
}
|
|
287
|
+
async raceBudget(p, budgetMs) {
|
|
288
|
+
let landed = false;
|
|
289
|
+
const settled = p.then(() => (landed = true), () => (landed = true));
|
|
290
|
+
if (budgetMs <= 0) {
|
|
291
|
+
await Promise.resolve();
|
|
292
|
+
return landed;
|
|
293
|
+
}
|
|
294
|
+
return Promise.race([settled.then(() => true), this.o.clock.sleep(budgetMs).then(() => false)]);
|
|
295
|
+
}
|
|
296
|
+
remainingMs(deadline) {
|
|
297
|
+
return Math.max(0, deadline - this.o.clock.now());
|
|
209
298
|
}
|
|
210
299
|
stopSupervisor(p) {
|
|
211
300
|
void p.catch(() => undefined);
|
package/dist/config-catalog.js
CHANGED
|
@@ -127,7 +127,7 @@ export const CONFIG_CATALOG = [
|
|
|
127
127
|
r("DOCKER_PATH", "orchestration", "path", "docker 二进制路径"),
|
|
128
128
|
r("DOCKER_PIDS_LIMIT", "orchestration", "number", "local-docker fork-bomb 背栓(--pids-limit;非法值回 adapter 默认 512+warn)", { danger: SEC, derivedDefaultNote: "缺席 ⇒ adapter 默认 512" }),
|
|
129
129
|
r("DOCKER_SANDBOX_ENV", "orchestration", "csv", "转发进容器的本进程 env **名字**表(值只在 worker env,不进 spec/center)", { danger: SEC }),
|
|
130
|
-
r("DRAIN_GRACE_MS", "limitsHttp", "number", "SIGTERM 优雅排空窗(ms;期间新计费提交 503+Retry-After)", { staticDefault: String(10 * 60 * 1000), configKey: "drainGraceMs", center: cLimits("drainGraceMs") }),
|
|
130
|
+
r("DRAIN_GRACE_MS", "limitsHttp", "number", "SIGTERM 优雅排空窗(ms;期间新计费提交 503+Retry-After)。出厂默认是**服务器部署**的取值;壳自 spawn 的引擎会被壳注入更短的窗 ⇒ 排壳形关机预算时以本行的 effectiveValue(= 本进程实际生效值)为准,不照抄 staticDefault", { staticDefault: String(10 * 60 * 1000), configKey: "drainGraceMs", center: cLimits("drainGraceMs") }),
|
|
131
131
|
r("DURABLE_APPROVAL", "approval", "boolean", "durable suspend/resume HITL(需 SQL 店;决定 checkpointStore 与 /v1/approvals 面的存在性)", { danger: SEC, derivedDefaultNote: "posture 三态(S-178):显式词恒赢,缺席 = 单用户(REQUIRE_PRINCIPAL≠true)且有耐久店 ⇒ on / 多租户或无耐久店 ⇒ off;生效来源见 GET /v1/diagnostics/wiring 的 serverGates.durableApproval.source", configKey: "durableApproval" }),
|
|
132
132
|
r("E2B_ALLOW_NET", "orchestration", "boolean", "e2b 沙箱出网许可", { danger: SEC, derivedDefaultNote: "缺席 ⇒ 不铸键(adapter 默认允许)" }),
|
|
133
133
|
r("E2B_API_KEY", "orchestration", "secret", "E2B 车道 API 凭据(REMOTE_EXEC=e2b 必需件)", { danger: [...SEC, ...BILL] }),
|