@sema-agent/server 7.14.0 → 7.16.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/MIGRATION.md +16 -1
  2. package/USAGE.md +38 -4
  3. package/dist/approval-ask-machine.d.ts +10 -0
  4. package/dist/approval-ask-machine.js +10 -0
  5. package/dist/approval-card.d.ts +65 -0
  6. package/dist/approval-card.js +54 -6
  7. package/dist/approval-reconciler.d.ts +17 -1
  8. package/dist/boot/memory-boundary.d.ts +6 -0
  9. package/dist/boot/memory-boundary.js +10 -2
  10. package/dist/boot/resolve-spec.js +31 -4
  11. package/dist/boot/runner-deps.d.ts +41 -2
  12. package/dist/boot/runner-deps.js +43 -0
  13. package/dist/boot/session-faces.js +26 -4
  14. package/dist/boot/shutdown.js +17 -0
  15. package/dist/boot/stores.js +28 -7
  16. package/dist/capabilities/memory-notice.d.ts +13 -9
  17. package/dist/capabilities/memory-notice.js +35 -15
  18. package/dist/config-center/apply-effective.d.ts +14 -0
  19. package/dist/config-center/apply-effective.js +81 -1
  20. package/dist/config-types.d.ts +28 -2
  21. package/dist/config.js +38 -2
  22. package/dist/fleet/fleet-terminal-window.d.ts +12 -0
  23. package/dist/fleet/fleet-terminal-window.js +27 -4
  24. package/dist/http/active-run-conflict.d.ts +41 -1
  25. package/dist/http/active-run-conflict.js +24 -10
  26. package/dist/http/routes/approvals-assistant.d.ts +11 -3
  27. package/dist/http/routes/approvals-assistant.js +97 -20
  28. package/dist/http/routes/capabilities.js +20 -1
  29. package/dist/http/routes/diagnostics.d.ts +18 -0
  30. package/dist/http/routes/diagnostics.js +26 -0
  31. package/dist/http/routes/runs.js +86 -19
  32. package/dist/http/routes/side-query.js +15 -1
  33. package/dist/http/routes/tasks.js +32 -3
  34. package/dist/http/routes/trace-usage.js +38 -37
  35. package/dist/http/server.js +67 -13
  36. package/dist/leader/fanout.d.ts +18 -0
  37. package/dist/leader/fanout.js +34 -1
  38. package/dist/leader/leader.js +9 -5
  39. package/dist/leader/wire.js +16 -5
  40. package/dist/main.js +1 -0
  41. package/dist/memory-scope.d.ts +20 -0
  42. package/dist/memory-scope.js +45 -0
  43. package/dist/model-select.d.ts +43 -1
  44. package/dist/model-select.js +70 -2
  45. package/dist/observability/fail-open.d.ts +8 -0
  46. package/dist/observability/fail-open.js +8 -0
  47. package/dist/observability/metrics.js +7 -1
  48. package/dist/parent-watch.d.ts +57 -0
  49. package/dist/parent-watch.js +108 -0
  50. package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
  51. package/dist/plugins/checkpoint-store-sql.js +133 -7
  52. package/dist/plugins/local-checkpoint-store.js +11 -1
  53. package/dist/plugins/memory-embedder-fingerprint.d.ts +119 -0
  54. package/dist/plugins/memory-embedder-fingerprint.js +280 -0
  55. package/dist/plugins/permission-rule-store-sql.d.ts +0 -3
  56. package/dist/plugins/permission-rule-store-sql.js +1 -7
  57. package/dist/plugins/pg-pool.js +3 -0
  58. package/dist/plugins/store-backend.d.ts +5 -6
  59. package/dist/plugins/store-backend.js +4 -1
  60. package/dist/plugins/store-contracts.d.ts +17 -0
  61. package/dist/plugins/store-contracts.js +33 -0
  62. package/dist/plugins/tidb-pool.js +7 -0
  63. package/dist/plugins/tool-result-store-sql.d.ts +18 -13
  64. package/dist/plugins/tool-result-store-sql.js +50 -29
  65. package/dist/run-local.js +1 -0
  66. package/dist/tool-approval.d.ts +10 -0
  67. package/dist/tool-approval.js +143 -9
  68. package/dist/trace/project.js +8 -0
  69. package/dist/trace/redact.d.ts +14 -1
  70. package/dist/trace/redact.js +14 -2
  71. package/package.json +3 -3
