@sema-agent/server 7.36.0 → 7.37.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.
- package/USAGE.md +9 -0
- package/dist/boot/resolve-spec.js +6 -1
- package/dist/boot/runner-deps.d.ts +21 -3
- package/dist/boot/runner-deps.js +31 -4
- package/dist/boot/session-faces.d.ts +3 -0
- package/dist/boot/session-faces.js +11 -2
- package/dist/boot/side-query-lane.d.ts +77 -54
- package/dist/boot/side-query-lane.js +102 -66
- package/dist/boot/stores.d.ts +1 -0
- package/dist/boot/stores.js +19 -1
- package/dist/boot/task-list-lane.d.ts +92 -0
- package/dist/boot/task-list-lane.js +63 -0
- package/dist/bounded-session-map.d.ts +3 -0
- package/dist/bounded-session-map.js +5 -0
- package/dist/capabilities/scenarios.d.ts +30 -2
- package/dist/capabilities/scenarios.js +16 -3
- package/dist/config-types.d.ts +21 -1
- package/dist/config.js +8 -1
- package/dist/hooks/branch-transcript.d.ts +3 -2
- package/dist/hooks/branch-transcript.js +7 -1
- package/dist/http/active-run-conflict.d.ts +43 -0
- package/dist/http/active-run-conflict.js +8 -0
- package/dist/http/route-ctx.d.ts +22 -1
- package/dist/http/routes/approvals-assistant.js +13 -1
- package/dist/http/routes/notify-wake.js +6 -0
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/routes/tasks.js +30 -2
- package/dist/http/server.js +103 -7
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -0
- package/dist/main.js +11 -3
- package/dist/memory-posture.d.ts +12 -1
- package/dist/memory-posture.js +2 -0
- package/dist/observability/fail-open.d.ts +9 -1
- package/dist/observability/fail-open.js +9 -1
- package/dist/plugins/memory-engine-pg.js +42 -4
- package/dist/plugins/memory-engine-tidb.js +38 -4
- package/dist/plugins/memory-origin-law.d.ts +69 -0
- package/dist/plugins/memory-origin-law.js +98 -0
- package/dist/plugins/retention-store-sql.d.ts +7 -0
- package/dist/plugins/retention-store-sql.js +24 -0
- package/dist/plugins/task-list-store-sql.d.ts +36 -25
- package/dist/plugins/task-list-store-sql.js +102 -0
- package/dist/run-local.js +14 -5
- package/dist/runs.js +17 -0
- package/dist/trace/engine-notice-wire.d.ts +128 -0
- package/dist/trace/engine-notice-wire.js +256 -0
- package/dist/trace/ledger-events.d.ts +11 -1
- package/dist/trace/ledger-sink.d.ts +17 -0
- package/dist/trace/ledger-sink.js +25 -0
- package/package.json +2 -2
package/dist/memory-posture.d.ts
CHANGED
|
@@ -63,10 +63,21 @@ export interface MemoryPosture {
|
|
|
63
63
|
* 诊断料,不是任何持凭证调用方该读的位。
|
|
64
64
|
*/
|
|
65
65
|
delegationEvidence: "static-face" | "attested-only" | null;
|
|
66
|
+
/**
|
|
67
|
+
* #307 件4(core 5.46.0 design/336 §13-3):本部署的 **provenance 姿态**(`MEMORY_PROVENANCE`)——
|
|
68
|
+
* 一个会话被判为已暴露之后,它的记忆写是**带标收录**(`carry`)还是**整体隔离**(`off`)。
|
|
69
|
+
*
|
|
70
|
+
* 🔴 `null` = **本部署没设这个键** ⇒ 引擎按它自己的缺省跑。**不是**「等于 carry」——
|
|
71
|
+
* 与 `delegationEvidence` / `vectorMode` 同一条诚实纪律:本读面不替 core 宣布它的缺省。
|
|
72
|
+
*
|
|
73
|
+
* 受众 = **operator 面独占**(与 `delegationEvidence` 同判):它回答的是「这台机器对暴露会话的写
|
|
74
|
+
* 怎么处置」,是运维诊断料,刻意不进 `GET /v1/capabilities` 的窄投影。
|
|
75
|
+
*/
|
|
76
|
+
provenance: "off" | "carry" | null;
|
|
66
77
|
}
|
|
67
78
|
/** 推导入参 = 装配现场的真事实(不在本函数里重算任何一件)。 */
|
|
68
79
|
export interface MemoryPostureFacts {
|
|
69
|
-
config: Pick<ServiceConfig, "memoryEngineEnabled" | "memoryEngineBackend" | "requirePrincipal" | "memoryEmbedder" | "memoryDelegationEvidence">;
|
|
80
|
+
config: Pick<ServiceConfig, "memoryEngineEnabled" | "memoryEngineBackend" | "requirePrincipal" | "memoryEmbedder" | "memoryDelegationEvidence" | "memoryProvenance">;
|
|
70
81
|
/** 引擎这一腿真的接上了吗(`memoryEngine !== undefined`)—— 装配结果。 */
|
|
71
82
|
engineWired: boolean;
|
|
72
83
|
/** 两支 SQL 后端从**实例 getter** 读到的档位;file 腿传 `undefined`(无此面)。 */
|
package/dist/memory-posture.js
CHANGED
|
@@ -25,6 +25,8 @@ export function buildMemoryPosture(facts) {
|
|
|
25
25
|
embedder: embedder !== undefined ? { model: embedder.model, dimensions: embedder.dimensions } : null,
|
|
26
26
|
// #299:键缺席 ⇒ `null`(「本部署没设」),不折成 core 的缺省词 —— 见字段 JSDoc 的诚实纪律。
|
|
27
27
|
delegationEvidence: config.memoryDelegationEvidence ?? null,
|
|
28
|
+
// #307 件4:同一条诚实纪律 —— 键缺席 ⇒ `null`(「本部署没设」),不折成 core 的缺省词 `carry`。
|
|
29
|
+
provenance: config.memoryProvenance ?? null,
|
|
28
30
|
};
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
@@ -101,9 +101,17 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
101
101
|
readonly cls: "F";
|
|
102
102
|
readonly note: "A-057.47:`buildActiveRunConflict` 的材料装配段(getRun / turnActivity / peekPendingScope / findPendingTokenBySession / checkpoint `get`)任一失败 ⇒ 整段退化成裸 409(只剩 error + errorCode + activeTaskId),`activeTaskStatus` / `msSinceLastActivity` / `pendingGate`(kind·decidePath·governanceForced·checkpointId)全部蒸发。放行的最坏后果 = **展示/分诊**面缺料:壳画不出「这条 run 卡在哪道门、去哪儿决」,人退回 `POST /v1/runs/{activeTaskId}/cancel` 这条保底真路 —— 不参与任何门/CAS/resume 判定(执法面读的是 checkpoint blob 的 `get()`,不经本材料;与 census 第 21 行同一条判据),故 F 类。方向刻意不改:本材料是 best-effort 增强,让它抛会把一次 store 抖动变成整个 409 路径的 500(顶注第 11 行逐字成文「store 面任何失败都不得挡 409 本体」)。必须留痕的理由:本文件零 logger 席、pool 层无 per-query 错误日志、调用侧(tasks.ts / runs.ts / server.ts 五处)因本函数恒不 reject 结构上拿不到信号 ⇒ 「approval 出路材料系统性丢失」此前在遥测里与「一切正常」同形;滚动升级期 `checkpoint-store-sql.ts` 的 version-too-new throw 与 strict parseJson 也一并被吞在这里。";
|
|
103
103
|
};
|
|
104
|
+
readonly "server.engine-notice.durable-append-failed": {
|
|
105
|
+
readonly cls: "F";
|
|
106
|
+
readonly note: "#310(codex 对抗复审 R1-[medium],验真后修):三条 run 腿把一条白名单通告写进 durable 账本时,那次 `appendEvent` **异步 reject**(store 抖动 / 连接断 / 行冲突)。结构上不能 await —— `onNotice` 是 core 的同步回调,拿一次账本写去阻塞引擎是更坏的交易 —— 所以写口只能 fire-and-forget,`route` 的同步 try 结构性观察不到这次失败 —— 「投出去了」与「真落盘了」不是同一件事。放行的最坏后果按腿分档,如实登记:**sync stream 腿**还有 live SSE 那一份(在线的人看得见,丢的是断连补看);**bg / resume 两腿账本是唯一的用户可见终点** ⇒ 那条通告对用户永久消失。仍判 F 而不是 P:通告是**披露**面不是执法面(不参与任何门/裁决),且运维面那一份(结构化日志 `engine_notice`)在分流之前就已经落定、一条不丢。此前是裸 `.catch(() => undefined)` —— 同文件 resume 腿的子代 forward 写口早在 C2/C5 批2 就因为「裸吞 = 事件从 durable log 永久消失且零信号」补过 `warnAppend`,本 tag 是同一条纪律在通告口的落实(warn 留细节 + 计数让「系统性在漏」显形)。不做重试队列是刻意的:账本里没有任何一族行做有界重试,单给通告开一条会造出第二套持久化语义。";
|
|
107
|
+
};
|
|
108
|
+
readonly "server.engine-notice.wire-sink-threw": {
|
|
109
|
+
readonly cls: "F";
|
|
110
|
+
readonly note: "#310:`engine_notice` 分流器把一条白名单通告投给某条 run 腿的登记口时,那只口抛了(live 口写向已断/已撕裂的 SSE socket,或 durable 口的账本写同步抛)。放行的最坏后果 = **这一条通告的这一个终点**缺席:①日志终点在分流之前已经逐字打过(事实一条不丢);②两个终点注册成两只独立 sink ⇒ live 抛不牵连 durable(断连后仍看得见的那半保住);③同会话其余口照投。故 F 类。不放行的代价是把异常回抛给 core 的 `deliverEngineNotice` —— 它会吞掉,于是同一次失败**既没有留痕也没有第二只口**,正是本 tag 要根除的形。留痕是承重的:静默吞掉之后「wire 腿为什么总有几条通告不到」在遥测里与「core 本来就没发」同形。";
|
|
111
|
+
};
|
|
104
112
|
readonly "server.model-key.off-route-model-uses-gateway-key": {
|
|
105
113
|
readonly cls: "P-DEBT";
|
|
106
|
-
readonly note: "A-057.59:一只 **baseUrl 指向别家主机**(非本部署 `gatewayBaseUrl`)的目录模型**没有** per-model key ⇒ `
|
|
114
|
+
readonly note: "A-057.59:一只 **baseUrl 指向别家主机**(非本部署 `gatewayBaseUrl`)的目录模型**没有** per-model key ⇒ `createPerModelAuthSeat` 回 `undefined`(side-query 面 = core 5.46.0 的 `SideQuerySpec.getApiKeyAndHeaders` 座位;WebFetch 摘要面 = 同一只座位包在 `createPerModelKeyBrain` 里,那面 core 至今无座位)⇒ core 的 `options?.apiKey ?? config.apiKey` 回落**主网关 key**,而同一行下面的 `model.baseUrl || config.baseUrl` 仍把请求路由到那台外部主机 —— 这一次调用真把共享网关凭据发给了运维在 config-center 里指定的另一家。方向明知不对(hook 面对逐字同形的场景是 TRUE fail-closed:「refusing to send the prompt off-gateway」),之所以记债而不是当场收严:该缺席语义与**主推理链**(`dist/engine/harness/agent-harness.js` 的 `auth?.apiKey !== undefined` 臂)逐字同语义,只收严 wrapper 这两面会造成「同一只模型跑任务能用、问一句 401」,且「同账号多网关主机/多区域」是既有且正当的部署形、无旋钮可退。计数覆盖两面(side-query / WebFetch 摘要),`detail` = 模型名。⚠️ 判据需要参照系:调用方没给 `gatewayBaseUrl` ⇒ **不计**(宁可漏计不可错计),故计数是**下限**。终局收口 = 跨面收严件 #309(三面同批 + 旋钮 + 表态制),届时本 tag 同批销。";
|
|
107
115
|
};
|
|
108
116
|
};
|
|
109
117
|
/** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
|
|
@@ -124,9 +124,17 @@ export const FAIL_OPEN_TAGS = {
|
|
|
124
124
|
cls: "F",
|
|
125
125
|
note: "A-057.47:`buildActiveRunConflict` 的材料装配段(getRun / turnActivity / peekPendingScope / findPendingTokenBySession / checkpoint `get`)任一失败 ⇒ 整段退化成裸 409(只剩 error + errorCode + activeTaskId),`activeTaskStatus` / `msSinceLastActivity` / `pendingGate`(kind·decidePath·governanceForced·checkpointId)全部蒸发。放行的最坏后果 = **展示/分诊**面缺料:壳画不出「这条 run 卡在哪道门、去哪儿决」,人退回 `POST /v1/runs/{activeTaskId}/cancel` 这条保底真路 —— 不参与任何门/CAS/resume 判定(执法面读的是 checkpoint blob 的 `get()`,不经本材料;与 census 第 21 行同一条判据),故 F 类。方向刻意不改:本材料是 best-effort 增强,让它抛会把一次 store 抖动变成整个 409 路径的 500(顶注第 11 行逐字成文「store 面任何失败都不得挡 409 本体」)。必须留痕的理由:本文件零 logger 席、pool 层无 per-query 错误日志、调用侧(tasks.ts / runs.ts / server.ts 五处)因本函数恒不 reject 结构上拿不到信号 ⇒ 「approval 出路材料系统性丢失」此前在遥测里与「一切正常」同形;滚动升级期 `checkpoint-store-sql.ts` 的 version-too-new throw 与 strict parseJson 也一并被吞在这里。",
|
|
126
126
|
},
|
|
127
|
+
"server.engine-notice.durable-append-failed": {
|
|
128
|
+
cls: "F",
|
|
129
|
+
note: "#310(codex 对抗复审 R1-[medium],验真后修):三条 run 腿把一条白名单通告写进 durable 账本时,那次 `appendEvent` **异步 reject**(store 抖动 / 连接断 / 行冲突)。结构上不能 await —— `onNotice` 是 core 的同步回调,拿一次账本写去阻塞引擎是更坏的交易 —— 所以写口只能 fire-and-forget,`route` 的同步 try 结构性观察不到这次失败 —— 「投出去了」与「真落盘了」不是同一件事。放行的最坏后果按腿分档,如实登记:**sync stream 腿**还有 live SSE 那一份(在线的人看得见,丢的是断连补看);**bg / resume 两腿账本是唯一的用户可见终点** ⇒ 那条通告对用户永久消失。仍判 F 而不是 P:通告是**披露**面不是执法面(不参与任何门/裁决),且运维面那一份(结构化日志 `engine_notice`)在分流之前就已经落定、一条不丢。此前是裸 `.catch(() => undefined)` —— 同文件 resume 腿的子代 forward 写口早在 C2/C5 批2 就因为「裸吞 = 事件从 durable log 永久消失且零信号」补过 `warnAppend`,本 tag 是同一条纪律在通告口的落实(warn 留细节 + 计数让「系统性在漏」显形)。不做重试队列是刻意的:账本里没有任何一族行做有界重试,单给通告开一条会造出第二套持久化语义。",
|
|
130
|
+
},
|
|
131
|
+
"server.engine-notice.wire-sink-threw": {
|
|
132
|
+
cls: "F",
|
|
133
|
+
note: "#310:`engine_notice` 分流器把一条白名单通告投给某条 run 腿的登记口时,那只口抛了(live 口写向已断/已撕裂的 SSE socket,或 durable 口的账本写同步抛)。放行的最坏后果 = **这一条通告的这一个终点**缺席:①日志终点在分流之前已经逐字打过(事实一条不丢);②两个终点注册成两只独立 sink ⇒ live 抛不牵连 durable(断连后仍看得见的那半保住);③同会话其余口照投。故 F 类。不放行的代价是把异常回抛给 core 的 `deliverEngineNotice` —— 它会吞掉,于是同一次失败**既没有留痕也没有第二只口**,正是本 tag 要根除的形。留痕是承重的:静默吞掉之后「wire 腿为什么总有几条通告不到」在遥测里与「core 本来就没发」同形。",
|
|
134
|
+
},
|
|
127
135
|
"server.model-key.off-route-model-uses-gateway-key": {
|
|
128
136
|
cls: "P-DEBT",
|
|
129
|
-
note: "A-057.59:一只 **baseUrl 指向别家主机**(非本部署 `gatewayBaseUrl`)的目录模型**没有** per-model key ⇒ `
|
|
137
|
+
note: "A-057.59:一只 **baseUrl 指向别家主机**(非本部署 `gatewayBaseUrl`)的目录模型**没有** per-model key ⇒ `createPerModelAuthSeat` 回 `undefined`(side-query 面 = core 5.46.0 的 `SideQuerySpec.getApiKeyAndHeaders` 座位;WebFetch 摘要面 = 同一只座位包在 `createPerModelKeyBrain` 里,那面 core 至今无座位)⇒ core 的 `options?.apiKey ?? config.apiKey` 回落**主网关 key**,而同一行下面的 `model.baseUrl || config.baseUrl` 仍把请求路由到那台外部主机 —— 这一次调用真把共享网关凭据发给了运维在 config-center 里指定的另一家。方向明知不对(hook 面对逐字同形的场景是 TRUE fail-closed:「refusing to send the prompt off-gateway」),之所以记债而不是当场收严:该缺席语义与**主推理链**(`dist/engine/harness/agent-harness.js` 的 `auth?.apiKey !== undefined` 臂)逐字同语义,只收严 wrapper 这两面会造成「同一只模型跑任务能用、问一句 401」,且「同账号多网关主机/多区域」是既有且正当的部署形、无旋钮可退。计数覆盖两面(side-query / WebFetch 摘要),`detail` = 模型名。⚠️ 判据需要参照系:调用方没给 `gatewayBaseUrl` ⇒ **不计**(宁可漏计不可错计),故计数是**下限**。终局收口 = 跨面收严件 #309(三面同批 + 旋钮 + 表态制),届时本 tag 同批销。",
|
|
130
138
|
},
|
|
131
139
|
};
|
|
132
140
|
/**
|
|
@@ -24,6 +24,8 @@ import { isUniqueViolation } from "./memory-engine-vector-util.js";
|
|
|
24
24
|
import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
|
|
25
25
|
import { pgSafeJsonStringify, pgHasUnstorable } from "./pg-safe-json.js";
|
|
26
26
|
import { assertMemoryScopeWidth, memoryEntryKeyWidthRefusal } from "./memory-key-guards.js"; // R5 批γ + #271 件1:scope/slug 写前宽守卫
|
|
27
|
+
// #307 件6(core 5.46.0 design/336 §2.3-2):external-origin 不可变律 —— 两支 SQL 孪生共用**一份**判据。
|
|
28
|
+
import { createBatchOriginBaseline, originWhitewashRefusal, rememberBatchOrigin, unknownPatchOpRefusal } from "./memory-origin-law.js";
|
|
27
29
|
/** Table names (single source). Deliberately DISJOINT from the legacy `agent_memory*` tables —
|
|
28
30
|
* the retired MemoryStore plane and this entry plane must never cross-write. */
|
|
29
31
|
export const PG_MEMORY_ENGINE_TABLES = {
|
|
@@ -224,6 +226,10 @@ export class PgMemoryEngineBackend {
|
|
|
224
226
|
const plannedSlugs = new Set();
|
|
225
227
|
// 142-S5.1: one history row per APPLIED patch, flushed as one batch below (append-only audit face).
|
|
226
228
|
const historyRows = [];
|
|
229
|
+
// #307 件6(design/336 §2.3-2):external-origin 不可变律的**批次基线**。本后端逐条 patch 直打真库,
|
|
230
|
+
// 所以同批 delete 之后再 add 时库里已无该行 —— 只看当下会把它误判成「合法新生命」。按本批第一次
|
|
231
|
+
// 触碰记下已提交 origin,整批按那一份判(理由与两半判据见 memory-origin-law.ts 顶注)。
|
|
232
|
+
const originBaseline = createBatchOriginBaseline();
|
|
227
233
|
for (const patch of patches) {
|
|
228
234
|
try {
|
|
229
235
|
if (patch.op !== "delete") {
|
|
@@ -233,7 +239,7 @@ export class PgMemoryEngineBackend {
|
|
|
233
239
|
}
|
|
234
240
|
nonDeleteIds.add(patch.id);
|
|
235
241
|
}
|
|
236
|
-
await this.applyOne(patch, report, plannedSlugs, historyRows);
|
|
242
|
+
await this.applyOne(patch, report, plannedSlugs, historyRows, originBaseline);
|
|
237
243
|
}
|
|
238
244
|
catch (err) {
|
|
239
245
|
report.conflicts.push({ op: patch.op, id: patch.id, reason: `io error: ${err instanceof Error ? err.message : String(err)}` });
|
|
@@ -256,8 +262,18 @@ export class PgMemoryEngineBackend {
|
|
|
256
262
|
}
|
|
257
263
|
return report;
|
|
258
264
|
}
|
|
259
|
-
async applyOne(patch, report, plannedSlugs, historyRows) {
|
|
265
|
+
async applyOne(patch, report, plannedSlugs, historyRows, originBaseline) {
|
|
260
266
|
const T = PG_MEMORY_ENGINE_TABLES.entry;
|
|
267
|
+
// #307 件6(codex R1-[high]):op 闭集**前置**判 —— 词表外的 op 会跳过两道白洗守卫(它们都写成
|
|
268
|
+
// `if (patch.op === "update")`)、落进 update 的 SQL 并报成一次 applied。零 I/O 拒绝(理由与
|
|
269
|
+
// 今日可达性见 memory-origin-law.ts 的 unknownPatchOpRefusal 顶注;tidb 孪生同处同判)。
|
|
270
|
+
{
|
|
271
|
+
const refusal = unknownPatchOpRefusal(patch.op);
|
|
272
|
+
if (refusal !== undefined) {
|
|
273
|
+
report.conflicts.push({ op: patch.op, id: patch.id, reason: refusal });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
261
277
|
// A patch whose entry carries a DIFFERENT id than the patch addresses is
|
|
262
278
|
// malformed — the two backends would otherwise diverge (File writes the entry's id, Pg would
|
|
263
279
|
// key the SELECT on patch.id but the write on entry.id). Refused up front, both backends alike.
|
|
@@ -299,8 +315,20 @@ export class PgMemoryEngineBackend {
|
|
|
299
315
|
// produces this input (harvest/materialize are scope-local); a conflict report is the honest
|
|
300
316
|
// fail-closed answer. Same-scope re-add stays the idempotent overwrite (File parity).
|
|
301
317
|
// One probe serves BOTH the E-02 guard below and the cross-scope refusal (File-backend parity).
|
|
302
|
-
const prior = await this.query(`SELECT scope, slug, rev FROM ${T} WHERE id = $1`, [entry.id]);
|
|
318
|
+
const prior = await this.query(`SELECT scope, slug, rev, frontmatter FROM ${T} WHERE id = $1`, [entry.id]);
|
|
303
319
|
const priorRow = prior.rows.length > 0 ? prior.rows[0] : undefined;
|
|
320
|
+
// #307 件6(design/336 §2.3-2)—— external-origin 不可变律,**判在 guard 之前**:core File backend
|
|
321
|
+
// 的 planOne 也是先判 origin 再判 `guard:"absent"`,而契约试剂盒对「带 guard 的洗白 add」要的是
|
|
322
|
+
// `/malformed patch refused/`;顺序反了会先报 `add_guard_absent_conflict`,同一输入两后端两个答复。
|
|
323
|
+
// 基线取「本批第一次触碰」那一份(同批 delete 之后的 re-add 照样被这条拒;已提交的墓碑才是合法出口)。
|
|
324
|
+
{
|
|
325
|
+
const committedOrigin = rememberBatchOrigin(originBaseline, entry.id, priorRow !== undefined ? fromJson(priorRow.frontmatter) : undefined);
|
|
326
|
+
const refusal = originWhitewashRefusal("add", committedOrigin, entry.frontmatter);
|
|
327
|
+
if (refusal !== undefined) {
|
|
328
|
+
report.conflicts.push({ op: "add", id: entry.id, reason: refusal });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
304
332
|
// E-02 add-if-absent guard (core 1.275.1/1.276.0 contract clause, design/142 S2.5): a
|
|
305
333
|
// `guard:"absent"` add must NEVER blind-overwrite an existing id — same rev AND same
|
|
306
334
|
// (scope, slug) projection = idempotent fall-through; different rev OR different projection =
|
|
@@ -387,8 +415,11 @@ export class PgMemoryEngineBackend {
|
|
|
387
415
|
// BEFORE the rev CAS): an update may not strip provenance/trust off a repo-ingested entry. Judged on
|
|
388
416
|
// the committed frontmatter of THIS SELECT — a write racing past it flips rev and the conditional
|
|
389
417
|
// write below re-reports as a CAS conflict, so the window stays closed.
|
|
418
|
+
const committedFm = fromJson(cur.rows[0].frontmatter);
|
|
419
|
+
// #307 件6:**delete 腿也要记基线** —— 同批「delete 掉一条带标记的行、再 add 一条不带标记的」正是
|
|
420
|
+
// 洗白的最直接写法,而 delete 一旦落库,后面那条 add 的 SELECT 就什么都看不到了。
|
|
421
|
+
const committedOrigin = rememberBatchOrigin(originBaseline, patch.id, committedFm);
|
|
390
422
|
if (patch.op === "update" && patch.entry !== undefined) {
|
|
391
|
-
const committedFm = fromJson(cur.rows[0].frontmatter);
|
|
392
423
|
if (committedFm.provenance?.kind === "repo_file") {
|
|
393
424
|
const next = patch.entry.frontmatter;
|
|
394
425
|
const stripsProvenance = next.provenance === undefined;
|
|
@@ -402,6 +433,13 @@ export class PgMemoryEngineBackend {
|
|
|
402
433
|
return;
|
|
403
434
|
}
|
|
404
435
|
}
|
|
436
|
+
// #307 件6:origin 不可变律的 update 腿。顺序与 core File backend 逐字同 —— repo_file 白洗判据
|
|
437
|
+
// 在前、origin 在后、两者都在 rev CAS 之前(judged on the committed frontmatter of THIS SELECT)。
|
|
438
|
+
const refusal = originWhitewashRefusal("update", committedOrigin, patch.entry.frontmatter);
|
|
439
|
+
if (refusal !== undefined) {
|
|
440
|
+
report.conflicts.push({ op: "update", id: patch.id, reason: refusal });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
405
443
|
}
|
|
406
444
|
if (patch.baseRev !== undefined && patch.baseRev !== currentRev) {
|
|
407
445
|
// Per-id CAS (§2.4 并发): NEVER blind-write over a concurrent change. No shadow-restore leg here —
|
|
@@ -25,6 +25,8 @@ import { jaccardDistance, termSet } from "@sema-agent/core";
|
|
|
25
25
|
import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
|
|
26
26
|
import { pgHasUnstorable } from "./pg-safe-json.js";
|
|
27
27
|
import { assertMemoryScopeWidth, memoryEntryKeyWidthRefusal } from "./memory-key-guards.js"; // R5 批γ + #271 件1:scope/slug 写前宽守卫
|
|
28
|
+
// #307 件6(core 5.46.0 design/336 §2.3-2):external-origin 不可变律 —— 与 Pg 孪生共用**一份**判据。
|
|
29
|
+
import { createBatchOriginBaseline, originWhitewashRefusal, rememberBatchOrigin, unknownPatchOpRefusal } from "./memory-origin-law.js";
|
|
28
30
|
import { isMysqlDupKeyError } from "./sql-errors.js";
|
|
29
31
|
/** Table names (single source) — SAME names as PG_MEMORY_ENGINE_TABLES (the two dialects never share
|
|
30
32
|
* one database), deliberately DISJOINT from the legacy `agent_memory*` MemoryStore plane. */
|
|
@@ -178,6 +180,9 @@ export class TiDBMemoryEngineBackend {
|
|
|
178
180
|
const plannedSlugs = new Set();
|
|
179
181
|
// 142-S5.1: one history row per APPLIED patch, flushed as one batch below (append-only audit face).
|
|
180
182
|
const historyRows = [];
|
|
183
|
+
// #307 件6(design/336 §2.3-2):external-origin 不可变律的**批次基线**(Pg 版同注)。逐条 patch 直打
|
|
184
|
+
// 真库 ⇒ 同批 delete 之后再 add 时库里已无该行,只看当下会误判成「合法新生命」。
|
|
185
|
+
const originBaseline = createBatchOriginBaseline();
|
|
181
186
|
for (const patch of patches) {
|
|
182
187
|
try {
|
|
183
188
|
if (patch.op !== "delete") {
|
|
@@ -187,7 +192,7 @@ export class TiDBMemoryEngineBackend {
|
|
|
187
192
|
}
|
|
188
193
|
nonDeleteIds.add(patch.id);
|
|
189
194
|
}
|
|
190
|
-
await this.applyOne(patch, report, plannedSlugs, historyRows);
|
|
195
|
+
await this.applyOne(patch, report, plannedSlugs, historyRows, originBaseline);
|
|
191
196
|
}
|
|
192
197
|
catch (err) {
|
|
193
198
|
report.conflicts.push({ op: patch.op, id: patch.id, reason: `io error: ${err instanceof Error ? err.message : String(err)}` });
|
|
@@ -210,8 +215,17 @@ export class TiDBMemoryEngineBackend {
|
|
|
210
215
|
}
|
|
211
216
|
return report;
|
|
212
217
|
}
|
|
213
|
-
async applyOne(patch, report, plannedSlugs, historyRows) {
|
|
218
|
+
async applyOne(patch, report, plannedSlugs, historyRows, originBaseline) {
|
|
214
219
|
const T = TIDB_MEMORY_ENGINE_TABLES.entry;
|
|
220
|
+
// #307 件6(codex R1-[high],Pg 版同注):op 闭集**前置**判 —— 词表外的 op 会跳过两道白洗守卫、
|
|
221
|
+
// 落进 update 的 SQL 并报成一次 applied。零 I/O 拒绝。
|
|
222
|
+
{
|
|
223
|
+
const refusal = unknownPatchOpRefusal(patch.op);
|
|
224
|
+
if (refusal !== undefined) {
|
|
225
|
+
report.conflicts.push({ op: patch.op, id: patch.id, reason: refusal });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
215
229
|
// A patch whose entry carries a DIFFERENT id than the patch addresses is malformed — refused up
|
|
216
230
|
// front, all backends alike (Pg 版同注).
|
|
217
231
|
if (patch.op !== "delete" && patch.entry !== undefined && patch.entry.id !== patch.id) {
|
|
@@ -247,9 +261,20 @@ export class TiDBMemoryEngineBackend {
|
|
|
247
261
|
// Cross-scope add refusal (opus 审 C3,Pg 版同注): an add whose id already lives in a DIFFERENT
|
|
248
262
|
// scope must not silently MOVE the row; same-scope re-add stays the idempotent overwrite.
|
|
249
263
|
// One probe serves BOTH the E-02 guard and the cross-scope refusal (File/Pg parity).
|
|
250
|
-
const prior = await this.selectRows(`SELECT scope, slug, rev FROM ${T} WHERE id = ?`, [entry.id]);
|
|
264
|
+
const prior = await this.selectRows(`SELECT scope, slug, rev, frontmatter FROM ${T} WHERE id = ?`, [entry.id]);
|
|
251
265
|
const priorRow = prior.length > 0 ? prior[0] : undefined;
|
|
252
266
|
let existsById = priorRow !== undefined;
|
|
267
|
+
// #307 件6(design/336 §2.3-2)—— external-origin 不可变律,**判在 guard 之前**(Pg 版同注:core
|
|
268
|
+
// File backend 的 planOne 同序;顺序反了带 guard 的洗白 add 会先报 add_guard_absent_conflict,
|
|
269
|
+
// 而契约试剂盒要的是 /malformed patch refused/,同一输入两后端两个答复)。
|
|
270
|
+
{
|
|
271
|
+
const committedOrigin = rememberBatchOrigin(originBaseline, entry.id, priorRow !== undefined ? fromJson(priorRow.frontmatter) : undefined);
|
|
272
|
+
const refusal = originWhitewashRefusal("add", committedOrigin, entry.frontmatter);
|
|
273
|
+
if (refusal !== undefined) {
|
|
274
|
+
report.conflicts.push({ op: "add", id: entry.id, reason: refusal });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
253
278
|
// E-02 add-if-absent guard (core 1.275.1/1.276.0 contract clause; caught live by the contract
|
|
254
279
|
// suite on real TiDB 2026-07-13): same rev AND same (scope, slug) projection = idempotent
|
|
255
280
|
// fall-through; different rev OR projection = REPORTED add_guard_absent_conflict + currentRev.
|
|
@@ -352,8 +377,11 @@ export class TiDBMemoryEngineBackend {
|
|
|
352
377
|
// BEFORE the rev CAS): an update may not strip provenance/trust off a repo-ingested entry. Judged on
|
|
353
378
|
// the committed frontmatter of THIS SELECT — a write racing past it flips rev and the conditional
|
|
354
379
|
// write below re-reports as a CAS conflict, so the window stays closed.
|
|
380
|
+
const committedFm = fromJson(cur[0].frontmatter);
|
|
381
|
+
// #307 件6:delete 腿也要记基线(Pg 版同注)—— 同批「delete 掉带标记的行、再 add 一条不带标记的」
|
|
382
|
+
// 正是洗白的最直接写法,而 delete 一旦落库,后面那条 add 的 SELECT 什么都看不到。
|
|
383
|
+
const committedOrigin = rememberBatchOrigin(originBaseline, patch.id, committedFm);
|
|
355
384
|
if (patch.op === "update" && patch.entry !== undefined) {
|
|
356
|
-
const committedFm = fromJson(cur[0].frontmatter);
|
|
357
385
|
if (committedFm.provenance?.kind === "repo_file") {
|
|
358
386
|
const next = patch.entry.frontmatter;
|
|
359
387
|
const stripsProvenance = next.provenance === undefined;
|
|
@@ -367,6 +395,12 @@ export class TiDBMemoryEngineBackend {
|
|
|
367
395
|
return;
|
|
368
396
|
}
|
|
369
397
|
}
|
|
398
|
+
// #307 件6:origin 不可变律的 update 腿(Pg 版同注:repo_file 在前、origin 在后、两者都在 rev CAS 之前)。
|
|
399
|
+
const refusal = originWhitewashRefusal("update", committedOrigin, patch.entry.frontmatter);
|
|
400
|
+
if (refusal !== undefined) {
|
|
401
|
+
report.conflicts.push({ op: "update", id: patch.id, reason: refusal });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
370
404
|
}
|
|
371
405
|
if (patch.baseRev !== undefined && patch.baseRev !== currentRev) {
|
|
372
406
|
// Per-id CAS (§2.4 并发): NEVER blind-write over a concurrent change. ABA note shared with the
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #307 件6(core 5.46.0 提货批,design/336 §2.3-2)—— **external-origin 标记不可变律**的 SQL 孪生实现。
|
|
3
|
+
*
|
|
4
|
+
* ## 这条法是什么、为什么落在本仓
|
|
5
|
+
*
|
|
6
|
+
* design/336 slice 1 给「已暴露会话」的普通记忆写打上引擎铸的 `frontmatter.origin` 标记(taint +
|
|
7
|
+
* 成因闭集 + 铸造时刻),取代 design/178 那种「整条隔离」的全禁。标记的全部价值来自**不可洗白**:
|
|
8
|
+
* 一旦某个 id 的**已提交**状态带了标记,之后**任何**写法(update / 裸 add / `guard:"absent"` add /
|
|
9
|
+
* 同批 delete+re-add)都必须把它 DEEP-EQUAL 地带下去,否则拒绝。合法出口只有一个 —— **已提交的**
|
|
10
|
+
* 墓碑(上一批真的 delete 掉了),那之后这个 id 开始一段未标记的新生命。
|
|
11
|
+
*
|
|
12
|
+
* core 5.46.0 把这条法写进了**记忆后端契约试剂盒**
|
|
13
|
+
* (`dist/core/memory-engine/memory-backend-contract.js`,新增一条必跑条目),而该契约**压在本仓身上**:
|
|
14
|
+
* core 只养 File backend,Pg / TiDB 两方言归 server。提货当天真双库实跑的红读数(A-002.21 教训的
|
|
15
|
+
* 直接兑现,不是自我声明「本批无 SQL 面」):
|
|
16
|
+
* `[engine=tidb] contract case "design/336: origin marker round-trips; …":
|
|
17
|
+
* an origin-stripping update must not apply — 1 !== 0`
|
|
18
|
+
* ⇒ 两只 SQL 孪生此前**没有**这条法:一条被标记为外部来源的记忆条目,可以被后续一次普通 update
|
|
19
|
+
* 悄悄抹掉标记(洗白),而 File backend 会拒。这是安全轴上的真缺口,不是「新契约还没接」。
|
|
20
|
+
*
|
|
21
|
+
* ## 为什么是一个共享模块而不是两处各写一遍
|
|
22
|
+
*
|
|
23
|
+
* 本仓的成文教训(`key-resolver.ts` 顶注「链只有一条,重复实现就会长出半条链的偏差」;
|
|
24
|
+
* `memory-key-guards.ts` 同族先例):两支方言各写一份判据,迟早在某一支上漏掉一个 op 拼写 ——
|
|
25
|
+
* 而漏掉的那一支**没有任何报错面**,表现只是「这台机器上的标记可以被洗掉」。判据函数与批次基线
|
|
26
|
+
* 记录器都在这里,两支后端只负责在自己的 I/O 点上调用。
|
|
27
|
+
*
|
|
28
|
+
* ## 判据的两半(缺一不可)
|
|
29
|
+
*
|
|
30
|
+
* ① **判据本身**({@link originWhitewashRefusal}):`committedOriginOf` / `originEquals` 都用 core 的
|
|
31
|
+
* 导出实现,**不重写** —— 它们要处理 `frontmatter.origin` 与「保留在 `extra` 里的 origin 形字节块」
|
|
32
|
+
* 两种承载形(legacy / 外来),自己写一份近似的等于给洗白留一条后门。
|
|
33
|
+
* ② **批次基线**({@link rememberBatchOrigin}):两支 SQL 后端逐条 patch 直打真库(没有 File backend
|
|
34
|
+
* 那种「先规划整批再落盘」的阶段),所以同批 `delete` 之后再 `add` 时,库里已经没有那一行了 ——
|
|
35
|
+
* 只看当下的库会把它误判成合法新生命。基线 map 按**本批第一次触碰**记下该 id 的已提交 origin,
|
|
36
|
+
* 之后同批的任何 op 都按那一份判。⇒「同批 delete+re-add 拒 / 已提交墓碑后 add 放」这条分界
|
|
37
|
+
* (core 契约逐字要求的那条)才成立。
|
|
38
|
+
*/
|
|
39
|
+
import { type MemoryEntryFrontmatter, type MemoryEntryOrigin } from "@sema-agent/core";
|
|
40
|
+
/** 本批次的「已提交 origin」基线:id → 本批**第一次**触碰它时库里那一份(无行 ⇒ `undefined`)。 */
|
|
41
|
+
export type BatchOriginBaseline = Map<string, MemoryEntryOrigin | undefined>;
|
|
42
|
+
/** 新建一只批次基线(每次 `applyPatches` 一只;跨批次**不得**复用 —— 已提交的墓碑正是靠「换批清零」放行)。 */
|
|
43
|
+
export declare function createBatchOriginBaseline(): BatchOriginBaseline;
|
|
44
|
+
/**
|
|
45
|
+
* 记下(或取回)某 id 在**本批开始时**的已提交 origin。
|
|
46
|
+
*
|
|
47
|
+
* `committedFm` = 这一次 I/O 真的从库里读到的已提交 frontmatter(没有行就传 `undefined`)。
|
|
48
|
+
* **首次触碰写入,之后只读** —— 同批里后面的 op 看到的必须还是批开始那一份,否则本批自己的写会把
|
|
49
|
+
* 基线推走(delete 之后 re-add 就又变成「库里没有 ⇒ 未标记」)。
|
|
50
|
+
*/
|
|
51
|
+
export declare function rememberBatchOrigin(baseline: BatchOriginBaseline, id: string, committedFm: MemoryEntryFrontmatter | undefined): MemoryEntryOrigin | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* 未知 op 拼写的**前置**拒绝(codex R1-[high],验真后采纳;**既有**缺口,不是本批引入)。
|
|
54
|
+
*
|
|
55
|
+
* 病:两支 SQL 后端的 `applyOne` 形状是「`if (op === "add") {…}` → 落到 update/delete 段」,而段内
|
|
56
|
+
* 的两道白洗守卫(repo_file provenance / 本模块的 origin 不可变律)都写成 `if (patch.op === "update")`。
|
|
57
|
+
* ⇒ 一个**词表外**的 op(`"upsert"` / 打错的字 / 将来 core 加的新词)会:跳过两道守卫 → 走到 update
|
|
58
|
+
* 的 SQL → 把行改掉 → 在 `report.applied` 里报成一次 update。TypeScript 的联合型是**编译期**的,
|
|
59
|
+
* 拦不住任何运行期调用方(sync 腿、嵌入方自建的调用点、future core)。
|
|
60
|
+
*
|
|
61
|
+
* 今日可达性(如实):本仓唯一的调用点 `src/memory-sync.ts` 自己铸 op(基线有 ⇒ update、无 ⇒ add),
|
|
62
|
+
* 词表外的值到不了。所以这条是**加固**而不是在修一次已发生的事故 —— 但它落在安全轴(白洗守卫的
|
|
63
|
+
* 有效性完全依赖「op 只有三种」这个未被检查的前提),按 CLAUDE.md #157「闭集 + 不可避免的运行期
|
|
64
|
+
* miss 臂必须说出来」补一道显式拒绝:未知 op **零写**,报 `malformed patch refused`(与本仓既有的
|
|
65
|
+
* id-mismatch 拒绝同族文案,消费方按同一条正则分类)。
|
|
66
|
+
*/
|
|
67
|
+
export declare function unknownPatchOpRefusal(op: string): string | undefined;
|
|
68
|
+
export declare function originWhitewashRefusal(op: "add" | "update", committed: MemoryEntryOrigin | undefined, next: MemoryEntryFrontmatter): string | undefined;
|
|
69
|
+
//# sourceMappingURL=memory-origin-law.d.ts.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #307 件6(core 5.46.0 提货批,design/336 §2.3-2)—— **external-origin 标记不可变律**的 SQL 孪生实现。
|
|
3
|
+
*
|
|
4
|
+
* ## 这条法是什么、为什么落在本仓
|
|
5
|
+
*
|
|
6
|
+
* design/336 slice 1 给「已暴露会话」的普通记忆写打上引擎铸的 `frontmatter.origin` 标记(taint +
|
|
7
|
+
* 成因闭集 + 铸造时刻),取代 design/178 那种「整条隔离」的全禁。标记的全部价值来自**不可洗白**:
|
|
8
|
+
* 一旦某个 id 的**已提交**状态带了标记,之后**任何**写法(update / 裸 add / `guard:"absent"` add /
|
|
9
|
+
* 同批 delete+re-add)都必须把它 DEEP-EQUAL 地带下去,否则拒绝。合法出口只有一个 —— **已提交的**
|
|
10
|
+
* 墓碑(上一批真的 delete 掉了),那之后这个 id 开始一段未标记的新生命。
|
|
11
|
+
*
|
|
12
|
+
* core 5.46.0 把这条法写进了**记忆后端契约试剂盒**
|
|
13
|
+
* (`dist/core/memory-engine/memory-backend-contract.js`,新增一条必跑条目),而该契约**压在本仓身上**:
|
|
14
|
+
* core 只养 File backend,Pg / TiDB 两方言归 server。提货当天真双库实跑的红读数(A-002.21 教训的
|
|
15
|
+
* 直接兑现,不是自我声明「本批无 SQL 面」):
|
|
16
|
+
* `[engine=tidb] contract case "design/336: origin marker round-trips; …":
|
|
17
|
+
* an origin-stripping update must not apply — 1 !== 0`
|
|
18
|
+
* ⇒ 两只 SQL 孪生此前**没有**这条法:一条被标记为外部来源的记忆条目,可以被后续一次普通 update
|
|
19
|
+
* 悄悄抹掉标记(洗白),而 File backend 会拒。这是安全轴上的真缺口,不是「新契约还没接」。
|
|
20
|
+
*
|
|
21
|
+
* ## 为什么是一个共享模块而不是两处各写一遍
|
|
22
|
+
*
|
|
23
|
+
* 本仓的成文教训(`key-resolver.ts` 顶注「链只有一条,重复实现就会长出半条链的偏差」;
|
|
24
|
+
* `memory-key-guards.ts` 同族先例):两支方言各写一份判据,迟早在某一支上漏掉一个 op 拼写 ——
|
|
25
|
+
* 而漏掉的那一支**没有任何报错面**,表现只是「这台机器上的标记可以被洗掉」。判据函数与批次基线
|
|
26
|
+
* 记录器都在这里,两支后端只负责在自己的 I/O 点上调用。
|
|
27
|
+
*
|
|
28
|
+
* ## 判据的两半(缺一不可)
|
|
29
|
+
*
|
|
30
|
+
* ① **判据本身**({@link originWhitewashRefusal}):`committedOriginOf` / `originEquals` 都用 core 的
|
|
31
|
+
* 导出实现,**不重写** —— 它们要处理 `frontmatter.origin` 与「保留在 `extra` 里的 origin 形字节块」
|
|
32
|
+
* 两种承载形(legacy / 外来),自己写一份近似的等于给洗白留一条后门。
|
|
33
|
+
* ② **批次基线**({@link rememberBatchOrigin}):两支 SQL 后端逐条 patch 直打真库(没有 File backend
|
|
34
|
+
* 那种「先规划整批再落盘」的阶段),所以同批 `delete` 之后再 `add` 时,库里已经没有那一行了 ——
|
|
35
|
+
* 只看当下的库会把它误判成合法新生命。基线 map 按**本批第一次触碰**记下该 id 的已提交 origin,
|
|
36
|
+
* 之后同批的任何 op 都按那一份判。⇒「同批 delete+re-add 拒 / 已提交墓碑后 add 放」这条分界
|
|
37
|
+
* (core 契约逐字要求的那条)才成立。
|
|
38
|
+
*/
|
|
39
|
+
import { committedOriginOf, originEquals } from "@sema-agent/core";
|
|
40
|
+
/** 新建一只批次基线(每次 `applyPatches` 一只;跨批次**不得**复用 —— 已提交的墓碑正是靠「换批清零」放行)。 */
|
|
41
|
+
export function createBatchOriginBaseline() {
|
|
42
|
+
return new Map();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 记下(或取回)某 id 在**本批开始时**的已提交 origin。
|
|
46
|
+
*
|
|
47
|
+
* `committedFm` = 这一次 I/O 真的从库里读到的已提交 frontmatter(没有行就传 `undefined`)。
|
|
48
|
+
* **首次触碰写入,之后只读** —— 同批里后面的 op 看到的必须还是批开始那一份,否则本批自己的写会把
|
|
49
|
+
* 基线推走(delete 之后 re-add 就又变成「库里没有 ⇒ 未标记」)。
|
|
50
|
+
*/
|
|
51
|
+
export function rememberBatchOrigin(baseline, id, committedFm) {
|
|
52
|
+
if (baseline.has(id))
|
|
53
|
+
return baseline.get(id);
|
|
54
|
+
const origin = committedFm !== undefined ? committedOriginOf(committedFm) : undefined;
|
|
55
|
+
baseline.set(id, origin);
|
|
56
|
+
return origin;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 不可变律的判据。返回 `undefined` = 放行;返回字符串 = 该字符串就是 `PatchReport.conflicts[].reason`。
|
|
60
|
+
*
|
|
61
|
+
* 文案与 core File backend 的两处**逐字同形**(`external-origin marker whitewash refused: the
|
|
62
|
+
* {add,update} strips or rewrites the origin marker of a marked entry (malformed patch refused)`):
|
|
63
|
+
* 契约试剂盒按 `/malformed patch refused/` 匹配,而跨后端同一输入必须给同一个答复 —— 消费方(引擎的
|
|
64
|
+
* harvest 腿、sync 对拍)按 reason 分类处置,两支方言各写各的文案就等于让同一件事在不同部署上分叉。
|
|
65
|
+
*
|
|
66
|
+
* 未标记的已提交条目(`committed === undefined`)一律放行:本法只约束**已经**带标记的那些 id,
|
|
67
|
+
* 「给未标记条目加标记」是引擎写路径自己的事(server 不铸 origin,见台账的 skip 行)。
|
|
68
|
+
*/
|
|
69
|
+
/** `NotePatch.op` 的运行期闭集(与 core 的联合逐字同)。 */
|
|
70
|
+
const PATCH_OPS = new Set(["add", "update", "delete"]);
|
|
71
|
+
/**
|
|
72
|
+
* 未知 op 拼写的**前置**拒绝(codex R1-[high],验真后采纳;**既有**缺口,不是本批引入)。
|
|
73
|
+
*
|
|
74
|
+
* 病:两支 SQL 后端的 `applyOne` 形状是「`if (op === "add") {…}` → 落到 update/delete 段」,而段内
|
|
75
|
+
* 的两道白洗守卫(repo_file provenance / 本模块的 origin 不可变律)都写成 `if (patch.op === "update")`。
|
|
76
|
+
* ⇒ 一个**词表外**的 op(`"upsert"` / 打错的字 / 将来 core 加的新词)会:跳过两道守卫 → 走到 update
|
|
77
|
+
* 的 SQL → 把行改掉 → 在 `report.applied` 里报成一次 update。TypeScript 的联合型是**编译期**的,
|
|
78
|
+
* 拦不住任何运行期调用方(sync 腿、嵌入方自建的调用点、future core)。
|
|
79
|
+
*
|
|
80
|
+
* 今日可达性(如实):本仓唯一的调用点 `src/memory-sync.ts` 自己铸 op(基线有 ⇒ update、无 ⇒ add),
|
|
81
|
+
* 词表外的值到不了。所以这条是**加固**而不是在修一次已发生的事故 —— 但它落在安全轴(白洗守卫的
|
|
82
|
+
* 有效性完全依赖「op 只有三种」这个未被检查的前提),按 CLAUDE.md #157「闭集 + 不可避免的运行期
|
|
83
|
+
* miss 臂必须说出来」补一道显式拒绝:未知 op **零写**,报 `malformed patch refused`(与本仓既有的
|
|
84
|
+
* id-mismatch 拒绝同族文案,消费方按同一条正则分类)。
|
|
85
|
+
*/
|
|
86
|
+
export function unknownPatchOpRefusal(op) {
|
|
87
|
+
if (PATCH_OPS.has(op))
|
|
88
|
+
return undefined;
|
|
89
|
+
return `unknown patch op ${JSON.stringify(op)} (expected add | update | delete — malformed patch refused)`;
|
|
90
|
+
}
|
|
91
|
+
export function originWhitewashRefusal(op, committed, next) {
|
|
92
|
+
if (committed === undefined)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (originEquals(committed, committedOriginOf(next)))
|
|
95
|
+
return undefined;
|
|
96
|
+
return `external-origin marker whitewash refused: the ${op} strips or rewrites the origin marker of a marked entry (malformed patch refused)`;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=memory-origin-law.js.map
|
|
@@ -135,6 +135,13 @@ export interface SqlRetentionStoreOptions {
|
|
|
135
135
|
* 就是静默 fail-open(附件行从此永不被留存清理,而没有任何人知道)。
|
|
136
136
|
*/
|
|
137
137
|
taskAttachmentTable: boolean;
|
|
138
|
+
/**
|
|
139
|
+
* #318 `task_list_meta` / `task_list_item` 两表是否在场。**必填,没有默认值**(与
|
|
140
|
+
* `taskAttachmentTable` 同一条理由:盲发 DELETE 会在没建表的库上把整轮 sweep 打红)。
|
|
141
|
+
* 生产上 `boot/task-list-lane.ts` 在**任何 SQL 后端**都无条件 ensure 这两张表 ⇒ 装配层照实传 true;
|
|
142
|
+
* 纯内存/local 车道压根没有 SQL 留存腿。
|
|
143
|
+
*/
|
|
144
|
+
taskListTables: boolean;
|
|
138
145
|
/** 诊断日志座(可选)。 */
|
|
139
146
|
logger?: {
|
|
140
147
|
info?(msg: string, meta?: unknown): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mysqlDriver, pgDriver } from "./sql-driver.js";
|
|
2
|
+
import { TASK_LIST_ITEM_TABLE, TASK_LIST_META_TABLE, taskListKeyFor } from "./task-list-store-sql.js";
|
|
2
3
|
/**
|
|
3
4
|
* 三只**被托管**的 SQL 店(session / checkpoint / tool-result)共用的 `retention` 声明常量。
|
|
4
5
|
*
|
|
@@ -690,6 +691,29 @@ export class SqlRetentionStore {
|
|
|
690
691
|
const s = set(col, 1);
|
|
691
692
|
await conn.query(`DELETE FROM ${table} WHERE ${s.sql}`, s.params);
|
|
692
693
|
}
|
|
694
|
+
// ④a #318 任务清单:与上面那批同族(per-session 用户内容,随会话删),但**键列不是 session_id**
|
|
695
|
+
// —— 本表按 `list_key` 分区,而会话的 list_key 是 sessionId 的确定性派生({@link taskListKeyFor},
|
|
696
|
+
// 属主在 task-list-store-sql.ts;在线协调器走同一只函数)。所以单独一格,谓词喂**派生后**的键集。
|
|
697
|
+
// meta 行(高水位 next_id)必须一起删:留着它,同一个会话 id 被别人重新登记后新主的第一条任务
|
|
698
|
+
// 会从旧主的高水位续号 —— 跨化身信息泄漏,与在线腿 `TaskListLane.deleteBySession` 的判据逐字同源。
|
|
699
|
+
// 🔴 **加锁次序必须与 `mutate` 同向**(codex 对抗复审 R2-[high],验真后修):清单的写事务是
|
|
700
|
+
// `meta 行 FOR UPDATE`(分区锁)→ 再读写 item 行;本腿若按 item→meta 的次序删,两条事务就是
|
|
701
|
+
// 经典的反向持锁 —— 留存持着 item 行等 meta,mutate 持着 meta 等 item,数据库挑一个牺牲者:
|
|
702
|
+
// 要么用户的一次 TaskUpdate 报错,要么**整轮**跨十余张表的留存事务回滚。所以先把本批 list_key
|
|
703
|
+
// 的 meta 行按**确定性次序**(list_key 升序)全锁住,再删 item、再删 meta —— 与在线腿
|
|
704
|
+
// `deleteTiDBTaskList`/`deletePgTaskList` 的事务姿势逐字同向。
|
|
705
|
+
// (meta 行不在 = 该会话从未写过任务:FOR UPDATE 锁 0 行,两条 DELETE 各命中 0 行,幂等。)
|
|
706
|
+
if (this.opts.taskListTables) {
|
|
707
|
+
const listKeys = ids.map((id) => taskListKeyFor(id)).sort();
|
|
708
|
+
{
|
|
709
|
+
const s = this.idSet("list_key", listKeys, 1);
|
|
710
|
+
await conn.query(`SELECT list_key FROM ${TASK_LIST_META_TABLE} WHERE ${s.sql} ORDER BY list_key FOR UPDATE`, s.params);
|
|
711
|
+
}
|
|
712
|
+
for (const table of [TASK_LIST_ITEM_TABLE, TASK_LIST_META_TABLE]) {
|
|
713
|
+
const s = this.idSet("list_key", listKeys, 1);
|
|
714
|
+
await conn.query(`DELETE FROM ${table} WHERE ${s.sql}`, s.params);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
693
717
|
// ④b workflow 完成信箱:**只删行,不写 purge 围栏**(三轮对抗复审打完的定谳;这一格看着像"少做了
|
|
694
718
|
// 一步",实际是在两种残余里选了伤害小的那一种,理由必须留全)。
|
|
695
719
|
// · 在线协调器 `SqlWorkflowCompletionInbox.purge` 先写围栏再删行,挡住与删除赛跑的 enqueue;
|
|
@@ -1,35 +1,46 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TaskListStore SQL twins(design/151 S3c server pg 半场,[1530] 提货单②)——core 团队共享任务
|
|
3
|
-
* 清单(TaskCreate/TaskUpdate/TaskList 工具族)的 TiDB/PG 双方言实现。挂载缝 = S3c 行为车定
|
|
4
|
-
* (createTaskListTools(store) 已收 store 参数);本文件先就绪 store 半场。
|
|
5
|
-
*
|
|
6
|
-
* 语义真源 = core file 实现(dist/stores/file/task-list-store.js,durable 对 durable)+ [1530] 定谳:
|
|
7
|
-
* 1. **`mutate` 必须后端事务原子**(接口 JSDoc codex S4 F2 句):read-modify-write 全序列进一个
|
|
8
|
-
* 事务;分区锁 = meta 行 `SELECT … FOR UPDATE`(天然 advisory lock,同清单 mutate 串行)。
|
|
9
|
-
* 2. **nextId 只增不回退、与分配同事务**(删最高 id 不复用):`task_list_meta` 单行携 `next_id`,
|
|
10
|
-
* **永不从存量 MAX 派生**。
|
|
11
|
-
* 3. metadata = JSON 列(`assertJsonMetadata` 在 set 面拦,file/memory 同形);id 任意 string
|
|
12
|
-
* (工具层走 allocateId 产数字串,接口不排除外部 id)。
|
|
13
|
-
*
|
|
14
|
-
* `list` 序:两个 core 参照本就不同(memory=Map 纯插入序;file=JS 对象序 = integer-like 键数字
|
|
15
|
-
* 升序、其余按插入序殿后)——SQL twin 对齐 **file 形**(durable 参照):`id_num` 物化列(canonical
|
|
16
|
-
* array-index 形才非 NULL)数字升序,非数字 id 按 `sort_seq`(插入序)殿后。set 覆盖已有 id 保位
|
|
17
|
-
* (sort_seq 不变)= 两参照一致的保位语义。`sort_seq` 从 **meta 行计数器同事务分配**(复审 F6:
|
|
18
|
-
* TiDB AUTO_INCREMENT 按 tidb-server 分段缓存,跨节点不单调——「插入序」在多网关部署下会倒序;
|
|
19
|
-
* meta 计数器 = allocateId 同姿势,真单调;覆盖写浪费一个号,单调性无损)。
|
|
20
|
-
*
|
|
21
|
-
* 分区键 `listKey` = 构造参数(file 形的 root 目录对位):谁的清单由挂载点决定(per-team 组装,
|
|
22
|
-
* S3c 行为车),store 层只管字节隔离(VARBINARY / COLLATE "C",roster F2 案)。PG unstorable
|
|
23
|
-
* bytes([1439]):item 正文(subject/description/metadata)= 内容面 lossy(pgSafeJsonStringify,
|
|
24
|
-
* U+FFFD);**身份键 listKey/id 拒绝式**(清洗形变 = 行与查询键错位)。TiDB verbatim。
|
|
25
|
-
*/
|
|
26
1
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
27
2
|
import type { Pool as PgPool } from "pg";
|
|
28
3
|
import { type TaskListStore } from "@sema-agent/core";
|
|
29
4
|
export declare const TASK_LIST_META_TABLE = "task_list_meta";
|
|
30
5
|
export declare const TASK_LIST_ITEM_TABLE = "task_list_item";
|
|
6
|
+
/**
|
|
7
|
+
* #318 `sessionId ↦ 本表的身份键`:`session:<可读前缀>-<sha256(sessionId) 前 16 位>`。
|
|
8
|
+
*
|
|
9
|
+
* 🔴 为什么不直接把 sessionId 当 listKey:sessionId 是**调用方可自报**的不透明串(提交门
|
|
10
|
+
* `http/server.ts` 只卡 ≤64 **字符** —— [#15] 刻意的宽松提交契约,不卡字节数也不卡字符集)。
|
|
11
|
+
* 而本文件的身份键是字节面严格的:{@link assertListKeyBytes} 对 >190 字节当场拒(TiDB
|
|
12
|
+
* `INSERT IGNORE` 静默截断案),PG twin 对孤代理/NUL 走 {@link PgUnstorableError} 拒。直接透传
|
|
13
|
+
* 的话,一个 64 字符全四字节 emoji 的会话(256 字节)会让**整条请求**在场景装配处 500 —— 而那条腿
|
|
14
|
+
* 只是待办清单。派生一个恒可存的键把这一整类失败模式消掉(既不是 fail-open 也不是 fail-loud,是让
|
|
15
|
+
* 那条臂不存在)。
|
|
16
|
+
*
|
|
17
|
+
* 判据:确定性(同 sessionId 跨副本/跨重启恒同键)、字节安全(纯 ASCII,≤ 8+24+1+16 = 49 字节)、
|
|
18
|
+
* 抗碰撞(摘要取**全串**,可读前缀只为运维肉眼对账)。
|
|
19
|
+
*
|
|
20
|
+
* `session:` 是**命名空间**位:design/147 §6 D2 的团队共享清单将来若按 team 分区,两族键同表不撞。
|
|
21
|
+
* 同族先例 = `env-facts.ts` 的 `scratchpadSessionSegment`(文件名面)——刻意**不**复用彼此的实现:
|
|
22
|
+
* 一个键空间改了算法不该把另一个键空间的存量行全孤儿化。
|
|
23
|
+
*
|
|
24
|
+
* 🔴 属主在**本文件**(而不是消费它的 boot 车道):留存腿(`retention-store-sql.ts`)按同一派生式
|
|
25
|
+
* 批量删本表的行,两个消费点必须读同一个函数 —— 键派生有两份就是「在线删除删得掉、留存腿删不掉」。
|
|
26
|
+
*/
|
|
27
|
+
export declare function taskListKeyFor(sessionId: string): string;
|
|
31
28
|
export declare function ensureTiDBTaskListSchema(pool: MySqlPool): Promise<void>;
|
|
32
29
|
export declare function ensurePgTaskListSchema(q: (text: string, params?: unknown[]) => Promise<unknown>): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* 整份清单删除(#318 / E21 会话删除级联;codex R1-[high] 验真后加)。
|
|
32
|
+
*
|
|
33
|
+
* 语义 = 「这个 listKey 的清单**不存在过**」:item 行 + meta 行(高水位 `next_id` / 插入序
|
|
34
|
+
* `next_sort`)一并抹掉。⚠️ **meta 行必须一起删**:留着它,同一个 listKey 被重新登记后
|
|
35
|
+
* (会话 id 由调用方自选、删除后可被别人 `register`)新主的第一条任务会从旧主的高水位续号 ——
|
|
36
|
+
* 那是一条跨化身的信息泄漏(「上一位在这里建过 47 条」)。
|
|
37
|
+
*
|
|
38
|
+
* 事务内先对 meta 行 `FOR UPDATE`(与 `mutate` 同一把分区锁)⇒ 与并发的清单写串行,不会删到
|
|
39
|
+
* 一半被插回新行。meta 行不在(从未写过任何任务)= 无锁可拿,DELETE 命中 0 行,幂等。
|
|
40
|
+
*/
|
|
41
|
+
export declare function deleteTiDBTaskList(pool: MySqlPool, listKey: string): Promise<void>;
|
|
42
|
+
/** {@link deleteTiDBTaskList} 的 PG 孪生(同语义、同事务姿势)。 */
|
|
43
|
+
export declare function deletePgTaskList(pool: PgPool, listKey: string): Promise<void>;
|
|
33
44
|
export declare function createTiDBTaskListStore(pool: MySqlPool, listKey: string): TaskListStore;
|
|
34
45
|
export declare function createPgTaskListStore(pool: PgPool, listKey: string): TaskListStore;
|
|
35
46
|
//# sourceMappingURL=task-list-store-sql.d.ts.map
|