@sema-agent/server 7.59.0 → 7.60.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 (40) hide show
  1. package/README.md +4 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +8 -1
  4. package/dist/boot/stores.d.ts +1 -0
  5. package/dist/boot/stores.js +4 -1
  6. package/dist/http/routes/approvals-assistant.js +3 -0
  7. package/dist/http/routes/capabilities.js +2 -0
  8. package/dist/http/routes/diagnostics.js +1 -0
  9. package/dist/http/server.d.ts +6 -0
  10. package/dist/main.js +10 -4
  11. package/dist/plugins/approval-ask-store-sql.d.ts +9 -9
  12. package/dist/plugins/approval-ask-store-sql.js +1 -1
  13. package/dist/plugins/device-store-sql.d.ts +11 -7
  14. package/dist/plugins/device-store-sql.js +6 -6
  15. package/dist/plugins/file-history-store-sql.d.ts +15 -3
  16. package/dist/plugins/file-history-store-sql.js +32 -29
  17. package/dist/plugins/image-bake-store-sql.js +1 -1
  18. package/dist/plugins/permission-rule-store-file.d.ts +4 -4
  19. package/dist/plugins/permission-rule-store-file.js +4 -4
  20. package/dist/plugins/permission-rule-store-sql.d.ts +15 -11
  21. package/dist/plugins/permission-rule-store-sql.js +7 -11
  22. package/dist/plugins/retention-lane-store-sql.d.ts +2 -2
  23. package/dist/plugins/retention-store-sql.d.ts +4 -12
  24. package/dist/plugins/retention-store-sql.js +4 -0
  25. package/dist/plugins/session-policy-store-sql.d.ts +9 -2
  26. package/dist/plugins/shared-memory-store-sql.d.ts +25 -19
  27. package/dist/plugins/shared-memory-store-sql.js +17 -15
  28. package/dist/plugins/sql-driver.d.ts +78 -26
  29. package/dist/plugins/sql-driver.js +97 -14
  30. package/dist/plugins/store-backend.d.ts +6 -2
  31. package/dist/rules-consent.d.ts +54 -12
  32. package/dist/rules-consent.js +4 -4
  33. package/dist/sql-engine-posture.d.ts +49 -0
  34. package/dist/sql-engine-posture.js +13 -0
  35. package/dist/tool-approval.d.ts +18 -0
  36. package/dist/tool-approval.js +1 -0
  37. package/dist/trace/core-keyset-guard.d.ts +2 -2
  38. package/dist/trace/project.d.ts +25 -0
  39. package/dist/trace/project.js +26 -0
  40. package/package.json +2 -2
package/README.md CHANGED
@@ -30,7 +30,10 @@ model gateways, and cloud agent execution behind an HTTP/SSE contract.
30
30
  - **Stateless by design.** Replicas are cattle: `docker run` starts one, start as many as you need
31
31
  behind a load balancer. Durable state — sessions, runs, replayable event logs, checkpoints,
32
32
  approvals — lives in an external SQL store (any MySQL-protocol database such as MySQL/TiDB/MariaDB,
33
- or PostgreSQL; a file-backed `local` mode covers single-machine use). Any replica can serve any
33
+ or PostgreSQL; a file-backed `local` mode covers single-machine use). Each pooled connection pins its
34
+ own session isolation (`REPEATABLE READ` on the MySQL-protocol leg, `READ COMMITTED` on PostgreSQL) and
35
+ verifies it by reading back; on TiDB the session is switched to pessimistic transaction mode, and boot is
36
+ refused only when that switch cannot be made or verified — see docs/DEPLOY-PREREQS.md. Any replica can serve any
34
37
  run's event stream; a task can suspend on one replica and resume on another.
35
38
  - **The full server-side capability surface** (discoverable at `GET /v1/capabilities`):
36
39
  asynchronous runs with replayable SSE, durable checkpoints with human-in-the-loop approvals,
package/README.zh-CN.md CHANGED
@@ -29,7 +29,7 @@
29
29
  不进请求体、不进模型、不进沙箱。
30
30
  - **无状态设计。** 副本是 cattle:`docker run` 即起一个,挂在负载均衡后想开多少开多少。
31
31
  持久化状态 —— 会话、run、可重放事件日志、检查点、审批 —— 全在外部 SQL 存储
32
- (任何 MySQL 协议数据库:MySQL/TiDB/MariaDB,或 PostgreSQL;单机场景另有文件持久化的
32
+ (任何 MySQL 协议数据库:MySQL/TiDB/MariaDB,或 PostgreSQL;每条池连接自钉会话隔离级(MySQL 协议腿 `REPEATABLE READ`、PostgreSQL `READ COMMITTED`)并回读复核,TiDB 上会话切到悲观事务模式、切不成或复核不符才拒启,见 docs/DEPLOY-PREREQS.md;单机场景另有文件持久化的
33
33
  `local` 模式)。任意副本都能服务任意 run 的事件流;任务可以在一个副本上挂起、在另一个副本上恢复。
34
34
  - **完整的服务端能力面**(可在 `GET /v1/capabilities` 发现):
35
35
  异步 run + 可重放 SSE、持久化检查点 + 人工审批(HITL)、会话与长期记忆、
package/USAGE.md CHANGED
@@ -29,7 +29,14 @@ docker compose up --build
29
29
  # 起来后 OA 只认 http://<host>:8090(LB)
30
30
  ```
31
31
 
32
- **生产(N worker 集群,需 MySQL 协议库——MySQL/TiDB 均可;支持异步 `/v1/runs` + 断线重连)**
32
+ **生产(N worker 集群,需 MySQL 协议库——MySQL(≥8.0)/ MariaDB(≥10.5)/ TiDB 均可;支持异步 `/v1/runs` + 断线重连)**
33
+
34
+ > 🔒 7.60.0 起本仓在**每条池连接**上把会话隔离级钉死并**回读复核**:MySQL 协议腿 `REPEATABLE READ`
35
+ > + TiDB 悲观事务模式(设不上就拒启),PG 腿 `READ COMMITTED`(同样是设了再验,不问服务器缺省 ——
36
+ > 把 PG 钉成 RR 或让部署把默认配成 RR/SERIALIZABLE,都会让每条 `FOR UPDATE` 变成 `40001`)。本仓所有
37
+ > `SELECT … FOR UPDATE` 的单赢家判据都以此为前提。运维读面:
38
+ > `GET /v1/diagnostics/wiring` 的 `sqlEngine` 段与 `GET /v1/capabilities` 的 `sql` 位。判据与升级句见
39
+ > `docs/DEPLOY-PREREQS.md`「事务读语义的结构保证」。
33
40
  ```bash
34
41
  IMAGE_REGISTRY=<registry-namespace> \
35
42
  DB_BACKEND=mysql MYSQL_HOST=… MYSQL_USER=… MYSQL_PASSWORD=… MYSQL_DATABASE=sema_server \
@@ -31,5 +31,6 @@ export declare function openStores(ctx: OpenStoresCtx): Promise<{
31
31
  usageWindowStore: import("@sema-agent/core").UsageWindowStore | undefined;
32
32
  memoryPosture: import("../memory-posture.js").MemoryPosture;
33
33
  taskListLane: import("./task-list-lane.js").TaskListLane;
34
+ sqlEngineFacts: (() => import("../plugins/sql-driver.js").SqlEngineFacts | null) | undefined;
34
35
  }>;
35
36
  //# sourceMappingURL=stores.d.ts.map
@@ -29,6 +29,7 @@ import { assertFileHistorySchema } from "../plugins/file-history-store-sql.js";
29
29
  import { resolveStreamApprovalGate } from "../tool-approval.js";
30
30
  import { buildMemoryWriteBoundaryAudit, effectiveMemoryPersistenceCapable } from "./memory-boundary.js";
31
31
  import { buildMemoryPosture } from "../memory-posture.js";
32
+ import { establishSqlEnginePosture } from "../sql-engine-posture.js";
32
33
  const DARK_REASON_PROSE = {
33
34
  engine_off: () => "MEMORY_ENGINE=off",
34
35
  multi_tenant_file_backend: () => "multi-tenant on the FILE backend (no tenant isolation; set MEMORY_ENGINE_BACKEND=pg|tidb to light up)",
@@ -387,9 +388,11 @@ export async function openStores(ctx) {
387
388
  await ensureTiDBTaskListSchema(mysqlPool);
388
389
  return createTaskListLane({ mysqlPool, pgPool, logger });
389
390
  })();
391
+ let sqlEngineFacts;
390
392
  {
391
393
  const mysqlPool = backend?.mysqlPool?.();
392
394
  const pgPool = backend?.pgPool?.();
395
+ sqlEngineFacts = await establishSqlEnginePosture({ mysqlPool, pgPool });
393
396
  if (pgPool)
394
397
  await ensurePgDeviceSchema(async (text, params) => pgPool.query(text, params));
395
398
  if (mysqlPool)
@@ -433,7 +436,7 @@ export async function openStores(ctx) {
433
436
  return {
434
437
  backend, storeBackendDegraded, memoryEngine, memorySyncCursors, rosterStore, backgroundAgentStore,
435
438
  taskAttachmentStore, mailboxStore, memoryExportBackend, memorySyncRunner, sessionStore, breakerState,
436
- usageWindowStore, memoryPosture, taskListLane,
439
+ usageWindowStore, memoryPosture, taskListLane, sqlEngineFacts,
437
440
  };
438
441
  }
439
442
  //# sourceMappingURL=stores.js.map
@@ -177,6 +177,9 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
177
177
  ...(s.spentMicroUsd !== undefined ? { spentMicroUsd: s.spentMicroUsd } : {}),
178
178
  ...(s.deadline !== undefined ? { deadline: s.deadline } : {}),
179
179
  ...(s.hasBidiControls === true ? { hasBidiControls: true } : {}),
180
+ ...(s.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
181
+ ...(s.denialLimitFallback !== undefined ? { denialLimitFallback: s.denialLimitFallback } : {}),
182
+ ...(s.origin !== undefined ? { origin: s.origin } : {}),
180
183
  objective: ctx?.body?.objective ?? null,
181
184
  input,
182
185
  };
@@ -9,6 +9,7 @@ import { mcpInjectionHonored } from "../../task-mcp.js";
9
9
  import { sendJson, sendError } from "../send.js";
10
10
  import { resolveStreamApprovalGate } from "../../tool-approval.js";
11
11
  import { projectMemoryEngineCapability } from "../../memory-posture.js";
12
+ import { projectSqlEngineCapability } from "../../sql-engine-posture.js";
12
13
  import { workflowModelAllowlistFor, selfOrchestrationDenial } from "../../task-workflow.js";
13
14
  import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
14
15
  import { judgeAutoModeArming } from "../../auto-mode-face.js";
@@ -76,6 +77,7 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
76
77
  memory: false,
77
78
  memoryWrite: false,
78
79
  memoryEngine: projectMemoryEngineCapability(deps.memoryPosture),
80
+ sql: projectSqlEngineCapability(deps.sqlEngineFacts?.()),
79
81
  sessionBackgroundable: Boolean(deps.runStore),
80
82
  runMemoryCaptureOptOut: Boolean(deps.runStore),
81
83
  sharedMemory: Boolean(deps.sharedMemoryStore && deps.orgMemoryDirectory),
@@ -86,6 +86,7 @@ async function handleDiagnosticsBody(req, res, url, ctx, miss) {
86
86
  sendJson(res, 200, {
87
87
  static: deps.staticWiring,
88
88
  memoryPosture: deps.memoryPosture ?? null,
89
+ sqlEngine: deps.sqlEngineFacts?.() ?? null,
89
90
  configApply: deps.configApplyState?.() ?? null,
90
91
  mcpRevocations: deps.mcpRevocationState?.() ?? null,
91
92
  serverGates: buildServerWiringGates({
@@ -654,6 +654,12 @@ export interface ServiceDeploymentDeps {
654
654
  * `GET /v1/capabilities` 的 `memoryEngine` 位(窄投影)。缺席 = 这个进程不是由 composition root 装
655
655
  * 起来的(测试夹具形)⇒ 诊断面报 null、能力位取诚实下限 false。 */
656
656
  memoryPosture?: import("../memory-posture.js").MemoryPosture;
657
+ /** S-131:SQL 引擎姿态的 **live getter**(`sql-engine-posture.ts` 的单一推导点 —— boot 期借一条池连接
658
+ * 触发驱动的连接初始化,读的是 `SET` 之后**回读复核**过的真值)。两个读面共用:
659
+ * `GET /v1/diagnostics/wiring` 的 `sqlEngine` 段(operator-only,全量含版本)与 `GET /v1/capabilities`
660
+ * 的 `sql` 位(窄投影)。getter 而不是快照:池重连后驱动会重新初始化并刷新事实,快照会说谎。
661
+ * 缺席 / 返回 null = 本部署没有 SQL 后端(env-only worker、local 文件后端)—— 诚实缺席,不铸空壳。 */
662
+ sqlEngineFacts?: () => import("../plugins/sql-driver.js").SqlEngineFacts | null;
657
663
  /** ① core ruling — SPLIT: `capabilities.workflows` = the ENGINE-CAN axis, boot-computed from core's own
658
664
  * `workflowsCapability(deps)` (hardened script runner ∧ governance), NOT the `Boolean(workflowRunStore)` store
659
665
  * proxy. Orthogonal to `workflowsList` (the durable-list axis = `workflowRunStore`). Falls back to the store
package/dist/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { Runner, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, probeSearchBackend, describeStaticWiring, expandTiers, DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
4
+ import { Runner, createPermissionRuleStoreProvider, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, probeSearchBackend, describeStaticWiring, expandTiers, DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
5
5
  import { createSessionTitler } from "./session-titler.js";
6
6
  import { posIntEnv } from "./session-watch.js";
7
7
  import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
@@ -128,7 +128,7 @@ async function main() {
128
128
  }
129
129
  const configCenter = await createConfigCenterRuntime({ config, logger, metrics, localRoot });
130
130
  await configCenter.applyLocalRemoteExec();
131
- const { backend, storeBackendDegraded, memoryEngine, memorySyncCursors, rosterStore, backgroundAgentStore, taskAttachmentStore, mailboxStore, memoryExportBackend, memorySyncRunner, sessionStore, breakerState, usageWindowStore, memoryPosture, taskListLane, } = await openStores({ config, logger, metrics, localRoot });
131
+ const { backend, storeBackendDegraded, memoryEngine, memorySyncCursors, rosterStore, backgroundAgentStore, taskAttachmentStore, mailboxStore, memoryExportBackend, memorySyncRunner, sessionStore, breakerState, usageWindowStore, memoryPosture, taskListLane, sqlEngineFacts, } = await openStores({ config, logger, metrics, localRoot });
132
132
  const instanceId = uuidv7();
133
133
  const sessionCaptureRecordStore = backend?.sessionCaptureRecords();
134
134
  const memoryBundleFaces = memoryEngine ? createMemoryBundleFaces(memoryEngine, { ...(sessionCaptureRecordStore !== undefined ? { captureRecordStore: sessionCaptureRecordStore } : {}) }) : undefined;
@@ -153,7 +153,12 @@ async function main() {
153
153
  }
154
154
  const { brain, pricing, counterDegradeHook, costQuota, modelUsageTracker, promptManifestTracker, fleetUsage, fleetLease, tracer, sideQueryAccounting, toolResultStore, sessionPolicyStore, fileHistoryStore, } = createBudgetAndTracing({ config, logger, metrics, backend, breakerState });
155
155
  const permissionRuleStores = config.permissionRulesEnabled && backend ? backend.permissionRule() : undefined;
156
- const ruleConsent = permissionRuleStores ? createRuleConsentLane(permissionRuleStores) : undefined;
156
+ const permissionRuleProvider = permissionRuleStores
157
+ ? createPermissionRuleStoreProvider({ durable: permissionRuleStores.durable })
158
+ : undefined;
159
+ const ruleConsent = permissionRuleStores && permissionRuleProvider
160
+ ? createRuleConsentLane({ ...permissionRuleStores, provider: permissionRuleProvider })
161
+ : undefined;
157
162
  if (config.permissionRulesEnabled && permissionRuleStores === undefined) {
158
163
  logger.info("permission_rules_lane_unavailable", {
159
164
  knob: "PERMISSION_RULES_ENABLED",
@@ -268,7 +273,7 @@ async function main() {
268
273
  sessionCaptureRecordStore,
269
274
  memorySyncRunner, toolResultStore: runnerOffloadStore, sessionPolicyStore, runtimeCapsResolver, fileHistoryStore,
270
275
  legacyRewindBoundaryProbe: backend?.legacyRewindBoundaryProbe ? backend.legacyRewindBoundaryProbe.bind(backend) : undefined,
271
- permissionRuleStore: permissionRuleStores?.provider,
276
+ permissionRuleStore: permissionRuleProvider,
272
277
  executionEnvFactory, lspManager, fleetBus, deploymentHooks, workflowRunStore, workflowJournalStore,
273
278
  workflowAgentRegistry, workflowNotifyGate, workflowCompletionInbox, deliverWorkflowCompletion, orgMemoryAdmission,
274
279
  governanceSeams,
@@ -695,6 +700,7 @@ async function main() {
695
700
  workflowModelAllowlist: workflowModelAllowlistSnapshot,
696
701
  staticWiring,
697
702
  memoryPosture,
703
+ sqlEngineFacts,
698
704
  instanceId,
699
705
  capabilities: {
700
706
  version: serviceVersion(),
@@ -14,10 +14,10 @@
14
14
  * (`rev = rev + 1`),这样只要「行在且谓词命中」就必然 affected=1,不会被这个驱动怪癖
15
15
  * 误判成「CAS 输了」。本文件每一条 CAS UPDATE 都带 `rev = rev + 1`。
16
16
  * 3. 事务:走 `SqlDriver.connect()` → `conn.begin()` → 一串 `conn.query()` → `conn.commit()`,失败
17
- * `conn.rollback()`(形照 image-bake-store-sql.ts 的 try/catch/finally 骨架)。本店没有 image-bake
18
- * 那种「NOT EXISTS 快照读」问题(每条 CAS 都是对已存在主键行的 `UPDATE WHERE state=?`,MySQL/TiDB
19
- * 对被更新行本身恒是 current read,不受 REPEATABLE READ 快照影响),所以走普通 `begin()`,不需要
20
- * `beginPessimistic()`。
17
+ * `conn.rollback()`(形照 image-bake-store-sql.ts 的 try/catch/finally 骨架)。事务动词只有一个;
18
+ * 读语义(隔离级 / TiDB 悲观模式 / 事务内集合读必须锁读)的判据只有一处:{@link SqlTxConn.begin}
19
+ * `@contract txn.read-semantics`。本店每条 CAS 都是对已存在主键行的 `UPDATE … WHERE state=?`
20
+ * (DML 对被更新行恒是 current read,与快照无关),败方读数则一律 `SELECT … FOR UPDATE`。
21
21
  *
22
22
  * ── 方言差异(显式写在每个调用点,[ref] A12 doctrine)──────────────────────────────────────────
23
23
  * - `?` 占位符(位置序)vs `$n`(显式编号)——动态列清单(`transitionAsk`/`resolveProvisional`,列集合
@@ -503,11 +503,11 @@ export declare class SqlApprovalAskStore implements ApprovalAskStore {
503
503
  * 取消臂的 STREAM_PENDING→VOID(此前走通用 `transitionAsk`,无败方读数)。
504
504
  *
505
505
  * **原子性证明(两方言各一句,红先钉在 db-integration 的 [ref] 组)**:
506
- * · 败方读数是**同一事务内**的 `SELECT … FOR UPDATE` —— TiDB 侧本事务显式 `BEGIN PESSIMISTIC`
507
- * (`beginPessimistic()`,不赌部署的 `tidb_txn_mode` 缺省):悲观锁定读是 CURRENT read,读到的是
508
- * 并发赢家**已提交**的最新版本(乐观模式下 FOR UPDATE 读 start_ts 快照,会把赢家终局读旧 ——
509
- * 正是 bindBatch 失败臂注记的那类方言分歧,这里用显式事务模式把它结构性关死);PG 侧
510
- * `beginPessimistic()` = 普通 `BEGIN`,READ COMMITTEDFOR UPDATE 本就等待并返回最新已提交版本。
506
+ * · 败方读数是**同一事务内**的 `SELECT … FOR UPDATE` —— TiDB 侧的悲观锁定读是 CURRENT read,读到的是
507
+ * 并发赢家**已提交**的最新版本(乐观模式下 FOR UPDATE start_ts 快照,会把赢家终局读旧 —— 正是
508
+ * bindBatch 失败臂注记的那类方言分歧)。悲观模式**不赌部署的 `tidb_txn_mode` 缺省**:它是连接初始化
509
+ * 的结构保证,设不上就拒启({@link SqlTxConn.begin} 的 @contract txn.read-semantics)PG 侧
510
+ * `BEGIN` = READ COMMITTED,FOR UPDATE 本就等待并返回最新已提交版本。
511
511
  * · CAS 输(affected=0)⇒ 行在 UPDATE 那一刻已非 STREAM_PENDING(或不在/错 scope),而非 pending 态
512
512
  * 在状态机上无回边 ⇒ 锁定读读到的恒 ≥ 线性化点,且 DECIDED 决议不可变 —— 答案自足。
513
513
  * · 本事务在输臂上除批行 rev 自增外零写,rollback 无痕。
@@ -440,7 +440,7 @@ export class SqlApprovalAskStore {
440
440
  const dialect = this.db.dialect;
441
441
  const conn = await this.db.connect();
442
442
  try {
443
- await conn.beginPessimistic();
443
+ await conn.begin();
444
444
  if (intent === "expire") {
445
445
  const lockRes = await conn.query(this.q(`UPDATE ${APPROVAL_BATCH_TABLE} SET rev=rev+1 WHERE batch_id=?`, `UPDATE ${APPROVAL_BATCH_TABLE} SET rev=rev+1 WHERE batch_id=$1`), [batchId]);
446
446
  if (lockRes.affected !== 1) {
@@ -18,9 +18,11 @@
18
18
  * ——**所有** CAS UPDATE 因此必须带一个恒变列(`rev = rev + 1`)。本文件每一条 CAS UPDATE 都带。
19
19
  * 在本店这条尤其致命:心跳续租在「同一毫秒内重复心跳」时 SET 的值可能与当前值相同,没有 rev
20
20
  * 就会被判成「本连接已失权」⇒ 设备被自己的心跳踢下线。
21
- * 3. 事务:`SqlDriver.connect()` → `begin()`/`beginPessimistic()` → `query()` → `commit()`,失败
22
- * `rollback()`。**失败臂的回读必须走当前这条 `conn`**(approval 店复审 F1 的真缺陷:走池级 query
23
- * 会与自己持有的连接死锁),且**先 rollback 再读**(读到最新已提交视图)。
21
+ * 3. 事务:`SqlDriver.connect()` → `begin()` → `query()` → `commit()`,失败 `rollback()`。读语义
22
+ * (隔离级 / TiDB 悲观模式 / 事务内集合读必须锁读)的判据只有一处:`sql-driver.ts` 头注的
23
+ * {@link SqlTxConn.begin} `@contract txn.read-semantics`。**失败臂的回读必须走当前这条 `conn`**
24
+ * (approval 店复审 F1 的真缺陷:走池级 query 会与自己持有的连接死锁),且**先 rollback 再读**
25
+ * (回滚后本连接回到 autocommit,那条 SELECT 自成事务 ⇒ 读到最新已提交视图)。
24
26
  *
25
27
  * ── 方言差异(A12 doctrine:每处显式写在调用点)────────────────────────────────────────────────────
26
28
  * - `?`(位置序) vs `$n`(显式编号);动态 WHERE(`listAudit`)靠 `ph(dialect, n)` 生成。
@@ -131,9 +133,10 @@ export declare class SqlDeviceStore implements DeviceStore {
131
133
  /**
132
134
  * §4.3.2 首绑写协议 —— **单事务**:设备校验(active ∧ owner)与首绑 CAS 插入在一个事务里。
133
135
  *
134
- * 🔴 `beginPessimistic` + `FOR UPDATE`(设备行):设备行必须是**当前读**。快照读下,一次与吊销并发的
135
- * 绑定会读到「还 active」的旧版本,于是把 session 绑到一台**刚被吊销**的设备上(sql-driver.ts 头注点名
136
- * 的 double-admit 形)。这把锁在 `devices` 表上,与下面被插入的 `device_session` 行不是同一行。
136
+ * 🔴 `FOR UPDATE`(设备行):设备行必须是**当前读**。普通读下,一次与吊销并发的绑定会读到「还 active」
137
+ * 的旧版本,于是把 session 绑到一台**刚被吊销**的设备上(double-admit 形)。锁读在两引擎都是当前读 ——
138
+ * 前提写在 {@link SqlTxConn.begin} @contract txn.read-semantics。这把锁在 `devices` 表上,与下面
139
+ * 被插入的 `device_session` 行不是同一行。
137
140
  *
138
141
  * 🔴 形 B · cas-insert(S-127 / B-013)—— 本站点是全仓 `INSERT IGNORE` 的**正当用法**:
139
142
  * `INSERT IGNORE` / `ON CONFLICT DO NOTHING` 的 affected(1/0)是引擎对「谁赢」的**权威**回答,而
@@ -166,7 +169,8 @@ export declare class SqlDeviceStore implements DeviceStore {
166
169
  * O4 显式换绑(2026-08-30 v2 稿 §2)—— **单事务**:目标设备门(当前读)+ 幂等短路 + 前态 CAS +
167
170
  * `session_rebound` 审计行一起提交。三形语义 oracle = `InMemoryDeviceStore.rebindSession`。
168
171
  *
169
- * 🔴 `beginPessimistic` + 两次 `FOR UPDATE`(bindSession 的同一条真库红的射程):
172
+ * 🔴 两次 `FOR UPDATE`(bindSession 的同一条真库红的射程;当前读的前提见
173
+ * {@link SqlTxConn.begin} 的 @contract txn.read-semantics):
170
174
  * · 设备行必须当前读 —— 快照读会把「与吊销并发的换绑」绑到一台刚被吊销的设备上(double-admit 形);
171
175
  * · 绑定行必须当前读且**持锁到提交** —— 两个并发换绑(同 expectedRev)在锁上串行:赢家 rev+1 提交,
172
176
  * 输家的锁读看到新 rev ⇒ 走 `rev_conflict` 携当前行(单赢家,无未定义态)。锁读判等之后,CAS
@@ -367,7 +367,7 @@ export class SqlDeviceStore {
367
367
  }
368
368
  const conn = await this.db.connect();
369
369
  try {
370
- await conn.beginPessimistic();
370
+ await conn.begin();
371
371
  const cas = await conn.query(this.q(`UPDATE ${DEVICE_ENROLL_TOKENS_TABLE} SET consumed_at = NOW(3), consumed_device_id = ?, consumed_pubkey = ?, rev = rev + 1
372
372
  WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > NOW(3) AND target_tenant = ? AND target_subject = ?`, `UPDATE ${DEVICE_ENROLL_TOKENS_TABLE} SET consumed_at = now(), consumed_device_id = $1, consumed_pubkey = $2, rev = rev + 1
373
373
  WHERE token_hash = $3 AND consumed_at IS NULL AND expires_at > now() AND target_tenant = $4 AND target_subject = $5`), [input.device.deviceId, input.device.pubkey, input.tokenHash, input.requester.tenant, input.requester.subject]);
@@ -454,7 +454,7 @@ export class SqlDeviceStore {
454
454
  assertRevokeShape(input);
455
455
  const conn = await this.db.connect();
456
456
  try {
457
- await conn.beginPessimistic();
457
+ await conn.begin();
458
458
  const cas = await conn.query(this.q(`UPDATE ${DEVICES_TABLE} SET status = 'revoked', revoked_at = NOW(3), revoked_by = ?, revoke_reason = ?, lease_expires_at = NULL, conn_generation = conn_generation + 1, rev = rev + 1
459
459
  WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? AND status = 'active'`, `UPDATE ${DEVICES_TABLE} SET status = 'revoked', revoked_at = now(), revoked_by = $1, revoke_reason = $2, lease_expires_at = NULL, conn_generation = conn_generation + 1, rev = rev + 1
460
460
  WHERE device_id = $3 AND owner_tenant = $4 AND owner_subject = $5 AND status = 'active'`), [input.revokedBy, input.reason ?? null, input.deviceId, input.owner.tenant, input.owner.subject]);
@@ -484,7 +484,7 @@ export class SqlDeviceStore {
484
484
  assertAcquireShape(input);
485
485
  const conn = await this.db.connect();
486
486
  try {
487
- await conn.beginPessimistic();
487
+ await conn.begin();
488
488
  const cas = await conn.query(this.q(`UPDATE ${DEVICES_TABLE} SET conn_generation = conn_generation + 1, conn_epoch = ?, replica_id = ?, lease_expires_at = ${nowPlusSeconds("tidb", "?")}, connected_at = NOW(3), last_seen_at = NOW(3), rev = rev + 1
489
489
  WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? AND status = 'active' AND (lease_expires_at IS NULL OR lease_expires_at <= NOW(3))`, `UPDATE ${DEVICES_TABLE} SET conn_generation = conn_generation + 1, conn_epoch = $1, replica_id = $2, lease_expires_at = ${nowPlusSeconds("pg", "$3")}, connected_at = now(), last_seen_at = now(), rev = rev + 1
490
490
  WHERE device_id = $4 AND owner_tenant = $5 AND owner_subject = $6 AND status = 'active' AND (lease_expires_at IS NULL OR lease_expires_at <= now())`), [input.connEpoch, input.replicaId, input.leaseSeconds, input.deviceId, input.owner.tenant, input.owner.subject]);
@@ -523,7 +523,7 @@ export class SqlDeviceStore {
523
523
  assertDeviceOwnerShape(input.owner);
524
524
  const conn = await this.db.connect();
525
525
  try {
526
- await conn.beginPessimistic();
526
+ await conn.begin();
527
527
  const dev = await conn.query(this.q(`SELECT status FROM ${DEVICES_TABLE} WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? FOR UPDATE`, `SELECT status FROM ${DEVICES_TABLE} WHERE device_id = $1 AND owner_tenant = $2 AND owner_subject = $3 FOR UPDATE`), [input.deviceId, input.owner.tenant, input.owner.subject]);
528
528
  if (!dev.rows[0]) {
529
529
  await conn.rollback();
@@ -554,7 +554,7 @@ export class SqlDeviceStore {
554
554
  async observeLostBind(input) {
555
555
  const conn = await this.db.connect();
556
556
  try {
557
- await conn.beginPessimistic();
557
+ await conn.begin();
558
558
  const { rows } = await conn.query(this.q(`SELECT ${sessionCols("tidb")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = ? FOR UPDATE`, `SELECT ${sessionCols("pg")} FROM ${DEVICE_SESSIONS_TABLE} WHERE root_session_id = $1 FOR UPDATE`), [input.rootSessionId]);
559
559
  await conn.commit();
560
560
  const row = rows[0] ? mapSessionRow(rows[0]) : null;
@@ -575,7 +575,7 @@ export class SqlDeviceStore {
575
575
  assertRebindShape(input);
576
576
  const conn = await this.db.connect();
577
577
  try {
578
- await conn.beginPessimistic();
578
+ await conn.begin();
579
579
  const dev = await conn.query(this.q(`SELECT status FROM ${DEVICES_TABLE} WHERE device_id = ? AND owner_tenant = ? AND owner_subject = ? FOR UPDATE`, `SELECT status FROM ${DEVICES_TABLE} WHERE device_id = $1 AND owner_tenant = $2 AND owner_subject = $3 FOR UPDATE`), [input.toDeviceId, input.owner.tenant, input.owner.subject]);
580
580
  if (!dev.rows[0] || parseDeviceStatus(bufToStr(dev.rows[0].status)) !== "active") {
581
581
  await conn.rollback();
@@ -79,7 +79,11 @@ export declare class SqlFileHistoryStore implements FileHistoryStore {
79
79
  env: ExecutionEnv;
80
80
  signal?: AbortSignal;
81
81
  }): Promise<FileHistoryResult>;
82
+ /** `forUpdate` = the {@link SqlTxConn.begin} `@contract txn.read-semantics` ③ arm: a collection read that
83
+ * runs inside a transaction must be a LOCKING read (a plain SELECT is pinned to the transaction's start on
84
+ * TiDB, so the lock-wait window's commits are invisible — B-022). Pool-level callers pass nothing. */
82
85
  private loadTracked;
86
+ /** `forUpdate` — see {@link loadTracked}: inside a transaction this is a collection read, so it locks. */
83
87
  private loadVersions;
84
88
  /** [ref] twin 保留条款:这个 entry 的键是否**已花掉**(被 reap 剪过)。台账 `file_history_reaped_entry`。
85
89
  * `forUpdate`(r3-①连带):发布 tx 内的复核必须是**锁定读**——TiDB 悲观事务的普通 SELECT 按文档读
@@ -109,14 +113,18 @@ export declare class SqlFileHistoryStore implements FileHistoryStore {
109
113
  /** PROTECTED RACE SEAM (test-only override): runs inside `restore` between the boundary-header check and
110
114
  * the mapping read. Production body is empty. */
111
115
  protected duringRestorePlan(_scope: string, _entryId: string): Promise<void>;
112
- /** PROTECTED RACE SEAM (test-only override): runs inside the publish tx AFTER `beginPessimistic` and
113
- * BEFORE the scope lock — the window where another replica's publish can commit while this tx's
114
- * snapshot is already pinned (TiDB snapshot-reads plain SELECTs, D2). Production body is empty. */
116
+ /** PROTECTED RACE SEAM (test-only override): runs inside the publish tx AFTER `begin()` and BEFORE
117
+ * the scope lock — the window where another replica's publish can commit while this tx's snapshot is
118
+ * already pinned. That window is exactly what B-022 rode: a plain SELECT taken after the lock is still
119
+ * pinned to the transaction's start, so this tx's collection reads are LOCKING reads
120
+ * ({@link SqlTxConn.begin} → `@contract txn.read-semantics`). Production body is empty. */
115
121
  protected beforePublishLock(_scope: string): Promise<void>;
116
122
  /** PROTECTED RACE SEAM (test-only override): runs inside `exportOf` between the boundary-header read and
117
123
  * the mapping read. Production body is empty. */
118
124
  protected duringExportGraph(_scope: string): Promise<void>;
119
125
  private applyBoundaryRetention;
126
+ /** `forUpdate` — see {@link loadTracked}: the in-transaction re-check must be a LOCKING read, or it just
127
+ * replays the pool-level pre-check's snapshot and its documented `null` arm becomes unreachable. */
120
128
  private boundaryExists;
121
129
  snapshot(scope: string, entryId: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryResult>;
122
130
  restore(scope: string, entryId: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileHistoryRestoreResult>;
@@ -146,6 +154,10 @@ export declare class SqlFileHistoryStore implements FileHistoryStore {
146
154
  hasBlobs(hashes: string[]): Promise<Set<string>>;
147
155
  adoptScope(fromScope: string, toScope: string): Promise<FileHistoryResult>;
148
156
  private scopeEmpty;
157
+ /** `forUpdate` — see {@link loadTracked}. Every in-transaction caller passes `true`: the four reads are
158
+ * one graph, and a graph materialized from a start-of-transaction snapshot disagrees with the LOCKING
159
+ * emptiness probe that decided to materialize it (that disagreement answered a legal idempotent
160
+ * re-import as `conflict` — B-022). */
149
161
  private exportOf;
150
162
  exportHistory(scope: string): Promise<FileHistoryExport | null>;
151
163
  importHistory(scope: string, data: FileHistoryExport, srcGetBlob: (hash: string) => Promise<Uint8Array | undefined>): Promise<FileHistoryResult>;
@@ -92,7 +92,7 @@ export class SqlFileHistoryStore {
92
92
  const conn = await this.db.connect();
93
93
  let minted = false;
94
94
  try {
95
- await conn.beginPessimistic();
95
+ await conn.begin();
96
96
  await this.lockScope(conn, scope);
97
97
  const ins = await conn.query(this.q("INSERT IGNORE INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES (?,?,?,?,?,?,0,?)", "INSERT INTO file_history_tracked (scope, key_hash, track_key, key_kind, key_family, base_available, observed_later, created_at_ms) VALUES ($1,$2,$3,$4,$5,$6,FALSE,$7) ON CONFLICT (scope, key_hash) DO NOTHING"), [scope, keyHash, key, keyDesc.kind, keyDesc.family, this.bool(baseAvailable), Date.now()]);
98
98
  minted = ins.affected === 1;
@@ -159,7 +159,7 @@ export class SqlFileHistoryStore {
159
159
  }
160
160
  const conn = await this.db.connect();
161
161
  try {
162
- await conn.beginPessimistic();
162
+ await conn.begin();
163
163
  await this.lockScope(conn, scope);
164
164
  const del = await conn.query(this.q(`DELETE FROM file_history_tracked WHERE scope = ? AND key_hash = ? AND observed_later = 0
165
165
  AND NOT EXISTS (SELECT 1 FROM file_history_boundary_file bf WHERE bf.scope = ? AND bf.key_hash = ?)
@@ -202,8 +202,9 @@ export class SqlFileHistoryStore {
202
202
  baseAvailable: truthy(row.base_available),
203
203
  }));
204
204
  }
205
- async loadVersions(exec, scope) {
206
- const r = await exec.query(this.q("SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = ? ORDER BY version", "SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = $1 ORDER BY version"), [scope]);
205
+ async loadVersions(exec, scope, forUpdate = false) {
206
+ const suffix = forUpdate ? " FOR UPDATE" : "";
207
+ const r = await exec.query(this.q(`SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = ? ORDER BY version${suffix}`, `SELECT key_hash, version, blob_hash, mode, parent_ident, backup_time FROM file_history_version WHERE scope = $1 ORDER BY version${suffix}`), [scope]);
207
208
  const out = new Map();
208
209
  for (const row of r.rows) {
209
210
  const kh = String(row.key_hash);
@@ -240,9 +241,9 @@ export class SqlFileHistoryStore {
240
241
  await this.beforeRetentionPass(scope);
241
242
  const conn = await this.db.connect();
242
243
  try {
243
- await conn.beginPessimistic();
244
+ await conn.begin();
244
245
  await this.lockScope(conn, scope);
245
- const { rows } = await conn.query(this.q("SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq ASC", "SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq ASC"), [scope]);
246
+ const { rows } = await conn.query(this.q("SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq ASC FOR UPDATE", "SELECT entry_id, publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq ASC FOR UPDATE"), [scope]);
246
247
  const ordered = rows.map((r) => ({ id: String(r.entry_id), seq: Number(r.publish_seq) }));
247
248
  if (ordered.length === 0) {
248
249
  await conn.rollback();
@@ -275,8 +276,9 @@ export class SqlFileHistoryStore {
275
276
  recordFailOpen("server.file-history.retention-trim-failed", `scope=${scope} entry=${justCommitted}: ${err instanceof Error ? err.message : String(err)}`);
276
277
  }
277
278
  }
278
- async boundaryExists(exec, scope, entryId) {
279
- const r = await exec.query(this.q("SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? LIMIT 1", "SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 LIMIT 1"), [scope, entryId]);
279
+ async boundaryExists(exec, scope, entryId, forUpdate = false) {
280
+ const suffix = forUpdate ? " FOR UPDATE" : "";
281
+ const r = await exec.query(this.q(`SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? LIMIT 1${suffix}`, `SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 LIMIT 1${suffix}`), [scope, entryId]);
280
282
  return Boolean(r.rows[0]);
281
283
  }
282
284
  async snapshot(scope, entryId, env, root, signal) {
@@ -344,7 +346,7 @@ export class SqlFileHistoryStore {
344
346
  await this.beforePublish(scope);
345
347
  const conn = await this.db.connect();
346
348
  try {
347
- await conn.beginPessimistic();
349
+ await conn.begin();
348
350
  await this.beforePublishLock(scope);
349
351
  await this.lockScope(conn, scope);
350
352
  if (signal?.aborted) {
@@ -356,7 +358,7 @@ export class SqlFileHistoryStore {
356
358
  await conn.rollback();
357
359
  return reapedEntryRefusal(entryId);
358
360
  }
359
- const seqRow = await conn.query(this.q("SELECT publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE", "SELECT publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq DESC LIMIT 1"), [scope]);
361
+ const seqRow = await conn.query(this.q("SELECT publish_seq FROM file_history_boundary WHERE scope = ? ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE", "SELECT publish_seq FROM file_history_boundary WHERE scope = $1 ORDER BY publish_seq DESC LIMIT 1 FOR UPDATE"), [scope]);
360
362
  const publishSeq = (seqRow.rows[0] !== undefined ? Number(seqRow.rows[0].publish_seq) : 0) + 1;
361
363
  const priorWinner = await conn.query(this.q("SELECT 1 FROM file_history_boundary WHERE scope = ? AND entry_id = ? FOR UPDATE", "SELECT 1 FROM file_history_boundary WHERE scope = $1 AND entry_id = $2 FOR UPDATE"), [scope, entryId]);
362
364
  if (priorWinner.rows[0]) {
@@ -496,16 +498,16 @@ export class SqlFileHistoryStore {
496
498
  async restorePlan(scope, entryId) {
497
499
  const conn = await this.db.connect();
498
500
  try {
499
- await conn.beginPessimistic();
501
+ await conn.begin();
500
502
  await this.lockScope(conn, scope);
501
- if (!(await this.boundaryExists(conn, scope, entryId))) {
503
+ if (!(await this.boundaryExists(conn, scope, entryId, true))) {
502
504
  await conn.rollback();
503
505
  return null;
504
506
  }
505
507
  await this.duringRestorePlan(scope, entryId);
506
- const tracked = (await this.loadTracked(conn, scope)).sort((a, b) => (a.trackKey < b.trackKey ? -1 : a.trackKey > b.trackKey ? 1 : 0));
507
- const versions = await this.loadVersions(conn, scope);
508
- const mapRows = await conn.query(this.q("SELECT key_hash, version FROM file_history_boundary_file WHERE scope = ? AND entry_id = ?", "SELECT key_hash, version FROM file_history_boundary_file WHERE scope = $1 AND entry_id = $2"), [scope, entryId]);
508
+ const tracked = (await this.loadTracked(conn, scope, true)).sort((a, b) => (a.trackKey < b.trackKey ? -1 : a.trackKey > b.trackKey ? 1 : 0));
509
+ const versions = await this.loadVersions(conn, scope, true);
510
+ const mapRows = await conn.query(this.q("SELECT key_hash, version FROM file_history_boundary_file WHERE scope = ? AND entry_id = ? FOR UPDATE", "SELECT key_hash, version FROM file_history_boundary_file WHERE scope = $1 AND entry_id = $2 FOR UPDATE"), [scope, entryId]);
509
511
  const boundary = new Map(mapRows.rows.map((r) => [String(r.key_hash), Number(r.version)]));
510
512
  await conn.commit();
511
513
  return { tracked, versions, boundary };
@@ -524,9 +526,9 @@ export class SqlFileHistoryStore {
524
526
  async reap(scope, keepEntryIds) {
525
527
  const conn = await this.db.connect();
526
528
  try {
527
- await conn.beginPessimistic();
529
+ await conn.begin();
528
530
  await this.lockScope(conn, scope);
529
- const ids = await conn.query(this.q("SELECT entry_id FROM file_history_boundary WHERE scope = ?", "SELECT entry_id FROM file_history_boundary WHERE scope = $1"), [scope]);
531
+ const ids = await conn.query(this.q("SELECT entry_id FROM file_history_boundary WHERE scope = ? FOR UPDATE", "SELECT entry_id FROM file_history_boundary WHERE scope = $1 FOR UPDATE"), [scope]);
530
532
  const dropped = await this.reapWithin(conn, scope, new Set(keepEntryIds), ids.rows.map((r) => String(r.entry_id)));
531
533
  await conn.commit();
532
534
  return dropped;
@@ -557,7 +559,7 @@ export class SqlFileHistoryStore {
557
559
  async deleteBySession(scope) {
558
560
  const conn = await this.db.connect();
559
561
  try {
560
- await conn.beginPessimistic();
562
+ await conn.begin();
561
563
  await this.lockScope(conn, scope);
562
564
  let total = 0;
563
565
  for (const table of ["file_history_boundary_file", "file_history_boundary", "file_history_reaped_entry", "file_history_version", "file_history_tracked", "file_history_scope"]) {
@@ -606,13 +608,13 @@ export class SqlFileHistoryStore {
606
608
  return { ok: true };
607
609
  const conn = await this.db.connect();
608
610
  try {
609
- await conn.beginPessimistic();
611
+ await conn.begin();
610
612
  await this.lockScope(conn, fromScope, toScope);
611
613
  if (!(await this.scopeEmpty(conn, toScope, true))) {
612
614
  await conn.rollback();
613
615
  return { ok: false, error: { code: "conflict", message: `adoptScope target "${toScope}" already has history of its own — adoption is legal only onto an EMPTY scope (the fork/resume boundary before the new session's first turn)` } };
614
616
  }
615
- if (await this.scopeEmpty(conn, fromScope)) {
617
+ if (await this.scopeEmpty(conn, fromScope, true)) {
616
618
  await this.copyReapedEntries(conn, fromScope, toScope);
617
619
  await conn.commit();
618
620
  return { ok: true };
@@ -645,14 +647,15 @@ export class SqlFileHistoryStore {
645
647
  const b = await exec.query(this.q(`SELECT 1 FROM file_history_boundary WHERE scope = ? LIMIT 1${suffix}`, `SELECT 1 FROM file_history_boundary WHERE scope = $1 LIMIT 1${suffix}`), [scope]);
646
648
  return !b.rows[0];
647
649
  }
648
- async exportOf(exec, scope) {
649
- const tracked = await this.loadTracked(exec, scope);
650
- const versions = await this.loadVersions(exec, scope);
651
- const headers = await exec.query(this.q("SELECT entry_id FROM file_history_boundary WHERE scope = ?", "SELECT entry_id FROM file_history_boundary WHERE scope = $1"), [scope]);
650
+ async exportOf(exec, scope, forUpdate = false) {
651
+ const suffix = forUpdate ? " FOR UPDATE" : "";
652
+ const tracked = await this.loadTracked(exec, scope, forUpdate);
653
+ const versions = await this.loadVersions(exec, scope, forUpdate);
654
+ const headers = await exec.query(this.q(`SELECT entry_id FROM file_history_boundary WHERE scope = ?${suffix}`, `SELECT entry_id FROM file_history_boundary WHERE scope = $1${suffix}`), [scope]);
652
655
  if (tracked.length === 0 && headers.rows.length === 0)
653
656
  return null;
654
657
  await this.duringExportGraph(scope);
655
- const files = await exec.query(this.q("SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = ?", "SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = $1"), [scope]);
658
+ const files = await exec.query(this.q(`SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = ?${suffix}`, `SELECT entry_id, key_hash, version FROM file_history_boundary_file WHERE scope = $1${suffix}`), [scope]);
656
659
  const keyOfHash = new Map(tracked.map((t) => [t.keyHash, t.trackKey]));
657
660
  const byEntry = new Map();
658
661
  for (const row of files.rows) {
@@ -688,9 +691,9 @@ export class SqlFileHistoryStore {
688
691
  async exportHistory(scope) {
689
692
  const conn = await this.db.connect();
690
693
  try {
691
- await conn.beginPessimistic();
694
+ await conn.begin();
692
695
  await this.lockScope(conn, scope);
693
- const out = await this.exportOf(conn, scope);
696
+ const out = await this.exportOf(conn, scope, true);
694
697
  await conn.commit();
695
698
  return out;
696
699
  }
@@ -743,10 +746,10 @@ export class SqlFileHistoryStore {
743
746
  }
744
747
  const conn = await this.db.connect();
745
748
  try {
746
- await conn.beginPessimistic();
749
+ await conn.begin();
747
750
  await this.lockScope(conn, scope);
748
751
  if (!(await this.scopeEmpty(conn, scope, true))) {
749
- const now = await this.exportOf(conn, scope);
752
+ const now = await this.exportOf(conn, scope, true);
750
753
  if (now !== null && fileHistoryExportDigest(now) === fileHistoryExportDigest(data)) {
751
754
  await conn.commit();
752
755
  return { ok: true };
@@ -139,7 +139,7 @@ export class SqlImageBake {
139
139
  let inserted = false;
140
140
  try {
141
141
  if (this.db.dialect === "tidb") {
142
- await conn.beginPessimistic();
142
+ await conn.begin();
143
143
  await conn.query("INSERT INTO image_bake_admit (pool) VALUES (?) ON DUPLICATE KEY UPDATE pool = pool", [this.poolName]);
144
144
  await conn.query("SELECT pool FROM image_bake_admit WHERE pool = ? FOR UPDATE", [this.poolName]);
145
145
  const busy = await conn.query("SELECT 1 FROM image_bake WHERE status IN ('queued','running') AND dry_run = 0 LIMIT 1 FOR UPDATE");
@@ -1,4 +1,4 @@
1
- import { FilePermissionRuleStoreProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope, type StaleRuleApprovalRecord } from "@sema-agent/core";
1
+ import { FileDurableRulePartitionProvider, type RuleApprovalRecord, type RuleApprovalRecordStore, type RuleScope, type StaleRuleApprovalRecord } from "@sema-agent/core";
2
2
  import { type RuleImportTicket, type RuleTicketPurpose, type RuleTicketReclaimResult, type RuleTicketRedeemResult, type RuleTicketSnapshot } from "./permission-rule-store-sql.js";
3
3
  import type { PermissionRuleStoreBundle } from "../rules-consent.js";
4
4
  /**
@@ -84,9 +84,9 @@ export declare class FileRuleImportTicketStore {
84
84
  * 那一条:过期只挡开始一次兑付,不挡做完一次已经开始的)。抢到之后**换一代认领**。 */
85
85
  reclaim(ticketId: string, principal: string, purpose: RuleTicketPurpose, minClaimAgeMs: number): Promise<RuleTicketReclaimResult>;
86
86
  }
87
- /** local 车道的三面束 + 生命周期。`dispose()` 释放 core File provider 的写锁(见 {@link createFilePermissionRuleStores})。 */
87
+ /** local 车道的三面束 + 生命周期。`dispose()` 释放 core File durable 分区 provider 的写锁(见 {@link createFilePermissionRuleStores})。 */
88
88
  export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
89
- provider: FilePermissionRuleStoreProvider;
89
+ durable: FileDurableRulePartitionProvider;
90
90
  approvals: FileRuleApprovalRecordStore;
91
91
  tickets: FileRuleImportTicketStore;
92
92
  dispose(): void;
@@ -94,7 +94,7 @@ export interface FilePermissionRuleStores extends PermissionRuleStoreBundle {
94
94
  /**
95
95
  * 装配 local 三面束。
96
96
  *
97
- * 🔴 `provider` 必须是**单例**(F1):core 的 `FilePermissionRuleStoreProvider` 在第一次取写面时对规则
97
+ * 🔴 `durable` 必须是**单例**(F1):core 的 `FileDurableRulePartitionProvider` 在第一次取写面时对规则
98
98
  * 目录取一把进程级写锁,每次 `new` 一个就是第二个持有者 —— 而它对第二个持有者是**响亮拒绝**,不是
99
99
  * 排队。所以束在 `LocalBackend` 上按字段持有,`LocalBackend.close()` 调 `dispose()` 释放
100
100
  * (不释放 ⇒ 一次优雅重启会被自己上一条命留下的锁挡在门外,与数据根 `root/LOCK` 同一个病)。
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { readdirSync } from "node:fs";
4
4
  import { performance } from "node:perf_hooks";
5
- import { FilePermissionRuleStoreProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
5
+ import { FileDurableRulePartitionProvider, AppendLog, ensureDir, readJsonlRecords, } from "@sema-agent/core";
6
6
  import { z } from "zod";
7
7
  import { buildRulePayloadHash } from "./permission-rule-store-sql.js";
8
8
  import { EDITED_RULE_BREADTH_WARNING_CODES, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
@@ -341,18 +341,18 @@ export class FileRuleImportTicketStore {
341
341
  export function createFilePermissionRuleStores(root, opts) {
342
342
  const dir = join(root, "permission-rules");
343
343
  ensureDir(dir);
344
- const provider = opts?.onError ? new FilePermissionRuleStoreProvider(dir, opts.onError) : new FilePermissionRuleStoreProvider(dir);
344
+ const durable = opts?.onError ? new FileDurableRulePartitionProvider(dir, opts.onError) : new FileDurableRulePartitionProvider(dir);
345
345
  const approvals = new FileRuleApprovalRecordStore(dir, opts?.onError);
346
346
  const tickets = new FileRuleImportTicketStore(dir, opts?.now, opts?.onError, opts?.monotonic);
347
347
  return {
348
- provider,
348
+ durable,
349
349
  approvals,
350
350
  tickets,
351
351
  countBuckets: async () => readdirSync(dir).filter((n) => n === "local-owner.json" || /^[0-9a-f]{64}\.json$/.test(n)).length,
352
352
  dispose: () => {
353
353
  approvals.close();
354
354
  tickets.close();
355
- provider.dispose();
355
+ durable.dispose();
356
356
  },
357
357
  };
358
358
  }