@@ -0,0 +1,280 @@
1
+ /**
2
+ * design/234 —— embedder **指纹门**(清空重建形,clay 终裁 [3647]⑤ 方向;稿=sema-internal
3
+ * `server/design/design-234-embedder-fingerprint-20260812.md` v1+v2)。
4
+ *
5
+ * ## 病灶
6
+ *
7
+ * 向量行落在 `agent_memory_engine_entry.embedding`,**行上没有任何「产自哪个 embedder」的记账**。既有
8
+ * 防线只有 embedder 的维度门(长度≠`MEMORY_EMBEDDER_DIM` ⇒ 抛),它只拦「换模型且维度恰好不同」;
9
+ * **同维度换模型**(768 家族之间、1024 家族之间迁移=最常见形)完全静默——旧空间与新空间的向量在同一
10
+ * 列里做 cosine,检索排序变噪音,没有任何一层会说出来。
11
+ *
12
+ * ## 形
13
+ *
14
+ * 指纹 = identity 两元组 `{ model, dimensions }`,**明文**存(不 hash:诊断可读性 > 紧凑,日志要能直接
15
+ * 念出「从 X 换到了 Y」)。**endpoint 不进指纹**——换供应商域名/代理/端口不换语义空间,进了会造成大量
16
+ * 误清;残余成文:同名模型在不同供应商若是不同实现(自托管 finetune 撞名)指纹辨不出,operator 明知
17
+ * 异实现时改 `MEMORY_EMBEDDER_MODEL` 名或手动清列(USAGE 有一行 SQL)。
18
+ *
19
+ * 存储位 = **新单行元表**(additive,零删库重建窗)。不给主表加列:主表改形=触发本仓「无 ALTER、删库
20
+ * 重建」BREAKING 窗;行级记账在「清空重建」语义下也无增益(全量清 ⇒ 不存在混合态)。
21
+ *
22
+ * ## 属主边界
23
+ *
24
+ * `memory-engine-pg.ts` 是 core 移入件(minimal-diff move-in 纪律)⇒ 元表 DDL + 比对清空腿全在**本
25
+ * 文件**(server 侧),移入文件零改动;表名常量本文件自有(`PG_MEMORY_ENGINE_TABLES` 不动)。
26
+ * 列名 `meta_key`/`meta_value`:`key` 是 MySQL 保留字,将来若补 TiDB 腿零反引号坑(K2)。
27
+ *
28
+ * ## 六态(v2 总表)
29
+ *
30
+ * | 态 | 判定 | 动作 |
31
+ * |---|---|---|
32
+ * | A | embedder 配置缺席 | **门不装**(连 ensure 都不调),元表不动——装配点的事,不在本文件 |
33
+ * | B | 元表有行、identity 逐键相等 | 照常,**零写** |
34
+ * | C | 元表有行、不等 | 清列 → upsert 新 identity(`reason:"changed"`) |
35
+ * | D | 元表无行、库内**有**非 NULL 向量 | 同 C 清(存量库无法证明同源=保守清,`reason:"unattributed"`) |
36
+ * | E | 元表无行、零向量行 | 只写 identity,不清(`memory_embedder_identity_armed`) |
37
+ * | F | 元表有行但 value 解析失败 | 走清空臂(`reason:"unreadable"`)——**绝不当成匹配**(边界必 schema) |
38
+ *
39
+ * (F 的判据是 `model`/`dimensions` 缺失或类型不对;**多出来的未知键不算坏**,见 schema 处的注。)
40
+ *
41
+ * ## 三条不可动的判据
42
+ *
43
+ * 1. **次序:先清列、后写元表**。反序若中间崩溃 = 新 identity 已记而旧向量还在 = 正是被修的病(此后
44
+ * 每次 boot 恒判 B,残留态永不复检);正序中间崩溃 = 下次 boot 无元表行、向量已 NULL ⇒ E 态补记,
45
+ * 幂等安全。
46
+ * 2. **CAS 循环**(v2 C1-2):元表写后**重读**,读回≠自写 ⇒ 另一副本并发写了**不同** identity(部署
47
+ * 配置漂移)⇒ 响亮 `memory_embedder_identity_conflict`(两值并示)+ 重跑比对,上限
48
+ * {@link CAS_MAX_ROUNDS} 轮,超限抛(拒启)。运行期旧副本混写非 CAS 可拦 ⇒ 归停机纪律(USAGE)。
49
+ * 3. **门自身失败 = 抛(拒启)**。吞掉 = 元表停留旧值 = 下次 boot 再清一次,把一次性代价变成每次 boot
50
+ * 的代价;且「门在场但没跑成」静默 = fail-open。
51
+ *
52
+ * 计数取法:`PgQueryResult` 刻意无 `rowCount` ⇒ 用 CTE `WITH c AS (UPDATE … RETURNING 1) SELECT
53
+ * count(*)::int`(禁 `RETURNING id` 全表拉回)。计数**按整次调用累计**、原因锚在第一次真清的那一轮 ——
54
+ * 「清过就必响亮」对终局判 B 的那一支同样成立(codex R2 finding 2)。
55
+ *
56
+ * ## 射程外(稿 §出界)
57
+ *
58
+ * 主动回填件(清空后不回填,行被 patch 时自然重嵌)/ 行级 embedder 记账列(结构性根治滚动窗)/
59
+ * pgvector 接线件的列 dim 校验 / TiDB 向量面(v1 lexical only)。
60
+ */
61
+ import { z } from "zod";
62
+ import { PG_MEMORY_ENGINE_TABLES } from "./memory-engine-pg.js";
63
+ /** 元表名(本文件自有 const —— core 移入文件的 `PG_MEMORY_ENGINE_TABLES` 零改动)。 */
64
+ export const PG_MEMORY_ENGINE_META_TABLE = "agent_memory_engine_meta";
65
+ /** 单行元表里 embedder 指纹那一行的键。 */
66
+ export const EMBEDDER_IDENTITY_META_KEY = "embedder_identity";
67
+ /** CAS 循环上限(v2 C1-2)。超限 = 并发副本带着**不同** identity 在互相覆盖 ⇒ 拒启。 */
68
+ export const CAS_MAX_ROUNDS = 3;
69
+ /**
70
+ * 指纹 schema —— 读回的 `meta_value` 必须过它(手改坏值/回滚残留一律走 F 态,禁 `JSON.parse as` 裸断言)。
71
+ *
72
+ * ⚠️ **未知键刻意放过**(zod 对象默认 strip,不加 `.strict()`):将来某个版本若往这行里多写一个键,
73
+ * 老副本读到它应当照常按 `model`/`dimensions` 比对,而不是判 F 态把整列清掉 —— 「新副本加了个字段」
74
+ * 与「这行坏了」不是一回事,后者才是 F 态要抓的。载荷只有这两个键,多出来的不参与判定。
75
+ */
76
+ const EmbedderIdentitySchema = z.object({
77
+ model: z.string().min(1),
78
+ dimensions: z.number().int().positive(),
79
+ });
80
+ /**
81
+ * 元表 DDL —— **只发 CREATE**(K1:混入 SELECT/UPDATE 会让 `scripts/dump-schema.ts` 的录制门当场抛)。
82
+ * 幂等,与其余 `ensurePg*Schema` 同姿势;新腿已注册 `PG_LEGS` + `docs/schema/baseline-pg.sql`。
83
+ */
84
+ export async function ensurePgMemoryEmbedderMetaSchema(query) {
85
+ await query(`CREATE TABLE IF NOT EXISTS ${PG_MEMORY_ENGINE_META_TABLE} (
86
+ meta_key varchar(190) COLLATE "C" PRIMARY KEY,
87
+ meta_value jsonb NOT NULL
88
+ )`);
89
+ }
90
+ /**
91
+ * 元表值 → 三态。入参是 `meta_value::text`(**JSON 文本**),**只解析一次**。
92
+ *
93
+ * 🔴 为什么读 `::text` 而不是读列本身(codex 对抗复审 R2 finding 1,验真为真):node-pg 已经替我们
94
+ * 解析过 jsonb,所以列里躺着一个 JSON **字符串**时,交到手上的就是一个 JS string —— 上一版对 string
95
+ * 再 `JSON.parse` 一次「宽容一格」,于是**双重编码**的坏值(`'"{\"model\":…}"'::jsonb`)会被解回合法
96
+ * 对象、判成「相等」,来源不明的旧向量原地存活 = 本模块要消灭的静默混空间换了个入口。读 `::text` 让
97
+ * 「对象」与「驱动序列化出的字符串」不再有歧义:解析一次之后**必须是普通对象**,真正的 JSON 字符串
98
+ * (以及数组/数字/null)一律落 F 态。顺带也不再依赖任何驱动的 jsonb 解析姿势。
99
+ */
100
+ function parseStoredText(text) {
101
+ let candidate;
102
+ try {
103
+ candidate = JSON.parse(text);
104
+ }
105
+ catch (err) {
106
+ // 解析错**当数据交出去**(不静默折成一句「读不出来」):这段文字会随 `reason:"unreadable"` 进
107
+ // 那条响亮日志,排障时「元表里躺的到底是什么」是第一个要问的。
108
+ return { kind: "unreadable", raw: `${text.slice(0, 160)} [JSON.parse: ${String(err).slice(0, 80)}]` };
109
+ }
110
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
111
+ return { kind: "unreadable", raw: `${text.slice(0, 160)} [not a JSON object]` };
112
+ }
113
+ const parsed = EmbedderIdentitySchema.safeParse(candidate);
114
+ if (!parsed.success)
115
+ return { kind: "unreadable", raw: text.slice(0, 200) };
116
+ return { kind: "ok", value: parsed.data };
117
+ }
118
+ async function readStored(query) {
119
+ const r = await query(`SELECT meta_value::text AS meta_value_text FROM ${PG_MEMORY_ENGINE_META_TABLE} WHERE meta_key = $1`, [EMBEDDER_IDENTITY_META_KEY]);
120
+ const row = r.rows[0];
121
+ if (row === undefined)
122
+ return { kind: "absent" };
123
+ const text = row.meta_value_text;
124
+ // 列是 NOT NULL 且 `::text` 恒给文本 —— 拿不到文本 = 这条读腿被换过(池包装/驱动改形),响亮落 F,
125
+ // 不猜。
126
+ if (typeof text !== "string")
127
+ return { kind: "unreadable", raw: `[meta_value::text did not return text: ${typeof text}]` };
128
+ return parseStoredText(text);
129
+ }
130
+ /** D/E 的判据:库里还有没有**没主**的向量(元表无行 ⇒ 存量向量无从证明同源)。`LIMIT 1` —— 只问存在性,不拉全表。 */
131
+ async function hasUnattributedVectors(query) {
132
+ const r = await query(`SELECT 1 FROM ${PG_MEMORY_ENGINE_TABLES.entry} WHERE embedding IS NOT NULL LIMIT 1`);
133
+ return r.rows.length > 0;
134
+ }
135
+ /**
136
+ * 清列 + 计数。`PgQueryResult` 无 `rowCount` ⇒ CTE 把受影响行数在 SQL 侧数完再回一行
137
+ * (`RETURNING id` 会把全表主键拉回进程,量级白烧)。
138
+ */
139
+ async function clearEmbeddings(query) {
140
+ const r = await query(`WITH c AS (UPDATE ${PG_MEMORY_ENGINE_TABLES.entry} SET embedding = NULL WHERE embedding IS NOT NULL RETURNING 1)
141
+ SELECT count(*)::int AS n FROM c`);
142
+ return Number(r.rows[0]?.n ?? 0);
143
+ }
144
+ /** 三臂统一 upsert(C2:并发首启两副本同时 INSERT 不撞 PK)。 */
145
+ async function writeIdentity(query, identity) {
146
+ await query(`INSERT INTO ${PG_MEMORY_ENGINE_META_TABLE} (meta_key, meta_value) VALUES ($1, $2::jsonb)
147
+ ON CONFLICT (meta_key) DO UPDATE SET meta_value = EXCLUDED.meta_value`, [EMBEDDER_IDENTITY_META_KEY, JSON.stringify(identity)]);
148
+ }
149
+ function sameIdentity(a, b) {
150
+ return a.model === b.model && a.dimensions === b.dimensions;
151
+ }
152
+ /** 换模型这件事的运维纪律(日志与 USAGE 同句;滚动窗混写是纪律面残余,门管 boot 面)。 */
153
+ const SCALE_ZERO_NOTE = "change the embedder identity with the fleet stopped (scale to 0, then start): during a ROLLING window the old replicas keep writing vectors in the OLD space after this gate cleared the column";
154
+ /**
155
+ * 指纹比对腿 —— boot 一次(env 族是 boot 快照,运行中不热换)。**门自身失败一律抛**(拒启)。
156
+ *
157
+ * ⚠️ 调用姿势:`ensurePgMemoryEmbedderMetaSchema` 之后、memory backend 构造之前;embedder 配置**在场**
158
+ * 才调(A 态连 ensure 都不调)。
159
+ */
160
+ export async function reconcileEmbedderFingerprint(query, identity, opts = {}) {
161
+ const { log } = opts;
162
+ const now = opts.now ?? Date.now;
163
+ // 自家入参也过 schema:装配点喂的是 config 解析结果(已校验),但这条腿也被脚本/测试直调,
164
+ // 坏 identity 写进元表 = 下次 boot 恒判 F 态清全库。
165
+ const want = EmbedderIdentitySchema.parse(identity);
166
+ const firstStartedAt = now();
167
+ // 🔴 跨轮累计(codex 对抗复审 R2 finding 2,验真为真):清空计数/原因/旧值**每轮重置**时,
168
+ // 「round1 清了 N 行、回读撞冲突、round2 只能再清到 0」会把一次整列缓存失效报成 `cleared=0`;更坏的
169
+ // 一支是 round2 恰好读回我方 identity ⇒ 从 match 臂直接返回、**一条日志都不发**,破坏性动作在运维
170
+ // 记录里彻底消失(与「任何清空动作必响亮」直接冲突)。所以计数按**整次调用**累计,原因/旧值锚在
171
+ // **第一次真清**的那一轮,并且只要清过就必发 `memory_embedder_identity_changed` —— 终局是 match
172
+ // 也不例外。
173
+ let clearedTotal = 0;
174
+ let firstClear;
175
+ // 🔴 **变更**本身也要跨轮记账,不能只记「清过行」(合并码重扫 codex 复审第二轮,验真为真):零向量库
176
+ // 的 C/D/F 轮 `cleared` 恒 0 ⇒ 它进不了 `firstClear`;若那一轮的回读又撞上并发冲突、下一轮才收敛到
177
+ // match,终局臂只看得见 `clearedTotal===0` 与 `terminalState==="match"`,于是一次**真发生过的** identity
178
+ // 变更(元表已被我方改写)在日志里彻底消失 —— 与「清过必响亮」同族的漏报,只是漏的是零清空那一支。
179
+ let firstChange;
180
+ const emitOutcome = (terminalState, rounds) => {
181
+ const elapsedMs = now() - firstStartedAt;
182
+ if (clearedTotal > 0) {
183
+ log?.info("memory_embedder_identity_changed", {
184
+ previous: firstClear?.previous ?? firstChange?.previous ?? null,
185
+ next: want,
186
+ cleared: clearedTotal,
187
+ reason: firstClear?.reason ?? firstChange?.reason ?? terminalState, // "changed" | "unattributed" | "unreadable"
188
+ rounds,
189
+ elapsedMs,
190
+ note: `${SCALE_ZERO_NOTE}; cleared vectors are NOT backfilled — rows re-embed as they are next written (retrieval falls back to the lexical floor meanwhile)`,
191
+ });
192
+ return;
193
+ }
194
+ // 🔴 **identity 真换了但没有向量可清,同样响亮**(合并码重扫,验真后修)。旧判据只有「清过行」与
195
+ // 「armed」两支,于是 C 态(不等)/ D 态 / F 态(元表行读不出来)碰上 `embedding` 列恰好全 NULL 时,
196
+ // 元表被静默改写、一条日志都不发 —— 可达形:上一次 boot 已清空过、期间无新语料;或回滚残留的坏元表
197
+ // 行 + 空向量列(恰恰是最该留痕的一形)。USAGE.md 把三个 reason 并列写进同一句承诺里,没有对
198
+ // 「清了 0 行」开例外 ⇒ 换 embedder 这件事在启动日志里不许无痕。`match` 仍是零写零响亮(B 态)。
199
+ // 判据是**本次调用里有没有真发生过 identity 变更**(`firstChange`),不是本轮的 `terminalState` ——
200
+ // 后者在「首轮 changed(零清空)→ CAS 冲突 → 次轮 match」这条路上会把变更说成没发生过。
201
+ if (firstChange !== undefined) {
202
+ log?.info("memory_embedder_identity_changed", {
203
+ previous: firstChange.previous,
204
+ next: want,
205
+ cleared: 0,
206
+ reason: firstChange.reason, // "changed" | "unattributed" | "unreadable"
207
+ rounds,
208
+ elapsedMs,
209
+ note: `${SCALE_ZERO_NOTE}; no vectors existed to clear on this boot — the identity itself changed, which is the event`,
210
+ });
211
+ return;
212
+ }
213
+ if (terminalState === "armed") {
214
+ log?.info("memory_embedder_identity_armed", {
215
+ identity: want,
216
+ note: "first boot with an embedder identity recorded; no vectors existed to clear",
217
+ rounds,
218
+ elapsedMs,
219
+ });
220
+ }
221
+ // B(且从未清过)= 零写零响亮:换模型才是事件。
222
+ };
223
+ for (let round = 1; round <= CAS_MAX_ROUNDS; round++) {
224
+ const stored = await readStored(query);
225
+ // ── 判定 ────────────────────────────────────────────────────────────────────────────────────
226
+ let state;
227
+ let previous = null;
228
+ if (stored.kind === "ok") {
229
+ previous = stored.value;
230
+ if (sameIdentity(stored.value, want)) {
231
+ // B:零写(连 upsert 都不发 —— 「匹配」这条路径上不许有任何写)。但前序轮若已经清过,
232
+ // 那次清必须照样被记上、被说出来。
233
+ emitOutcome("match", round);
234
+ return { state: "match", cleared: clearedTotal, previous, identity: want, rounds: round };
235
+ }
236
+ state = "changed"; // C
237
+ }
238
+ else if (stored.kind === "unreadable") {
239
+ state = "unreadable"; // F —— 绝不当成匹配
240
+ }
241
+ else {
242
+ state = (await hasUnattributedVectors(query)) ? "unattributed" : "armed"; // D / E
243
+ }
244
+ // ── 动作:🔴 先清列、后写元表(次序即正确性,见头注 1)────────────────────────────────────
245
+ if (state !== "armed" && firstChange === undefined) {
246
+ // 变更的锚:第一次判出「不是 match、不是 armed」的那一轮(与清了几行无关)。
247
+ // ⚠️ 如实登记的残余(codex 复审第三轮提出,判为**过报**而非误报,故不改 SQL 语义):两只**同配置**
248
+ // 副本同时首启时,两边都可能先读到旧值 ⇒ 各自 clear+upsert(赢家清到行,输家清 0)⇒ 两边各发一条
249
+ // `memory_embedder_identity_changed`(同一次逻辑变更在两个进程的日志里各留一条)。方向是安全的那
250
+ // 一侧:多一条过报 < 漏一条(本键的全部理由是「换 embedder 不许无痕」),且两条都如实描述了各自
251
+ // 观察到的世界(旧值 → 新值)。根治要把 upsert 换成「只在值真变时才写并回带是否变过」的条件形 ——
252
+ // 那是元表写腿的 SQL 语义变更(双库门),不在本收口批射程,登记为后续件。
253
+ firstChange = { reason: state, previous: previous ?? (stored.kind === "unreadable" ? { unreadable: stored.raw } : null) };
254
+ }
255
+ const cleared = state === "armed" ? 0 : await clearEmbeddings(query);
256
+ if (cleared > 0 && firstClear === undefined) {
257
+ firstClear = { reason: state, previous: previous ?? (stored.kind === "unreadable" ? { unreadable: stored.raw } : null) };
258
+ }
259
+ clearedTotal += cleared;
260
+ await writeIdentity(query, want);
261
+ // ── CAS 回读(见头注 2)────────────────────────────────────────────────────────────────────
262
+ const after = await readStored(query);
263
+ if (after.kind === "ok" && sameIdentity(after.value, want)) {
264
+ emitOutcome(state, round);
265
+ return { state, cleared: clearedTotal, previous, identity: want, rounds: round };
266
+ }
267
+ // 读回≠自写 ⇒ 另一副本并发写了**不同** identity(部署配置漂移)。响亮 + 重跑比对。
268
+ log?.error("memory_embedder_identity_conflict", {
269
+ ours: want,
270
+ theirs: after.kind === "ok" ? after.value : after.kind === "unreadable" ? { unreadable: after.raw } : null,
271
+ round,
272
+ maxRounds: CAS_MAX_ROUNDS,
273
+ note: "another replica wrote a DIFFERENT embedder identity concurrently — the deployment's MEMORY_EMBEDDER_* config has drifted between replicas; re-running the comparison",
274
+ });
275
+ }
276
+ throw new Error(`reconcileEmbedderFingerprint: the embedder identity in ${PG_MEMORY_ENGINE_META_TABLE} kept being overwritten with a DIFFERENT value ` +
277
+ `after ${CAS_MAX_ROUNDS} rounds (ours: ${want.model}/${want.dimensions}) — concurrent replicas are booting with different ` +
278
+ "MEMORY_EMBEDDER_* config; align the deployment config and restart with the fleet stopped. Refusing to start.");
279
+ }
280
+ //# sourceMappingURL=memory-embedder-fingerprint.js.map
@@ -2,7 +2,6 @@ import { type PersistedAllowRule, type PermissionRuleStore, type PermissionRuleS
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { PgQueryFn } from "./pg-query.js";
4
4
  import { type SqlDriver } from "./sql-driver.js";
5
- import type { Pool as PgPool } from "pg";
6
5
  /** core `permission-rule-store.ts` 的 `PERMISSION_RULE_WRITER` 字面值(逐字)。`writerOf(store)` 读的
7
6
  * 就是这把键——它是 core 与 backend 之间**事实上的**协议名,只是没被导出。 */
8
7
  export declare const PERMISSION_RULE_WRITER_KEY = "__semaPermissionRuleWriter";
@@ -132,8 +131,6 @@ export declare class SqlPermissionRuleStoreProvider implements PermissionRuleSto
132
131
  forPrincipal(principal: string | undefined): PermissionRuleStore;
133
132
  forLocalOwner(): PermissionRuleStore;
134
133
  }
135
- export declare function createTiDBPermissionRuleStoreProvider(pool: MySqlPool, now?: () => number): SqlPermissionRuleStoreProvider;
136
- export declare function createPgPermissionRuleStoreProvider(pool: PgPool, now?: () => number): SqlPermissionRuleStoreProvider;
137
134
  /**
138
135
  * durable 审批记录。CAS **按 rev**,不按 state —— core 的原话:批记录的第二个候选会 redeemed→redeemed,
139
136
  * 只比 state 的两次并发重试会**都**认为自己看到了预期状态,各铸一个 dot、各写一次,后者静默覆盖前者的
@@ -50,7 +50,7 @@
50
50
  import { createHash, randomBytes } from "node:crypto";
51
51
  import { z } from "zod";
52
52
  import { applyTombstones, parseAllowRuleText, sameScope, screenRuleSyncState, RULE_SYNC_DROP_CODES, } from "@sema-agent/core";
53
- import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
53
+ import { dialectProtocolJsonEncoder } from "./sql-driver.js";
54
54
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
55
55
  // core 私有写面的**本地镜像**(顶注 §「上游契约缺口」逐条理由)
56
56
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
@@ -601,12 +601,6 @@ const ZERO_RULE_STORE = {
601
601
  list: async () => ({ rules: [], tombstones: [], rev: 0 }),
602
602
  durability: "process-local",
603
603
  };
604
- export function createTiDBPermissionRuleStoreProvider(pool, now) {
605
- return new SqlPermissionRuleStoreProvider(mysqlDriver(pool), now);
606
- }
607
- export function createPgPermissionRuleStoreProvider(pool, now) {
608
- return new SqlPermissionRuleStoreProvider(pgDriver(pool), now);
609
- }
610
604
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
611
605
  // 审批记录店(core `RuleApprovalRecordStore` 的 SQL 形)
612
606
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
@@ -113,6 +113,9 @@ export const PG_SCHEMA_STATEMENTS = [
113
113
  pending_steer_queue TEXT COLLATE "C",
114
114
  pending_steer_rev BIGINT NOT NULL DEFAULT 0,
115
115
  risk_descriptor TEXT COLLATE "C",
116
+ -- rule_suggestions:语义、铸行侧的窄读/脱敏/截断纪律、以及「NULL = 无供给 ⇒ 读口省键」的完整由来见
117
+ -- MySQL 孪生(tidb-pool.ts 同名列的行内注)。
118
+ rule_suggestions TEXT COLLATE "C",
116
119
  PRIMARY KEY (token)
117
120
  )`,
118
121
  `CREATE INDEX IF NOT EXISTS idx_checkpoint_status_deadline ON checkpoint (status, deadline)`,
@@ -87,16 +87,15 @@ export type ImageBake = TiDBImageBake | PgImageBake;
87
87
  export type CheckpointStoreFull = TiDBCheckpointStore | PgCheckpointStore | LocalCheckpointStore;
88
88
  /** The full tool-result store: core `ToolResultStore` + the service maintenance extras. The SQL twins carry
89
89
  * both extras; the LOCAL lane returns core's `FileToolResultStore` (core 1.219 — durable across a
90
- * process restart, the TOC "ReadToolResult(ref) 恒空" fix) which carries NEITHER consumers must optional-call
91
- * (`store.reapOlderThan?.()`). Structural (not a nominal union) so all three backends flow through one seam. */
90
+ * process restart, the TOC "ReadToolResult(ref) 恒空" fix) which since core 5.28.0 carries `deleteBySession`
91
+ * (owner sidecar 判属主,无边车存量计 unattributable 不删)but still NOT `reapOlderThan` consumers must
92
+ * optional-call (`store.reapOlderThan?.()`,local 上恒 no-op)。(R5 复扫纠:旧句「carries NEITHER」自
93
+ * core 5.28 起半假——本类型注是消费方第一落点,与 LocalBackend 内部注必须同真。)
94
+ * Structural (not a nominal union) so all three backends flow through one seam. */
92
95
  export type ToolResultStoreFull = ToolResultStore & {
93
96
  /** TTL sweep (SQL twins only): purge rows older than the cutoff. Local file store omits it (CC posture:
94
97
  * a single user's tool-result files persist like transcripts; bounded by being text previews). */
95
98
  reapOlderThan?(cutoffMs: number): Promise<number>;
96
- /** E21 purge (SQL twins only): delete this session's refs when a session is purged. core 5.26.0 (#119)
97
- * changed the mint from `tr_<sid>_<call>` to `tr_<sid>~<call>~<content>`, so the twin matches BOTH prefixes —
98
- * see `deleteBySession` in tool-result-store-sql.ts for why dropping either one is a silent purge failure. */
99
- deleteBySession?(sessionId: string): Promise<number>;
100
99
  };
101
100
  /** Cross-replica counter twins expose the write-behind lifecycle (startRefresh/stop) main.ts drives. */
102
101
  export type BreakerStateStore = TiDBBreakerState | PgBreakerState;
@@ -339,7 +339,10 @@ class LocalBackend {
339
339
  // core 1.219 (dogfood finding "ReadToolResult(ref) 恒空"): the TOC lane now has a DURABLE tool-result
340
340
  // store — core's FileToolResultStore under the same data root (one file per ref, write-once, restart-durable).
341
341
  // In-process cross-task deref was already fixed core-side (1.219 Runner-shared fallback); this adds the
342
- // cross-RESTART half. No reapOlderThan/deleteBySession (optional extras) single-user text previews, CC posture.
342
+ // cross-RESTART half. deleteBySession:core 5.28 FileToolResultStore **有**(R3 复扫更正过一次、
343
+ // R4 复扫再纠:上一版注写成「两者都有」过头了——`reapOlderThan` 在 core 5.28.0 dist 全文零命中,
344
+ // TTL 腿仍是 SQL twins only,reapers.ts 的 `?.` 可选链在 local 上永久 no-op,别拆)。deleteBySession
345
+ // 按 .owner.json 边车判属主,无边车的存量计 unattributable 不删,与 SQL 孪生同口径。
343
346
  toolResult() { return this.fileBackend.toolResultStore; }
344
347
  mysqlPool() { return undefined; }
345
348
  pgPool() { return undefined; }
@@ -59,6 +59,23 @@ export interface RunRecord {
59
59
  * (suspended 与 needs_review 都还能再动,`running` 更不必说)。
60
60
  */
61
61
  export declare function isTerminalRunStatus(status: RunRecord["status"]): boolean;
62
+ /**
63
+ * 「这一行**停在门上**(park)了吗」——`RunRecord["status"]` 的第二张穷举判据,与 {@link isTerminalRunStatus}
64
+ * 同住词表属主处(扫描P2,2026-08-12)。
65
+ *
66
+ * 🔴 为什么必须有名字:park 是**两个词**,不是一个 —— `suspended`(tool_approval / policy_ask / human /
67
+ * resource_limit 门)与 `needs_review`(`plan_review` / dry-run 拦截门,写点 = 各 store 的
68
+ * `setNeedsReview`)。两者的行为面**完全一致**:行还活着、`task_active` claim 还占着、checkpoint 还能被
69
+ * 决议。凡是「行是不是还停着」的判断只手抄 `=== "suspended"` 的地方,对 plan_review 腿一律给出**相反**
70
+ * 结论 —— 它会被当成终局:取消谎报 "already terminal — no-op"(claim 从此无人释放,正是 [868] 那个把会话
71
+ * 锁死的指纹)、trace 面无帧、leader 面不触发 park 反应。这条判据的存在就是为了不再有第三次手抄。
72
+ *
73
+ * 未知词的方向与 {@link isTerminalRunStatus} 同向保守:判**不是** park。理由是消费点会据此做**不可逆**
74
+ * 动作(cancel 的恢复把手会 CAS-expire 一张 checkpoint);对一个本进程读不懂的状态,少动一次远好过错杀。
75
+ * 真正的执法点是下面那条 `never`:core 的 `TaskStatus` 一加成员就 tsc 红,加成员的人必须当场回答
76
+ * 「这个新态算不算 park」。
77
+ */
78
+ export declare function isParkedRunStatus(status: RunRecord["status"]): boolean;
62
79
  /** One row of the GET /v1/sessions list (CC /resume picker): a DISTINCT session (sessionId) aggregated from its
63
80
  * task_run rows — newest-first by last activity. The preview/status are the LATEST run's ("continue where I left
64
81
  * off"); the counts/timestamps span the whole session. Owner included so a fleet-wide (ops) listing is attributable. */
@@ -41,6 +41,39 @@ export function isTerminalRunStatus(status) {
41
41
  }
42
42
  }
43
43
  }
44
+ /**
45
+ * 「这一行**停在门上**(park)了吗」——`RunRecord["status"]` 的第二张穷举判据,与 {@link isTerminalRunStatus}
46
+ * 同住词表属主处(扫描P2,2026-08-12)。
47
+ *
48
+ * 🔴 为什么必须有名字:park 是**两个词**,不是一个 —— `suspended`(tool_approval / policy_ask / human /
49
+ * resource_limit 门)与 `needs_review`(`plan_review` / dry-run 拦截门,写点 = 各 store 的
50
+ * `setNeedsReview`)。两者的行为面**完全一致**:行还活着、`task_active` claim 还占着、checkpoint 还能被
51
+ * 决议。凡是「行是不是还停着」的判断只手抄 `=== "suspended"` 的地方,对 plan_review 腿一律给出**相反**
52
+ * 结论 —— 它会被当成终局:取消谎报 "already terminal — no-op"(claim 从此无人释放,正是 [868] 那个把会话
53
+ * 锁死的指纹)、trace 面无帧、leader 面不触发 park 反应。这条判据的存在就是为了不再有第三次手抄。
54
+ *
55
+ * 未知词的方向与 {@link isTerminalRunStatus} 同向保守:判**不是** park。理由是消费点会据此做**不可逆**
56
+ * 动作(cancel 的恢复把手会 CAS-expire 一张 checkpoint);对一个本进程读不懂的状态,少动一次远好过错杀。
57
+ * 真正的执法点是下面那条 `never`:core 的 `TaskStatus` 一加成员就 tsc 红,加成员的人必须当场回答
58
+ * 「这个新态算不算 park」。
59
+ */
60
+ export function isParkedRunStatus(status) {
61
+ switch (status) {
62
+ case "suspended":
63
+ case "needs_review":
64
+ return true;
65
+ case "running":
66
+ case "completed":
67
+ case "failed":
68
+ case "blocked":
69
+ return false;
70
+ default: {
71
+ const unhandled = status; // 词表增删两向的**编译期**执行点(同 isTerminalRunStatus 顶注)
72
+ void unhandled;
73
+ return false; // 运行时保守:未知 ⇒ 不当 park(不可逆动作宁可不做)
74
+ }
75
+ }
76
+ }
44
77
  /** SELECT row → {@link BakeRecord} — shared by BOTH SQL twins (design/158 S8 归位, A3 沉底). */
45
78
  export function mapBakeRow(r) {
46
79
  return {
@@ -298,6 +298,13 @@ export const SCHEMA_STATEMENTS = [
298
298
  -- triage-sort the supervisor inbox by severity DESC — WITHOUT parsing the checkpoint blob per row.
299
299
  -- NULL on gates with no descriptor.
300
300
  risk_descriptor TEXT NULL,
301
+ -- rule_suggestions ([3683]-2/[3684]② mint 面归因):core 挂在 pendingAction.ruleSuggestions 上的规则
302
+ -- 候选(design/179 §4;与同步腿 AskRequest.ruleSuggestions 同一条契约),在 put() 落列 —— 于是
303
+ -- listPending 不必为一格展示材料去拖整只 checkpoint blob(那是整份 suspend 快照)。落列前过
304
+ -- boundedRuleSuggestions:窄读(坏条丢弃)+ redactSecrets(候选文本由命令原文铸出,而运维队列跨租户
305
+ -- 可见)+ 基数截断。NULL = 无供给(规则车道没武装 / 命令说不出规则 / 这只 ask 规则清不掉),读口
306
+ -- 据此省键而不是铸空数组。
307
+ rule_suggestions TEXT NULL,
301
308
  PRIMARY KEY (token),
302
309
  KEY idx_checkpoint_status_deadline (status, deadline),
303
310
  KEY idx_checkpoint_session_status (session_id, status)
@@ -1,4 +1,4 @@
1
- import type { ToolResultProvenance, ToolResultStore, ToolResultSlice } from "@sema-agent/core";
1
+ import type { ToolResultProvenance, ToolResultStore, ToolResultSlice, ToolResultDeletionReport } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { Pool as PgPool, PoolClient as PgPoolClient } from "pg";
4
4
  import { type SqlDriver } from "./sql-driver.js";
@@ -29,8 +29,9 @@ export declare class SqlToolResultStore implements ToolResultStore {
29
29
  constructor(db: SqlDriver);
30
30
  /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */
31
31
  private q;
32
- /** D1(试剂盒揪出,RB-266 语义):ref 是主键且 deleteBySession`tr_<sid>_%` 前缀清理——不合规
33
- * ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。
32
+ /** D1(试剂盒揪出,RB-266 语义):ref 是主键,而 `deleteBySession` 只认**记录下来的出处**与单射的
33
+ * `tr_<sid>~` 前缀(重扫二轮起;旧注写的 `tr_<sid>_%` 前缀清理已作废,理由见该方法顶注)——
34
+ * 不合规 ref 的行既进不来、也就谈不上被 session 删除清掉,只能等 TTL。
34
35
  *
35
36
  * 🔴 判定**单源**:直调 core 的 `assertSafeToolResultRef`(core 2.8.0 起入公共面)。
36
37
  * 在此之前这里是那 6 个判别条件的**本地镜像** —— 我在 [2159] 主动交出过这条裂缝:镜像是第二真源,
@@ -71,18 +72,22 @@ export declare class SqlToolResultStore implements ToolResultStore {
71
72
  * (boot/reapers.ts) already wraps this call in `.catch(() => undefined)` so the reap loop itself never dies. */
72
73
  reapOlderThan(cutoffMs: number): Promise<number>;
73
74
  /**
74
- * E21 (§0.5 session delete) — purge offloaded tool results for one session. core namespaces every ref as
75
- * `tr_<sessionId>_<toolCallId>`, so a `LIKE 'tr_<sessionId>_%'` prefix match catches them all. The sessionId
76
- * is LIKE-escaped (`\`, `%`, `_`) so a crafted id can never widen the match across tenants. Returns rows removed.
75
+ * E21 (§0.5 session delete) — purge offloaded tool results for one session. Selection is by the row's
76
+ * **recorded provenance** (`owner_session_id`, the #119 column `put` writes in the same statement as the
77
+ * content) and by NOTHING else **zero ref-derived arms**(codex R1-[high] 收窄:连单射新前缀也不作
78
+ * 属主证据,理由在方法体内逐字引 core 顶注)。Returns `{deleted, unattributable}`; why NO prefix
79
+ * (legacy `_` or injective `~`) is a deletion selector is inline below.
77
80
  *
78
- * 🔴 NO SQL owner guard (adversarial-review MEDIUM, deliberate): tool_result is keyed ONLY by `ref` — there is
79
- * no `session_id` (or `owner`) column to guard on, and the ref is not a session_meta foreign key, so an
80
- * owner sub-select would be an awkward ref-correlated EXISTS. This purge stays ROUTE-guarded (the DELETE route
81
- * owner-matches the session via `ownerOf` before calling, and the owner-guarded run-ledger delete runs first in
82
- * the same coordinator) — belt-and-suspenders only, not an exploitable leak. The LIKE-escape below is the
83
- * data-layer protection that matters (a crafted id can't widen the prefix across tenants).
81
+ * ⚠️ 旧注两处已作废(重扫二轮更正,留档防复辟):① 「core namespaces every ref as `tr_<sid>_<callId>`」——
82
+ * 5.26.0 起是 `~` 分隔的单射四段形,`_` 形只剩存量;② 「there is no `session_id` (or `owner`) column to
83
+ * guard on」—— #119 起**有**(`owner_session_id` / `owner_task_id`,boot 期还有拒启断言把没升级的表挡在
84
+ * 外面),所以「按出处选行」不再是「awkward ref-correlated EXISTS」,它就是本方法现在的主选择器。
85
+ *
86
+ * 路由侧的 owner 门照旧在(DELETE 路由先 `ownerOf` 对属主、同一协调器里先跑 owner-guarded run 账本
87
+ * 删除),但它不再是唯一防线:LIKE 转义只挡「精心构造的 id 把前缀撑宽」,挡不住「**合法**的邻会话 id
88
+ * 恰好是本会话 id 的扩展」——那一条现在由出处判定挡。
84
89
  */
85
- deleteBySession(sessionId: string): Promise<number>;
90
+ deleteBySession(sessionId: string): Promise<ToolResultDeletionReport>;
86
91
  }
87
92
  /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */
88
93
  export declare class TiDBToolResultStore extends SqlToolResultStore {
@@ -30,14 +30,12 @@
30
30
  * integration test + pg-pool.ts's central apply).
31
31
  * - `put`'s UTF-16 sanitize is a GENUINE algorithm divergence, not just SQL text — see the dialect branch
32
32
  * inline for why.
33
- * - ESCAPE-clause literal: TiDB spells `ESCAPE '\\\\'` (declares the backslash escape char explicitly —
34
- * under `sql_mode=NO_BACKSLASH_ESCAPES` `\` is NOT the LIKE escape char by default, so leaving it
35
- * implicit would silently stop escaping and let a crafted sessionId's `_`/`%` over-match OTHER
36
- * sessions' rows); PG spells `ESCAPE '\'` (its default escape char already, kept for lock-step intent
37
- * with the TiDB twin rather than out of necessity).
33
+ * - (历史条目,已随码消失)ESCAPE-clause literal:`deleteBySession` 曾按 ref 前缀 LIKE 选行,两方言因此
34
+ * 各自显式声明转义符。重扫二轮起该腿改为**只按 `owner_session_id` 出处选行**(core 契约:ref
35
+ * opaque handle,禁解析),本文件已无 LIKE,这条方言差随之退役 —— 留档以防有人「照旧例」把前缀
36
+ * 选行请回来。
38
37
  */
39
38
  import { MAX_MINTED_TOOL_RESULT_REF_CHARS, assertSafeToolResultRef, assertToolResultProvenanceMatch, normalizeToolResultProvenance } from "@sema-agent/core";
40
- import { escapeLike } from "./sql-escape.js";
41
39
  import { pgSanitizeText } from "./pg-safe-json.js";
42
40
  import { mysqlDriver, pgDriver } from "./sql-driver.js";
43
41
  /** PG schema for the tool_result table — the PG translation of tidb-pool.ts's tool_result DDL
@@ -114,8 +112,9 @@ export class SqlToolResultStore {
114
112
  q(tidb, pg) {
115
113
  return this.db.dialect === "tidb" ? tidb : pg;
116
114
  }
117
- /** D1(试剂盒揪出,RB-266 语义):ref 是主键且 deleteBySession`tr_<sid>_%` 前缀清理——不合规
118
- * ref 的行既躲过 session 删除也只能等 TTL,永远清不掉。
115
+ /** D1(试剂盒揪出,RB-266 语义):ref 是主键,而 `deleteBySession` 只认**记录下来的出处**与单射的
116
+ * `tr_<sid>~` 前缀(重扫二轮起;旧注写的 `tr_<sid>_%` 前缀清理已作废,理由见该方法顶注)——
117
+ * 不合规 ref 的行既进不来、也就谈不上被 session 删除清掉,只能等 TTL。
119
118
  *
120
119
  * 🔴 判定**单源**:直调 core 的 `assertSafeToolResultRef`(core 2.8.0 起入公共面)。
121
120
  * 在此之前这里是那 6 个判别条件的**本地镜像** —— 我在 [2159] 主动交出过这条裂缝:镜像是第二真源,
@@ -244,31 +243,53 @@ export class SqlToolResultStore {
244
243
  return affected;
245
244
  }
246
245
  /**
247
- * E21 (§0.5 session delete) — purge offloaded tool results for one session. core namespaces every ref as
248
- * `tr_<sessionId>_<toolCallId>`, so a `LIKE 'tr_<sessionId>_%'` prefix match catches them all. The sessionId
249
- * is LIKE-escaped (`\`, `%`, `_`) so a crafted id can never widen the match across tenants. Returns rows removed.
246
+ * E21 (§0.5 session delete) — purge offloaded tool results for one session. Selection is by the row's
247
+ * **recorded provenance** (`owner_session_id`, the #119 column `put` writes in the same statement as the
248
+ * content) and by NOTHING else **zero ref-derived arms**(codex R1-[high] 收窄:连单射新前缀也不作
249
+ * 属主证据,理由在方法体内逐字引 core 顶注)。Returns `{deleted, unattributable}`; why NO prefix
250
+ * (legacy `_` or injective `~`) is a deletion selector is inline below.
250
251
  *
251
- * 🔴 NO SQL owner guard (adversarial-review MEDIUM, deliberate): tool_result is keyed ONLY by `ref` — there is
252
- * no `session_id` (or `owner`) column to guard on, and the ref is not a session_meta foreign key, so an
253
- * owner sub-select would be an awkward ref-correlated EXISTS. This purge stays ROUTE-guarded (the DELETE route
254
- * owner-matches the session via `ownerOf` before calling, and the owner-guarded run-ledger delete runs first in
255
- * the same coordinator) — belt-and-suspenders only, not an exploitable leak. The LIKE-escape below is the
256
- * data-layer protection that matters (a crafted id can't widen the prefix across tenants).
252
+ * ⚠️ 旧注两处已作废(重扫二轮更正,留档防复辟):① 「core namespaces every ref as `tr_<sid>_<callId>`」——
253
+ * 5.26.0 起是 `~` 分隔的单射四段形,`_` 形只剩存量;② 「there is no `session_id` (or `owner`) column to
254
+ * guard on」—— #119 起**有**(`owner_session_id` / `owner_task_id`,boot 期还有拒启断言把没升级的表挡在
255
+ * 外面),所以「按出处选行」不再是「awkward ref-correlated EXISTS」,它就是本方法现在的主选择器。
256
+ *
257
+ * 路由侧的 owner 门照旧在(DELETE 路由先 `ownerOf` 对属主、同一协调器里先跑 owner-guarded run 账本
258
+ * 删除),但它不再是唯一防线:LIKE 转义只挡「精心构造的 id 把前缀撑宽」,挡不住「**合法**的邻会话 id
259
+ * 恰好是本会话 id 的扩展」——那一条现在由出处判定挡。
257
260
  */
258
261
  async deleteBySession(sessionId) {
259
- // Escape the WHOLE literal prefix `tr_<sessionId><sep>` (incl. the literal underscore in `tr_` and, on the
260
- // legacy arm, the separator underscore) so the only LIKE wildcard is the trailing `%` — a `_` left
261
- // un-escaped is a single-char wildcard that could over-match across tenants.
262
+ // 🔴 **选行只按记录下来的出处,一个 ref 也不解析**(重扫二轮红先修 + codex 复审 R1-[high] 收窄)。
263
+ // core `tool-result-store.d.ts` 对本操作写死了三条,逐字照做:
264
+ // 「**selection is by RECORDED PROVENANCE, never by parsing the ref.** A ref is an opaque handle:
265
+ // its session segment may be folded, refs minted before the injective form decompose two ways
266
+ // (`tr_team_blue_x` is both ("team","blue_x") and ("team_blue","x")), **and a caller may mint its
267
+ // own**. Prefix-matching a ref therefore both misses rows and reaches rows of a NEIGHBOURING
268
+ // session, and over-deletion here destroys a live session's readable bytes」;
269
+ // ② 「**`taskId` is ignored in the match**」——`{sessionId, taskId}` 是同一会话的窄化,不是别的属主;
270
+ // ③ 「**an UNOWNED entry is never deleted** … It is counted instead (`unattributable`) so the caller
271
+ // learns the deletion was incomplete rather than being told a clean "done"」。
262
272
  //
263
- // 🔴 #119(core 5.26.0):**两个前缀**,因为铸法在 5.26.0 换了分隔符。5.25.0 及以前铸 `tr_<sid>_<callId>`,
264
- // 5.26.0 起铸 `tr_<sid>~<callId>~<contentSeg>`(单射 ref)。只留旧前缀 = 升级后一行也命不中,§0.5 会话
265
- // 删除**静默清零**;只留新前缀 = 升级前写下的存量行永远删不掉。删除面漏删比多跑一次 LIKE 贵得多,
266
- // 所以两条都发,而且都保持「整段字面量转义 + 只有末尾 % 是通配」的老纪律。
267
- const legacyPrefix = `${escapeLike(`tr_${sessionId}_`)}%`;
268
- const modernPrefix = `${escapeLike(`tr_${sessionId}~`)}%`;
269
- // Explicit `ESCAPE` clause see the file-header dialect-delta note for why each dialect spells it as it does.
270
- const { affected } = await this.db.query(this.q("DELETE FROM tool_result WHERE ref LIKE ? ESCAPE '\\\\' OR ref LIKE ? ESCAPE '\\\\'", "DELETE FROM tool_result WHERE ref LIKE $1 ESCAPE '\\' OR ref LIKE $2 ESCAPE '\\'"), [legacyPrefix, modernPrefix]);
271
- return affected;
273
+ // 旧形拿两条 LIKE 前缀全删,踩的正是 ①:提交面对 session id **只限长、不限形**(`server.ts` 的
274
+ // "a client MAY supply an ARBITRARY id … LENGTH-ONLY — NOT a uuidv7 shape check"),而 `_` core 的
275
+ // `NATIVE_REF_CHARSET`(`/^[A-Za-z0-9_.-]+$/`)里 会话 `<uuid>_sub` 合法,它的产物铸出
276
+ // `tr_<uuid>_sub~c1~s<hash>`;删父会话 `<uuid>`(过得了 DELETE 路由的 uuidv7 门)时旧前缀 LIKE 命中它,
277
+ // 一条**活着的**邻会话的卸载全文被销毁,还被算进本次的 `deleted`。
278
+ // 🔴 **新前缀也不作属主证据**(codex R1-[high],验真后收):`tr_<sid>~` 的单射只保证「core 用这个
279
+ // sessionId 铸出来的 ref 长这样」,**不**保证「长这样的 ref 一定是 core 用这个 sessionId 铸的」——
280
+ // `put(ref, content)` ref opaque handle、两参形合法、调用方可自铸(core 顶注 逐字点名)
281
+ // 拿它删无出处行 = 又一次「按 ref 猜属主」,只是猜得像一点。
282
+ //
283
+ // ⇒ `deleted` = `owner_session_id` 命中的行(ref 形状无关,会话 id 触发 `refSegment` 折叠时也不漏);
284
+ // `unattributable` = 库里**所有**无出处行的计数(与 core `InMemoryToolResultStore.deleteBySession`
285
+ // 逐字同口径:它遍历全表,`provenance === undefined` 的一律 `unattributable++` 且**跳过不删**)。
286
+ // 这个数是「本店有多少行没人认领」这一**全库**事实,读法 = 「这次会话删除不可能是完整的」,
287
+ // 不是「本会话残留了 N 行」。存量无出处行的实体清理归 TTL(`reapOlderThan`)。
288
+ // ⚠️ 登记后续件:`owner_session_id` 上没有索引(本表只有 PK(ref) 与 created_at 索引),删与计数都是
289
+ // 全表扫。会话删除是管理面低频动作,先取正确与完整;要提速得改 DDL + 重生成两方言 baseline,那是独立一车。
290
+ const deletion = await this.db.query(this.q("DELETE FROM tool_result WHERE owner_session_id = ?", "DELETE FROM tool_result WHERE owner_session_id = $1"), [sessionId]);
291
+ const { rows } = await this.db.query(this.q("SELECT COUNT(*) AS n FROM tool_result WHERE owner_session_id IS NULL", "SELECT COUNT(*) AS n FROM tool_result WHERE owner_session_id IS NULL"));
292
+ return { deleted: deletion.affected, unattributable: Number(rows[0]?.n ?? 0) };
272
293
  }
273
294
  }
274
295
  /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */