@sema-agent/server 7.82.0 → 7.83.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.
Files changed (89) hide show
  1. package/USAGE.md +17 -14
  2. package/dist/bench/s1/arms.d.ts +4 -4
  3. package/dist/bench/s1/arms.js +1 -2
  4. package/dist/bench/s1/live-deps.js +0 -1
  5. package/dist/bench/s1/runner-ctx.d.ts +3 -3
  6. package/dist/bench/s1/tasks.d.ts +8 -4
  7. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  8. package/dist/boot/stage-07-capability-layer.js +1 -1
  9. package/dist/boot/stage-08-reapers.d.ts +1 -0
  10. package/dist/boot/stage-09-leader.d.ts +1 -0
  11. package/dist/boot/stage-10-http-server.d.ts +1 -0
  12. package/dist/boot/stage-10-http-server.js +2 -1
  13. package/dist/config-catalog.js +2 -3
  14. package/dist/config.js +1 -0
  15. package/dist/http/routes/capabilities.js +2 -0
  16. package/dist/http/server.d.ts +8 -0
  17. package/dist/leader/endpoint.js +2 -0
  18. package/dist/leader/leader.d.ts +33 -6
  19. package/dist/leader/leader.js +54 -45
  20. package/dist/leader/merge.d.ts +38 -29
  21. package/dist/leader/merge.js +45 -38
  22. package/dist/leader/wire.d.ts +2 -17
  23. package/dist/leader/wire.js +5 -58
  24. package/dist/observability/fail-open.d.ts +5 -5
  25. package/dist/observability/fail-open.js +5 -2
  26. package/dist/observability/run-terminal-log.d.ts +0 -7
  27. package/dist/observability/run-terminal-log.js +9 -20
  28. package/dist/plugins/adoption-log-sql.d.ts +3 -0
  29. package/dist/plugins/adoption-log-sql.js +5 -1
  30. package/dist/plugins/approval-ask-store-sql.d.ts +3 -0
  31. package/dist/plugins/approval-ask-store-sql.js +9 -5
  32. package/dist/plugins/background-agent-store-sql.d.ts +3 -0
  33. package/dist/plugins/background-agent-store-sql.js +7 -2
  34. package/dist/plugins/device-store-sql.d.ts +3 -0
  35. package/dist/plugins/device-store-sql.js +9 -4
  36. package/dist/plugins/ensure-index.d.ts +42 -0
  37. package/dist/plugins/ensure-index.js +81 -0
  38. package/dist/plugins/ensure-schema-lock-policy.d.ts +29 -0
  39. package/dist/plugins/ensure-schema-lock-policy.js +31 -0
  40. package/dist/plugins/image-bake-store-sql.d.ts +3 -0
  41. package/dist/plugins/image-bake-store-sql.js +6 -2
  42. package/dist/plugins/image-index-sql.d.ts +3 -0
  43. package/dist/plugins/image-index-sql.js +8 -4
  44. package/dist/plugins/leader-run-store-sql.d.ts +3 -0
  45. package/dist/plugins/leader-run-store-sql.js +6 -2
  46. package/dist/plugins/mailbox-store-sql.d.ts +3 -0
  47. package/dist/plugins/mailbox-store-sql.js +6 -1
  48. package/dist/plugins/memory-engine-pg.d.ts +4 -0
  49. package/dist/plugins/memory-engine-pg.js +6 -6
  50. package/dist/plugins/memory-engine-tidb.js +5 -0
  51. package/dist/plugins/memory-sync-store-pg.d.ts +5 -0
  52. package/dist/plugins/memory-sync-store-pg.js +9 -2
  53. package/dist/plugins/memory-sync-store-tidb.js +6 -0
  54. package/dist/plugins/one-time-migrations.d.ts +1 -1
  55. package/dist/plugins/one-time-migrations.js +7 -14
  56. package/dist/plugins/outcome-ledger-sql.d.ts +3 -0
  57. package/dist/plugins/outcome-ledger-sql.js +7 -3
  58. package/dist/plugins/permission-rule-store-sql.d.ts +3 -0
  59. package/dist/plugins/permission-rule-store-sql.js +9 -5
  60. package/dist/plugins/pg-cost-quota.d.ts +3 -0
  61. package/dist/plugins/pg-cost-quota.js +5 -1
  62. package/dist/plugins/pg-pool.d.ts +23 -2
  63. package/dist/plugins/pg-pool.js +22 -37
  64. package/dist/plugins/pg-rate-limiter.d.ts +3 -0
  65. package/dist/plugins/pg-rate-limiter.js +5 -1
  66. package/dist/plugins/pg-session-storage.d.ts +3 -0
  67. package/dist/plugins/pg-session-storage.js +8 -4
  68. package/dist/plugins/remote-env-adb.d.ts +62 -3
  69. package/dist/plugins/remote-env-adb.js +27 -15
  70. package/dist/plugins/remote-env-host.js +1 -1
  71. package/dist/plugins/remote-shell.d.ts +7 -11
  72. package/dist/plugins/remote-shell.js +3 -3
  73. package/dist/plugins/retention-store-sql.d.ts +3 -0
  74. package/dist/plugins/retention-store-sql.js +7 -3
  75. package/dist/plugins/roster-store-sql.d.ts +3 -0
  76. package/dist/plugins/roster-store-sql.js +6 -1
  77. package/dist/plugins/schema-indexes.d.ts +21 -0
  78. package/dist/plugins/schema-indexes.js +25 -0
  79. package/dist/plugins/task-attachment-store.d.ts +3 -0
  80. package/dist/plugins/task-attachment-store.js +7 -2
  81. package/dist/plugins/tidb-pool.d.ts +15 -3
  82. package/dist/plugins/tidb-pool.js +38 -15
  83. package/dist/plugins/tool-result-store-sql.d.ts +3 -0
  84. package/dist/plugins/tool-result-store-sql.js +5 -1
  85. package/dist/plugins/web-search.d.ts +50 -2
  86. package/dist/plugins/web-search.js +9 -2
  87. package/dist/trace/project.d.ts +2 -1
  88. package/dist/trace/redact.d.ts +6 -0
  89. package/package.json +2 -2
@@ -8,6 +8,7 @@ import { contentForkRelation, fastForwardSharedContentDiverged } from "../sessio
8
8
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, } from "../session-sync-kernel.js";
9
9
  import { isPgUniqueViolation } from "./sql-errors.js";
10
10
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
11
+ import { ensureIndexes } from "./ensure-index.js";
11
12
  const isDupKey = isPgUniqueViolation;
