@sema-agent/server 7.58.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 (76) hide show
  1. package/README.md +4 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +10 -1
  4. package/dist/approval-card.d.ts +47 -0
  5. package/dist/approval-card.js +18 -0
  6. package/dist/boot/parked-revive-gate.d.ts +3 -0
  7. package/dist/boot/parked-revive-gate.js +2 -2
  8. package/dist/boot/resolve-spec.js +1 -1
  9. package/dist/boot/runner-deps.d.ts +2 -2
  10. package/dist/boot/runner-deps.js +4 -0
  11. package/dist/boot/stores.d.ts +1 -0
  12. package/dist/boot/stores.js +7 -4
  13. package/dist/config-catalog.d.ts +0 -29
  14. package/dist/config-catalog.js +3 -0
  15. package/dist/config-types.d.ts +12 -2
  16. package/dist/config.js +13 -2
  17. package/dist/cross-session-settings.d.ts +70 -0
  18. package/dist/cross-session-settings.js +37 -0
  19. package/dist/deployment-governance.d.ts +30 -1
  20. package/dist/deployment-governance.js +11 -5
  21. package/dist/http/routes/approvals-assistant.js +3 -0
  22. package/dist/http/routes/capabilities.js +2 -0
  23. package/dist/http/routes/diagnostics.js +1 -0
  24. package/dist/http/server.d.ts +6 -0
  25. package/dist/main.js +11 -4
  26. package/dist/memory-scope.d.ts +20 -0
  27. package/dist/memory-scope.js +9 -1
  28. package/dist/observability/run-terminal-log.d.ts +4 -0
  29. package/dist/observability/run-terminal-log.js +14 -0
  30. package/dist/plugins/approval-ask-store-sql.d.ts +9 -9
  31. package/dist/plugins/approval-ask-store-sql.js +1 -1
  32. package/dist/plugins/device-store-sql.d.ts +38 -10
  33. package/dist/plugins/device-store-sql.js +28 -9
  34. package/dist/plugins/file-history-store-sql.d.ts +20 -3
  35. package/dist/plugins/file-history-store-sql.js +39 -36
  36. package/dist/plugins/image-bake-store-sql.js +2 -2
  37. package/dist/plugins/mailbox-store-sql.d.ts +5 -2
  38. package/dist/plugins/mailbox-store-sql.js +2 -2
  39. package/dist/plugins/permission-rule-store-file.d.ts +4 -4
  40. package/dist/plugins/permission-rule-store-file.js +4 -4
  41. package/dist/plugins/permission-rule-store-sql.d.ts +15 -11
  42. package/dist/plugins/permission-rule-store-sql.js +7 -11
  43. package/dist/plugins/retention-lane-store-sql.d.ts +10 -6
  44. package/dist/plugins/retention-lane-store-sql.js +1 -1
  45. package/dist/plugins/retention-store-sql.d.ts +11 -15
  46. package/dist/plugins/retention-store-sql.js +6 -2
  47. package/dist/plugins/session-policy-store-sql.d.ts +9 -2
  48. package/dist/plugins/shared-memory-store-sql.d.ts +25 -19
  49. package/dist/plugins/shared-memory-store-sql.js +17 -15
  50. package/dist/plugins/sql-driver.d.ts +78 -26
  51. package/dist/plugins/sql-driver.js +97 -14
  52. package/dist/plugins/store-backend.d.ts +6 -2
  53. package/dist/plugins/task-list-store-sql.d.ts +2 -2
  54. package/dist/plugins/task-list-store-sql.js +1 -1
  55. package/dist/plugins/usage-window-store-sql.d.ts +5 -3
  56. package/dist/plugins/usage-window-store-sql.js +1 -5
  57. package/dist/rules-consent.d.ts +54 -12
  58. package/dist/rules-consent.js +4 -4
  59. package/dist/run-local.d.ts +2 -1
  60. package/dist/run-local.js +3 -3
  61. package/dist/runs.d.ts +13 -0
  62. package/dist/runs.js +2 -0
  63. package/dist/sql-engine-posture.d.ts +49 -0
  64. package/dist/sql-engine-posture.js +13 -0
  65. package/dist/task-settings.d.ts +13 -3
  66. package/dist/task-settings.js +1 -1
  67. package/dist/tool-approval.d.ts +41 -1
  68. package/dist/tool-approval.js +7 -1
  69. package/dist/trace/core-keyset-guard.d.ts +4 -4
  70. package/dist/trace/engine-notice-wire.d.ts +1 -1
  71. package/dist/trace/engine-notice-wire.js +3 -0
  72. package/dist/trace/project.d.ts +25 -0
  73. package/dist/trace/project.js +26 -0
  74. package/dist/trace/sema-provenance.d.ts +7 -1
  75. package/dist/trace/sema-provenance.js +1 -1
  76. package/package.json +3 -3
