@sema-agent/server 7.93.4 → 7.93.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/bake-runner/main.d.ts +28 -1
  2. package/dist/bake-runner/main.js +122 -38
  3. package/dist/bake-runner/runner.d.ts +85 -7
  4. package/dist/bake-runner/runner.js +116 -27
  5. package/dist/boot/exit-boundary.d.ts +22 -0
  6. package/dist/boot/exit-boundary.js +73 -0
  7. package/dist/boot/shutdown.d.ts +2 -2
  8. package/dist/boot/shutdown.js +5 -62
  9. package/dist/hooks/hook-runner.js +5 -1
  10. package/dist/observability/fail-open.d.ts +16 -0
  11. package/dist/observability/fail-open.js +16 -0
  12. package/dist/plugins/approval-ask-store-sql.d.ts +1 -1
  13. package/dist/plugins/approval-ask-store-sql.js +36 -19
  14. package/dist/plugins/background-agent-store-sql.js +36 -16
  15. package/dist/plugins/checkpoint-store-sql.d.ts +5 -1
  16. package/dist/plugins/checkpoint-store-sql.js +111 -66
  17. package/dist/plugins/device-store-sql.d.ts +10 -0
  18. package/dist/plugins/device-store-sql.js +12 -6
  19. package/dist/plugins/host-platform.d.ts +70 -6
  20. package/dist/plugins/host-platform.js +14 -3
  21. package/dist/plugins/leader-run-store-sql.js +16 -6
  22. package/dist/plugins/one-time-migrations.d.ts +3 -0
  23. package/dist/plugins/one-time-migrations.js +16 -4
  24. package/dist/plugins/permission-rule-store-sql.d.ts +1 -0
  25. package/dist/plugins/permission-rule-store-sql.js +37 -28
  26. package/dist/plugins/remote-env-host.js +9 -21
  27. package/dist/plugins/run-store-sql.d.ts +1 -1
  28. package/dist/plugins/run-store-sql.js +41 -10
  29. package/dist/plugins/session-policy-store-sql.d.ts +24 -0
  30. package/dist/plugins/session-policy-store-sql.js +17 -18
  31. package/dist/plugins/sql-json-column.d.ts +177 -0
  32. package/dist/plugins/sql-json-column.js +74 -0
  33. package/dist/plugins/task-list-store-sql.d.ts +13 -1
  34. package/dist/plugins/task-list-store-sql.js +23 -10
  35. package/dist/plugins/workflow-run-store-sql.js +14 -4
  36. package/dist/run-local.d.ts +15 -0
  37. package/dist/run-local.js +32 -0
  38. package/package.json +1 -1
@@ -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
@@ -1,13 +1,15 @@
1
- import { spawn, exec as execCb } from "node:child_process";
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
- function makeSpawner() {
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
- spawn(opts) {
96
- const child = spawn(opts.argv[0], opts.argv.slice(1), {
97
- cwd: opts.cwd,
98
- detached: true,
99
- stdio: ["ignore", "pipe", "pipe"],
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 exec(`df -P -BG ${shellSafe(env.dataDir)}`);
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 exec(`git -C ${dir} fetch --depth 1 origin ${shellSafe(ref)} && ` +
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 exec(`git -C ${dir} fetch origin && git -C ${dir} checkout --quiet --detach ${shellSafe(ref)}`);
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 exec(`docker push ${shellSafe(tag)}`);
166
- const { stdout } = await exec(`docker image inspect ${shellSafe(tag)} --format '{{index .RepoDigests 0}}'`);
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
- const shutdown = (sig) => {
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.stop();
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", () => shutdown("SIGTERM"));
213
- process.on("SIGINT", () => shutdown("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 stopped;
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
- /** Cooperative shutdown — finishes the in-flight bake's current frame, then exits the claim loop. */
108
- stop(): void;
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 `stop()`. */
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
- /** Kill the process GROUP: SIGTERM, then SIGKILL shortly after (a SIGKILL mid-docker-build can leave dangling
126
- * layers; the dup-check + lockhash prevent a poisoned re-bake, and a startup workdir sweep reclaims them — §P2.11). */
127
- private killGroup;
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
- stopped = false;
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
- stop() {
20
- this.stopped = true;
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.stopped) {
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 this.runOne(claimed);
93
+ await run;
37
94
  }
38
- catch (err) {
39
- this.o.log.error("bake_run_error", { bakeId: claimed.bakeId, err: String(err) });
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 || killedFor)
181
+ if (childExited || inFlight.killed)
111
182
  return;
112
183
  const elapsed = this.o.clock.now() - startedAt;
113
184
  if (elapsed >= deadlineMs) {
114
- killedFor = "timeout";
185
+ inFlight.killed = { kind: "timeout" };
115
186
  this.o.log.warn("bake_deadline_kill", { bakeId: bake.bakeId, elapsedMs: elapsed, deadlineMs });
116
- this.killGroup(child);
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
- killedFor = "cancelled";
199
+ inFlight.killed = { kind: "cancelled" };
129
200
  this.o.log.info("bake_cancel_kill", { bakeId: bake.bakeId });
130
- this.killGroup(child);
201
+ await this.startKill(child, this.shutdownGraceMs);
131
202
  return;
132
203
  }
133
204
  if (ack.leaseValid === false) {
134
- killedFor = "timeout";
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.killGroup(child);
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
- if (killedFor) {
154
- await this.safeIngest(bake, synthDoneFrame(nextOrd(ord), killedFor));
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
- killGroup(child) {
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("SIGTERM");
282
+ child.killGroup(signal);
199
283
  }
200
284
  catch {
201
285
  }
202
- void this.o.clock.sleep(5_000).then(() => {
203
- try {
204
- child.killGroup("SIGKILL");
205
- }
206
- catch {
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);
@@ -0,0 +1,22 @@
1
+ import type { Logger } from "../observability/logger.js";
2
+ /** {@link installProcessExitBoundary} 的把手。 */
3
+ export interface ProcessExitBoundary {
4
+ /**
5
+ * **优化点**,不承担不变量:受控退的收尾链在它自己的终点早按一次子进程腿(理由逐字写在
6
+ * `boot/shutdown.ts` 的调用点上 —— 那之后进程还活十秒量级,后台腿的软→硬梯子跑得完)。
7
+ * 工作区腿**刻意不给**这条早按:停机中段 `rm -rf` 会把在飞 turn 正在写的目录从它脚下抽掉。
8
+ */
9
+ reapChildProcessesEarly(): void;
10
+ /** 摘掉本次装配的 `exit` 钩。幂等(同进程内反复装配的调用方=测试必须用它收尾)。 */
11
+ dispose(): void;
12
+ }
13
+ /**
14
+ * 装上本进程的退出边界(`process.once("exit")`)。返回 {@link ProcessExitBoundary}。
15
+ *
16
+ * **进程级注册表是全局单例** ⇒ 装两次就是两只钩(两个写者)。生产里每条车道只装一次、装到死;
17
+ * 同进程内反复装配的调用方(测试)必须 `dispose()`。
18
+ */
19
+ export declare function installProcessExitBoundary({ logger }: {
20
+ logger: Logger;
21
+ }): ProcessExitBoundary;
22
+ //# sourceMappingURL=exit-boundary.d.ts.map