12
13
  function parseEntry(p) {
13
14
  return (typeof p === "string" ? JSON.parse(p) : p);
@@ -36,8 +37,6 @@ export const PG_SESSION_SCHEMA = [
36
37
  placed_at TIMESTAMPTZ(3),
37
38
  PRIMARY KEY (session_id)
38
39
  )`,
39
- `CREATE INDEX IF NOT EXISTS idx_session_meta_owner ON session_meta (owner)`,
40
- `CREATE INDEX IF NOT EXISTS idx_session_meta_placement ON session_meta (placement_kind, placement_scope, placed_at)`,
41
40
  `CREATE TABLE IF NOT EXISTS session_event (
42
41
  session_id VARCHAR(64) COLLATE "C" NOT NULL,
43
42
  seq BIGINT NOT NULL,
@@ -48,12 +47,17 @@ export const PG_SESSION_SCHEMA = [
48
47
  ts TIMESTAMPTZ(3) NOT NULL,
49
48
  PRIMARY KEY (session_id, seq)
50
49
  )`,
51
- `CREATE UNIQUE INDEX IF NOT EXISTS uq_session_event_entry ON session_event (session_id, entry_id)`,
52
- `CREATE INDEX IF NOT EXISTS idx_session_event_type ON session_event (session_id, type, seq)`,
50
+ ];
51
+ export const SESSION_INDEXES = [
52
+ { table: "session_meta", name: "idx_session_meta_owner", columns: ["owner"] },
53
+ { table: "session_meta", name: "idx_session_meta_placement", columns: ["placement_kind", "placement_scope", "placed_at"] },
54
+ { table: "session_event", name: "uq_session_event_entry", columns: ["session_id", "entry_id"], unique: true },
55
+ { table: "session_event", name: "idx_session_event_type", columns: ["session_id", "type", "seq"] },
53
56
  ];
54
57
  export async function ensureSchema(pool) {
55
58
  for (const stmt of PG_SESSION_SCHEMA)
56
59
  await pool.query(stmt);
60
+ await ensureIndexes((t, p) => pool.query(t, p), "pg", SESSION_INDEXES);
57
61
  }
58
62
  export class PgSessionStorage extends BaseSessionStorage {
59
63
  pool;
@@ -41,7 +41,23 @@ export interface AdbEnvDeps {
41
41
  connectOnce?: (signal?: AbortSignal) => Promise<ConnectResult>;
42
42
  }
43
43
  /** [R78]#1: adb stderr markers that mean the DEVICE TRANSPORT died mid-op (not a normal command failure) —
44
- * typed retryable (reconnect + retry per idempotency), never a fake command result. Exported for tests. */
44
+ * typed retryable (reconnect + retry per idempotency), never a fake command result. Exported for tests.
45
+ *
46
+ * 🔴 **S-401 把这个判据的输入面改了一半(复审 F12 纠正:原来写的「此前 stderr 恒空 ⇒ 整条是死码」是
47
+ * **overclaim**,被本车自己的真机读数推翻)**。`exec-out` 合流的是**设备**的 stderr;**adb 客户端自己的**
48
+ * 诊断在两种传输下都照样走宿主 stderr。真机实测(同一台设备,拔掉 serial):
49
+ * `adb -s BOGUS exec-out 'true'` ⇒ host exit **255** + stderr `error: device 'BOGUS' not found`;
50
+ * `adb -s BOGUS shell -T -- 'true'` ⇒ host exit **1** + stderr `adb: device 'BOGUS' not found`。
51
+ * ⇒ 本判据在 S-401 之前**对 adb 客户端级的传输故障是工作的**(这也正是原作者把词表写成这些 adb 自家
52
+ * 措辞的原因);S-401 改变的是**设备侧 stderr 从此也进这条通道**。
53
+ *
54
+ * ⚠️ **随之而来的、如实登记的一格**:分流之后落到这里的 stderr **既有 adb 自己的诊断,也有设备命令自己的
55
+ * 输出**(adb 客户端把两者写在同一个 fd 上,真机实测无法在传输层分开)。于是设备上一条自己打印
56
+ * `Connection reset by peer` 的命令(curl/nc)会被判成 `transport_lost`:本次命令结果不返回、`connected`
57
+ * 置假、下一次 exec fail-closed 直到 reconnect。**刻意不收窄词表**——词表收窄的方向是「漏判一次真掉线」,
58
+ * 那是 fail-open;误判的方向是多断一次连接,是 fail-closed。[ref] 轴上后者必须赢(能力探测的失败方向要与
59
+ * 能力语义方向一致)。ssh / k8s 两腿不吃这一格,因为它们的传输层自己给「通道关闭却没有 exit status」这个
60
+ * **结构**信号;adb 的 shell v2 没有等价物,所以这里只能靠标记词,**这条臂就把话说在这里**。 */
45
61
  export declare function isAdbTransportLost(stderr: string): boolean;
46
62
  export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
47
63
  /**
@@ -123,8 +139,15 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
123
139
  execStream(command: string, options?: ExecStreamOptions): AsyncIterable<OutputChunk>;
124
140
  absolutePath(p: string): Promise<Result<string, FileError>>;
125
141
  joinPath(parts: string[]): Promise<Result<string, FileError>>;
126
- /** 文件面的终态门 —— 读面此前**连 `connected` 都不查**,所以 destroy 之后还能从真设备读出文件内容。
127
- * 与 host/local-docker/k8s/e2b 的姿态对齐(它们的文件面都在 destroy 后 fail-closed)。 */
142
+ /** 文件面的门 —— 与 host/local-docker/k8s/e2b 的姿态对齐(它们的文件面在未连/已拆时都 fail-closed)。
143
+ *
144
+ * 🔴 **S-401 复审 F2:这道门此前只查 `destroyed`,不查 `connected`**,于是 `readBinaryFile` /
145
+ * `readTextFile` / `readTextLines` / `appendFile` 能在**从没连过**的 env 上直接打一次真设备往返。
146
+ * 在本批之前那只是个不一致;本批**新开的 `shell_v2` fail-closed 闸就长在 `connect()` 里**,
147
+ * 所以「不查 connected」= 那道闸对整个读面**不生效** —— 而 `readTextFile` 把错误文本当文件内容返回
148
+ * 正是本 P1 点名的症状之一。闸的射程必须覆盖它要保护的每一个面,否则它只是一句话。
149
+ * ⇒ 规则收成一条:**这只 env 在 connect 成功之前、destroy 之后,文件面一个字都不答。**
150
+ * (`exec`/`execStream` 本就是这条规则,见 `:257`/`:276`;文件面从此同判,不再是两套。) */
128
151
  private fsGate;
129
152
  readBinaryFile(p: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
130
153
  readTextFile(p: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
@@ -162,6 +185,42 @@ export declare class RemoteAdbExecutionEnv implements RemoteExecutionEnv {
162
185
  }): Promise<Result<string, FileError>>;
163
186
  /** Spawn the adb binary with the given args; collect stdout/stderr (+ raw bytes when binary), bound by timeout/abort. */
164
187
  private adb;
188
+ /**
189
+ * 🔴 **S-401 —— 本适配器跑设备命令的唯一往返形**(`exec` / `execStream` / `readBinaryFile` 三个调用点
190
+ * 共用一只;此前 `exec-out` 写了三遍,于是「传输到底给不给退出码」这件事没有属主)。
191
+ *
192
+ * **`adb shell`(shell protocol v2)而不是 `adb exec-out`。** 协议出处 = adb 自己的 `adb help`:
193
+ * `-x: disable remote exit codes and stdout/stderr separation` —— 即**不带** `-x` 的 `adb shell` 就是
194
+ * 「远端退出码 + stdout/stderr 分流」那一形。`exec-out` 走的是另一条 `exec:` 服务:单条裸流,设备
195
+ * stderr **合流进 stdout**,宿主进程退出码**恒 0**。
196
+ *
197
+ * 真机实测(车GV 2026-09-17,SM-S721B / Android 15 / toybox 0.8.11 / adb 1.0.41 `35.0.2`,设备 features
198
+ * 含 `shell_v2`),逐条复现而不是从文档推断:
199
+ * - `exec-out 'false'` ⇒ 宿主 exit **0**;`shell -T -- 'false'` ⇒ **1**;`shell -T -- 'exit 7'` ⇒ **7**
200
+ * - `exec-out 'realpath -- <坏链>'` ⇒ exit 0 + 错误文本在 **stdout**;`shell -T --` ⇒ exit 1 + 在 **stderr**
201
+ * - 1 MiB 随机字节 `cat` 回读:两形 md5 **相同**(`shell -T` 无 CRLF 转换、无包裹字节),故 binary 面照用
202
+ * - 同一条 `realpath` 的墙钟:`shell -T` 68 ms/次 vs `exec-out` 79 ms/次(n=10)—— 不是性能代价
203
+ *
204
+ * 修前的后果链([ref] 安全轴静默 fail-open):共享基座 `posix-shell-fs.ts` 的**每一处**失败判据(7 处)
205
+ * 与链原语 `remote-shell.ts` 的 `runSymlinkChain`(第 8 处)认的都是 `exitCode !== 0`,而这条传输恒答 0
206
+ * ⇒ adb 腿上 `realpath` / `readlink` / `stat` / `ls` / `cat` **永不失败**,`canonicalPath` 把那行**错误
207
+ * 文本**当成 canonical key 交给敏感路径门。⚠️ **复审 F15 纠正:原文写「五处」是数错了**,而且漏掉的
208
+ * 第 8 处恰恰是最贵的一处 —— `canonicalChain`(S-362 / core 7.21.0 [ref],上一版刚接)在 exit 恒 0 下会把
209
+ * **合流进 stdout 的错误文本当成链上的跳**解析出来,等于给 core 的路径判官递一条**伪造的** symlink 链。
210
+ *
211
+ * 🔴 **`-T` 与 `--` 都是承重件,不是装饰**:
212
+ * - `-T` = 不分配 pty。带 pty 的交互 shell 会把 stderr 并进 stdout 并做 CRLF 转换(council §9.2 的那句话
213
+ * 本身没错,错的是它被用来给 `exec-out` 背书)。给了命令的 `adb shell` 默认本就不开 pty,`-T` 是**显式**
214
+ * 关闭,不依赖那个默认。
215
+ * - `--` = 终止 adb 自己的旗标解析。没有它,**首字符为 `-` 的设备命令会被 adb 当成自己的旗标吃掉**
216
+ * (真机复现:`adb shell -T '-x'` 不报错,直接变成一个挂住的交互 shell)—— 而 `-x` 恰好就是关掉
217
+ * 远端退出码与分流的那根旗标,即「一条以 `-x` 开头的命令能静默把本修关回去」。真机验证 `--` 之后的
218
+ * 参数原样落到设备 shell(`adb shell -T -- '-x'` ⇒ 设备答 `/system/bin/sh: -c: requires an argument`)。
219
+ *
220
+ * 命令永远是**最后一个**参数,整条包在一个 argv 元素里(设备侧 `sh -c` 收全串,与 `withCwdEnv` 的
221
+ * `cd … && …` 前缀语义一致)。
222
+ */
223
+ private deviceShellArgs;
165
224
  private withCwdEnv;
166
225
  }
167
226
  /**
@@ -5,7 +5,7 @@ import { spawn } from "node:child_process";
5
5
  import { shellQuote, armPipeDestroyGrace } from "./remote-shell.js";
6
6
  import { signalNumber } from "./host-platform.js";
7
7
  import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
8
- import { fileErrorFromExec } from "./remote-env-file-error.js";
8
+ import { classifyFsStderr, fileErrorFromExec } from "./remote-env-file-error.js";
9
9
  import { createPosixShellFs } from "./posix-shell-fs.js";
10
10
  const PROVIDER = "adb";
11
11
  const ok = (value) => ({ ok: true, value });
@@ -76,6 +76,18 @@ export class RemoteAdbExecutionEnv {
76
76
  const state = st.value.stdout.trim();
77
77
  const err = `${st.value.stdout} ${st.value.stderr}`.toLowerCase();
78
78
  if (state === "device") {
79
+ const feats = await this.adb(["-s", this.cfg.serial, "features"], { signal, timeoutMs: this.cfg.commandTimeoutMs });
80
+ if (!feats.ok)
81
+ return { ok: false, error: new RemoteExecutionError("connect_failed", `adb features failed: ${feats.error.message}`, feats.error) };
82
+ if (feats.value.exitCode !== 0) {
83
+ return { ok: false, error: new RemoteExecutionError("connect_failed", `adb features exited ${feats.value.exitCode} for ${this.cfg.serial}: ${feats.value.stderr.trim().slice(0, 200) || "(no stderr)"} — could not read the device's feature list this attempt`) };
84
+ }
85
+ if (!/(^|\s)shell_v2(\s|$)/.test(feats.value.stdout.trim())) {
86
+ return {
87
+ ok: false,
88
+ error: new RemoteExecutionError("unsupported", `device ${this.cfg.serial} does not advertise the 'shell_v2' feature, so this adapter cannot establish that its command round-trip carries a real exit code with stderr kept separate — the two properties every filesystem judgment on this lane depends on to tell failure from success. Refusing to connect rather than run on an unverifiable transport: attach a device that supports adb shell protocol v2.`),
89
+ };
90
+ }
79
91
  this.connected = true;
80
92
  return ok(this.workspaceHandle());
81
93
  }
@@ -112,7 +124,7 @@ export class RemoteAdbExecutionEnv {
112
124
  if (!this.connected)
113
125
  return { ok: false, error: new ExecutionError("shell_unavailable", "ADB device not connected — call connect() first") };
114
126
  const wrapped = this.withCwdEnv(command, options?.cwd, options?.env);
115
- const r = await this.adb(["-s", this.cfg.serial, "exec-out", wrapped], {
127
+ const r = await this.adb(this.deviceShellArgs(wrapped), {
116
128
  signal: options?.abortSignal,
117
129
  timeoutMs: options?.timeout != null ? options.timeout * 1000 : this.cfg.commandTimeoutMs,
118
130
  onStdout: options?.onStdout,
@@ -130,7 +142,7 @@ export class RemoteAdbExecutionEnv {
130
142
  if (!this.connected)
131
143
  throw new RemoteExecutionError("connect_failed", "ADB device not connected — call connect() first");
132
144
  const wrapped = this.withCwdEnv(command, options?.cwd, options?.env);
133
- const child = this.spawnFn(this.cfg.adbPath, ["-s", this.cfg.serial, "exec-out", wrapped], { stdio: ["ignore", "pipe", "pipe"] });
145
+ const child = this.spawnFn(this.cfg.adbPath, this.deviceShellArgs(wrapped), { stdio: ["ignore", "pipe", "pipe"] });
134
146
  const queue = [];
135
147
  let finished = false;
136
148
  let failure;
@@ -176,6 +188,7 @@ export class RemoteAdbExecutionEnv {
176
188
  if (queue.length >= 512 && !paused) {
177
189
  paused = true;
178
190
  child.stdout.pause();
191
+ child.stderr.pause();
179
192
  }
180
193
  signalReady();
181
194
  };
@@ -206,6 +219,7 @@ export class RemoteAdbExecutionEnv {
206
219
  if (paused) {
207
220
  paused = false;
208
221
  child.stdout.resume();
222
+ child.stderr.resume();
209
223
  }
210
224
  if (failure)
211
225
  throw failure;
@@ -244,6 +258,8 @@ export class RemoteAdbExecutionEnv {
244
258
  fsGate(p) {
245
259
  if (this.destroyed)
246
260
  return { ok: false, error: new FileError("unknown", "execution env already destroyed", this.resolve(p)) };
261
+ if (!this.connected)
262
+ return { ok: false, error: new FileError("unknown", "ADB device not connected — call connect() first", this.resolve(p)) };
247
263
  return undefined;
248
264
  }
249
265
  async readBinaryFile(p, abortSignal) {
@@ -251,21 +267,11 @@ export class RemoteAdbExecutionEnv {
251
267
  if (gated)
252
268
  return gated;
253
269
  const abs = this.resolve(p);
254
- const r = await this.adb(["-s", this.cfg.serial, "exec-out", `cat ${shellQuote(abs)}`], { signal: abortSignal, timeoutMs: this.cfg.dataTimeoutMs, binary: true });
270
+ const r = await this.adb(this.deviceShellArgs(`cat ${shellQuote(abs)}`), { signal: abortSignal, timeoutMs: this.cfg.dataTimeoutMs, binary: true });
255
271
  if (!r.ok)
256
272
  return { ok: false, error: fileErrorFromExec(r.error, abs) };
257
- const errOut = r.value.stderr.trim();
258
- if (errOut) {
259
- if (/no such file/i.test(errOut))
260
- return { ok: false, error: new FileError("not_found", errOut, abs) };
261
- if (/permission denied/i.test(errOut))
262
- return { ok: false, error: new FileError("permission_denied", errOut, abs) };
263
- if (/is a directory/i.test(errOut))
264
- return { ok: false, error: new FileError("is_directory", errOut, abs) };
265
- return { ok: false, error: new FileError("unknown", errOut, abs) };
266
- }
267
273
  if (r.value.exitCode !== 0)
268
- return { ok: false, error: new FileError("unknown", `adb exec-out cat exited ${r.value.exitCode}`, abs) };
274
+ return { ok: false, error: classifyFsStderr(r.value.stderr.trim() || `adb cat exited ${r.value.exitCode}`, abs) };
269
275
  return ok(r.value.stdoutBytes ?? new Uint8Array(0));
270
276
  }
271
277
  async readTextFile(p, abortSignal) {
@@ -284,6 +290,9 @@ export class RemoteAdbExecutionEnv {
284
290
  return ok(lines);
285
291
  }
286
292
  async writeFile(p, content, abortSignal) {
293
+ const gated = this.fsGate(p);
294
+ if (gated)
295
+ return gated;
287
296
  const abs = this.resolve(p);
288
297
  const dir = path.posix.dirname(abs);
289
298
  if (dir && dir !== "." && dir !== "/") {
@@ -436,6 +445,9 @@ export class RemoteAdbExecutionEnv {
436
445
  });
437
446
  });
438
447
  }
448
+ deviceShellArgs(command) {
449
+ return ["-s", this.cfg.serial, "shell", "-T", "--", command];
450
+ }
439
451
  withCwdEnv(command, cwd, env) {
440
452
  const parts = [];
441
453
  const dir = cwd ?? this.cwd;
@@ -1012,8 +1012,8 @@ export class RemoteHostExecutionEnv {
1012
1012
  if (!ready.ok)
1013
1013
  return ready;
1014
1014
  const hops = [];
1015
- const seen = new Set();
1016
1015
  let cur = this.resolve(p);
1016
+ const seen = new Set([cur]);
1017
1017
  for (let i = 0; i < SYMLINK_CHAIN_MAX_HOPS; i += 1) {
1018
1018
  let target;
1019
1019
  try {
@@ -64,16 +64,12 @@ export declare function buildSymlinkChainCommand(absPath: string): string;
64
64
  * {@link buildSymlinkChainCommand} 的 stdout → `SymlinkChain.hops`。
65
65
  *
66
66
  * 三件事**只在这里**做一次(四条 shell 腿 + e2b 共读):
67
- * · **环即止** —— 出现重复跳时在**重复的那一跳之前**截断(CC `vr` 的 `d.has(u)` 同律)。
68
- * 🔴 **一条如实登记的、与 core 参考实现的分歧(合并复审车GU 亲核两树,验真后**披露不修**)**:本族(六腿同形)
69
- * 的 seen 表**从空开始**,而 core 的 `NodeExecutionEnv.canonicalChain` 用**被寻址的拼法**给它播种
70
- * (`new Set([resolved])`)。于是**环绕回起点**的那一类链上两边差一跳:`a -> b`、`b -> a`,问 `a`,本族答
71
- * `[b, a]`,core 答 `[b]`。**只在真有环时可观察**(无环链上 seen 永远碰不到起点,两边逐字节相同),而有环的
72
- * 链上那次写本来就会以 `ELOOP` 失败。不修的两条理由:①方向是**多报一跳**(判官多一个视图 ⇒ 多问,从不
73
- * 造成误放行),而改成与 core 同形是**摘掉**一个视图 —— 在 [ref] 轴上那是错的方向,要动得先有裁定;
74
- * ②那一跳是 `b` **真的写下的**目标,契约句「不重复被寻址的拼法」两种读法都讲得通(core 读作「起点不入表」,
75
- * 本族读作「每条链接写下的目标都入表」),属**待 core 裁**的一格,不是本仓单方面能定的。
76
- * ⚠️ 故各腿头注里「与 core 同形 / 逐字节对得上」那句话的射程是**无环链**;有环的那一格以本段为准。
67
+ * · **环即止** —— 出现重复跳时在**重复的那一跳之前**截断(CC `vr` 的 `d.has(u)` 同律),且 seen 表用
68
+ * **被寻址的拼法播种**(S-408,core 裁 2026-09-17:契约句「不重复被寻址的拼法」的正解是**起点本身不是一跳**——
69
+ * 链视图列的是「从被寻址拼法出发经过的中间拼法」,链绕回起点即结束、起点不再列;成环的事实由终点判决
70
+ * `canonicalPath`(realpath ELOOP ⇒ unresolvable ⇒ 拒)承担,不由链视图承担)。于是 `a -> b`、`b -> a`,问 `a`,
71
+ * 答 `[b]`,与 core `NodeExecutionEnv.canonicalChain`(`new Set([resolved])` 播种)逐字节同形。此前本族从空表
72
+ * 开始、答 `[b, a]`(7.82.0 及之前;多一个冗余视图 —— 判官对 `a` 已按拼法判过一次),裁定后对齐。
77
73
  * 脚本侧不做:
78
74
  * POSIX sh 里维护一张 seen 表要么靠内嵌换行的 `case` 模式(易碎),要么另起一个进程;放在这里则
79
75
  * **一份实现**被四条腿与 e2b 共用,且与 host 腿的 Node 循环逐字节对得上。代价如实登记:链上真有环时,
@@ -83,7 +79,7 @@ export declare function buildSymlinkChainCommand(absPath: string): string;
83
79
  * 处理,不假装支持)。残留如实写出来:目标拼法真带首尾空白时,trim 后的跳可能与真目标差几个字节 ——
84
80
  * 方向上只会让一条跳**匹配不到**受保护行(少问,不是多放行),而终点判决(`canonicalPath`)完全不经本路。
85
81
  */
86
- export declare function symlinkChainHopsFromStdout(stdout: string): string[];
82
+ export declare function symlinkChainHopsFromStdout(stdout: string, addressed: string): string[];
87
83
  /** {@link runSymlinkChain} 的传输面 —— 各腿的 `exec` 收敛成的那一个形(与 `PosixShellFs` 基座的 `run` 同形)。 */
88
84
  export type ShellChainRun = (cmd: string) => Promise<Result<{
89
85
  stdout: string;
@@ -47,9 +47,9 @@ export function buildSymlinkChainCommand(absPath) {
47
47
  `*) d="${"${c%/*}"}"; case "$d" in "") c="/$t" ;; */) c="$d$t" ;; *) c="$d/$t" ;; esac ;; esac; ` +
48
48
  `printf '%s\\n' "$c"; n=$((n+1)); done; }`);
49
49
  }
50
- export function symlinkChainHopsFromStdout(stdout) {
50
+ export function symlinkChainHopsFromStdout(stdout, addressed) {
51
51
  const hops = [];
52
- const seen = new Set();
52
+ const seen = new Set([addressed]);
53
53
  for (const raw of stdout.split("\n")) {
54
54
  const hop = raw.trim();
55
55
  if (hop === "")
@@ -69,6 +69,6 @@ export async function runSymlinkChain(absPath, run) {
69
69
  return { ok: false, error: fileErrorFromExec(r.error, absPath) };
70
70
  if (r.value.exitCode !== 0)
71
71
  return { ok: false, error: classifyFsStderr(r.value.stderr, absPath, "unknown") };
72
- return { ok: true, value: { hops: symlinkChainHopsFromStdout(r.value.stdout) } };
72
+ return { ok: true, value: { hops: symlinkChainHopsFromStdout(r.value.stdout, absPath) } };
73
73
  }
74
74
  //# sourceMappingURL=remote-shell.js.map
@@ -49,6 +49,7 @@ import type { ManagedRetentionCapability, RetentionDeclaration, RetentionReceipt
49
49
  import type { Pool as MySqlPool } from "mysql2/promise";
50
50
  import type { Pool as PgPool } from "pg";
51
51
  import { type SqlDriver, type SqlTxConn } from "./sql-driver.js";
52
+ import { type IndexSpec } from "./ensure-index.js";
52
53
  /**
53
54
  * 三只**被托管**的 SQL 店(session / checkpoint / tool-result)共用的 `retention` 声明常量。
54
55
  *
@@ -90,6 +91,8 @@ export declare const TIDB_RETENTION_STATEMENTS: readonly string[];
90
91
  /** PG 方言的建表语句(MySQL 孪生的逐条翻译:AUTO_INCREMENT→BIGSERIAL,逐列 `COLLATE "C"`,内联 KEY→独立
91
92
  * CREATE INDEX)。语义逐字见 MySQL 孪生的行内注,此处不复述(两份注释迟早分叉)。 */
92
93
  export declare const PG_RETENTION_SCHEMA: readonly string[];
94
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgRetentionSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
95
+ export declare const RETENTION_INDEXES: readonly IndexSpec[];
93
96
  /** PG 侧的幂等 schema apply(由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合;裸 `PgQueryFn` 形,与
94
97
  * approval-ask / leader-run 同姿势 —— DDL 仍跑在中央那条 client 上,advisory lock 的 session 语义不受影响)。 */
95
98
  export declare function ensurePgRetentionSchema(query: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
2
2
  import { TASK_LIST_ITEM_TABLE, TASK_LIST_META_TABLE, taskListKeyFor } from "./task-list-store-sql.js";
3
+ import { ensureIndexes } from "./ensure-index.js";
3
4
  export const MANAGED_RETENTION = "managed";
4
5
  export const UNMANAGED_RETENTION = "none";
5
6
  export const RETENTION_AUDIT_TABLE = "retention_audit";
@@ -93,8 +94,6 @@ export const PG_RETENTION_SCHEMA = [
93
94
  error TEXT COLLATE "C" NULL,
94
95
  PRIMARY KEY (id)
95
96
  )`,
96
- `CREATE INDEX IF NOT EXISTS idx_retention_audit_domain ON ${RETENTION_AUDIT_TABLE} (domain, id)`,
97
- `CREATE INDEX IF NOT EXISTS idx_retention_audit_executed ON ${RETENTION_AUDIT_TABLE} (executed_at_ms)`,
98
97
  `CREATE TABLE IF NOT EXISTS ${RETENTION_HOLD_TABLE} (
99
98
  domain VARCHAR(190) COLLATE "C" NOT NULL,
100
99
  -- held:1 = 冻结中,0 = 只是那把域级互斥行锁的哨兵。完整判据(为什么 presence 形不成立)见 MySQL 孪生。
@@ -119,11 +118,16 @@ export const PG_RETENTION_SCHEMA = [
119
118
  -- 主键带 domain(理由逐字见 MySQL 孪生:会话 id 是调用方可自选的,跨租户同名合法)。
120
119
  PRIMARY KEY (domain, kind, row_key)
121
120
  )`,
122
- `CREATE INDEX IF NOT EXISTS idx_retention_tombstone_key ON ${RETENTION_TOMBSTONE_TABLE} (kind, row_key)`,
121
+ ];
122
+ export const RETENTION_INDEXES = [
123
+ { table: `${RETENTION_AUDIT_TABLE}`, name: "idx_retention_audit_domain", columns: ["domain", "id"] },
124
+ { table: `${RETENTION_AUDIT_TABLE}`, name: "idx_retention_audit_executed", columns: ["executed_at_ms"] },
125
+ { table: `${RETENTION_TOMBSTONE_TABLE}`, name: "idx_retention_tombstone_key", columns: ["kind", "row_key"] },
123
126
  ];
124
127
  export async function ensurePgRetentionSchema(query) {
125
128
  for (const stmt of PG_RETENTION_SCHEMA)
126
129
  await query(stmt);
130
+ await ensureIndexes(query, "pg", RETENTION_INDEXES);
127
131
  }
128
132
  class RetentionAborted extends Error {
129
133
  why;
@@ -23,8 +23,11 @@
23
23
  import type { Pool } from "mysql2/promise";
24
24
  import { type RosterStore, type RosterEntry, type RosterAccess } from "@sema-agent/core";
25
25
  import type { PgQueryFn } from "./pg-query.js";
26
+ import { type IndexSpec } from "./ensure-index.js";
26
27
  export declare const ROSTER_TABLE = "agent_roster";
27
28
  export declare function ensureTiDBRosterSchema(pool: Pool): Promise<void>;
29
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgRosterSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
30
+ export declare const ROSTER_INDEXES: readonly IndexSpec[];
28
31
  export declare function ensurePgRosterSchema(q: PgQueryFn): Promise<void>;
29
32
  export declare class TiDBRosterStore implements RosterStore {
30
33
  private readonly pool;
@@ -1,4 +1,5 @@
1
1
  import { normalizeAgentName } from "@sema-agent/core";
2
+ import { ensureIndexes } from "./ensure-index.js";
2
3
  export const ROSTER_TABLE = "agent_roster";
3
4
  function rowToEntry(r) {
4
5
  return {
@@ -42,7 +43,11 @@ export async function ensureTiDBRosterSchema(pool) {
42
43
  PRIMARY KEY (name_key, owner_key, scope_key),
43
44
  KEY idx_roster_agent (agent_id)
44
45
  ) COLLATE utf8mb4_bin`);
46
+ await ensureIndexes((text, params) => pool.query(text, params), "mysql", ROSTER_INDEXES);
45
47
  }
48
+ export const ROSTER_INDEXES = [
49
+ { table: `${ROSTER_TABLE}`, name: "idx_roster_agent", columns: ["agent_id"] },
50
+ ];
46
51
  export async function ensurePgRosterSchema(q) {
47
52
  await q(`CREATE TABLE IF NOT EXISTS ${ROSTER_TABLE} (
48
53
  name_key VARCHAR(190) COLLATE "C" NOT NULL,
@@ -66,7 +71,7 @@ export async function ensurePgRosterSchema(q) {
66
71
  recorded_at_ms BIGINT NOT NULL,
67
72
  PRIMARY KEY (name_key, owner_key, scope_key)
68
73
  )`);
69
- await q(`CREATE INDEX IF NOT EXISTS idx_roster_agent ON ${ROSTER_TABLE} (agent_id)`);
74
+ await ensureIndexes(q, "pg", ROSTER_INDEXES);
70
75
  }
71
76
  function upsertParams(entry, now) {
72
77
  return [
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 中央 schema(`tidb-pool.ts` 的 `SCHEMA_STATEMENTS` ⇄ `pg-pool.ts` 的 `PG_SCHEMA_STATEMENTS` 这一对孪生)
3
+ * 所拥有的那批表的索引**声明** —— S-287,两方言**共用同一份**。
4
+ *
5
+ * ## 为什么自成一个模块
6
+ * 两只池各自 `import` 了自己的驱动(`mysql2/promise` / `pg`,都是**值**导入)。若把这份声明放进其中一只,
7
+ * 另一只就得把对方的驱动一起拖进进程 —— 单后端部署因此多驮一个它永远用不到的驱动。声明是纯数据、零驱动
8
+ * 依赖,所以它住在两只池**之外**,谁都可以白拿。
9
+ *
10
+ * ## 谁应用它
11
+ * · **PG**:`pg-pool.ts` 的 `ensurePgSchema`,在所有 `CREATE TABLE` 之后(PG 方言没有内联 `KEY`);
12
+ * · **MySQL/TiDB**:`tidb-pool.ts` 的 `ensureSchema`,同样在所有 `CREATE TABLE` 之后 —— 新建库上这批索引
13
+ * 早已随 `CREATE TABLE` 的内联 `KEY` 到货,`ensureIndex` 的探测因此**一条 DDL 都不发**;只有存量库
14
+ * (建表那天还没有这条索引)才真的发一次 `ALTER TABLE … ADD INDEX`。
15
+ *
16
+ * 🔴 每条声明的**列序**必须与 `tidb-pool.ts` 那张表的内联 `KEY` 逐字一致(等值前缀在前、排序列在后);
17
+ * 两处列序分叉 = 两方言的访问路径分叉,而探测只看索引名、看不出列序不同。
18
+ */
19
+ import type { IndexSpec } from "./ensure-index.js";
20
+ export declare const CENTRAL_SCHEMA_INDEXES: readonly IndexSpec[];
21
+ //# sourceMappingURL=schema-indexes.d.ts.map
@@ -0,0 +1,25 @@
1
+ export const CENTRAL_SCHEMA_INDEXES = [
2
+ { table: "task_run", name: "idx_task_run_session_status", columns: ["session_id", "status"] },
3
+ { table: "task_run", name: "idx_task_run_owner", columns: ["owner"] },
4
+ { table: "task_run", name: "idx_task_run_created", columns: ["created_at"] },
5
+ { table: "task_run", name: "idx_task_run_job", columns: ["job_id"] },
6
+ { table: "task_active", name: "idx_task_active_task", columns: ["task_id"] },
7
+ { table: "checkpoint", name: "idx_checkpoint_status_deadline", columns: ["status", "deadline"] },
8
+ { table: "checkpoint", name: "idx_checkpoint_session_status", columns: ["session_id", "status"] },
9
+ { table: "resume_anchor", name: "idx_resume_anchor_session", columns: ["session_id"] },
10
+ { table: "approval_nonce", name: "idx_approval_nonce_expires", columns: ["expires_at_ms"] },
11
+ { table: "sendfile_link", name: "idx_sendfile_link_scope", columns: ["scope", "id"] },
12
+ { table: "session_policy", name: "idx_session_policy_session", columns: ["session_id"] },
13
+ { table: "snapshot_blob", name: "idx_snapshot_blob_created", columns: ["created_at"] },
14
+ { table: "file_history_version", name: "idx_file_history_version_blob", columns: ["blob_hash"] },
15
+ { table: "file_history_boundary_file", name: "idx_file_history_bf_key", columns: ["scope", "key_hash"] },
16
+ { table: "file_history_blob", name: "idx_file_history_blob_created", columns: ["created_at"] },
17
+ { table: "workflow_run", name: "idx_wfrun_scope_created", columns: ["scope", "created_at_ms"] },
18
+ { table: "workflow_run", name: "idx_wfrun_scope_status", columns: ["scope", "status"] },
19
+ { table: "workflow_agent_session", name: "idx_wfagentsess_run", columns: ["run_id"] },
20
+ { table: "workflow_completion_inbox", name: "idx_wfinbox_session_seq", columns: ["session_id", "seq"] },
21
+ { table: "workflow_inbox_fence", name: "idx_wffence_until", columns: ["until_ms"] },
22
+ { table: "workflow_notify_journal", name: "idx_wfnotify_acked", columns: ["acked"] },
23
+ { table: "shared_memory_store", name: "idx_shared_memory_store_scope", columns: ["scope_key", "store_id"] },
24
+ ];
25
+ //# sourceMappingURL=schema-indexes.js.map
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import type { Pool } from "mysql2/promise";
28
28
  import type { PgQueryFn } from "./pg-query.js";
29
+ import { type IndexSpec } from "./ensure-index.js";
29
30
  export interface TaskAttachmentRecord {
30
31
  id: string;
31
32
  owner: string;
@@ -116,6 +117,8 @@ export declare function materializeAttachmentsInto(env: {
116
117
  }>;
117
118
  }, store: TaskAttachmentStore, sessionId: string): Promise<number>;
118
119
  export declare function ensureTiDBTaskAttachmentSchema(pool: Pool): Promise<void>;
120
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensurePgTaskAttachmentSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
121
+ export declare const TASK_ATTACHMENT_INDEXES: readonly IndexSpec[];
119
122
  export declare function ensurePgTaskAttachmentSchema(q: PgQueryFn): Promise<void>;
120
123
  /** bytes → 4 MiB 块序列(共享切块器,两方言 + 完整性校验共用)。 */
121
124
  export declare class TiDBTaskAttachmentStore implements TaskAttachmentStore {
@@ -1,3 +1,4 @@
1
+ import { ensureIndexes } from "./ensure-index.js";
1
2
  export const TASK_ATTACHMENT_TABLE = "task_attachment";
2
3
  export function materializedRelPaths(atts) {
3
4
  const used = new Set();
@@ -76,7 +77,12 @@ export async function ensureTiDBTaskAttachmentSchema(pool) {
76
77
  KEY idx_att_session (session_id),
77
78
  KEY idx_att_unbound (created_at_ms)
78
79
  ) COLLATE utf8mb4_bin`);
80
+ await ensureIndexes((text, params) => pool.query(text, params), "mysql", TASK_ATTACHMENT_INDEXES);
79
81
  }
82
+ export const TASK_ATTACHMENT_INDEXES = [
83
+ { table: `${TASK_ATTACHMENT_TABLE}`, name: "idx_att_session", columns: ["session_id"] },
84
+ { table: `${TASK_ATTACHMENT_TABLE}`, name: "idx_att_unbound", columns: ["created_at_ms"] },
85
+ ];
80
86
  export async function ensurePgTaskAttachmentSchema(q) {
81
87
  await q(`CREATE TABLE IF NOT EXISTS ${TASK_ATTACHMENT_TABLE} (
82
88
  id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -91,8 +97,7 @@ export async function ensurePgTaskAttachmentSchema(q) {
91
97
  bound_at_ms BIGINT,
92
98
  PRIMARY KEY (id)
93
99
  )`);
94
- await q(`CREATE INDEX IF NOT EXISTS idx_att_session ON ${TASK_ATTACHMENT_TABLE} (session_id)`);
95
- await q(`CREATE INDEX IF NOT EXISTS idx_att_unbound ON ${TASK_ATTACHMENT_TABLE} (created_at_ms)`);
100
+ await ensureIndexes(q, "pg", TASK_ATTACHMENT_INDEXES);
96
101
  }
97
102
  export class TiDBTaskAttachmentStore {
98
103
  pool;
@@ -1,6 +1,7 @@
1
1
  import mysql from "mysql2/promise";
2
2
  import type { Pool, PoolConnection } from "mysql2/promise";
3
3
  import type { ServiceConfig } from "../config-types.js";
4
+ import { type IndexSpec } from "./ensure-index.js";
4
5
  /**
5
6
  * Shared TiDB (MySQL wire-compatible) connection pool for the durable Session center (L1).
6
7
  * Owned by `main.ts`; closed on shutdown.
@@ -57,11 +58,22 @@ export declare const ENSURE_SCHEMA_LOCK = "sema_ensure_schema";
57
58
  * lock row — which both exhausts TiDB's "pessimistic lock retry limit" (surfacing as a thrown error) AND starves the
58
59
  * app's OWN `SELECT … FOR UPDATE` paths (image-bake admission/lease) of pessimistic-lock budget. So we acquire
59
60
  * NON-BLOCKING (GET_LOCK(name, 0) returns 0 immediately if held) and POLL with jittered backoff: one holder, the
60
- * rest sleeping (not blocked in-DB). Return false only after exhausting retries (the caller then DEGRADES to
61
- * unserialized rather than blocking boot forever — the DDL is still individually idempotent). */
62
- export declare function acquireEnsureSchemaLock(conn: PoolConnection, tag?: string): Promise<boolean>;
61
+ * rest sleeping (not blocked in-DB). Polls until acquired (7.83.0: never degrades to unserialized — the S-287
62
+ * `ADD INDEX` leg is not race-safe); a connection whose probe keeps throwing surfaces as a boot failure. */
63
+ export declare function acquireEnsureSchemaLock(conn: PoolConnection, tag?: string): Promise<void>;
63
64
  /** Release {@link ENSURE_SCHEMA_LOCK} on `conn` (best-effort — the lock also auto-frees when the connection closes). */
64
65
  export declare function releaseEnsureSchemaLock(conn: PoolConnection, tag?: string): Promise<void>;
66
+ /**
67
+ * S-287:`SCHEMA_STATEMENTS` 建的那批表**在存量库上可能缺的索引**(声明与 PG 侧逐条共用同一份 —— 见
68
+ * `plugins/ensure-index.ts` 头注与 `plugins/schema-indexes.ts`)。
69
+ *
70
+ * 组合口径与 `SCHEMA_STATEMENTS` **一一对应**:凡是它建的表,索引声明就进这个数组;它不建的表
71
+ * (device / roster / background-agent / mailbox / task-attachment / memory 两族……由 `boot/stores.ts`
72
+ * 各自的 `ensureTiDB*Schema` 腿建)由那条腿自己应用自己的声明 —— 否则这里会对一张还不存在的表发 ALTER。
73
+ *
74
+ * 新建库上这批索引早已随 `CREATE TABLE` 的内联 `KEY` 到货 ⇒ `ensureIndex` 的探测全部命中、**零 DDL**。
75
+ */
76
+ export declare const SCHEMA_INDEXES: readonly IndexSpec[];
65
77
  /** Create tables if absent (+ seed the image-bake admission sentinel). Safe to call on every startup.
66
78
  *
67
79
  * Per the SCHEMA POLICY on {@link SCHEMA_STATEMENTS} this is a pure CREATE run — there are NO additive
@@ -6,6 +6,21 @@ import { TIDB_LEADER_RUN_STATEMENTS } from "./leader-run-store-sql.js";
6
6
  import { TIDB_ADOPTION_LOG_STATEMENTS } from "./adoption-log-sql.js";
7
7
  import { TIDB_PERMISSION_RULE_STATEMENTS } from "./permission-rule-store-sql.js";
8
8
  import { TIDB_RETENTION_STATEMENTS } from "./retention-store-sql.js";
9
+ import { ensureIndexes } from "./ensure-index.js";
10
+ import { CENTRAL_SCHEMA_INDEXES } from "./schema-indexes.js";
11
+ import { SESSION_INDEXES } from "./pg-session-storage.js";
12
+ import { TOOL_RESULT_INDEXES } from "./tool-result-store-sql.js";
13
+ import { COST_QUOTA_INDEXES } from "./pg-cost-quota.js";
14
+ import { RATE_LIMIT_INDEXES } from "./pg-rate-limiter.js";
15
+ import { OUTCOME_LEDGER_INDEXES } from "./outcome-ledger-sql.js";
16
+ import { IMAGE_INDEX_INDEXES } from "./image-index-sql.js";
17
+ import { IMAGE_BAKE_INDEXES } from "./image-bake-store-sql.js";
18
+ import { APPROVAL_ASK_INDEXES } from "./approval-ask-store-sql.js";
19
+ import { ADOPTION_LOG_INDEXES } from "./adoption-log-sql.js";
20
+ import { PERMISSION_RULE_INDEXES } from "./permission-rule-store-sql.js";
21
+ import { RETENTION_INDEXES } from "./retention-store-sql.js";
22
+ import { LEADER_RUN_INDEXES } from "./leader-run-store-sql.js";
23
+ import { pollUntilLocked } from "./ensure-schema-lock-policy.js";
9
24
  export function tidbPoolOptions(tidb) {
10
25
  const { host, port, user, password, database, connectionLimit } = tidb;
11
26
  const limit = connectionLimit ?? 10;
@@ -670,20 +685,10 @@ export const SCHEMA_STATEMENTS = [
670
685
  ];
671
686
  export const ENSURE_SCHEMA_LOCK = "sema_ensure_schema";
672
687
  export async function acquireEnsureSchemaLock(conn, tag = "ensureSchema") {
673
- await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 400)));
674
- for (let attempt = 0; attempt < 40; attempt++) {
675
- try {
676
- const [rows] = await conn.query("SELECT GET_LOCK(?, 0) AS got", [ENSURE_SCHEMA_LOCK]);
677
- if (Number(rows[0]?.got) === 1)
678
- return true;
679
- }
680
- catch (err) {
681
- console.warn(`[${tag}] GET_LOCK('${ENSURE_SCHEMA_LOCK}') attempt ${attempt + 1}/40:`, err?.message ?? err);
682
- }
683
- await new Promise((r) => setTimeout(r, 100 + Math.min(attempt, 3) * 100 + Math.floor(Math.random() * 150)));
684
- }
685
- console.warn(`[${tag}] could not acquire '${ENSURE_SCHEMA_LOCK}' after retries`);
686
- return false;
688
+ await pollUntilLocked(async () => {
689
+ const [rows] = await conn.query("SELECT GET_LOCK(?, 0) AS got", [ENSURE_SCHEMA_LOCK]);
690
+ return Number(rows[0]?.got) === 1;
691
+ }, { tag, label: `GET_LOCK('${ENSURE_SCHEMA_LOCK}')` });
687
692
  }
688
693
  export async function releaseEnsureSchemaLock(conn, tag = "ensureSchema") {
689
694
  try {
@@ -693,14 +698,32 @@ export async function releaseEnsureSchemaLock(conn, tag = "ensureSchema") {
693
698
  console.warn(`[${tag}] RELEASE_LOCK('${ENSURE_SCHEMA_LOCK}') failed (lock auto-frees on connection close):`, err);
694
699
  }
695
700
  }
701
+ export const SCHEMA_INDEXES = [
702
+ ...CENTRAL_SCHEMA_INDEXES,
703
+ ...SESSION_INDEXES,
704
+ ...TOOL_RESULT_INDEXES,
705
+ ...COST_QUOTA_INDEXES,
706
+ ...RATE_LIMIT_INDEXES,
707
+ ...OUTCOME_LEDGER_INDEXES,
708
+ ...IMAGE_INDEX_INDEXES,
709
+ ...IMAGE_BAKE_INDEXES,
710
+ ...APPROVAL_ASK_INDEXES,
711
+ ...ADOPTION_LOG_INDEXES,
712
+ ...PERMISSION_RULE_INDEXES,
713
+ ...RETENTION_INDEXES,
714
+ ...LEADER_RUN_INDEXES,
715
+ ];
696
716
  export async function ensureSchema(pool) {
697
717
  const conn = await pool.getConnection();
698
- const locked = await acquireEnsureSchemaLock(conn);
718
+ let locked = false;
699
719
  try {
720
+ await acquireEnsureSchemaLock(conn);
721
+ locked = true;
700
722
  for (const stmt of SCHEMA_STATEMENTS) {
701
723
  await conn.query(stmt);
702
724
  }
703
725
  await conn.query("INSERT IGNORE INTO image_bake_admit (pool) VALUES ('standard')");
726
+ await ensureIndexes((text, params) => conn.query(text, params), "mysql", SCHEMA_INDEXES);
704
727
  }
705
728
  finally {
706
729
  if (locked)
@@ -2,10 +2,13 @@ import type { ToolResultProvenance, ToolResultStore, ToolResultSlice, ToolResult
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { Pool as PgPool, PoolClient as PgPoolClient } from "pg";
4
4
  import { type SqlDriver } from "./sql-driver.js";
5
+ import { type IndexSpec } from "./ensure-index.js";
5
6
  /** PG schema for the tool_result table — the PG translation of tidb-pool.ts's tool_result DDL
6
7
  * (LONGTEXT→TEXT, DATETIME(3)→TIMESTAMPTZ(3), inline KEY→separate CREATE INDEX). Disjoint from other stores'
7
8
  * tables, so a self-contained ensureSchema is safe (central pg-pool aggregation is done separately). */
8
9
  export declare const PG_TOOL_RESULT_SCHEMA: string[];
10
+ /** S-287:本 store 的索引**声明**(两方言共用一份)。PG 侧由下面的 `ensureSchema` 应用,MySQL 侧由 `tidb-pool.ts` 的中央 `ensureSchema` 应用(那里内联 `KEY` 已在 `CREATE TABLE` 里 ⇒ 新建库探到即零 DDL,存量库缺谁补谁)。加索引以外的 schema 变更仍归运维,见 `plugins/ensure-index.ts` 头注。 */
11
+ export declare const TOOL_RESULT_INDEXES: readonly IndexSpec[];
9
12
  /** Idempotent schema apply for the tool_result table (for the integration test to call). */
10
13
  export declare function ensureSchema(pool: PgPool | PgPoolClient): Promise<void>;
11
14
  /**