@@ -49,7 +49,7 @@ export class SqlFileHistoryStore {
49
49
  for (const scope of [...new Set(scopes)].sort()) {
50
50
  let locked = false;
51
51
  for (let i = 0; i < 3 && !locked; i++) {
52
- await conn.query(this.q("INSERT IGNORE INTO file_history_scope (scope, created_at_ms) VALUES (?,?)", "INSERT INTO file_history_scope (scope, created_at_ms) VALUES ($1,$2) ON CONFLICT (scope) DO NOTHING"), [scope, Date.now()]);
52
+ await conn.query(this.q("INSERT INTO file_history_scope (scope, created_at_ms) VALUES (?,?) ON DUPLICATE KEY UPDATE scope = scope", "INSERT INTO file_history_scope (scope, created_at_ms) VALUES ($1,$2) ON CONFLICT (scope) DO NOTHING"), [scope, Date.now()]);
53
53
  const r = await conn.query(this.q("SELECT scope FROM file_history_scope WHERE scope = ? FOR UPDATE", "SELECT scope FROM file_history_scope WHERE scope = $1 FOR UPDATE"), [scope]);
54
54
  locked = Boolean(r.rows[0]);
55
55
  }
@@ -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,17 +358,17 @@ 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
- const ins = await conn.query(this.q("INSERT IGNORE INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES (?,?,CAST(UNIX_TIMESTAMP(NOW(3))*1000 AS SIGNED),?)", "INSERT INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES ($1,$2,(EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint,$3) ON CONFLICT (scope, entry_id) DO NOTHING"), [scope, entryId, publishSeq]);
362
- if (ins.affected !== 1) {
363
- const won = 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]);
364
- if (!won.rows[0]) {
365
- throw new Error(`boundary publish for entry "${entryId}" was suppressed without a same-entry winner (publish_seq allocation invariant breach) — refusing to report a boundary that was not stored`);
366
- }
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]);
364
+ if (priorWinner.rows[0]) {
367
365
  await conn.rollback();
368
366
  return { ok: true };
369
367
  }
368
+ const ins = await conn.query(this.q("INSERT IGNORE INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES (?,?,CAST(UNIX_TIMESTAMP(NOW(3))*1000 AS SIGNED),?)", "INSERT INTO file_history_boundary (scope, entry_id, published_at_ms, publish_seq) VALUES ($1,$2,(EXTRACT(EPOCH FROM clock_timestamp())*1000)::bigint,$3) ON CONFLICT (scope, entry_id) DO NOTHING"), [scope, entryId, publishSeq]);
369
+ if (ins.affected !== 1) {
370
+ throw new Error(`boundary publish for entry "${entryId}" was suppressed without a same-entry winner (publish_seq allocation invariant breach) — refusing to report a boundary that was not stored`);
371
+ }
370
372
  for (const [keyHash, cap] of staged) {
371
373
  let version;
372
374
  if (cap.kind === "unchanged") {
@@ -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,8 +139,8 @@ export class SqlImageBake {
139
139
  let inserted = false;
140
140
  try {
141
141
  if (this.db.dialect === "tidb") {
142
- await conn.beginPessimistic();
143
- await conn.query("INSERT IGNORE INTO image_bake_admit (pool) VALUES (?)", [this.poolName]);
142
+ await conn.begin();
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");
146
146
  if (busy.rows.length === 0) {
@@ -36,8 +36,11 @@
36
36
  * - reap 候删 = 事务外粗筛 + 事务内**逐盒锁定重验**(当前读 newest):窗内新到的消息救活盒,
37
37
  * 绝不连带删信;盒行已被并发 drop = 跳过不计。
38
38
  * - append 的 ensure 与 FOR UPDATE 之间被并发 drop 提交 = 同事务内重建一次(语义 = drop 后重建,
39
- * seq 从 1)。键长 > 190 字节写入即拒(fail-loud)——TiDB `INSERT IGNORE` 会把超长键**静默截断**,
40
- * 截断键与全长查询参数字节比对恒 miss(分配读空行崩/隔离键错位)。
39
+ * seq 从 1)。建盒动词 = A(S-127)`ON DUPLICATE KEY UPDATE <pk> = <pk>`:重复键取 X 锁,与随后的
40
+ * `FOR UPDATE` **同锁模式**(`INSERT IGNORE` 取 S 再升 X = InnoDB 上的并发死锁形,B-013)。
41
+ * - 键长 > 190 字节写入即拒(fail-loud):截断键与全长查询参数字节比对恒 miss(分配读空行崩/隔离键
42
+ * 错位)。守卫在**语句之前**,与建行动词无关 —— 换成 ON DUPLICATE 之后超长键由引擎直接报错(严格
43
+ * 模式下 IGNORE 会把它降级成静默截断),方向=更响亮,守卫依旧是第一道。
41
44
  */
42
45
  import type { Pool as MySqlPool } from "mysql2/promise";
43
46
  import type { Pool as PgPool } from "pg";
@@ -189,10 +189,10 @@ export class TiDBMailboxStore {
189
189
  const hopChain = encodeHopChain(msg.hopChain);
190
190
  const peerMeta = encodePeerMeta(msg.peerMeta);
191
191
  return this.tx(async (c) => {
192
- await c.query(`INSERT IGNORE INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES (?, ?, ?, 1)`, [scope, handle, scope]);
192
+ await c.query(`INSERT INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE scope_key = scope_key`, [scope, handle, scope]);
193
193
  let [rows] = (await c.query(`SELECT next_seq FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]));
194
194
  if (rows.length === 0) {
195
- await c.query(`INSERT IGNORE INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES (?, ?, ?, 1)`, [scope, handle, scope]);
195
+ await c.query(`INSERT INTO ${MAILBOX_TABLE} (scope_key, handle, scope, next_seq) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE scope_key = scope_key`, [scope, handle, scope]);
196
196
  [rows] = (await c.query(`SELECT next_seq FROM ${MAILBOX_TABLE} WHERE scope_key = ? AND handle = ? FOR UPDATE`, [scope, handle]));
197
197
  }
198
198
  const seq = Number(rows[0].next_seq);
@@ -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
  }
@@ -1,10 +1,10 @@
1
1
  import { z } from "zod";
2
- import { type PermissionRuleWriter, type PermissionRuleStore, type PermissionRuleStoreProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
2
+ import { type PermissionRuleWriter, type DurableRulePartition, type DurableRulePartitionProvider, type RuleScope, type RuleSyncFrontier, type RuleApprovalRecord, type StaleRuleApprovalRecord, type RuleApprovalRecordStore, type RuleOwner } from "@sema-agent/core";
3
3
  import type { Pool as MySqlPool } from "mysql2/promise";
4
4
  import type { PgQueryFn } from "./pg-query.js";
5
5
  import { type SqlDriver } from "./sql-driver.js";
6
6
  /**
7
- * 一只店的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
7
+ * 一只 durable 分区的写面,或 `undefined`(该 backend 从引擎侧看是只读的)。
8
8
  *
9
9
  * 与 core 的 `writerOf` 的**唯一**区别是判据更严:core 只看 `apply`/`nextDot` 两个方法在不在(它服务的是
10
10
  * 引擎自己的兑付腿),本函数还要求 `readRaw` —— 本仓的消费点(测试/取证)读的是**完整**写面,少一个方法
@@ -13,7 +13,7 @@ import { type SqlDriver } from "./sql-driver.js";
13
13
  * 结构检查而不是断言:没有这个函数,每个调用点都会各写一次 `as unknown as {…}`,那既是三处宽松断言,
14
14
  * 也是三份会各自漂的形状假设。
15
15
  */
16
- export declare function writerOfSqlRuleStore(store: PermissionRuleStore): PermissionRuleWriter | undefined;
16
+ export declare function writerOfSqlRuleStore(store: DurableRulePartition): PermissionRuleWriter | undefined;
17
17
  /**
18
18
  * 382P1(core 7.0.0 [ref] §4.3)—— 这是 **DURABLE 二员面**的 at-rest 形:`{global, project-with-root}`,
19
19
  * 与 core `isValidDurableScope` 逐格等价(`test/permission-rule-durable-face-pin.test.ts` 探针表钉住)。
@@ -127,17 +127,20 @@ export declare function buildRuleBucketChecksum(body: {
127
127
  observedVector?: RuleSyncFrontier;
128
128
  }): string;
129
129
  /**
130
- * SQL 双方言的 {@link PermissionRuleStoreProvider}
130
+ * SQL 双方言的 {@link DurableRulePartitionProvider} —— [ref] 里的 **durable 分区后端**
131
+ * (`user` + `project` 两源的 OR-Set CRDT),**不是**引擎接缝本身:
132
+ * `RunnerDeps.permissionRuleStore` 是 `createPermissionRuleStoreProvider({ durable })` 合成出来的
133
+ * 统一店,合成点在 `main.ts` 一处(分区构成是**部署**决定,不是 backend 决定)。
131
134
  *
132
- * `forPrincipal(undefined)` 恒解析成**零规则**店(core 硬条款:未鉴权任务解析到零规则而不是一只共享桶
133
- * —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
135
+ * `forPrincipal(undefined)` 恒解析成**零规则**分区(core 硬条款:未鉴权任务解析到零规则而不是一只
136
+ * 共享桶 —— 放宽面上的 fail-closed 是「更少的允许」,绝不是「一只共享的」)。
134
137
  */
135
- export declare class SqlPermissionRuleStoreProvider implements PermissionRuleStoreProvider {
138
+ export declare class SqlDurableRulePartitionProvider implements DurableRulePartitionProvider {
136
139
  private readonly db;
137
140
  private readonly now;
138
141
  constructor(db: SqlDriver, now?: () => number);
139
- forPrincipal(principal: string | undefined): PermissionRuleStore;
140
- forLocalOwner(): PermissionRuleStore;
142
+ forPrincipal(principal: string | undefined): DurableRulePartition;
143
+ forLocalOwner(): DurableRulePartition;
141
144
  }
142
145
  /**
143
146
  * durable 审批记录。CAS **按 rev**,不按 state —— core 的原话:批记录的第二个候选会 redeemed→redeemed,
@@ -428,9 +431,10 @@ export declare const RULE_PENDING_APPROVAL_RETENTION_MS: number;
428
431
  * 同口径)。在那之前,MySQL 存量库上这条腿仍是全表扫 —— 但每轮 500 行的上界让它的**单次**代价仍然有界。
429
432
  */
430
433
  export declare const RULE_REAP_BATCH = 500;
431
- /** 三个 SQL 店的一次性装配束(one driver, three faces) */
434
+ /** 三个 SQL 面的一次性装配束(one driver, three faces)。`durable` = [ref] 的 durable 分区后端;
435
+ * 引擎接缝上的统一店由 `main.ts` 用 `createPermissionRuleStoreProvider({ durable })` 合成一次。 */
432
436
  export interface PermissionRuleStores {
433
- provider: SqlPermissionRuleStoreProvider;
437
+ durable: SqlDurableRulePartitionProvider;
434
438
  approvals: SqlRuleApprovalRecordStore;
435
439
  tickets: SqlRuleImportTicketStore;
436
440
  /** [ref] §3 —— boot 期休眠行审计的**窄读口**:库里已有几只桶(`permission_rule` 一行一桶)。
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
3
+ import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseAllowRuleText, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
4
4
  import { dialectProtocolJsonEncoder } from "./sql-driver.js";
5
5
  import { isMissingColumnError } from "./sql-errors.js";
6
6
  import { canonicalJson } from "../canonical-json.js";
@@ -332,7 +332,7 @@ function parseColumn(schema, raw, column, ownerKey) {
332
332
  throw new Error(`permission_rule.${column} for bucket ${ownerKey} does not carry a readable shape: ${parsed.error.message}`);
333
333
  return parsed.data;
334
334
  }
335
- class SqlPermissionRuleStore {
335
+ class SqlDurableRulePartition {
336
336
  db;
337
337
  owner;
338
338
  now;
@@ -490,7 +490,7 @@ class SqlPermissionRuleStore {
490
490
  },
491
491
  };
492
492
  }
493
- export class SqlPermissionRuleStoreProvider {
493
+ export class SqlDurableRulePartitionProvider {
494
494
  db;
495
495
  now;
496
496
  constructor(db, now = Date.now) {
@@ -499,17 +499,13 @@ export class SqlPermissionRuleStoreProvider {
499
499
  }
500
500
  forPrincipal(principal) {
501
501
  if (typeof principal !== "string" || principal === "")
502
- return ZERO_RULE_STORE;
503
- return new SqlPermissionRuleStore(this.db, { kind: "principal", principal }, this.now);
502
+ return EMPTY_DURABLE_RULE_PARTITION;
503
+ return new SqlDurableRulePartition(this.db, { kind: "principal", principal }, this.now);
504
504
  }
505
505
  forLocalOwner() {
506
- return new SqlPermissionRuleStore(this.db, { kind: "local-owner" }, this.now);
506
+ return new SqlDurableRulePartition(this.db, { kind: "local-owner" }, this.now);
507
507
  }
508
508
  }
509
- const ZERO_RULE_STORE = {
510
- list: async () => ({ rules: [], tombstones: [], rev: 0 }),
511
- durability: "process-local",
512
- };
513
509
  export class SqlRuleApprovalRecordStore {
514
510
  db;
515
511
  now;
@@ -744,7 +740,7 @@ export const RULE_REAP_BATCH = 500;
744
740
  export function createSqlPermissionRuleStores(db, now) {
745
741
  const q = (tidb, pg) => (db.dialect === "tidb" ? tidb : pg);
746
742
  return {
747
- provider: new SqlPermissionRuleStoreProvider(db, now),
743
+ durable: new SqlDurableRulePartitionProvider(db, now),
748
744
  approvals: new SqlRuleApprovalRecordStore(db, now),
749
745
  tickets: new SqlRuleImportTicketStore(db),
750
746
  reapExpired: async (nowMs) => {
@@ -15,12 +15,14 @@
15
15
  *
16
16
  * ── 方言差异台账(显式,永不藏进抽象;A12 判据)───────────────────────────────────────────────────
17
17
  * - `?` 占位符 vs `$n`;
18
- * - 幂等插入:`INSERT IGNORE` vs `ON CONFLICT (…) DO NOTHING`;
18
+ * - 幂等建行(哨兵形 A,S-127):`ON DUPLICATE KEY UPDATE <pk> = <pk>` vs `ON CONFLICT (…) DO NOTHING`
19
+ * —— MySQL 侧刻意**不用** `INSERT IGNORE`:重复键的 IGNORE 取 S 锁,与随后的 `FOR UPDATE` 之间是
20
+ * S→X 升级,InnoDB 上两并发事务互等即 ER_LOCK_DEADLOCK(B-013);ON DUPLICATE 的重复键取的就是 X;
19
21
  * - upsert:`ON DUPLICATE KEY UPDATE col = VALUES(col)` vs `ON CONFLICT (…) DO UPDATE SET col = EXCLUDED.col`;
20
22
  * - 条件自增:`IF(cond, 0, 1)` vs `CASE WHEN cond THEN 0 ELSE 1 END`(本文件不用 —— 见 `acquire` 顶注:
21
23
  * 自增判据在**应用层**算,因为它同时要产出返回给调用方的 token);
22
- * - 事务动词:平凡 `begin()`(理由逐字见车1 `SqlRetentionStore.tx` 的头注:`BEGIN PESSIMISTIC` 是 TiDB
23
- * 专有语法,发到普通 MySQL 上是语法错,而发车门的 canonical `TIDB_TEST_URL` 指向的正是普通 MySQL)。
24
+ * - 事务动词:只有 `begin()` 一个;读语义的判据在 {@link SqlTxConn.begin} `@contract txn.read-semantics`
25
+ * (本店唯一的读是 `acquire` 的租约行 `FOR UPDATE` —— 锁读在两引擎都是当前读)。
24
26
  * - schema 属主:**四张表全部由车1 的 `retention-store-sql.ts` 建**(`TIDB_RETENTION_STATEMENTS` /
25
27
  * `PG_RETENTION_SCHEMA`)。本文件一条 DDL 都不发 —— 「一张表恰好一个 DDL 属主」(db-gate.ts 顶注
26
28
  * 的实测结论:第二个属主的 `IF NOT EXISTS` 会静默吞掉列/索引差异)。
@@ -122,8 +124,10 @@ export declare class SqlRetentionLaneStore implements RetentionLaneStore {
122
124
  * **本 tick 零写静默跳过**。
123
125
  *
124
126
  * 事务三步,一步不能省:
125
- * ① `INSERT IGNORE`(PG:`ON CONFLICT DO NOTHING`)一条 `expires_at_ms = 0` 的**哨兵行** —— 它永远
126
- * 不会改写一条既存租约(两方言的这两个动词都只在缺行时写);
127
+ * ① 幂等建一条 `expires_at_ms = 0` 的**哨兵行**(形 A,S-127:MySQL `ON DUPLICATE KEY UPDATE
128
+ * singleton = singleton` / PG `ON CONFLICT DO NOTHING`)—— 它永远不会改写一条既存租约(两方言的这
129
+ * 两个动词都只在缺行时写),且重复键取的是 **X** 锁,与 ② 的 `FOR UPDATE` 同锁模式(B-013:
130
+ * `INSERT IGNORE` 在这里取 S 再升 X,InnoDB 上两副本抢租互等直接死锁);
127
131
  * ② `SELECT … FOR UPDATE` —— 行现在必定存在,锁真的拿得到;
128
132
  * ③ 判 + `UPDATE`,同事务提交。
129
133
  *
@@ -170,7 +174,7 @@ export declare class SqlRetentionLaneStore implements RetentionLaneStore {
170
174
  * (车1 交接件②,逐字)。
171
175
  *
172
176
  * 🔴 为什么删行是**拆锁**而不是"解除":`retention_hold` 的行同时是**域级互斥哨兵** —— 车1 的三条破坏性
173
- * 事务首步就是锁读这一行(`INSERT IGNORE` 补哨兵 → `SELECT … FOR UPDATE`),而 `FOR UPDATE`
177
+ * 事务首步就是锁读这一行(ON DUPLICATE 补哨兵 → `SELECT … FOR UPDATE`),而 `FOR UPDATE`
174
178
  * **锁不住一条不存在的行**(TiDB 无 gap lock;PG READ COMMITTED 同病)。删掉行 = 下一个 PUT 与一条
175
179
  * 在飞的删除事务又可以同时读到"没有 hold",于是 operator 拿到成功回执之后数据仍被删 —— 那正是 §5 F2
176
180
  * 要消灭的那件事。upsert 让两者抢**同一把行锁**,「PUT 提交完成 ⇒ 其后每个破坏性事务的首读必见
@@ -14,7 +14,7 @@ export class SqlRetentionLaneStore {
14
14
  const conn = await this.db.connect();
15
15
  try {
16
16
  await conn.begin();
17
- await conn.query(this.q(`INSERT IGNORE INTO ${RETENTION_LEASE_TABLE} (singleton, holder, fencing_token, expires_at_ms) VALUES ('x','',0,0)`, `INSERT INTO ${RETENTION_LEASE_TABLE} (singleton, holder, fencing_token, expires_at_ms) VALUES ('x','',0,0) ON CONFLICT (singleton) DO NOTHING`));
17
+ await conn.query(this.q(`INSERT INTO ${RETENTION_LEASE_TABLE} (singleton, holder, fencing_token, expires_at_ms) VALUES ('x','',0,0) ON DUPLICATE KEY UPDATE singleton = singleton`, `INSERT INTO ${RETENTION_LEASE_TABLE} (singleton, holder, fencing_token, expires_at_ms) VALUES ('x','',0,0) ON CONFLICT (singleton) DO NOTHING`));
18
18
  const { rows } = await conn.query(this.q(`SELECT holder, fencing_token, expires_at_ms FROM ${RETENTION_LEASE_TABLE} WHERE singleton = 'x' FOR UPDATE`, `SELECT holder, fencing_token, expires_at_ms FROM ${RETENTION_LEASE_TABLE} WHERE singleton = 'x' FOR UPDATE`));
19
19
  const cur = rows[0];
20
20
  const curHolder = String(cur?.holder ?? "");
@@ -28,14 +28,15 @@
28
28
  * ── 方言差异台账(显式,永不藏进抽象;A12 判据)───────────────────────────────────────────────────
29
29
  * - `?` 占位符 vs `$n`;多值集合 MySQL 用 `IN (?,?,…)`、PG 用 `= ANY($n::text[])`(先例:
30
30
  * workflow-run-store-sql 的 reap)
31
- * - 幂等插入:`INSERT IGNORE` vs `ON CONFLICT (…) DO NOTHING`
31
+ * - 幂等建行:哨兵形(形 A,S-127)`ON DUPLICATE KEY UPDATE <pk> = <pk>` vs `ON CONFLICT (…) DO NOTHING`
32
+ * —— 随后有同行 `FOR UPDATE` 的建行**必须**用 ON DUPLICATE(重复键取 X,同锁模式);墓碑那种
33
+ * 「只读 affected 不加锁读」的写点(形 B)才保留 `INSERT IGNORE`
32
34
  * - 级联 DELETE 的 JOIN 形:`DELETE te FROM a te JOIN b …` vs `DELETE FROM a te USING b …`
33
35
  * - null-safe 比较:`<=>` vs `IS NOT DISTINCT FROM`(及其**占位符数量**差:`?` 不可复用)
34
36
  * - JSON 提取(workflow_run 的 originatingSessionId):`JSON_UNQUOTE(JSON_EXTRACT(run,'$.x'))` vs
35
37
  * `run::json->>'x'`
36
38
  * - 表级 `COLLATE utf8mb4_bin` vs 逐列 `COLLATE "C"`(`isolation-key-collation` 门执法)
37
- * - 事务动词:两条方言都是平凡 `begin()` —— **不是** `beginPessimistic()`(它是 TiDB 专有语法,发到
38
- * 普通 MySQL 上语法错;完整判据见 `SqlRetentionStore.tx` 的头注)
39
+ * - 事务动词:只有 `begin()` 一个;读语义的判据在 {@link SqlTxConn.begin} `@contract txn.read-semantics`
39
40
  * - schema 属主:MySQL DDL 由本文件导出、展开进 `tidb-pool.ts` 的 `SCHEMA_STATEMENTS`;PG DDL 由本文件的
40
41
  * `ensurePgRetentionSchema` 导出、由 `pg-pool.ts` 的中央 `ensurePgSchema` 组合(与 leader-run 同姿势)。
41
42
  *
@@ -189,16 +190,9 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
189
190
  params: unknown[];
190
191
  };
191
192
  /**
192
- * 事务壳 —— **平凡 `begin()`,不是 `beginPessimistic()`**(真库实测定谳,不是口味):
193
- *
194
- * · MySQL-protocol 腿:`BEGIN PESSIMISTIC` 是 **TiDB 专有语法**,发到普通 MySQL 上是语法错
195
- * (本机 MySQL 9.7 实测 `ER_PARSE_ERROR near 'PESSIMISTIC'`)—— 而发车门 2.6 的 canonical
196
- * `TIDB_TEST_URL` 默认指向的正是一台**普通 MySQL**。用那条动词等于让本店在标准工作流上必红。
197
- * · 语义上也不需要它:本店要的只是 `SELECT … FOR UPDATE` 是**当前读**。InnoDB 的加锁读在 RR 下本来
198
- * 就读最新已提交版本;TiDB 自 3.0.8 起事务默认就是悲观模式,同样是当前读。显式动词只在「部署把
199
- * TiDB 改回乐观模式」这一种配置下才多出保障,而那种部署会同时打破仓内其它 FOR UPDATE 腿
200
- * (run-store 的 task_active 门、checkpoint 的 approval 门),不是本店能独自承担的假设。
201
- * · PG:READ COMMITTED 逐语句取新快照,平凡 `BEGIN` 即所需。
193
+ * 事务壳。本店要的是「`SELECT FOR UPDATE` 是**当前读**」—— 判据不在这里,在
194
+ * {@link SqlTxConn.begin} 的 `@contract txn.read-semantics`(隔离级与 TiDB 悲观模式是**连接的事实**,
195
+ * 每条池连接初始化时钉一次,设不上就拒启)。
202
196
  *
203
197
  * `protected` = 真库套件的语句录制注入点(判据「hold 锁读是事务首条 SQL」靠它自证)。
204
198
  */
@@ -213,8 +207,10 @@ export declare class SqlRetentionStore implements ManagedRetentionCapability {
213
207
  * 事务**首步**:拿本域的互斥行锁,并读出「这个域是不是冻结中」(§5 F2 承重)。
214
208
  *
215
209
  * 两步,缺一不可:
216
- * ① `INSERT IGNORE`(PG:`ON CONFLICT DO NOTHING`)一条 `held = 0` 的**哨兵行** —— 它永远不会改写
217
- * 一条既存的 hold(两方言的这两个动词都只在缺行时写),只保证**接下来那把行锁有东西可锁**;
210
+ * ① 幂等建一条 `held = 0` 的**哨兵行**(形 A,S-127:MySQL `ON DUPLICATE KEY UPDATE domain = domain` /
211
+ * PG `ON CONFLICT DO NOTHING`)—— 它永远不会改写一条既存的 hold(两方言的这两个动词都只在缺行时
212
+ * 写),只保证**接下来那把行锁有东西可锁**;重复键取的是 **X** 锁,与 ② 的 `FOR UPDATE` 同锁模式
213
+ * (`INSERT IGNORE` 在这里取 S 再升 X = InnoDB 上两个并发 sweep 互等的死锁形,B-013);
218
214
  * ② `SELECT held … FOR UPDATE` —— 现在这行必定存在,于是锁真的拿到了。
219
215
  *
220
216
  * 🔴 为什么必须先造行(codex 交叉复审 R1-[high],验真后改):`FOR UPDATE` 锁不住缺席行(TiDB 无 gap
@@ -38,7 +38,7 @@ export const TIDB_RETENTION_STATEMENTS = [
38
38
  -- 而 SELECT … FOR UPDATE **锁不住一条不存在的行**(TiDB 无 gap lock;PG 的 READ COMMITTED 同样
39
39
  -- 不锁缺席行)。presence 形下两边都读到「没有 hold」是完全可能的,于是 operator 拿到成功回执之后
40
40
  -- 数据仍被删 —— 那正是 F2 要消灭的那件事,只是换了个位置复发。
41
- -- ⇒ 行**恒存在**(破坏性事务首步 INSERT IGNORE 一条 held=0 的哨兵),冻结与否写在这一列上。
41
+ -- ⇒ 行**恒存在**(破坏性事务首步幂等建一条 held=0 的哨兵),冻结与否写在这一列上。
42
42
  -- 于是 hold 的放置(同一 PK 的 upsert)与三只破坏性事务抢的是**同一把行锁**,串行化由引擎给出:
43
43
  -- PUT 提交完成 ⇒ 其后每一个破坏性事务的首读必见 held=1。放置/解除都必须走这一列(车2 交接件)。
44
44
  held TINYINT(1) NOT NULL DEFAULT 0,
@@ -186,7 +186,7 @@ export class SqlRetentionStore {
186
186
  }
187
187
  async holdInForce(conn, domain) {
188
188
  const key = domain;
189
- await conn.query(this.q(`INSERT IGNORE INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES (?,0,NULL,NULL,NULL)`, `INSERT INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES ($1,0,NULL,NULL,NULL) ON CONFLICT (domain) DO NOTHING`), [key]);
189
+ await conn.query(this.q(`INSERT INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES (?,0,NULL,NULL,NULL) ON DUPLICATE KEY UPDATE domain = domain`, `INSERT INTO ${RETENTION_HOLD_TABLE} (domain, held, placed_by, placed_at_ms, note) VALUES ($1,0,NULL,NULL,NULL) ON CONFLICT (domain) DO NOTHING`), [key]);
190
190
  const { rows } = await conn.query(this.q(`SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = ? FOR UPDATE`, `SELECT held FROM ${RETENTION_HOLD_TABLE} WHERE domain = $1 FOR UPDATE`), [key]);
191
191
  return Number(rows[0]?.held ?? 0) === 1;
192
192
  }
@@ -367,6 +367,10 @@ export class SqlRetentionStore {
367
367
  const s = set("owner_session_id", 1);
368
368
  await conn.query(`DELETE FROM tool_result WHERE ${s.sql}`, s.params);
369
369
  }
370
+ {
371
+ const s = set("scope", 1);
372
+ await conn.query(`SELECT scope FROM file_history_scope WHERE ${s.sql} ORDER BY scope FOR UPDATE`, s.params);
373
+ }
370
374
  for (const [table, col] of [
371
375
  ["resume_anchor", "session_id"],
372
376
  ["approval_exemption", "session_id"],
@@ -29,8 +29,15 @@ export declare class SqlSessionPolicyStore implements SessionPolicyStore {
29
29
  * Return shape aligned to core 1.423's optional interface seam (`Promise<void>`) — the affectedRows count was
30
30
  * incidental and unconsumed; the integration test asserts the post-state (listBySession empty) instead. */
31
31
  deleteBySession(sessionId: string): Promise<void>;
32
- /** `begin()` (NOT `beginPessimistic()`) — matches both twins' original call shape: TiDB's native
33
- * `beginTransaction()` (optimistic), PG's plain `BEGIN`. Normalized by SqlDriver's shared tx plumbing. */
32
+ /**
33
+ * The store's one read-check-write transaction. Its load-bearing read is line ~106's
34
+ * `SELECT rules, rev … FOR UPDATE` — a LOCKING read, therefore a CURRENT read on both engines, which is
35
+ * what makes the loosen-diff decision see the prior it is diffing against. That "therefore" is not a
36
+ * property of the verb: it holds because {@link SqlTxConn.begin}'s `@contract txn.read-semantics` pins
37
+ * TiDB to pessimistic transactions at connection init (an optimistic session would read `FOR UPDATE` off
38
+ * the start-ts snapshot). Second line of defence, unchanged: the `rev` re-check plus the first write's
39
+ * PK race.
40
+ */
34
41
  private tx;
35
42
  }
36
43
  /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */