@sema-agent/server 7.9.0 → 7.11.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 (82) hide show
  1. package/USAGE.md +6 -0
  2. package/dist/adoption/plan.d.ts +152 -0
  3. package/dist/adoption/plan.js +513 -0
  4. package/dist/adoption/runner.d.ts +54 -0
  5. package/dist/adoption/runner.js +505 -0
  6. package/dist/adoption/sql.d.ts +76 -0
  7. package/dist/adoption/sql.js +106 -0
  8. package/dist/adoption/wire.d.ts +250 -0
  9. package/dist/adoption/wire.js +153 -0
  10. package/dist/approval-card.d.ts +24 -0
  11. package/dist/approval-card.js +32 -0
  12. package/dist/approval-reconciler.d.ts +1 -1
  13. package/dist/approval-reconciler.js +1 -1
  14. package/dist/boot/adoption.d.ts +30 -0
  15. package/dist/boot/adoption.js +57 -0
  16. package/dist/boot/coordinators.d.ts +4 -0
  17. package/dist/boot/coordinators.js +3 -1
  18. package/dist/boot/parked-revive-gate.d.ts +38 -5
  19. package/dist/boot/parked-revive-gate.js +53 -6
  20. package/dist/boot/reapers.js +3 -3
  21. package/dist/boot/runner-deps.d.ts +10 -2
  22. package/dist/boot/runner-deps.js +12 -1
  23. package/dist/capabilities/repo-tools.d.ts +36 -2
  24. package/dist/capabilities/repo-tools.js +125 -11
  25. package/dist/capabilities/scenarios.d.ts +2 -2
  26. package/dist/capabilities/scenarios.js +1 -1
  27. package/dist/config-types.d.ts +22 -2
  28. package/dist/config.js +44 -2
  29. package/dist/fleet/fleet-terminal-window.d.ts +1 -1
  30. package/dist/fleet/fleet-terminal-window.js +1 -1
  31. package/dist/git-api-kind.d.ts +7 -0
  32. package/dist/git-api-kind.js +8 -0
  33. package/dist/http/routes/adoption.d.ts +26 -0
  34. package/dist/http/routes/adoption.js +120 -0
  35. package/dist/http/routes/approvals-assistant.js +2 -2
  36. package/dist/http/routes/capabilities.js +12 -0
  37. package/dist/http/routes/rules.d.ts +23 -0
  38. package/dist/http/routes/rules.js +117 -0
  39. package/dist/http/routes/shared-memory.d.ts +31 -0
  40. package/dist/http/routes/shared-memory.js +181 -0
  41. package/dist/http/routes/trace-usage.js +139 -3
  42. package/dist/http/server.d.ts +19 -1
  43. package/dist/http/server.js +47 -5
  44. package/dist/index.d.ts +1 -1
  45. package/dist/index.js +1 -1
  46. package/dist/main.js +54 -5
  47. package/dist/observability/fail-open.d.ts +8 -0
  48. package/dist/observability/fail-open.js +8 -0
  49. package/dist/plugins/adoption-log-sql.d.ts +191 -0
  50. package/dist/plugins/adoption-log-sql.js +273 -0
  51. package/dist/plugins/checkpoint-store-sql.d.ts +22 -9
  52. package/dist/plugins/checkpoint-store-sql.js +40 -29
  53. package/dist/plugins/local-checkpoint-store.d.ts +11 -1
  54. package/dist/plugins/local-checkpoint-store.js +10 -2
  55. package/dist/plugins/memory-engine-pg.js +3 -3
  56. package/dist/plugins/permission-rule-store-sql.d.ts +242 -0
  57. package/dist/plugins/permission-rule-store-sql.js +817 -0
  58. package/dist/plugins/pg-pool.js +44 -7
  59. package/dist/plugins/run-store-sql.d.ts +3 -3
  60. package/dist/plugins/run-store-sql.js +3 -3
  61. package/dist/plugins/session-policy-store-sql.d.ts +6 -0
  62. package/dist/plugins/session-policy-store-sql.js +7 -1
  63. package/dist/plugins/shared-memory-store-sql.d.ts +223 -0
  64. package/dist/plugins/shared-memory-store-sql.js +516 -0
  65. package/dist/plugins/store-backend.d.ts +30 -0
  66. package/dist/plugins/store-backend.js +14 -0
  67. package/dist/plugins/tidb-pool.js +55 -8
  68. package/dist/plugins/workflow-journal-store-sql.d.ts +3 -3
  69. package/dist/plugins/workflow-journal-store-sql.js +6 -6
  70. package/dist/plugins/workflow-run-store-sql.d.ts +1 -1
  71. package/dist/plugins/workflow-run-store-sql.js +12 -12
  72. package/dist/rules-consent.d.ts +126 -0
  73. package/dist/rules-consent.js +198 -0
  74. package/dist/run-local.js +2 -2
  75. package/dist/shared-memory-scope-authorizer.d.ts +29 -0
  76. package/dist/shared-memory-scope-authorizer.js +17 -0
  77. package/dist/tool-approval.d.ts +55 -0
  78. package/dist/tool-approval.js +124 -5
  79. package/dist/trace/core-keyset-guard.d.ts +2 -2
  80. package/dist/trace/project.d.ts +1 -0
  81. package/dist/trace/project.js +1 -0
  82. package/package.json +3 -3
@@ -0,0 +1,273 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mysqlDriver, pgDriver } from "./sql-driver.js";
3
+ export const ADOPTION_LOG_TABLE = "adoption_log";
4
+ /** 弧的阶段(183 §3.2 根级状态机的 form b 投影)。**单调**,只增不减。 */
5
+ export const ADOPTION_PHASE = {
6
+ /** ② 意图已落库(行在,尚未动任何数据行)。 */
7
+ INTENT: 2,
8
+ /** ③ 身份轴重绑腿全部完成(与腿的 UPDATE **同一个事务**)。 */
9
+ REBOUND: 3,
10
+ /** ④ 解析切换 / 配置清单产出。form b 的 server 半场:配置账已物化进 `configs`。 */
11
+ CONFIGS: 4,
12
+ /** ⑤ 承运腿。**form b 恒零承运**(数据本来就在 SQL)—— 阶段位仍显式推进,好让 form a 上车时挂得上。 */
13
+ CARRIED: 5,
14
+ /** ⑥ 永久终态。 */
15
+ TERMINAL: 6,
16
+ };
17
+ /** MySQL 协议方言的建表语句(展开进 tidb-pool 的中央 `SCHEMA_STATEMENTS`,与 approval-ask 同姿势:
18
+ * 跟着中央 ensureSchema 在 named-lock 的那条 conn 上建,不绕开 DDL 串行化)。 */
19
+ export const TIDB_ADOPTION_LOG_STATEMENTS = [
20
+ `CREATE TABLE IF NOT EXISTS ${ADOPTION_LOG_TABLE} (
21
+ adoption_id VARCHAR(64) NOT NULL,
22
+ -- from_principal 上的 UNIQUE = 183 D7「同一个源不许被收编两次」的**数据库强形**。
23
+ -- 它同时是发起腿的幂等身份:并发同参 POST 由这条约束仲裁,不靠应用层先查后插。
24
+ from_principal VARCHAR(190) NOT NULL,
25
+ to_principal VARCHAR(190) NOT NULL,
26
+ phase INT NOT NULL,
27
+ state VARCHAR(16) NOT NULL,
28
+ -- legs/configs/report 是 TEXT 不是 JSON:report 的契约是「逐字节恒同」,JSON 列会重排键序(顶注)。
29
+ legs MEDIUMTEXT NOT NULL,
30
+ configs MEDIUMTEXT NOT NULL,
31
+ report MEDIUMTEXT NULL,
32
+ reject_code VARCHAR(64) NULL,
33
+ reject_detail TEXT NULL,
34
+ created_at_ms BIGINT NOT NULL,
35
+ updated_at_ms BIGINT NOT NULL,
36
+ PRIMARY KEY (adoption_id),
37
+ UNIQUE KEY uq_adoption_log_from (from_principal),
38
+ KEY idx_adoption_log_state (state)
39
+ ) COLLATE utf8mb4_bin`,
40
+ ];
41
+ /** PostgreSQL 孪生(由 `ensurePgSchema` 在 advisory lock 的那条 client 上调用)。 */
42
+ export async function ensurePgAdoptionLogSchema(q) {
43
+ await q(`CREATE TABLE IF NOT EXISTS ${ADOPTION_LOG_TABLE} (
44
+ adoption_id VARCHAR(64) COLLATE "C" NOT NULL,
45
+ -- MySQL 孪生的行内注解释了这条 UNIQUE 为什么是幂等身份的真源(183 D7 的 DB 强形)。
46
+ from_principal VARCHAR(190) COLLATE "C" NOT NULL,
47
+ to_principal VARCHAR(190) COLLATE "C" NOT NULL,
48
+ phase INT NOT NULL,
49
+ state VARCHAR(16) COLLATE "C" NOT NULL,
50
+ legs TEXT COLLATE "C" NOT NULL,
51
+ configs TEXT COLLATE "C" NOT NULL,
52
+ report TEXT COLLATE "C",
53
+ reject_code VARCHAR(64) COLLATE "C",
54
+ reject_detail TEXT COLLATE "C",
55
+ created_at_ms BIGINT NOT NULL,
56
+ updated_at_ms BIGINT NOT NULL,
57
+ PRIMARY KEY (adoption_id),
58
+ CONSTRAINT uq_adoption_log_from UNIQUE (from_principal)
59
+ )`);
60
+ await q(`CREATE INDEX IF NOT EXISTS idx_adoption_log_state ON ${ADOPTION_LOG_TABLE} (state)`);
61
+ }
62
+ const COLS = "adoption_id, from_principal, to_principal, phase, state, legs, configs, report, reject_code, reject_detail, created_at_ms, updated_at_ms";
63
+ function toRow(r) {
64
+ return {
65
+ adoptionId: String(r.adoption_id),
66
+ fromPrincipal: String(r.from_principal),
67
+ toPrincipal: String(r.to_principal),
68
+ phase: Number(r.phase),
69
+ state: String(r.state),
70
+ legsJson: String(r.legs),
71
+ configsJson: String(r.configs),
72
+ reportJson: r.report == null ? null : String(r.report),
73
+ rejectCode: r.reject_code == null ? null : String(r.reject_code),
74
+ rejectDetail: r.reject_detail == null ? null : String(r.reject_detail),
75
+ createdAtMs: Number(r.created_at_ms),
76
+ updatedAtMs: Number(r.updated_at_ms),
77
+ };
78
+ }
79
+ /** PG advisory lock 的 32 位对象键(类键固定,对象键 = 名字的 sha256 前 4 字节转有符号 int4)。 */
80
+ const PG_ADOPTION_LOCK_CLASS = 4907;
81
+ function pgLockObjectKey(name) {
82
+ return createHash("sha256").update(name).digest().readInt32BE(0);
83
+ }
84
+ /**
85
+ * 弧锁的名字。**定长**(前缀 14 + 32 位十六进制 = 46 字符)。
86
+ *
87
+ * 🔴 为什么必须 hash 而不是把 principal 直接拼进去(codex R3-F3,亲核属实):TiDB/MySQL 的 `GET_LOCK`
88
+ * 锁名上限是 **64 字符**,而请求面收的 principal 到 190 —— 超过 50 字符的源身份会让取锁**在弧动起来
89
+ * 之前**就失败,而那时意图行已经落库,于是每一次重发、每一次副本 boot 续跑都确定性地再撞一次同一堵墙。
90
+ * (PG 那条腿本来就走 hash 出来的 int4 对象键,这里只是把 MySQL 腿补齐到同一姿势。)
91
+ */
92
+ export function adoptionLockName(fromPrincipal) {
93
+ return `sema_adoption:${createHash("sha256").update(fromPrincipal).digest("hex").slice(0, 32)}`;
94
+ }
95
+ /**
96
+ * 收编日志店(单文件双方言,SqlDriver 形——checkpoint-store / approval-ask-store 同款)。
97
+ *
98
+ * 每条语句的两方言文本**并排写在调用点**(A12 判据:方言差异必须显式,不许用占位符编号循环拼)。
99
+ */
100
+ export class SqlAdoptionLogStore {
101
+ db;
102
+ constructor(db) {
103
+ this.db = db;
104
+ }
105
+ q(tidb, pg) {
106
+ return this.db.dialect === "tidb" ? tidb : pg;
107
+ }
108
+ /** 池级只读执行面(phase 0 的 preflight 扫描用——它**零写**,不需要也不该占一条事务连接)。
109
+ * 写路径一律走 {@link rebindTransaction};把驱动整个暴露出去会让调用方能绕开 phase CAS。 */
110
+ get reader() {
111
+ return this.db;
112
+ }
113
+ /**
114
+ * 发起腿的 **insert-or-fetch 原子形**。返回读回的行(可能是别人插的)+ 是否本次插入。
115
+ * 调用方据 `row.toPrincipal !== toPrincipal` 判「同 from 异 to」⇒ typed 409。
116
+ */
117
+ async ensureAdoption(row) {
118
+ const emptyLegs = "[]";
119
+ const res = await this.db.query(this.q(`INSERT IGNORE INTO ${ADOPTION_LOG_TABLE} (${COLS}) VALUES (?, ?, ?, ?, 'in_flight', ?, ?, NULL, NULL, NULL, ?, ?)`, `INSERT INTO ${ADOPTION_LOG_TABLE} (${COLS}) VALUES ($1, $2, $3, $4, 'in_flight', $5, $6, NULL, NULL, NULL, $7, $8) ON CONFLICT (from_principal) DO NOTHING`), [row.adoptionId, row.fromPrincipal, row.toPrincipal, ADOPTION_PHASE.INTENT, emptyLegs, emptyLegs, row.nowMs, row.nowMs]);
120
+ const inserted = res.affected > 0;
121
+ const stored = await this.getByFrom(row.fromPrincipal);
122
+ if (!stored)
123
+ throw new Error(`ensureAdoption: row vanished right after insert (from=${row.fromPrincipal})`);
124
+ return { row: stored, inserted };
125
+ }
126
+ async getById(adoptionId, exec = this.db) {
127
+ const res = await exec.query(this.q(`SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE adoption_id = ?`, `SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE adoption_id = $1`), [adoptionId]);
128
+ return res.rows[0] ? toRow(res.rows[0]) : null;
129
+ }
130
+ async getByFrom(fromPrincipal, exec = this.db) {
131
+ const res = await exec.query(this.q(`SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE from_principal = ?`, `SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE from_principal = $1`), [fromPrincipal]);
132
+ return res.rows[0] ? toRow(res.rows[0]) : null;
133
+ }
134
+ /** I6 server 同族:每副本 boot 必查的在飞名单(**响亮**,不静默跳过)。 */
135
+ async listInFlight(exec = this.db) {
136
+ const res = await exec.query(this.q(`SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE state = 'in_flight' ORDER BY created_at_ms ASC`, `SELECT ${COLS} FROM ${ADOPTION_LOG_TABLE} WHERE state = 'in_flight' ORDER BY created_at_ms ASC`), []);
137
+ return res.rows.map(toRow);
138
+ }
139
+ /**
140
+ * 迁移事务:腿的全部 UPDATE **与** phase CAS 落在**同一个事务**里。
141
+ *
142
+ * 🔴 为什么必须同事务(而不是「先迁完再推 phase」):两句分开就留下一个窗 —— 腿提交了、phase 没推,
143
+ * 崩溃后接棒副本重跑腿(幂等,零行变化)却把**首次的行数读数**丢了,回执里的 `legs` 会变成一串 0。
144
+ * 同事务之后这个窗根本不存在:要么「行迁了且 phase=3 且 legs 读数在」,要么什么都没发生。
145
+ * 任何异常(含唯一键违例 = 跨轴组合冲突)⇒ ROLLBACK ⇒ **源/目的地两侧字节零变更**。
146
+ */
147
+ async rebindOn(conn, adoptionId, expectPhase, nextPhase, nowMs, work, encodeLegs) {
148
+ try {
149
+ await conn.begin();
150
+ const legs = await work(conn);
151
+ const cas = await conn.query(
152
+ // 🔴 谓词里必须带 `state = 'in_flight'`(codex R2-F1..F3 的 F3):被拒的行**故意**停在 phase INTENT
153
+ // (「两侧字节零变更」要求它一步都别走),所以只判 phase 的 CAS 对一条已 rejected 的行是**满足**的
154
+ // —— 一个在锁上排队的过期调用方于是能把一条永久拒了的弧推去迁数据。终态永久性是库级不变量,
155
+ // 这道守卫就该在库里,不在调用方的 if 里。
156
+ this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, legs = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ? AND state = 'in_flight'`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, legs = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND phase = $5 AND state = 'in_flight'`), [nextPhase, encodeLegs(legs), nowMs, adoptionId, expectPhase]);
157
+ if (cas.affected !== 1) {
158
+ await conn.rollback();
159
+ return { ok: false, reason: "phase_moved" };
160
+ }
161
+ await conn.commit();
162
+ return { ok: true, legs };
163
+ }
164
+ catch (err) {
165
+ try {
166
+ await conn.rollback();
167
+ }
168
+ catch (rollbackErr) {
169
+ // 🔴 不空吞(#191 静默降级门):回滚本身失败通常意味着连接已经废了,原始异常才是要上抛的那个,
170
+ // 但「回滚没成功」是运维必须看见的事 —— 它决定了库里可能留着一段未提交/已废弃的事务状态。
171
+ console.warn("[adoption-log] rollback after a failed rebind transaction also failed:", rollbackErr);
172
+ }
173
+ throw err;
174
+ }
175
+ }
176
+ /**
177
+ * 终态**之后**的迟到行清扫事务(无 phase 变更 —— phase 已经是终态,而终态是永久的)。
178
+ *
179
+ * 🔴 它不是「再收编一次」:腿本身幂等,清扫只是把收编**结束后**又落到旧身份下的行(配置未随迁的
180
+ * 部署会持续制造它们)一并搬过去,并让 `current.residualSourceRows` 有个能归零的动作。
181
+ * `immutableReport` 一个字节都不动(史实与现势分家,183 §6)。
182
+ */
183
+ async sweepOn(conn, work) {
184
+ try {
185
+ await conn.begin();
186
+ const out = await work(conn);
187
+ await conn.commit();
188
+ return out;
189
+ }
190
+ catch (err) {
191
+ try {
192
+ await conn.rollback();
193
+ }
194
+ catch (rollbackErr) {
195
+ console.warn("[adoption-log] rollback after a failed sweep transaction also failed:", rollbackErr); // 同上:不空吞
196
+ }
197
+ throw err;
198
+ }
199
+ }
200
+ /** 单调 CAS 推进(无数据写的阶段;`false` = 别人已经推过了/相位不符 ⇒ 调用方重读行)。 */
201
+ async advancePhase(adoptionId, expectPhase, nextPhase, nowMs, exec = this.db) {
202
+ const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ?`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, updated_at_ms = $2 WHERE adoption_id = $3 AND phase = $4`), [nextPhase, nowMs, adoptionId, expectPhase]);
203
+ return res.affected === 1;
204
+ }
205
+ /** phase 4:配置账物化(与 phase 推进同一条语句,免得账落了相位没推)。 */
206
+ async putConfigs(adoptionId, expectPhase, nextPhase, configsJson, nowMs, exec = this.db) {
207
+ const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, configs = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ?`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, configs = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND phase = $5`), [nextPhase, configsJson, nowMs, adoptionId, expectPhase]);
208
+ return res.affected === 1;
209
+ }
210
+ /**
211
+ * ⑥ 终态:`report` 一次写入,`state='adopted'`。
212
+ * 🔴 `AND report IS NULL` 是 I4(永久终态 + 不可变史实)的**数据库强形**:即使调用序漂了,快照也
213
+ * 不可能被第二次写盖掉 —— 「逐字节恒同」于是不依赖调用方自觉。
214
+ */
215
+ async finalizeAdopted(adoptionId, expectPhase, reportJson, nowMs, exec = this.db) {
216
+ const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET phase = ?, state = 'adopted', report = ?, updated_at_ms = ? WHERE adoption_id = ? AND phase = ? AND report IS NULL`, `UPDATE ${ADOPTION_LOG_TABLE} SET phase = $1, state = 'adopted', report = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND phase = $5 AND report IS NULL`), [ADOPTION_PHASE.TERMINAL, reportJson, nowMs, adoptionId, expectPhase]);
217
+ return res.affected === 1;
218
+ }
219
+ /** 目的地冲突 ⇒ rejected 终态(源/目的地两侧字节零变更;phase 停在 INTENT)。 */
220
+ async finalizeRejected(adoptionId, code, detail, nowMs, exec = this.db) {
221
+ const res = await exec.query(this.q(`UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = ?, reject_detail = ?, updated_at_ms = ? WHERE adoption_id = ? AND state = 'in_flight'`, `UPDATE ${ADOPTION_LOG_TABLE} SET state = 'rejected', reject_code = $1, reject_detail = $2, updated_at_ms = $3 WHERE adoption_id = $4 AND state = 'in_flight'`), [code, detail, nowMs, adoptionId]);
222
+ return res.affected === 1;
223
+ }
224
+ /**
225
+ * 收编弧的 advisory 锁(183 I1 的 form b 形:「与任何活写互斥」在 SQL 侧 = 一次只有一个副本在推这条弧)。
226
+ *
227
+ * **非阻塞取 + 有界轮询**(tidb-pool 的 `acquireEnsureSchemaLock` 同款判据:阻塞式 GET_LOCK 会把并发
228
+ * 调用方全部park 在 TiDB 的悲观锁行上,既耗它的重试预算又饿死应用自己的 FOR UPDATE 路)。取不到 ⇒
229
+ * 返回 `undefined`,调用方按「在飞」应答 —— **不假装成功,也不无限等**。
230
+ */
231
+ async withLock(name, fn, attempts = 20, sleepMs = 100) {
232
+ const conn = await this.db.connect();
233
+ let held = false;
234
+ try {
235
+ for (let i = 0; i < attempts && !held; i += 1) {
236
+ const res = await conn.query(this.q("SELECT GET_LOCK(?, 0) AS got", "SELECT pg_try_advisory_lock($1, $2) AS got"), this.db.dialect === "tidb" ? [name] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(name)]);
237
+ const got = res.rows[0]?.got;
238
+ held = got === 1 || got === true || got === "1";
239
+ if (!held)
240
+ await new Promise((r) => setTimeout(r, sleepMs));
241
+ }
242
+ if (!held)
243
+ return undefined;
244
+ // 🔴 把**这一条**连接交出去:整条弧(读 / 事务 / CAS)都跑在它上面,弧内绝不再向池要第二条
245
+ // ——`connectionLimit=1` 的部署上那第二条永远等不到(codex R3-F2)。
246
+ return await fn(conn);
247
+ }
248
+ finally {
249
+ if (held) {
250
+ try {
251
+ await conn.query(this.q("SELECT RELEASE_LOCK(?) AS released", "SELECT pg_advisory_unlock($1, $2) AS released"), this.db.dialect === "tidb" ? [name] : [PG_ADOPTION_LOCK_CLASS, pgLockObjectKey(name)]);
252
+ }
253
+ catch (releaseErr) {
254
+ // 锁在连接关闭时会自动释放,所以释放失败不该盖掉真正的结果 —— 但同样不空吞:
255
+ // 释放失败意味着这把弧锁会一直持到连接回收,下一个副本要多等一会儿,那是要能看见的。
256
+ console.warn("[adoption-log] releasing the adoption arc lock failed (it auto-frees when the connection closes):", releaseErr);
257
+ }
258
+ }
259
+ conn.release();
260
+ }
261
+ }
262
+ }
263
+ export class TiDBAdoptionLogStore extends SqlAdoptionLogStore {
264
+ constructor(pool) {
265
+ super(mysqlDriver(pool));
266
+ }
267
+ }
268
+ export class PgAdoptionLogStore extends SqlAdoptionLogStore {
269
+ constructor(pool) {
270
+ super(pgDriver(pool));
271
+ }
272
+ }
273
+ //# sourceMappingURL=adoption-log-sql.js.map
@@ -4,7 +4,7 @@ import { type Checkpoint, type CheckpointGate, type CheckpointState, type Checkp
4
4
  import { type SqlDriver } from "./sql-driver.js";
5
5
  /**
6
6
  * design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
7
- * checkpoint's lifetime, stamped at put() into `terminal_at` INDEPENDENT of the per-approval `deadline`. The
7
+ * checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The
8
8
  * SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row
9
9
  * with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a
10
10
  * forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines).
@@ -13,9 +13,9 @@ import { type SqlDriver } from "./sql-driver.js";
13
13
  */
14
14
  export declare const TERMINAL_BACKSTOP_MS: number;
15
15
  /**
16
- * design/80 D-D (adversarial fix): the crash-safe `terminal_at` backstop must fall STRICTLY AFTER any SLA
17
- * `deadline`, never AT it. `terminal_at = max(createdAt+backstop, deadline)` made the two coincide whenever an
18
- * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at-branch (which
16
+ * design/80 D-D (adversarial fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA
17
+ * `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an
18
+ * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which
19
19
  * has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep
20
20
  * wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep
21
21
  * at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so
@@ -152,6 +152,19 @@ export declare class SqlCheckpointStore implements CheckpointStore {
152
152
  * `wiring-governance-operator.test.ts` 的 #172 组;已上报上游求一个能表达该宽度的词。
153
153
  */
154
154
  readonly fidelity: StoreFidelity;
155
+ /**
156
+ * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按能力判**:
157
+ * 本店的 {@link SqlCheckpointStore.reopen} 是真 CAS 实现(`resolved`→`pending` 原子翻回 + `reopen_reason`
158
+ * 权威列,design/80 D-1 的两条重开腿都走它),声明 `reopen: true` 是读数不是抬举。core 的沙箱准入
159
+ * 模式(pre-flight 要求 `redecision.reopen === true`,declaration never duck-typing——方法在场不算数)
160
+ * 由这一格武装:Kata 腿(`capabilities.isolation` 真声明)+ durable park 部署下,`sandbox_local` ask
161
+ * 自动放行并以 `permission.sandbox_admitted` durable 事件披露(缺席披露=core 侧变异恰红)。
162
+ * `validatingLease` **不声明**:本店没有 durable validating-lease 纪律,不承诺没有的东西。
163
+ * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
164
+ */
165
+ readonly redecision: {
166
+ readonly reopen: true;
167
+ };
155
168
  constructor(db: SqlDriver, logger?: {
156
169
  info?(msg: string, meta?: unknown): void;
157
170
  } | undefined);
@@ -252,8 +265,8 @@ export declare class SqlCheckpointStore implements CheckpointStore {
252
265
  /**
253
266
  * GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant-
254
267
  * sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count.
255
- * Called with `cutoff = Date.now()` (deadline/terminal_at are ABSOLUTE epoch-ms), so it expires any pending row
256
- * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at` crash-safe backstop has passed —
268
+ * Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row
269
+ * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed —
257
270
  * the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured).
258
271
  *
259
272
  * design/80 D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask
@@ -262,7 +275,7 @@ export declare class SqlCheckpointStore implements CheckpointStore {
262
275
  * AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is
263
276
  * incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path
264
277
  * (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL)
265
- * stay on the expire path. The terminal_at-branch is the crash-safe backstop for ANY kind (incl. a human gate
278
+ * stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate
266
279
  * whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after
267
280
  * the deadline, so it never races the deny-sweep at the deadline instant).
268
281
  */
@@ -291,7 +304,7 @@ export declare class SqlCheckpointStore implements CheckpointStore {
291
304
  * (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is
292
305
  * core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full
293
306
  * {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order =
294
- * created_at ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
307
+ * created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
295
308
  * is the suspend-time snapshot), so it overrides the blob's status before the summary is derived.
296
309
  *
297
310
  * LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous
@@ -309,7 +322,7 @@ export declare class SqlCheckpointStore implements CheckpointStore {
309
322
  *
310
323
  * 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
311
324
  * 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
312
- * 谓词精确查——`(scope, session_id, tool_call_id, created_at ≥ sinceMs)` 这组条件下的行数天然极小,
325
+ * 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小,
313
326
  * 一次全量返回,结构上没有分页假阴性。
314
327
  *
315
328
  * 三条口径,逐条都是判据:
@@ -37,7 +37,7 @@ import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.
37
37
  const MAX_TOOL_INPUT_CHARS = 8192;
38
38
  /**
39
39
  * design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
40
- * checkpoint's lifetime, stamped at put() into `terminal_at` INDEPENDENT of the per-approval `deadline`. The
40
+ * checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The
41
41
  * SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row
42
42
  * with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a
43
43
  * forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines).
@@ -46,15 +46,15 @@ const MAX_TOOL_INPUT_CHARS = 8192;
46
46
  */
47
47
  export const TERMINAL_BACKSTOP_MS = Math.max(60_000, Number(process.env.APPROVAL_TERMINAL_BACKSTOP_MS) || 30 * 86_400_000);
48
48
  /**
49
- * design/80 D-D (adversarial fix): the crash-safe `terminal_at` backstop must fall STRICTLY AFTER any SLA
50
- * `deadline`, never AT it. `terminal_at = max(createdAt+backstop, deadline)` made the two coincide whenever an
51
- * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at-branch (which
49
+ * design/80 D-D (adversarial fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA
50
+ * `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an
51
+ * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which
52
52
  * has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep
53
53
  * wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep
54
54
  * at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so
55
55
  * it never meaningfully delays the eventual crash-safe GC.
56
56
  */
57
- export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time terminal_at derivation (anti-drift) // 1h — many reaper intervals of deny-sweep runway past the SLA deadline
57
+ export const TERMINAL_GRACE_MS = 3_600_000; // exported for the LOCAL twin's read-time terminal_at_ms derivation (anti-drift) // 1h — many reaper intervals of deny-sweep runway past the SLA deadline
58
58
  /**
59
59
  * The capability token IS the resume credential (token-as-auth) — anyone who reads it can impersonate a
60
60
  * resume, so it must never reach the logs (which fan out to a log-aggregation pipeline). For the diagnostic
@@ -269,6 +269,17 @@ export class SqlCheckpointStore {
269
269
  * `wiring-governance-operator.test.ts` 的 #172 组;已上报上游求一个能表达该宽度的词。
270
270
  */
271
271
  fidelity = "json";
272
+ /**
273
+ * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按能力判**:
274
+ * 本店的 {@link SqlCheckpointStore.reopen} 是真 CAS 实现(`resolved`→`pending` 原子翻回 + `reopen_reason`
275
+ * 权威列,design/80 D-1 的两条重开腿都走它),声明 `reopen: true` 是读数不是抬举。core 的沙箱准入
276
+ * 模式(pre-flight 要求 `redecision.reopen === true`,declaration never duck-typing——方法在场不算数)
277
+ * 由这一格武装:Kata 腿(`capabilities.isolation` 真声明)+ durable park 部署下,`sandbox_local` ask
278
+ * 自动放行并以 `permission.sandbox_admitted` durable 事件披露(缺席披露=core 侧变异恰红)。
279
+ * `validatingLease` **不声明**:本店没有 durable validating-lease 纪律,不承诺没有的东西。
280
+ * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
281
+ */
282
+ redecision = { reopen: true };
272
283
  constructor(db, logger) {
273
284
  this.db = db;
274
285
  this.logger = logger;
@@ -303,8 +314,8 @@ export class SqlCheckpointStore {
303
314
  // payload without an N+1 trace.turns fetch. Stored as a stringified JSON column value.
304
315
  const toolInput = boundedToolInput(pa?.args);
305
316
  try {
306
- await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at, terminal_at, gate_kind, bound_input_hash, risk_descriptor) " +
307
- "VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at, terminal_at, gate_kind, bound_input_hash, risk_descriptor) " +
317
+ await this.db.query(this.q("INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor) " +
318
+ "VALUES (?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,?)", "INSERT INTO checkpoint (token, scope, session_id, version, status, tool_name, tool_call_id, tool_input, checkpoint, deadline, created_at_ms, terminal_at_ms, gate_kind, bound_input_hash, risk_descriptor) " +
308
319
  "VALUES ($1,$2,$3,$4,'pending',$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14)"), [
309
320
  token,
310
321
  cp.scope,
@@ -316,7 +327,7 @@ export class SqlCheckpointStore {
316
327
  this.json(cp, "checkpoint"),
317
328
  cp.deadline ?? null,
318
329
  cp.createdAt,
319
- Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS), // D-1 §3 inv#3 backstop — STRICTLY after any SLA deadline (grace) so the terminal_at-branch never races the D-D deny-sweep, and never pre-empts an operator's longer TTL
330
+ Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS), // D-1 §3 inv#3 backstop — STRICTLY after any SLA deadline (grace) so the terminal_at_ms-branch never races the D-D deny-sweep, and never pre-empts an operator's longer TTL
320
331
  cp.gate?.kind ?? null, // D-D SLA split: human/irreversible_ask deadline → resolve-deny; others → expire
321
332
  pa?.boundInputHash ?? null, // D-1: the opaque hash the portal must echo on /decide (surfaced via listPending so the TOCTOU binding is reachable)
322
333
  ((g) => (g?.riskDescriptor ? this.json(g.riskDescriptor, "risk descriptor") : null))(cp.gate), // riskDescriptor inbox: stamp core's INERT descriptor for triage-sort
@@ -413,8 +424,8 @@ export class SqlCheckpointStore {
413
424
  const params = [this.json(outcome, "checkpoint outcome"), Date.now(), token, scope];
414
425
  if (expect)
415
426
  params.push(expect.rev);
416
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'resolved', outcome = ?, decided_at = ?, rev = rev + 1, reopen_reason = NULL WHERE token = ? AND scope = ? AND status = 'pending'" +
417
- (expect ? " AND rev = ?" : ""), "UPDATE checkpoint SET status = 'resolved', outcome = $1::jsonb, decided_at = $2, rev = rev + 1, reopen_reason = NULL WHERE token = $3 AND scope = $4 AND status = 'pending'" +
427
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'resolved', outcome = ?, decided_at_ms = ?, rev = rev + 1, reopen_reason = NULL WHERE token = ? AND scope = ? AND status = 'pending'" +
428
+ (expect ? " AND rev = ?" : ""), "UPDATE checkpoint SET status = 'resolved', outcome = $1::jsonb, decided_at_ms = $2, rev = rev + 1, reopen_reason = NULL WHERE token = $3 AND scope = $4 AND status = 'pending'" +
418
429
  (expect ? " AND rev = $5" : "")), params);
419
430
  return res.affected === 1;
420
431
  }
@@ -508,19 +519,19 @@ export class SqlCheckpointStore {
508
519
  * Uses `expired` (not a `resolve`-deny) so a CANCELLED checkpoint never pollutes resolved-count / outcome.
509
520
  */
510
521
  async expire(token, scope) {
511
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? WHERE token = ? AND scope = ? AND status = 'pending'", "UPDATE checkpoint SET status = 'expired', decided_at = $1 WHERE token = $2 AND scope = $3 AND status = 'pending'"), [Date.now(), token, scope]);
522
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE token = ? AND scope = ? AND status = 'pending'", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE token = $2 AND scope = $3 AND status = 'pending'"), [Date.now(), token, scope]);
512
523
  return res.affected === 1;
513
524
  }
514
525
  /** Interface reap: CAS-expire pending checkpoints in `scope` past `cutoff`. Returns count. */
515
526
  async reap(scope, cutoff) {
516
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? WHERE scope = ? AND status = 'pending' AND deadline IS NOT NULL AND deadline <= ?", "UPDATE checkpoint SET status = 'expired', decided_at = $1 WHERE scope = $2 AND status = 'pending' AND deadline IS NOT NULL AND deadline <= $3"), [Date.now(), scope, cutoff]);
527
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? WHERE scope = ? AND status = 'pending' AND deadline IS NOT NULL AND deadline <= ?", "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 WHERE scope = $2 AND status = 'pending' AND deadline IS NOT NULL AND deadline <= $3"), [Date.now(), scope, cutoff]);
517
528
  return res.affected;
518
529
  }
519
530
  /**
520
531
  * GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant-
521
532
  * sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count.
522
- * Called with `cutoff = Date.now()` (deadline/terminal_at are ABSOLUTE epoch-ms), so it expires any pending row
523
- * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at` crash-safe backstop has passed —
533
+ * Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row
534
+ * whose per-approval `deadline` OR its design/80 D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed —
524
535
  * the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured).
525
536
  *
526
537
  * design/80 D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask
@@ -529,14 +540,14 @@ export class SqlCheckpointStore {
529
540
  * AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is
530
541
  * incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path
531
542
  * (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL)
532
- * stay on the expire path. The terminal_at-branch is the crash-safe backstop for ANY kind (incl. a human gate
543
+ * stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate
533
544
  * whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after
534
545
  * the deadline, so it never races the deny-sweep at the deadline instant).
535
546
  */
536
547
  async reapExpired(cutoff) {
537
- const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? " +
538
- `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= ?))`, "UPDATE checkpoint SET status = 'expired', decided_at = $1 " +
539
- `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= $3))`), [Date.now(), cutoff, cutoff]);
548
+ const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at_ms = ? " +
549
+ `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= ?))`, "UPDATE checkpoint SET status = 'expired', decided_at_ms = $1 " +
550
+ `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at_ms IS NOT NULL AND terminal_at_ms <= $3))`), [Date.now(), cutoff, cutoff]);
540
551
  return res.affected;
541
552
  }
542
553
  /**
@@ -560,11 +571,11 @@ export class SqlCheckpointStore {
560
571
  * joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its
561
572
  * session claim, so the join is live for every pending row; null only in pathological windows). */
562
573
  async listPending(scope) {
563
- const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.created_at, c.deadline, c.gate_kind, ta.task_id " +
574
+ const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.created_at_ms, c.deadline, c.gate_kind, ta.task_id " +
564
575
  "FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
565
576
  const { rows } = scope
566
- ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at ASC`, [scope])
567
- : await this.db.query(`${base} ORDER BY c.created_at ASC`);
577
+ ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at_ms ASC`, [scope])
578
+ : await this.db.query(`${base} ORDER BY c.created_at_ms ASC`);
568
579
  const out = rows.map((r) => {
569
580
  const toolCallId = r.tool_call_id ?? null;
570
581
  const boundInputHash = r.bound_input_hash ?? null;
@@ -587,7 +598,7 @@ export class SqlCheckpointStore {
587
598
  // Both drivers return the JSON column already parsed; null for pre-migration rows. PG 库内恒干净
588
599
  // (拒绝式)——直读即审阅面=执行面。
589
600
  input: r.tool_input ?? null,
590
- createdAt: Number(r.created_at),
601
+ createdAt: Number(r.created_at_ms),
591
602
  deadline: r.deadline == null ? null : Number(r.deadline),
592
603
  riskDescriptor: parseJson(r.risk_descriptor),
593
604
  };
@@ -602,14 +613,14 @@ export class SqlCheckpointStore {
602
613
  * (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is
603
614
  * core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full
604
615
  * {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order =
605
- * created_at ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
616
+ * created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob
606
617
  * is the suspend-time snapshot), so it overrides the blob's status before the summary is derived.
607
618
  *
608
619
  * LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous
609
620
  * ceiling that still protects memory/latency if a scope ever accumulates pathologically many pending gates.
610
621
  */
611
622
  async listByScope(scope) {
612
- const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at ASC LIMIT 500"), [scope]);
623
+ const { rows } = await this.db.query(this.q("SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = ? ORDER BY created_at_ms ASC LIMIT 500", "SELECT checkpoint, status FROM checkpoint WHERE status = 'pending' AND scope = $1 ORDER BY created_at_ms ASC LIMIT 500"), [scope]);
613
624
  const out = [];
614
625
  for (const r of rows) {
615
626
  // A corrupt/missing blob skips THAT row rather than crashing the whole scheduler view (review w16yqkkxv):
@@ -638,7 +649,7 @@ export class SqlCheckpointStore {
638
649
  *
639
650
  * 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
640
651
  * 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
641
- * 谓词精确查——`(scope, session_id, tool_call_id, created_at ≥ sinceMs)` 这组条件下的行数天然极小,
652
+ * 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小,
642
653
  * 一次全量返回,结构上没有分页假阴性。
643
654
  *
644
655
  * 三条口径,逐条都是判据:
@@ -661,9 +672,9 @@ export class SqlCheckpointStore {
661
672
  * 维,列命中行结构上也必须解 blob 才拿得到它。
662
673
  */
663
674
  async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
664
- const { rows } = await this.db.query(this.q("SELECT token, status, created_at, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
665
- "WHERE scope=? AND session_id=? AND created_at>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at ASC", "SELECT token, status, created_at, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
666
- "WHERE scope=$1 AND session_id=$2 AND created_at>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at ASC"), [scope, sessionId, sinceMs, toolCallId]);
675
+ const { rows } = await this.db.query(this.q("SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
676
+ "WHERE scope=? AND session_id=? AND created_at_ms>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at_ms ASC", "SELECT token, status, created_at_ms, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
677
+ "WHERE scope=$1 AND session_id=$2 AND created_at_ms>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at_ms ASC"), [scope, sessionId, sinceMs, toolCallId]);
667
678
  const out = [];
668
679
  for (const r of rows) {
669
680
  // `sourceTaskId: null` 是**所有 unparseable 臂的共同底**(读不出的行不许带出一个可用于身份比对的
@@ -671,7 +682,7 @@ export class SqlCheckpointStore {
671
682
  const base = {
672
683
  token: String(r.token),
673
684
  status: String(r.status),
674
- createdAtMs: Number(r.created_at),
685
+ createdAtMs: Number(r.created_at_ms),
675
686
  boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
676
687
  sourceTaskId: null,
677
688
  };
@@ -27,6 +27,16 @@ export declare class LocalCheckpointStore {
27
27
  * 内核哪天改宽度,包装层的声明会被那条钉当场揪出来,而不是靠人记得同步。
28
28
  */
29
29
  readonly fidelity: StoreFidelity;
30
+ /**
31
+ * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按内核判**,
32
+ * 与 durability/fidelity 同一条判据:内核 core `FileCheckpointStore` **自己**声明 `{ reopen: true }`,
33
+ * 本类的 {@link LocalCheckpointStore.reopen} 纯委派给它 ⇒ 抄内核的读数是诚实的。包装类**不继承**被
34
+ * 包装者的声明(委派不是子类),所以这一格必须自己写。`validatingLease` 不声明(内核也没声明)。
35
+ * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
36
+ */
37
+ readonly redecision: {
38
+ readonly reopen: true;
39
+ };
30
40
  private readonly inner;
31
41
  private readonly scopesPath;
32
42
  private readonly sessionTokensPath;
@@ -84,7 +94,7 @@ export declare class LocalCheckpointStore {
84
94
  * design/80 D-D global expiry sweep — same kind-split as the TiDB twin: the deadline branch abort-expires
85
95
  * every kind EXCEPT a human/irreversible_ask approval gate (those get the graceful resolve-DENY via
86
96
  * listExpiredApprovalGates), with the AskUserQuestion carve-back (denying a question is incoherent → expire);
87
- * the terminal_at branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
97
+ * the terminal_at_ms branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
88
98
  */
89
99
  reapExpired(cutoff: number): Promise<number>;
90
100
  /** design/80 D-D SLA deny-sweep input — pending human/irreversible_ask gates past deadline (excl. AskUserQuestion). */
@@ -26,7 +26,7 @@ import { join } from "node:path";
26
26
  import { isApprovalGateKind } from "../tool-approval.js"; // A-002.1 单一属主
27
27
  import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
28
28
  import { boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
29
- /** design/80 D-D read-time twin of the TiDB put-time `terminal_at` column (same formula — anti-drift). */
29
+ /** design/80 D-D read-time twin of the TiDB put-time `terminal_at_ms` column (same formula — anti-drift). */
30
30
  function terminalAtOf(cp) {
31
31
  return Math.max(cp.createdAt + TERMINAL_BACKSTOP_MS, (cp.deadline ?? 0) + TERMINAL_GRACE_MS);
32
32
  }
@@ -54,6 +54,14 @@ export class LocalCheckpointStore {
54
54
  * 内核哪天改宽度,包装层的声明会被那条钉当场揪出来,而不是靠人记得同步。
55
55
  */
56
56
  fidelity = "json";
57
+ /**
58
+ * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[3342] clay 裁放行)——**如实按内核判**,
59
+ * 与 durability/fidelity 同一条判据:内核 core `FileCheckpointStore` **自己**声明 `{ reopen: true }`,
60
+ * 本类的 {@link LocalCheckpointStore.reopen} 纯委派给它 ⇒ 抄内核的读数是诚实的。包装类**不继承**被
61
+ * 包装者的声明(委派不是子类),所以这一格必须自己写。`validatingLease` 不声明(内核也没声明)。
62
+ * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。
63
+ */
64
+ redecision = { reopen: true };
57
65
  inner;
58
66
  scopesPath;
59
67
  sessionTokensPath;
@@ -286,7 +294,7 @@ export class LocalCheckpointStore {
286
294
  * design/80 D-D global expiry sweep — same kind-split as the TiDB twin: the deadline branch abort-expires
287
295
  * every kind EXCEPT a human/irreversible_ask approval gate (those get the graceful resolve-DENY via
288
296
  * listExpiredApprovalGates), with the AskUserQuestion carve-back (denying a question is incoherent → expire);
289
- * the terminal_at branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
297
+ * the terminal_at_ms branch (derived read-time via the SAME formula the TiDB put stamps) abort-expires ANY kind.
290
298
  */
291
299
  async reapExpired(cutoff) {
292
300
  let n = 0;
@@ -44,8 +44,8 @@ export async function ensurePgMemoryEngineSchema(query, opts = {}) {
44
44
  const embeddingCol = vec?.pgvector ? `vector(${vec.dimensions})` : "jsonb";
45
45
  await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_TABLES.entry} (
46
46
  id text COLLATE "C" PRIMARY KEY,
47
- scope text COLLATE "C" NOT NULL,
48
- slug text COLLATE "C" NOT NULL,
47
+ scope varchar(190) COLLATE "C" NOT NULL,
48
+ slug varchar(512) COLLATE "C" NOT NULL,
49
49
  frontmatter jsonb NOT NULL,
50
50
  body text COLLATE "C" NOT NULL,
51
51
  rev text COLLATE "C" NOT NULL,
@@ -66,7 +66,7 @@ export async function ensurePgMemoryEngineSchema(query, opts = {}) {
66
66
  // suffix. NOT wrapped in try/catch — if this constraint cannot be created, the backend is unsafe.
67
67
  await query(`CREATE UNIQUE INDEX IF NOT EXISTS uq_${PG_MEMORY_ENGINE_TABLES.entry}_scope_slug ON ${PG_MEMORY_ENGINE_TABLES.entry} (scope, slug)`);
68
68
  await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_TABLES.cursor} (
69
- scope text COLLATE "C" PRIMARY KEY,
69
+ scope varchar(190) COLLATE "C" PRIMARY KEY,
70
70
  cursor text COLLATE "C" NOT NULL
71
71
  )`);
72
72
  }