@sema-agent/server 7.92.0 → 7.92.2

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.
@@ -103,6 +103,55 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
103
103
  * `exitCode 0`。对模型而言那与「成功且无输出」**不可区分**。(与 core [ref] 同形:写已释放的 inode。)
104
104
  * 判据与 `hasShells()` 并列:**只要还有人在读写这个根下的文件,就不删它**。 */
105
105
  private readonly liveFgSpools;
106
+ /** 🔴 S-539 在飞的 fs **写**登记表(`liveFgSpools` 同律:「还有人在动这棵树,就别删它」)。
107
+ * 为什么按 promise 存而不是计数:只有 promise 才能被 `destroy()` **await**;计数只能轮询。 */
108
+ private readonly inflightFs;
109
+ /** 一次性:慢 drain 只抱怨一次(一条按次数刷屏的 teardown 读数没人会看)。 */
110
+ private inflightFsDrainWarned;
111
+ /**
112
+ * 🔴 S-539 —— 把一只**会在 workspace / spool 根里建路径或改路径**的 fs 动作登记为「在飞」,直到它 settle。
113
+ *
114
+ * ── 病(旧 `destroy()` 的自称与实情)──────────────────────────────────────────────────────────
115
+ * `destroy()` 只 await 了 `ensurePrepared()` 那只 memo(`preparing`),于是它顶注里的
116
+ * 「eliminating the prepared-flag race entirely」只对 **prepare 那一段**成立。写面在
117
+ * `await this.fsReady(p)` 放行**之后**还会自己碰一次磁盘(`writeFile`/`appendFile` 的
118
+ * `mkdir(dirname)`、`createDir` 的 `mkdir`、`createTempDir`/`createTempFile` 在 spool 根下建),
119
+ * 那一次调用与 destroy 的 `fs.rm` 是**两条独立 fs 调用在 libuv 线程池里赛跑**:rm 先 ⇒ `mkdir -p` 把已拆除
120
+ * 的工作区重建成**孤儿**(而 rm 成功后 `untrackEphemeralHostWorkspace()` 已注销 ⇒ 兜底腿也捞不回);
121
+ * mkdir 先 ⇒ 随后 rm 删掉,调用方拿到 `EINVAL`/`ENOENT` 而不是 "already destroyed"。
122
+ *
123
+ * ── 治(一处定义,十几个面共用)────────────────────────────────────────────────────────────────
124
+ * 调用点必须在方法体的**第一个同步段**里调它(第一个 `await` 之前),登记才与 `destroy()` 置 `destroyed`
125
+ * 的同步段互斥。于是二者只有两种相对次序,**两种都安全**:
126
+ * · 先登记 ⇒ destroy 的快照看得到它,`rm` 等它 settle 之后才发;
127
+ * · 后登记 ⇒ 这一面自己的 `fsReady` 看得到 `destroyed`,一个字节都不碰磁盘。
128
+ * 登记放在 `await this.fsReady(...)` **之后**就没有这个性质:那一刻已经跨过一个 microtask,destroy 可能刚好
129
+ * 在这中间把快照取完了。所以形状是 `return this.withInflightFs(async () => { …原方法体… })`,
130
+ * 而不是在方法体里补一句 `if (this.destroyed)` —— 后者仍然不原子(查完到动手之间还有让出点)。
131
+ *
132
+ * ── 读面刻意不进来 ─────────────────────────────────────────────────────────────────────────────
133
+ * ① 读不建路径:拆除之后一次在飞的读拿到 ENOENT 是**诚实**的,复活目录这条病与它无关;
134
+ * ② 把读拉进来会让 teardown 去等一次可能**永不 settle** 的读(workspace 里的 FIFO / 特殊文件),
135
+ * 把 destroy 挂死 —— 拿一条新的可用性病换一条不存在的正确性收益。
136
+ * 判据因此是一句话:**「可能建路径或改路径的那些面」进在飞集合**,不是「过了 fsReady 的都进」。
137
+ */
138
+ private withInflightFs;
139
+ /**
140
+ * 等 `destroy()` 取到的那批在飞写全部 settle。**永不抛**;答「在时限内清空了吗」。
141
+ *
142
+ * 🔴 两条都不能要,所以有第三条(合并复审 [high] 打出来的):
143
+ * ① 无界等 —— workspace 里的 FIFO / 特殊文件会让一次 `writeFile` **永不 settle**,而拆除在 run 收尾路径上
144
+ * ⇒ 整条收尾停摆。用一条新的可用性病换一条正确性收益,不划算。
145
+ * ② 到点就照删 —— 把 S-539 的竞态**原样**搬回来(rm 与在飞 mkdir 再次在线程池里赛跑),只是把窗口从
146
+ * 「总是」改成「慢的时候」;一条确定的病换一条间歇的病,更糟。
147
+ * ③ **到点就不再占着调用方,但 rm 依旧排在那批写之后**(`destroy()` 里的 `void …then(removeOwnedTrees)`)。
148
+ * 次序保证一个字节不松,teardown 变成「有界返回」。teardown 契约(core 1.258.2)禁的是**抛**,不是**慢**;
149
+ * 这一条连慢都不欠。
150
+ *
151
+ * 快照是 `destroy()` 在同步段取的,所以 destroy **之后**才登记的那些面(唯一一族:`appendFile` 的
152
+ * `lingeringBgWrite` 残余镜像写)不在这一批里 —— 它们按设计就是跨 destroy 存活的,等它们等于等后台 shell。
153
+ */
154
+ private drainInflightFs;
106
155
  private ensureSpoolRoot;
