@sema-agent/server 7.91.2 → 7.92.1

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 (73) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +8 -0
  3. package/dist/approval-ask-machine.d.ts +1 -1
  4. package/dist/approval-reconciler.d.ts +1 -1
  5. package/dist/approval.d.ts +1 -1
  6. package/dist/bench/s1/repair-oracle-adapter.d.ts +1 -1
  7. package/dist/bench/s1/runner-ctx.d.ts +1 -1
  8. package/dist/boot/runner-deps.d.ts +9 -9
  9. package/dist/capabilities/memory-notice.d.ts +27 -10
  10. package/dist/capabilities/memory-notice.js +21 -2
  11. package/dist/config-catalog.js +3 -1
  12. package/dist/config-center/types.d.ts +1 -1
  13. package/dist/config.d.ts +1 -1
  14. package/dist/env-facts.d.ts +1 -1
  15. package/dist/execution-lane-caps.d.ts +1 -1
  16. package/dist/file-history-disclosure.d.ts +2 -2
  17. package/dist/fleet/fleet-bus.d.ts +7 -1
  18. package/dist/fleet/fleet-bus.js +5 -3
  19. package/dist/http/admission.d.ts +1 -1
  20. package/dist/http/idempotency.d.ts +27 -0
  21. package/dist/http/route-ctx.d.ts +2 -5
  22. package/dist/http/routes/memory-compliance.d.ts +1 -1
  23. package/dist/http/routes/memory-origin.d.ts +2 -2
  24. package/dist/http/routes/rules.js +3 -1
  25. package/dist/http/routes/runs.js +3 -6
  26. package/dist/http/routes/session-sync.js +3 -2
  27. package/dist/http/routes/sessions.js +3 -2
  28. package/dist/http/routes/tasks.js +35 -19
  29. package/dist/http/wire-types.d.ts +29 -2
  30. package/dist/leader/wire.d.ts +1 -1
  31. package/dist/lsp/manager.d.ts +2 -2
  32. package/dist/memory-operator-faces.d.ts +13 -13
  33. package/dist/memory-scope.d.ts +2 -2
  34. package/dist/observability/fail-open.d.ts +1 -1
  35. package/dist/observability/fail-open.js +1 -1
  36. package/dist/observability/metrics.js +2 -1
  37. package/dist/orchestration/workflow-agent-session-index.d.ts +2 -2
  38. package/dist/peer-directory.d.ts +3 -2
  39. package/dist/permission-rule-vocab.d.ts +16 -1
  40. package/dist/plugins/file-run-store.d.ts +1 -1
  41. package/dist/plugins/local-session-store.d.ts +1 -1
  42. package/dist/plugins/memory-session-policy-store.d.ts +22 -3
  43. package/dist/plugins/memory-session-policy-store.js +11 -4
  44. package/dist/plugins/permission-rule-store-sql.js +16 -7
  45. package/dist/plugins/pg-pool.js +11 -0
  46. package/dist/plugins/remote-env-host.d.ts +65 -0
  47. package/dist/plugins/remote-env-host.js +155 -100
  48. package/dist/plugins/session-policy-store-sql.d.ts +51 -3
  49. package/dist/plugins/session-policy-store-sql.js +77 -38
  50. package/dist/plugins/sql-errors.d.ts +25 -0
  51. package/dist/plugins/sql-errors.js +10 -0
  52. package/dist/plugins/store-backend.d.ts +5 -5
  53. package/dist/plugins/store-contracts.d.ts +2 -2
  54. package/dist/plugins/tidb-pool.d.ts +1 -1
  55. package/dist/plugins/tidb-pool.js +19 -0
  56. package/dist/rules-consent.d.ts +16 -9
  57. package/dist/rules-consent.js +14 -35
  58. package/dist/run-cancel-context.d.ts +1 -1
  59. package/dist/run-local.d.ts +1 -1
  60. package/dist/runtime-caps-resolver.d.ts +10 -10
  61. package/dist/runtime-governance.d.ts +2 -2
  62. package/dist/session-policy-wire.d.ts +25 -1
  63. package/dist/session-policy-wire.js +11 -0
  64. package/dist/spec-fields.d.ts +4 -4
  65. package/dist/task-cwd.d.ts +1 -1
  66. package/dist/task-settings.d.ts +2 -2
  67. package/dist/tool-approval.d.ts +1 -1
  68. package/dist/trace/core-keyset-guard.d.ts +1 -1
  69. package/dist/trace/project.d.ts +25 -1
  70. package/dist/trace/project.js +4 -0
  71. package/dist/trace/redact.d.ts +1 -1
  72. package/dist/trace/redact.js +8 -3
  73. package/package.json +3 -3
@@ -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) {
@@ -25,9 +25,57 @@ export declare class SqlSessionPolicyStore implements SessionPolicyStore {
25
25
  listBySession(sessionId: string): Promise<SessionRulesRecord[]>;
26
26
  /** Operator/owner write: CAS + tighten-only (unless operator) + rev bump, atomic in one txn; first-write race retried. */
27
27
  putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
28
- /** E21 purge — drop ALL policy rows for a session (every principal). Scoped by session_id (the route owner-gates).
29
- * Return shape aligned to core 1.423's optional interface seam (`Promise<void>`) — the affectedRows count was
30
- * incidental and unconsumed; the integration test asserts the post-state (listBySession empty) instead. */
28
+ /**
29
+ * The store's ONE retry arm: re-run the whole read-check-write transaction while the failure is an engine
30
+ * ARBITRATION artifact rather than an answer. Two such artifacts, and only two:
31
+ * · **dup-key** — a concurrent FIRST write won the PK (no row ⇒ `FOR UPDATE` locked nothing). The re-read takes
32
+ * the UPDATE/CAS path, which is core's serialized outcome; converting it to `conflict` in place would be a
33
+ * spurious answer the caller can't tell from a real CAS loss.
34
+ * · **deadlock** — the engine picked THIS transaction as the victim and already rolled it back. Which side gets
35
+ * picked is arbitrary, so propagating it hands the caller an internal coin flip. 🔴 This arm is NOT a licence
36
+ * for sloppy lock order: the order is still policy row → mark row on BOTH methods, and the oracle for it is
37
+ * `test/sql-lock-shape-innodb-integration.test.ts` (site ⑩, three engines). It exists because on REAL InnoDB
38
+ * (REPEATABLE READ) a `FOR UPDATE` read of a MISSING primary key takes a GAP lock, and gap locks are mutually
39
+ * compatible while both block the other side's INSERT — so N concurrent first writes on one key deadlock NO
40
+ * MATTER what order any table is touched in. Measured on the real engine (2026-09-20, S-529, 8-way race on
41
+ * `main`'s statement sequence, zero mark table involved): InnoDB 1 winner / 7 `ER_LOCK_DEADLOCK`, TiDB 0,
42
+ * PG 0 — i.e. this is a PRE-EXISTING MySQL-leg defect that S-529's lock-shape site made visible, not a new one.
43
+ * After the first insert commits, the retried writers find the row and take the UPDATE path (record lock,
44
+ * no gap) ⇒ the loop converges in one extra round per missing-row window; the bound is the suite's concurrency.
45
+ * Lock-WAIT-TIMEOUT is deliberately NOT retried ({@link isDeadlockError}'s own note: it is a latency signal,
46
+ * and retrying it multiplies an already-long wait).
47
+ */
48
+ private retrying;
49
+ /** E21 purge — drop ALL policy rows for a session (every principal), KEEPING each key's rev high-water mark.
50
+ * Scoped by session_id (the route owner-gates). Return shape aligned to core 1.423's optional interface seam
51
+ * (`Promise<void>`) — the affectedRows count was incidental and unconsumed; the integration test asserts the
52
+ * post-state (listBySession empty) instead.
53
+ *
54
+ * S-529 (`@contract session_policy.rev_survives_delete`): the rules ROW dies, the key's REV LINE does not, and the
55
+ * erase ALSO ENDS THE ROW'S LINEAGE (`gen` + 1 — the next write on this key belongs to another generation, which no
56
+ * reader treats as a continuation of the erased row whatever rev it wears) — one
57
+ * transaction, three statements, and BOTH the order and the locking verb are load-bearing:
58
+ * ① `SELECT … ORDER BY policy_key FOR UPDATE` — the rows to erase are read and X-LOCKED first, in a DETERMINISTIC
59
+ * order. This is the SAME lock order putRules uses (policy row → mark row), which is the whole point: the
60
+ * obvious shape (`INSERT … SELECT` the marks, then DELETE) locks the MARK first and takes only a SHARED lock
61
+ * on the policy rows it reads, i.e. a reversed order PLUS an S→X upgrade — the textbook deadlock this repo
62
+ * 已经吃过两次(retention-store-sql.ts ④a/④b 的 S-131 案:一边持 boundary 等哨兵,一边持哨兵等 boundary)。
63
+ * ② the marks (their revs come from ①'s already-locked rows) — `GREATEST` merge, so a mark only ever RISES and
64
+ * repeated erases never walk the line back.
65
+ * ③ the DELETE — scoped to ①'s POLICY_KEY SET, never re-scanned by `session_id`. 🔴 This is the whole of
66
+ * codex r1 [high] #1 (verified, not rebutted): both engines re-evaluate a `WHERE session_id = ?` DELETE
67
+ * against a CURRENT read (PG is pinned to READ COMMITTED, TiDB's DML is a current read), so a row for a
68
+ * DIFFERENT principal that a concurrent putRules commits BETWEEN ① and ③ would be deleted by a session-wide
69
+ * DELETE while ② never marked it — a silently reset rev line, exactly the defect this batch exists to close.
70
+ * Scoped to the key set, the invariant is exact and checkable: **every row this seam deletes leaves a mark**.
71
+ * A row born after ① is simply not part of this erase (same as one born a microsecond after ③ — the caller
72
+ * that needs "and nothing came back" re-issues; the seam is idempotent).
73
+ * No rows ⇒ return early: an erase of a session that never had rules writes NO mark (no line, nothing to keep),
74
+ * which is what makes a repeated/empty delete idempotent instead of resurrecting a mark.
75
+ * Why the mark is a separate table and not a tombstone row: see the DDL comment in tidb-pool.ts — a tombstone
76
+ * would keep `session_id` + the DENORMALIZED plaintext `principal` in the table after an E21 purge, i.e. the
77
+ * right-to-delete would degrade to buy rev ordering. The mark keeps only the one-way hash, the session id and
78
+ * an integer. */
31
79
  deleteBySession(sessionId: string): Promise<void>;
32
80
  /**
33
81
  * The store's one read-check-write transaction. Its load-bearing read is line ~106's
@@ -2,10 +2,22 @@ import { createHash } from "node:crypto";
2
2
  import { loosenReasons, SessionPolicyError, normalizeRules, stripRev } from "@sema-agent/core";
3
3
  import { pgHasUnstorable } from "./pg-safe-json.js";
4
4
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
5
- import { isDupKeyError } from "./sql-errors.js";
5
+ import { isDeadlockError, isDupKeyError } from "./sql-errors.js";
6
6
  export const policyKeyFor = (sessionId, principal) => createHash("sha256").update(JSON.stringify([sessionId, principal ?? null])).digest("hex");
7
7
  const policyKey = policyKeyFor;
8
+ const RETRY_LIMIT = 8;
8
9
  const parseRules = (v) => (typeof v === "string" ? JSON.parse(v) : v);
10
+ function rowToStored(r, where) {
11
+ const rev = Number(r.rev);
12
+ if (!Number.isFinite(rev) || !Number.isInteger(rev) || rev < 1) {
13
+ throw new SessionPolicyError("corrupt", `session rules row ${where} carries rev ${String(r.rev)} — a stored row's rev is always ≥ 1 (the stamp is floor+1), and rev 0 is the live reader's synthetic empty seed; refusing rather than serve it as rules`);
14
+ }
15
+ const gen = Number(r.gen ?? 0);
16
+ if (!Number.isFinite(gen) || !Number.isInteger(gen) || gen < 0) {
17
+ throw new SessionPolicyError("corrupt", `session rules row ${where} carries lineage gen ${String(r.gen)} — a generation is a whole number ≥ 0; refusing rather than let a reader judge lineage by a value nothing minted`);
18
+ }
19
+ return { ...parseRules(r.rules), rev, gen };
20
+ }
9
21
  export class SqlSessionPolicyStore {
10
22
  db;
11
23
  constructor(db) {
@@ -18,62 +30,89 @@ export class SqlSessionPolicyStore {
18
30
  return isDupKeyError(this.db.dialect, e);
19
31
  }
20
32
  async getRules(sessionId, principal) {
21
- const { rows } = await this.db.query(this.q("SELECT rules, rev FROM session_policy WHERE policy_key = ?", "SELECT rules, rev FROM session_policy WHERE policy_key = $1"), [policyKey(sessionId, principal)]);
33
+ const { rows } = await this.db.query(this.q("SELECT rules, rev, gen FROM session_policy WHERE policy_key = ?", "SELECT rules, rev, gen FROM session_policy WHERE policy_key = $1"), [policyKey(sessionId, principal)]);
22
34
  if (!rows[0])
23
35
  return null;
24
- return { ...parseRules(rows[0].rules), rev: Number(rows[0].rev) };
36
+ return rowToStored(rows[0], `for session ${JSON.stringify(sessionId)}`);
25
37
  }
26
38
  async listBySession(sessionId) {
27
- const { rows } = await this.db.query(this.q("SELECT principal, rules, rev FROM session_policy WHERE session_id = ?", "SELECT principal, rules, rev FROM session_policy WHERE session_id = $1"), [sessionId]);
39
+ const { rows } = await this.db.query(this.q("SELECT principal, rules, rev, gen FROM session_policy WHERE session_id = ?", "SELECT principal, rules, rev, gen FROM session_policy WHERE session_id = $1"), [sessionId]);
28
40
  return rows.map((r) => ({
29
41
  principal: r.principal == null ? undefined : String(r.principal),
30
- rules: { ...parseRules(r.rules), rev: Number(r.rev) },
42
+ rules: rowToStored(r, `for session ${JSON.stringify(sessionId)} (enumeration)`),
31
43
  }));
32
44
  }
33
45
  async putRules(sessionId, principal, rules, opts) {
34
46
  const key = policyKey(sessionId, principal);
35
- for (let attempt = 0;; attempt++) {
47
+ return this.retrying(() => this.tx(async (conn) => {
48
+ const { rows } = await conn.query(this.q("SELECT rules, rev, gen FROM session_policy WHERE policy_key = ? FOR UPDATE", "SELECT rules, rev, gen FROM session_policy WHERE policy_key = $1 FOR UPDATE"), [key]);
49
+ const prior = rows[0] ? rowToStored(rows[0], `for session ${JSON.stringify(sessionId)} (pre-write snapshot)`) : undefined;
50
+ const priorRev = prior?.rev ?? 0;
51
+ if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {
52
+ throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${priorRev})`);
53
+ }
54
+ const { rows: markRows } = await conn.query(this.q("SELECT rev, gen FROM session_policy_rev_mark WHERE policy_key = ? FOR UPDATE", "SELECT rev, gen FROM session_policy_rev_mark WHERE policy_key = $1 FOR UPDATE"), [key]);
55
+ const markRev = markRows[0] ? Number(markRows[0].rev) : 0;
56
+ const markGen = markRows[0] ? Number(markRows[0].gen) : 0;
57
+ if (markRows[0] !== undefined && (!Number.isInteger(markGen) || markGen < 0 || !Number.isFinite(markRev) || markRev < 0)) {
58
+ throw new SessionPolicyError("corrupt", `the session rules rev mark for session ${JSON.stringify(sessionId)} is unreadable (rev ${String(markRows[0].rev)}, gen ${String(markRows[0].gen)}) — refusing rather than stamp a rev/lineage nothing proves`);
59
+ }
60
+ const clean = normalizeRules(stripRev(rules));
61
+ if (prior && opts?.operator !== true) {
62
+ const loosens = loosenReasons(prior, clean);
63
+ if (loosens.length > 0) {
64
+ throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
65
+ }
66
+ }
67
+ if (this.db.dialect === "pg" && pgHasUnstorable(clean)) {
68
+ throw new SessionPolicyError("loosen_forbidden", "rules contain unstorable bytes (NUL / lone surrogate) — refused; strip them at the source (authorization rules are never rewritten by the store)");
69
+ }
70
+ const nextRev = Math.max(priorRev, markRev) + 1;
71
+ const nextGen = prior?.gen ?? markGen;
72
+ const json = JSON.stringify(clean);
73
+ const now = new Date();
74
+ if (prior) {
75
+ const upd = await conn.query(this.q("UPDATE session_policy SET principal = ?, rules = ?, rev = ?, gen = ?, updated_at = ? WHERE policy_key = ? AND rev = ?", "UPDATE session_policy SET principal = $1, rules = $2::jsonb, rev = $3, gen = $4, updated_at = $5 WHERE policy_key = $6 AND rev = $7"), [principal ?? null, json, nextRev, nextGen, now, key, priorRev]);
76
+ if (upd.affected !== 1)
77
+ throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts?.expectedRev ?? priorRev}, have ${priorRev})`);
78
+ }
79
+ else {
80
+ await conn.query(this.q("INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, gen, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)", "INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, gen, created_at, updated_at) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7,$8)"), [key, sessionId, principal ?? null, json, nextRev, nextGen, now, now]);
81
+ }
82
+ return structuredClone({ ...clean, rev: nextRev, gen: nextGen });
83
+ }));
84
+ }
85
+ async retrying(attempt) {
86
+ for (let tries = 0;; tries++) {
36
87
  try {
37
- return await this.tx(async (conn) => {
38
- const { rows } = await conn.query(this.q("SELECT rules, rev FROM session_policy WHERE policy_key = ? FOR UPDATE", "SELECT rules, rev FROM session_policy WHERE policy_key = $1 FOR UPDATE"), [key]);
39
- const prior = rows[0] ? { rules: parseRules(rows[0].rules), rev: Number(rows[0].rev) } : undefined;
40
- const priorRev = prior?.rev ?? 0;
41
- if (opts?.expectedRev !== undefined && priorRev !== opts.expectedRev) {
42
- throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${priorRev})`);
43
- }
44
- const clean = normalizeRules(stripRev(rules));
45
- if (prior && opts?.operator !== true) {
46
- const loosens = loosenReasons(prior.rules, clean);
47
- if (loosens.length > 0) {
48
- throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
49
- }
50
- }
51
- if (this.db.dialect === "pg" && pgHasUnstorable(clean)) {
52
- throw new SessionPolicyError("loosen_forbidden", "rules contain unstorable bytes (NUL / lone surrogate) — refused; strip them at the source (authorization rules are never rewritten by the store)");
53
- }
54
- const nextRev = priorRev + 1;
55
- const json = JSON.stringify(clean);
56
- const now = new Date();
57
- if (prior) {
58
- const upd = await conn.query(this.q("UPDATE session_policy SET principal = ?, rules = ?, rev = ?, updated_at = ? WHERE policy_key = ? AND rev = ?", "UPDATE session_policy SET principal = $1, rules = $2::jsonb, rev = $3, updated_at = $4 WHERE policy_key = $5 AND rev = $6"), [principal ?? null, json, nextRev, now, key, priorRev]);
59
- if (upd.affected !== 1)
60
- throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts?.expectedRev ?? priorRev}, have ${priorRev})`);
61
- }
62
- else {
63
- await conn.query(this.q("INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, created_at, updated_at) VALUES (?,?,?,?,?,?,?)", "INSERT INTO session_policy (policy_key, session_id, principal, rules, rev, created_at, updated_at) VALUES ($1,$2,$3,$4::jsonb,$5,$6,$7)"), [key, sessionId, principal ?? null, json, nextRev, now, now]);
64
- }
65
- return structuredClone({ ...clean, rev: nextRev });
66
- });
88
+ return await attempt();
67
89
  }
68
90
  catch (e) {
69
- if (this.isDupKey(e) && attempt < 4)
91
+ if (tries < RETRY_LIMIT && (this.isDupKey(e) || isDeadlockError(this.db.dialect, e)))
70
92
  continue;
71
93
  throw e;
72
94
  }
73
95
  }
74
96
  }
75
97
  async deleteBySession(sessionId) {
76
- await this.db.query(this.q("DELETE FROM session_policy WHERE session_id = ?", "DELETE FROM session_policy WHERE session_id = $1"), [sessionId]);
98
+ await this.retrying(() => this.tx(async (conn) => {
99
+ const { rows } = await conn.query(this.q("SELECT policy_key, rev, gen FROM session_policy WHERE session_id = ? ORDER BY policy_key FOR UPDATE", "SELECT policy_key, rev, gen FROM session_policy WHERE session_id = $1 ORDER BY policy_key FOR UPDATE"), [sessionId]);
100
+ if (rows.length === 0)
101
+ return;
102
+ const now = new Date();
103
+ const keys = rows.map((r) => String(r.policy_key));
104
+ const params = [];
105
+ const tuples = rows.map((r) => {
106
+ params.push(String(r.policy_key), sessionId, Number(r.rev), Number(r.gen ?? 0) + 1, now);
107
+ const base = params.length - 5;
108
+ return this.db.dialect === "tidb" ? "(?,?,?,?,?)" : `($${base + 1},$${base + 2},$${base + 3},$${base + 4},$${base + 5})`;
109
+ });
110
+ await conn.query(this.q(`INSERT INTO session_policy_rev_mark (policy_key, session_id, rev, gen, erased_at) VALUES ${tuples.join(",")}` +
111
+ " ON DUPLICATE KEY UPDATE rev = GREATEST(session_policy_rev_mark.rev, VALUES(rev)), gen = GREATEST(session_policy_rev_mark.gen + 1, VALUES(gen)), erased_at = VALUES(erased_at)", `INSERT INTO session_policy_rev_mark (policy_key, session_id, rev, gen, erased_at) VALUES ${tuples.join(",")}` +
112
+ " ON CONFLICT (policy_key) DO UPDATE SET rev = GREATEST(session_policy_rev_mark.rev, EXCLUDED.rev), gen = GREATEST(session_policy_rev_mark.gen + 1, EXCLUDED.gen), erased_at = EXCLUDED.erased_at"), params);
113
+ const holes = keys.map((_, i) => (this.db.dialect === "tidb" ? "?" : `$${i + 1}`)).join(",");
114
+ await conn.query(`DELETE FROM session_policy WHERE policy_key IN (${holes})`, keys);
115
+ }));
77
116
  }
78
117
  async tx(fn) {
79
118
  const conn = await this.db.connect();