107
156
  /** True when `workspaceDir` was given (user-owned persistent dir) → destroy() must NOT delete it. */
108
157
  private readonly persistent;
@@ -155,6 +204,10 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
155
204
  * core 留活过的驻留 watcher 又杀了一遍(run 尾 dispose→destroy 顺序,destroy 是第二刀)。隔离 env(容器/VM)
156
205
  * 整体拆机自然带走进程,与本契约不冲突(k8s/e2b 各自 destroy 不走这条路径)。 */
157
206
  destroy(): Promise<void>;
207
+ /** `destroy()` 真正动手删的那一段(本 env **自有**的两棵树:spool 根 + ephemeral workspace)。
208
+ * 抽成一只是因为它有两个调用时机 —— 在飞写按时清空时由 `destroy()` 直接 await,超时时挂在那批写后面异步跑
209
+ * (见 {@link drainInflightFs} 头注③)。永不抛(teardown 契约)。 */
210
+ private removeOwnedTrees;
158
211
  cleanup(): Promise<void>;
159
212
  get backgroundCapabilities(): BackgroundShellCapability["backgroundCapabilities"];
160
213
  private _bgManager?;
@@ -210,7 +263,12 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
210
263
  abortSignal?: AbortSignal;
211
264
  }): Promise<Result<string[], FileError>>;
212
265
  readBinaryFile(p: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
266
+ /** S-539:整只方法体(**含** `fsReady` 那道门)走 `withInflightFs` —— 登记必须与调用方同一同步段,
267
+ * 否则 destroy 的快照可能刚好在 `await this.fsReady(...)` 的让出点上取完。见那处头注。 */
213
268
  writeFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
269
+ /** S-539(写面一律 `withInflightFs`,见那处头注)。本面是唯一能在 `destroyed` **之后**过门的写面
270
+ * (`lingeringBgWrite`):那一族登记在 destroy 的快照**之后**,所以 destroy 不会去等它们 —— 正确,
271
+ * 它们按设计跨 destroy 存活(等它们等于等后台 shell)。 */
214
272
  appendFile(p: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise<Result<void, FileError>>;
215
273
  fileInfo(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
216
274
  listDir(p: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
@@ -231,10 +289,14 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
231
289
  */
232
290
  canonicalChain(p: string, abortSignal?: AbortSignal): Promise<Result<SymlinkChain, FileError>>;
233
291
  exists(p: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
292
+ /** S-539(写面一律 `withInflightFs`,见那处头注)。 */
234
293
  createDir(p: string, options?: {
235
294
  recursive?: boolean;
236
295
  abortSignal?: AbortSignal;
237
296
  }): Promise<Result<void, FileError>>;
297
+ /** S-539(写面一律 `withInflightFs`,见那处头注)。本面**不会**复活目录(`fs.rm` 只删),进在飞集合的理由是
298
+ * 同一条:destroy 的 rm 把树从它脚下抽走 ⇒ 一次本该 ok 的删返回 `not_found`。判据是「可能建或改路径」,
299
+ * 不是「可能复活目录」—— 少一条例外就少一处以后要重新想清楚的地方。 */
238
300
  remove(p: string, options?: {
239
301
  recursive?: boolean;
240
302
  force?: boolean;
@@ -252,7 +314,10 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
252
314
  * · 镜像写仍然可用 —— `isLingeringMirrorTarget` 同批放行这个根(见那里的旁注);
253
315
  * · ephemeral 形反而更好:`destroy()` 在**有后台 shell 在册**时不删这个根,而 workspace 是会被 rm 的。
254
316
  * ⚠️ 这是 **host-only**:k8s/local-docker 的临时文件必须落在**容器内**可见的 cwd,ssh/e2b 本来就用远端系统 tmp。 */
317
+ /** S-539(写面一律 `withInflightFs`,见那处头注)。这一面建在 **spool 根**下,而 destroy 也会整树删那个根 ——
318
+ * `fs.mkdtemp` 不建父目录,所以它不会复活什么,坏处是一次本该成功的调用返回 `not_found`。 */
255
319
  createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
320
+ /** S-539(写面一律 `withInflightFs`,见那处头注;spool 根同 `createTempDir`)。 */
256
321
  createTempFile(options?: {
257
322
  prefix?: string;
258
323
  suffix?: string;
@@ -14,6 +14,7 @@ import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
14
14
  import { trackEphemeralHostWorkspace, untrackEphemeralHostWorkspace } from "./host-workspace-registry.js";
15
15
  const PROVIDER = "host";
16
16
  const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60_000;
17
+ const inflightFsDrainWaitMs = () => numEnvOr("HOST_FS_DRAIN_WAIT_MS", 10_000, 1);
17
18
  const HOST_BG_MAX_CONCURRENT = numEnvOr("HOST_BG_MAX_CONCURRENT", 8, 1);
18
19
  const HOST_BG_DEFAULT_TIMEOUT_SEC = numEnvOr("HOST_BG_DEFAULT_TIMEOUT_SEC", 300, 1);
19
20
  const HOST_BG_MAX_TIMEOUT_SEC = numEnvOr("HOST_BG_MAX_TIMEOUT_SEC", 1800, HOST_BG_DEFAULT_TIMEOUT_SEC);
@@ -62,6 +63,41 @@ export class RemoteHostExecutionEnv {
62
63
  destroyed = false;
63
64
  spoolRootDir;
64
65
  liveFgSpools = new Set();
66
+ inflightFs = new Set();
67
+ inflightFsDrainWarned = false;
68
+ withInflightFs(fn) {
69
+ const p = fn();
70
+ this.inflightFs.add(p);
71
+ const forget = () => void this.inflightFs.delete(p);
72
+ void p.then(forget, forget);
73
+ return p;
74
+ }
75
+ async drainInflightFs(inflight) {
76
+ if (inflight.length === 0)
77
+ return true;
78
+ const settled = Promise.allSettled(inflight);
79
+ const waitMs = inflightFsDrainWaitMs();
80
+ const timedOut = Symbol("inflight-fs-drain-timeout");
81
+ let timer;
82
+ const deadline = new Promise((resolve) => {
83
+ timer = setTimeout(() => resolve(timedOut), waitMs);
84
+ timer.unref?.();
85
+ });
86
+ try {
87
+ const drained = (await Promise.race([settled, deadline])) !== timedOut;
88
+ if (!drained && !this.inflightFsDrainWarned) {
89
+ this.inflightFsDrainWarned = true;
90
+ console.warn(`RemoteHostExecutionEnv.destroy: ${this.inflightFs.size} in-flight filesystem write(s) did not settle within ` +
91
+ `${waitMs}ms — returning from teardown now; removal of ${this.workspaceDir} stays ordered AFTER those writes ` +
92
+ `and will land when they finish (a write that never settles, e.g. a FIFO target inside the workspace, defers it)`);
93
+ }
94
+ return drained;
95
+ }
96
+ finally {
97
+ if (timer)
98
+ clearTimeout(timer);
99
+ }
100
+ }
65
101
  ensureSpoolRoot() {
66
102
  if (!this.spoolRootDir)
67
103
  this.spoolRootDir = mkdtempSync(path.join(os.tmpdir(), "sema-host-spool-"));
@@ -130,25 +166,32 @@ export class RemoteHostExecutionEnv {
130
166
  this.destroyed = true;
131
167
  const p = this.preparing;
132
168
  this.preparing = undefined;
169
+ const inflight = [...this.inflightFs];
133
170
  try {
134
171
  await p?.catch(() => { });
135
172
  }
136
173
  finally {
137
- if (this.spoolRootDir && !this.bgManager.hasShells() && this.liveFgSpools.size === 0) {
138
- try {
139
- await fs.rm(this.spoolRootDir, { recursive: true, force: true });
140
- }
141
- catch { }
142
- this.spoolRootDir = undefined;
143
- }
144
- if (this.persistent)
145
- return;
174
+ if (await this.drainInflightFs(inflight))
175
+ await this.removeOwnedTrees();
176
+ else
177
+ void Promise.allSettled(inflight).then(() => this.removeOwnedTrees());
178
+ }
179
+ }
180
+ async removeOwnedTrees() {
181
+ if (this.spoolRootDir && !this.bgManager.hasShells() && this.liveFgSpools.size === 0) {
146
182
  try {
147
- await fs.rm(this.workspaceDir, { recursive: true, force: true });
148
- untrackEphemeralHostWorkspace(this.workspaceDir);
149
- }
150
- catch {
183
+ await fs.rm(this.spoolRootDir, { recursive: true, force: true });
151
184
  }
185
+ catch { }
186
+ this.spoolRootDir = undefined;
187
+ }
188
+ if (this.persistent)
189
+ return;
190
+ try {
191
+ await fs.rm(this.workspaceDir, { recursive: true, force: true });
192
+ untrackEphemeralHostWorkspace(this.workspaceDir);
193
+ }
194
+ catch {
152
195
  }
153
196
  }
154
197
  async cleanup() {
@@ -905,37 +948,41 @@ export class RemoteHostExecutionEnv {
905
948
  return { ok: false, error: this.fileError(e, abs, abortSignal) };
906
949
  }
907
950
  }
908
- async writeFile(p, content, abortSignal) {
909
- const ready = await this.fsReady(p);
910
- if (!ready.ok)
911
- return ready;
912
- const abs = this.resolve(p);
913
- try {
914
- await fs.mkdir(path.dirname(abs), { recursive: true });
915
- const data = typeof content === "string" ? content : Buffer.from(content);
916
- await fs.writeFile(abs, data, { signal: abortSignal });
917
- return ok(undefined);
918
- }
919
- catch (e) {
920
- return { ok: false, error: this.fileError(e, abs, abortSignal) };
921
- }
951
+ writeFile(p, content, abortSignal) {
952
+ return this.withInflightFs(async () => {
953
+ const ready = await this.fsReady(p);
954
+ if (!ready.ok)
955
+ return ready;
956
+ const abs = this.resolve(p);
957
+ try {
958
+ await fs.mkdir(path.dirname(abs), { recursive: true });
959
+ const data = typeof content === "string" ? content : Buffer.from(content);
960
+ await fs.writeFile(abs, data, { signal: abortSignal });
961
+ return ok(undefined);
962
+ }
963
+ catch (e) {
964
+ return { ok: false, error: this.fileError(e, abs, abortSignal) };
965
+ }
966
+ });
922
967
  }
923
- async appendFile(p, content, abortSignal) {
924
- const ready = await this.fsReady(p, { lingeringBgWrite: true });
925
- if (!ready.ok)
926
- return ready;
927
- const abs = this.resolve(p);
928
- try {
929
- await fs.mkdir(path.dirname(abs), { recursive: true });
930
- if (abortSignal?.aborted)
931
- throw Object.assign(new Error("aborted"), { name: "AbortError" });
932
- const data = typeof content === "string" ? content : Buffer.from(content);
933
- await fs.appendFile(abs, data);
934
- return ok(undefined);
935
- }
936
- catch (e) {
937
- return { ok: false, error: this.fileError(e, abs, abortSignal) };
938
- }
968
+ appendFile(p, content, abortSignal) {
969
+ return this.withInflightFs(async () => {
970
+ const ready = await this.fsReady(p, { lingeringBgWrite: true });
971
+ if (!ready.ok)
972
+ return ready;
973
+ const abs = this.resolve(p);
974
+ try {
975
+ await fs.mkdir(path.dirname(abs), { recursive: true });
976
+ if (abortSignal?.aborted)
977
+ throw Object.assign(new Error("aborted"), { name: "AbortError" });
978
+ const data = typeof content === "string" ? content : Buffer.from(content);
979
+ await fs.appendFile(abs, data);
980
+ return ok(undefined);
981
+ }
982
+ catch (e) {
983
+ return { ok: false, error: this.fileError(e, abs, abortSignal) };
984
+ }
985
+ });
939
986
  }
940
987
  async fileInfo(p, abortSignal) {
941
988
  const ready = await this.fsReady(p);
@@ -1058,66 +1105,74 @@ export class RemoteHostExecutionEnv {
1058
1105
  return { ok: false, error: fe };
1059
1106
  }
1060
1107
  }
1061
- async createDir(p, options) {
1062
- const ready = await this.fsReady(p);
1063
- if (!ready.ok)
1064
- return ready;
1065
- const abs = this.resolve(p);
1066
- try {
1067
- await fs.mkdir(abs, { recursive: options?.recursive ?? true });
1068
- return ok(undefined);
1069
- }
1070
- catch (e) {
1071
- return { ok: false, error: this.fileError(e, abs, options?.abortSignal) };
1072
- }
1073
- }
1074
- async remove(p, options) {
1075
- const ready = await this.fsReady(p);
1076
- if (!ready.ok)
1077
- return ready;
1078
- const abs = this.resolve(p);
1079
- try {
1080
- if (!options?.force) {
1081
- await fs.lstat(abs);
1108
+ createDir(p, options) {
1109
+ return this.withInflightFs(async () => {
1110
+ const ready = await this.fsReady(p);
1111
+ if (!ready.ok)
1112
+ return ready;
1113
+ const abs = this.resolve(p);
1114
+ try {
1115
+ await fs.mkdir(abs, { recursive: options?.recursive ?? true });
1116
+ return ok(undefined);
1082
1117
  }
1083
- await fs.rm(abs, { recursive: options?.recursive ?? false, force: options?.force ?? false });
1084
- return ok(undefined);
1085
- }
1086
- catch (e) {
1087
- const fe = this.fileError(e, abs, options?.abortSignal);
1088
- if (options?.force && fe.code === "not_found")
1118
+ catch (e) {
1119
+ return { ok: false, error: this.fileError(e, abs, options?.abortSignal) };
1120
+ }
1121
+ });
1122
+ }
1123
+ remove(p, options) {
1124
+ return this.withInflightFs(async () => {
1125
+ const ready = await this.fsReady(p);
1126
+ if (!ready.ok)
1127
+ return ready;
1128
+ const abs = this.resolve(p);
1129
+ try {
1130
+ if (!options?.force) {
1131
+ await fs.lstat(abs);
1132
+ }
1133
+ await fs.rm(abs, { recursive: options?.recursive ?? false, force: options?.force ?? false });
1089
1134
  return ok(undefined);
1090
- return { ok: false, error: fe };
1091
- }
1135
+ }
1136
+ catch (e) {
1137
+ const fe = this.fileError(e, abs, options?.abortSignal);
1138
+ if (options?.force && fe.code === "not_found")
1139
+ return ok(undefined);
1140
+ return { ok: false, error: fe };
1141
+ }
1142
+ });
1092
1143
  }
1093
- async createTempDir(prefix = "tmp-", abortSignal) {
1094
- const ready = await this.fsReady(".");
1095
- if (!ready.ok)
1096
- return ready;
1097
- try {
1098
- return ok(await fs.mkdtemp(path.join(this.ensureSpoolRoot(), prefix)));
1099
- }
1100
- catch (e) {
1101
- return { ok: false, error: this.fileError(e, this.workspaceDir, abortSignal) };
1102
- }
1144
+ createTempDir(prefix = "tmp-", abortSignal) {
1145
+ return this.withInflightFs(async () => {
1146
+ const ready = await this.fsReady(".");
1147
+ if (!ready.ok)
1148
+ return ready;
1149
+ try {
1150
+ return ok(await fs.mkdtemp(path.join(this.ensureSpoolRoot(), prefix)));
1151
+ }
1152
+ catch (e) {
1153
+ return { ok: false, error: this.fileError(e, this.workspaceDir, abortSignal) };
1154
+ }
1155
+ });
1103
1156
  }
1104
- async createTempFile(options) {
1105
- const ready = await this.fsReady(".");
1106
- if (!ready.ok)
1107
- return ready;
1108
- const prefix = options?.prefix ?? "";
1109
- const suffix = options?.suffix ?? "";
1110
- try {
1111
- const name = `${prefix}${randomBytes(8).toString("hex")}${suffix}`;
1112
- const abs = path.join(this.ensureSpoolRoot(), name);
1113
- if (options?.abortSignal?.aborted)
1114
- throw Object.assign(new Error("aborted"), { name: "AbortError" });
1115
- await fs.writeFile(abs, "", { flag: "wx" });
1116
- return ok(abs);
1117
- }
1118
- catch (e) {
1119
- return { ok: false, error: this.fileError(e, this.workspaceDir, options?.abortSignal) };
1120
- }
1157
+ createTempFile(options) {
1158
+ return this.withInflightFs(async () => {
1159
+ const ready = await this.fsReady(".");
1160
+ if (!ready.ok)
1161
+ return ready;
1162
+ const prefix = options?.prefix ?? "";
1163
+ const suffix = options?.suffix ?? "";
1164
+ try {
1165
+ const name = `${prefix}${randomBytes(8).toString("hex")}${suffix}`;
1166
+ const abs = path.join(this.ensureSpoolRoot(), name);
1167
+ if (options?.abortSignal?.aborted)
1168
+ throw Object.assign(new Error("aborted"), { name: "AbortError" });
1169
+ await fs.writeFile(abs, "", { flag: "wx" });
1170
+ return ok(abs);
1171
+ }
1172
+ catch (e) {
1173
+ return { ok: false, error: this.fileError(e, this.workspaceDir, options?.abortSignal) };
1174
+ }
1175
+ });
1121
1176
  }
1122
1177
  resolve(p) {
1123
1178
  return path.isAbsolute(p) ? path.normalize(p) : path.normalize(path.resolve(this.cwd, p));
@@ -1207,7 +1262,7 @@ export class RemoteHostExecutionEnv {
1207
1262
  const rel = path.relative(real(root), real(abs));
1208
1263
  return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
1209
1264
  };
1210
- return inside(this.workspaceDir) || (this.spoolRootDir !== undefined && inside(this.spoolRootDir));
1265
+ return (this.spoolRootDir !== undefined && inside(this.spoolRootDir)) || (this.persistent && inside(this.workspaceDir));
1211
1266
  }
1212
1267
  async fsReady(p, opts) {
1213
1268
  if (this.destroyed) {
@@ -1,5 +1,5 @@
1
1
  import { redactErrorMessage, redactLedgerEventData, redactTerminalResult } from "../observability/run-terminal.js";
2
- import { terminalProjection } from "@sema-agent/core";
2
+ import { outwardPlane } from "../terminal.js";
3
3
  import { projectUsageStats, USAGE_SCAN_LIMIT } from "../usage-analytics.js";
4
4
  import { escapeLike } from "./sql-escape.js";
5
5
  import { pgSanitizeText } from "./pg-safe-json.js";
@@ -120,7 +120,7 @@ export class SqlRunStore {
120
120
  async setTerminal(taskId, status, result, error) {
121
121
  result = redactTerminalResult(result);
122
122
  error = error === null ? null : (redactErrorMessage(error) ?? null);
123
- const errorCode = result === null ? null : (terminalProjection(result.terminal).errorCode ?? null);
123
+ const errorCode = result === null ? null : (outwardPlane(result.terminal).errorCode ?? null);
124
124
  let won = false;
125
125
  let observed;
126
126
  await this.tx(async (conn) => {
@@ -153,7 +153,7 @@ export class SqlRunStore {
153
153
  resultTaskId: result?.taskId,
154
154
  model: result?.model,
155
155
  errorCode: errorCode ?? undefined,
156
- errorMessage: (result === null ? undefined : terminalProjection(result.terminal).errorMessage) ?? error ?? undefined,
156
+ errorMessage: (result === null ? undefined : outwardPlane(result.terminal).errorMessage) ?? error ?? undefined,
157
157
  elapsedMs: observed?.createdAt === undefined ? undefined : Math.max(0, Date.now() - observed.createdAt),
158
158
  });
159
159
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * **启动期「库 ≟ 代码」列集对账探针**(S-547)—— 存量库缺列/多列一律**响亮拒启**。
3
+ *
4
+ * ── 病(test T-47 三库一致的真读数)───────────────────────────────────────────────────────────────
5
+ * 7.91.2 建的 `session_policy`(那时还没有 `gen` 列)+ 一行数据,7.92.0 的二进制接上去:`CREATE TABLE
6
+ * IF NOT EXISTS` 对存量表是**空操作** ⇒ 启动全绿、日志零提 `gen`、健康检查 healthy,直到**用户路径上**
7
+ * 第一次读写策略行才炸 —— `PUT/GET /v1/sessions/:id/policy` 500,`POST /v1/tasks` 的 run 终局对外
8
+ * `errorCode` 直接是裸 `42703` / `ER_BAD_FIELD_ERROR`。
9
+ *
10
+ * ── 与 SCHEMA POLICY(`tidb-pool.ts` 顶注,clay 裁 2026-07-26)不冲突 ─────────────────────────────
11
+ * 那条口径禁的是 **ALTER seam**:「代码是 schema 的唯一真源、存量库删库重建、`ensure*` 里不许再出现
12
+ * `ALTER TABLE`」。本探针**一条 DDL 都不发**,它是只读的 information_schema 对账 —— 恰恰是那条口径的
13
+ * **执行面**:口径说「库必须等于代码」,在此之前没有任何东西检查过这句话是不是真的。两者一个是禁令
14
+ * (不许代码偷改库)、一个是对账(库偏离了就别起),同向不同面。
15
+ *
16
+ * ── 为什么两个方向都拒(缺列 **和** 多列)──────────────────────────────────────────────────────
17
+ * 缺列 = 库比代码旧(升级没重建库):读写会在用户路径上炸。多列 = 库比代码**新** —— 滚动升级窗里一个
18
+ * **旧副本**连上了已被新副本重建过的库;它按旧列集读写,新列上的不变量(本例:`gen` 血统)在它眼里
19
+ * 根本不存在,于是它会悄悄写出违反新契约的行。后者比前者更隐蔽,所以不做「幽灵列只警告」。
20
+ * ⇒ 运维顺序因此是**先升库再升副本**(见 `docs/DEPLOY-PREREQS.md` 滚动升级窗那一节)。
21
+ *
22
+ * ── 期望列集从哪来:**机器派生,不许手抄**([ref])────────────────────────────────────────────
23
+ * 期望值 = `schema-ensure-legs.ts` 的录制驱动跑一遍**真的** `ensure*Schema`,把录到的 `CREATE TABLE`
24
+ * 语句文本解析出列集。于是「代码里的 schema」与「探针拿来对账的 schema」是**同一份字节**,不存在第二
25
+ * 处清单会腐掉。(更好的形是把语句数组本身改成结构化再生成 SQL —— 那要改 70 张表的 DDL 写法,爆炸
26
+ * 半径远大于本批;先解析文本,理由写在这里。)拒启文案里的那句 `ALTER TABLE … ADD COLUMN …` 也由
27
+ * **同一份解析结果**生成,不是手写的。
28
+ *
29
+ * ── 失败方向:读不到就拒,不跳过 ─────────────────────────────────────────────────────────────
30
+ * information_schema 查询抛错、或整库一行都读不出来 ⇒ **拒启**。探针的语义是「我证明了库等于代码」,
31
+ * 证不出来时放行等于把一条保护性断言静默降级成祝福([ref]:保护型缺席必须 fail-closed)。
32
+ * `recordFailOpen` 在这条腿上**不适用** —— 它是 F 类(装饰/缓存)的登记口,不是 P 类保护臂的逃生门。
33
+ */
34
+ import { type SchemaDialect } from "./schema-ensure-legs.js";
35
+ /** 一列:名字 + 它在 `CREATE TABLE` 里的**原样定义文本**(生成 ADD COLUMN 指路用,不另手写)。 */
36
+ export interface ParsedColumn {
37
+ readonly name: string;
38
+ readonly ddl: string;
39
+ }
40
+ /** 一张表的列形。 */
41
+ export interface ParsedTable {
42
+ readonly table: string;
43
+ readonly columns: readonly ParsedColumn[];
44
+ }
45
+ /**
46
+ * 解析一条 `CREATE TABLE`。不是建表语句(CREATE INDEX / INSERT / SELECT / CREATE EXTENSION)⇒ `undefined`。
47
+ *
48
+ * 认识面窄得刻意:只取**表名 + 顶层列名 + 该列的定义文本**。类型、默认值、可空性一律**不比对** ——
49
+ * 本探针守的是「列在不在」这一类(T-47 的真病),把类型也拉进来会让方言差异(`DATETIME(3)` ⇄
50
+ * `TIMESTAMPTZ(3)`、`vector(d)` ⇄ `jsonb` 的运行期参数化)变成一堆假红,而那是另一条判据另一台车。
51
+ */
52
+ export declare function parseCreateTable(stmt: string): ParsedTable | undefined;
53
+ /** 启动时真会建出来的**全部**表形(录制 → 解析)。同名表二次出现以**先到**为准(`IF NOT EXISTS` 的真语义)。 */
54
+ export declare function expectedTableShapes(dialect: SchemaDialect): Promise<Map<string, ParsedTable>>;
55
+ /** 拒启码(机读)。附录 A / 拒启码表的登记名。 */
56
+ export declare const SCHEMA_MISMATCH_ERROR_CODE = "config.schema_mismatch";
57
+ /** 一张表的对账结论。 */
58
+ export interface TableMismatch {
59
+ readonly table: string;
60
+ /** 代码有、库没有(库比代码**旧**)。 */
61
+ readonly missing: readonly ParsedColumn[];
62
+ /** 库有、代码没有(库比代码**新** —— 滚动窗里的旧副本)。 */
63
+ readonly ghost: readonly string[];
64
+ }
65
+ /** 探针读口:一条 SQL → 行数组(两方言由调用方各自绑)。 */
66
+ export type SchemaProbeRead = (sql: string) => Promise<Array<Record<string, unknown>>>;
67
+ /**
68
+ * 库里实际的列集(表名 → 列名集合)。
69
+ *
70
+ * 🔴 **PG 侧读的是「查询真正会解析到的那个关系」,不是 `current_schema()`**(codex 对抗复审本轮 [medium]
71
+ * 采纳):店里的 SQL 全是**不限定 schema** 的裸表名,它按 `search_path` **逐个** schema 解析,命中第一个;
72
+ * 而 `current_schema()` 只是路径的第一段。两者不等的部署是真的存在(角色的 `search_path` 配了多段),那时
73
+ * 只读第一段会让探针对「表其实住在路径后段、而且缺列」这一形**完全沉默**。⇒ 按 `current_schemas(false)`
74
+ * 的**顺序**取每张表的第一次命中 —— 探针于是与执行器看同一张表。MySQL 协议没有这个概念(库 = 连接上的
75
+ * `DATABASE()`),那条臂一个字不变。
76
+ */
77
+ export declare function readLiveColumns(read: SchemaProbeRead, dialect: SchemaDialect): Promise<Map<string, Set<string>>>;
78
+ /** 对账(纯函数:期望 × 实况 → 不符表清单)。 */
79
+ export declare function diffSchema(expected: Map<string, ParsedTable>, live: Map<string, Set<string>>): TableMismatch[];
80
+ /** 拒启文案(表名 + 缺/多列 + 两条路;ADD COLUMN 句由解析结果**生成**)。 */
81
+ export declare function describeSchemaMismatch(dialect: SchemaDialect, mismatches: readonly TableMismatch[]): string;
82
+ /**
83
+ * 启动期对账:**建表之后**跑,不符即抛(= 拒启)。两方言 + InnoDB 同腿(MySQL 协议共用 `mysql` 臂)。
84
+ *
85
+ * 失败方向见文件头注:读抛 / 整库零行 ⇒ 拒。
86
+ */
87
+ export declare function assertSchemaMatchesCode(read: SchemaProbeRead, dialect: SchemaDialect): Promise<void>;
88
+ //# sourceMappingURL=schema-consistency.d.ts.map