@sema-agent/server 7.2.0 → 7.3.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/README.md +2 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +6 -1
- package/dist/approval-ask-machine.d.ts +39 -0
- package/dist/approval-ask-machine.js +101 -0
- package/dist/approval-card.d.ts +244 -0
- package/dist/approval-card.js +237 -0
- package/dist/approval-deny-reasons.d.ts +56 -0
- package/dist/approval-deny-reasons.js +54 -0
- package/dist/approval-reconciler.d.ts +167 -0
- package/dist/approval-reconciler.js +307 -0
- package/dist/boot/coordinators.d.ts +1 -0
- package/dist/boot/coordinators.js +36 -3
- package/dist/boot/lexical-path-env.d.ts +14 -0
- package/dist/boot/lexical-path-env.js +116 -0
- package/dist/boot/reapers.d.ts +34 -0
- package/dist/boot/reapers.js +198 -23
- package/dist/boot/resolve-spec.js +82 -30
- package/dist/config-types.d.ts +61 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +130 -2
- package/dist/elicitation.d.ts +4 -0
- package/dist/elicitation.js +2 -2
- package/dist/http/routes/capabilities.js +14 -0
- package/dist/http/routes/runs.d.ts +1 -0
- package/dist/http/routes/runs.js +548 -16
- package/dist/http/routes/tasks.js +151 -9
- package/dist/http/server.d.ts +1 -1
- package/dist/http/server.js +114 -4
- package/dist/http/sse-log.d.ts +51 -0
- package/dist/http/sse-log.js +64 -0
- package/dist/http/wire-types.d.ts +20 -5
- package/dist/main.js +1 -1
- package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
- package/dist/plugins/approval-ask-store-memory.js +299 -0
- package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
- package/dist/plugins/approval-ask-store-sql.js +705 -0
- package/dist/plugins/background-agent-store-sql.js +20 -1
- package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
- package/dist/plugins/checkpoint-store-sql.js +297 -16
- package/dist/plugins/local-checkpoint-store.d.ts +6 -5
- package/dist/plugins/local-checkpoint-store.js +4 -0
- package/dist/plugins/pg-pool.js +11 -0
- package/dist/plugins/store-backend.d.ts +18 -0
- package/dist/plugins/store-backend.js +10 -0
- package/dist/plugins/tidb-pool.js +27 -4
- package/dist/question.d.ts +3 -0
- package/dist/question.js +2 -2
- package/dist/runs.d.ts +16 -1
- package/dist/runs.js +61 -3
- package/dist/runtime-caps-resolver.d.ts +7 -1
- package/dist/runtime-caps-resolver.js +65 -3
- package/dist/spec-fields.d.ts +4 -0
- package/dist/spec-fields.js +6 -0
- package/dist/task-settings.d.ts +35 -15
- package/dist/task-settings.js +19 -5
- package/dist/tool-approval.d.ts +296 -3
- package/dist/tool-approval.js +1066 -50
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/ledger-sink.js +14 -1
- package/dist/trace/project.d.ts +55 -0
- package/dist/trace/project.js +135 -0
- package/package.json +4 -3
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
|
|
2
|
+
import { canAskTransition } from "../approval-ask-machine.js";
|
|
3
|
+
/**
|
|
4
|
+
* 幂等键的入店卫生门(属主兜底轮 F2,三 twin 同判——SQL 双方言与 InMemory 都从这里过)。
|
|
5
|
+
*
|
|
6
|
+
* 🔴 为什么必须在店门口拒而不是「存什么比什么」:TiDB/MySQL 的 VARCHAR 等值与 UNIQUE 检查带
|
|
7
|
+
* PAD SPACE 语义(utf8mb4_bin 也一样)——`'retry'` 与 `'retry '` 在那侧是**同一个键**,而 PG 与
|
|
8
|
+
* InMemory 的严格字符串比较把它们当两个键 ⇒ 同一份调用方代码在不同后端拿到不同的
|
|
9
|
+
* `idempotency_conflict`/回放结果。键是调用方铸的 opaque 值,首尾空白没有任何合法语义,
|
|
10
|
+
* 在边界上 fail-loud 拒掉,方言分歧面整个消失(比把列改 VARBINARY 更窄、且对三形同时成立)。
|
|
11
|
+
*/
|
|
12
|
+
export function assertIdempotencyKeyShape(key) {
|
|
13
|
+
if (key.length === 0 || key.length > 255 || key.trim() !== key) {
|
|
14
|
+
throw new Error(`invalid idempotency key: must be 1-255 chars with no leading/trailing whitespace (got ${JSON.stringify(key.slice(0, 32))}${key.length > 32 ? "…" : ""}, length ${key.length})`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* #151 车3 刀 3b(§14.2 遗留记账):两条**重放读口**被调用方的 deadline 掐断时抛的具名错误。
|
|
19
|
+
*
|
|
20
|
+
* 具名类而不是「按 message 文本判别」:错误文案是给人看的,拿它当控制流 = 上游改一句人话下游静默失效
|
|
21
|
+
* (#90 工程规范 v3 门②)。调用方(开流 preamble)对它的处置与其余读错误一样 —— fail-open、零卡、一次
|
|
22
|
+
* warn —— 所以它今天没有专门的分支;类在这里是为了让**将来**想分「超时 vs 店真报错」的消费点有得分。
|
|
23
|
+
*/
|
|
24
|
+
export class ApprovalAskReadAbortedError extends Error {
|
|
25
|
+
constructor(where) {
|
|
26
|
+
super(`approval-ask read ${where} aborted by caller`);
|
|
27
|
+
this.name = "ApprovalAskReadAbortedError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function abortedReadError(where) {
|
|
31
|
+
return new ApprovalAskReadAbortedError(where);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 把一次在飞的读与调用方的 `AbortSignal` 竞速(#151 车3 刀 3b)。
|
|
35
|
+
*
|
|
36
|
+
* 🔴 **它做到什么、做不到什么**(诚实边界,同 {@link ApprovalAskStore.listPendingBySession} 的注):
|
|
37
|
+
* 本仓的 `SqlDriver` 没有取消 seam,所以这里能做的是「让**调用方**不再被吊住」,不是「让**库**停下来」。
|
|
38
|
+
* 输掉竞速的那条 promise 仍在飞——它的 rejection 必须被接住(否则变成 unhandled rejection),它的成功
|
|
39
|
+
* 结果被丢弃(纯读,零副作用)。监听器用完即摘(`once` + finally 清理),不给长命 signal 攒 listener。
|
|
40
|
+
*/
|
|
41
|
+
async function withReadSignal(op, signal, where) {
|
|
42
|
+
if (!signal)
|
|
43
|
+
return op;
|
|
44
|
+
let onAbort;
|
|
45
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
46
|
+
onAbort = () => reject(abortedReadError(where));
|
|
47
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
48
|
+
});
|
|
49
|
+
aborted.catch(() => undefined); // 竞速赢家是 op 时,这条 promise 无人 await —— 先给它一个处理者
|
|
50
|
+
void op.catch(() => undefined); // 同理:竞速输了的 op 的 rejection 也必须有处理者
|
|
51
|
+
try {
|
|
52
|
+
return await Promise.race([op, aborted]);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
if (onAbort)
|
|
56
|
+
signal.removeEventListener("abort", onAbort);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
// DDL
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
export const APPROVAL_ASKS_TABLE = "approval_asks";
|
|
63
|
+
export const APPROVAL_BATCHES_TABLE = "approval_batches";
|
|
64
|
+
/**
|
|
65
|
+
* TiDB/MySQL-protocol 侧的两条建表语句,**抽成导出数组**(车3 刀 3a):`tidb-pool.ts` 的
|
|
66
|
+
* `SCHEMA_STATEMENTS` 用 `...TIDB_APPROVAL_ASK_STATEMENTS` 展开它们,于是这两张表随中央
|
|
67
|
+
* `ensureSchema` 一起在 **named-lock 的那条 `conn`** 上建 —— 把一个 pool 级 ensure 函数塞进
|
|
68
|
+
* `SCHEMA_STATEMENTS` 的执行循环里做不到这点(会绕开 DDL 串行化),所以真源必须是**语句文本**。
|
|
69
|
+
* 下面的 `ensureTiDBApprovalAskSchema` 保留为遍历这个数组的薄壳(集成测试直接调它建表,
|
|
70
|
+
* 不必为了建两张表去拉起整套中央 schema)。
|
|
71
|
+
*
|
|
72
|
+
* SCHEMA POLICY 同 `tidb-pool.ts` 头注:纯 CREATE,禁 ALTER 增量 seam,改列直接改这里 + 重建库
|
|
73
|
+
* (`docs/schema/baseline-mysql.sql` 是由这些文本机器录制出的**产物**,永不手改)。
|
|
74
|
+
*/
|
|
75
|
+
export const TIDB_APPROVAL_ASK_STATEMENTS = [
|
|
76
|
+
`CREATE TABLE IF NOT EXISTS ${APPROVAL_BATCHES_TABLE} (
|
|
77
|
+
batch_id VARCHAR(255) NOT NULL,
|
|
78
|
+
task_id VARCHAR(255) NOT NULL,
|
|
79
|
+
state VARCHAR(32) NOT NULL,
|
|
80
|
+
bound_ask_id VARCHAR(255) NULL,
|
|
81
|
+
version INT NOT NULL DEFAULT 0,
|
|
82
|
+
created_at_ms BIGINT NOT NULL,
|
|
83
|
+
updated_at_ms BIGINT NOT NULL,
|
|
84
|
+
PRIMARY KEY (batch_id),
|
|
85
|
+
KEY idx_batch_task (task_id)
|
|
86
|
+
) COLLATE utf8mb4_bin`,
|
|
87
|
+
`CREATE TABLE IF NOT EXISTS ${APPROVAL_ASKS_TABLE} (
|
|
88
|
+
ask_id VARCHAR(255) NOT NULL,
|
|
89
|
+
task_id VARCHAR(255) NOT NULL,
|
|
90
|
+
source_task_id VARCHAR(255) NOT NULL,
|
|
91
|
+
session_id VARCHAR(255) NOT NULL,
|
|
92
|
+
owner VARCHAR(255) NULL,
|
|
93
|
+
batch_id VARCHAR(255) NOT NULL,
|
|
94
|
+
tool_call_id VARCHAR(255) NOT NULL,
|
|
95
|
+
-- leg_key: 车3 §3.2′ 身份轴——sha256(resume checkpoint token) 的 hex(64 字符),首腿存空串。
|
|
96
|
+
-- 取代原来的数值 leg 列:一腿一凭据,零 DDL 依赖 task_run、零 markResuming 改动。NOT NULL(空串
|
|
97
|
+
-- 是首腿的**真值**,不是缺席)。
|
|
98
|
+
leg_key VARCHAR(64) NOT NULL,
|
|
99
|
+
parent_tool_call_id VARCHAR(255) NULL,
|
|
100
|
+
-- bound_input_hash(车5 §9 C2):铸卡时呈给人看的那份 input 的服务端摘要,对账判据 1 的第二道等式。
|
|
101
|
+
-- 宽度跟同表的 gate_bound_input_hash 对齐(255,≥ checkpoint.bound_input_hash 的 190 ⇒ 抄回来不会截断)。
|
|
102
|
+
-- 与 gate_bound_input_hash 的区别见 AskRow.boundInputHash 头注(铸行时 vs PARK 成功时,别混)。
|
|
103
|
+
bound_input_hash VARCHAR(255) NULL,
|
|
104
|
+
state VARCHAR(32) NOT NULL,
|
|
105
|
+
-- clay 批 2026-07-26 TINYINT(1) 归一先例(image-bake-store-sql.ts 头注):布尔列 TiDB 侧 TINYINT(1).
|
|
106
|
+
provisional TINYINT(1) NOT NULL DEFAULT 0,
|
|
107
|
+
version INT NOT NULL DEFAULT 0,
|
|
108
|
+
decision VARCHAR(16) NULL,
|
|
109
|
+
decision_actor TEXT NULL,
|
|
110
|
+
decision_note TEXT NULL,
|
|
111
|
+
decided_at_ms BIGINT NULL,
|
|
112
|
+
denied_reason VARCHAR(64) NULL,
|
|
113
|
+
gate_token VARCHAR(512) NULL,
|
|
114
|
+
gate_bound_call_id VARCHAR(255) NULL,
|
|
115
|
+
gate_bound_input_hash VARCHAR(255) NULL,
|
|
116
|
+
idempotency_key VARCHAR(255) NULL,
|
|
117
|
+
-- LONGTEXT 照 image-bake/background-agent 的 pending_steer 先例:重放帧载荷可能超 64K TEXT 墙。
|
|
118
|
+
card_json LONGTEXT NOT NULL,
|
|
119
|
+
schema_version INT NOT NULL,
|
|
120
|
+
expires_at_ms BIGINT NOT NULL,
|
|
121
|
+
created_at_ms BIGINT NOT NULL,
|
|
122
|
+
updated_at_ms BIGINT NOT NULL,
|
|
123
|
+
PRIMARY KEY (ask_id),
|
|
124
|
+
-- uq_approval_ask_idem(车4 §12-E,由 (idempotency_key) 改 (task_id, idempotency_key)):键归回决专用,
|
|
125
|
+
-- 作用域收到 task 内。全局唯一有两个真问题——① 跨租户键互撞(两个 task 的调用方各自生成 "retry-1" 就
|
|
126
|
+
-- 有一方被拒);② 存在性外泄(拿别人的 key 撞库能探到「它在别处存在」)。表未进生产 ⇒ 此刻改零迁移。
|
|
127
|
+
UNIQUE KEY uq_approval_ask_idem (task_id, idempotency_key),
|
|
128
|
+
KEY idx_ask_task_state (task_id, state),
|
|
129
|
+
-- idx_ask_session_state: 车3 §5.1 的 sync 腿开流重放读口(listPendingBySession)的支撑索引。
|
|
130
|
+
-- 表尚未进生产 + SCHEMA POLICY 是删库重建 ⇒ 此刻加索引零迁移成本,晚一车加就要动生产表。
|
|
131
|
+
KEY idx_ask_session_state (session_id, state),
|
|
132
|
+
KEY idx_ask_batch (batch_id),
|
|
133
|
+
KEY idx_ask_state (state)
|
|
134
|
+
) COLLATE utf8mb4_bin`,
|
|
135
|
+
];
|
|
136
|
+
/** {@link TIDB_APPROVAL_ASK_STATEMENTS} 的遍历壳——生产路径走 `tidb-pool.ts` 的中央 `ensureSchema`
|
|
137
|
+
* (那里展开了同一份数组),本函数留给只需要这两张表的集成测试。 */
|
|
138
|
+
export async function ensureTiDBApprovalAskSchema(pool) {
|
|
139
|
+
for (const stmt of TIDB_APPROVAL_ASK_STATEMENTS)
|
|
140
|
+
await pool.query(stmt);
|
|
141
|
+
}
|
|
142
|
+
export async function ensurePgApprovalAskSchema(q) {
|
|
143
|
+
await q(`CREATE TABLE IF NOT EXISTS ${APPROVAL_BATCHES_TABLE} (
|
|
144
|
+
batch_id VARCHAR(255) NOT NULL,
|
|
145
|
+
task_id VARCHAR(255) NOT NULL,
|
|
146
|
+
state VARCHAR(32) NOT NULL,
|
|
147
|
+
bound_ask_id VARCHAR(255),
|
|
148
|
+
version INT NOT NULL DEFAULT 0,
|
|
149
|
+
created_at_ms BIGINT NOT NULL,
|
|
150
|
+
updated_at_ms BIGINT NOT NULL,
|
|
151
|
+
PRIMARY KEY (batch_id)
|
|
152
|
+
)`);
|
|
153
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_batch_task ON ${APPROVAL_BATCHES_TABLE} (task_id)`);
|
|
154
|
+
await q(`CREATE TABLE IF NOT EXISTS ${APPROVAL_ASKS_TABLE} (
|
|
155
|
+
ask_id VARCHAR(255) NOT NULL,
|
|
156
|
+
task_id VARCHAR(255) NOT NULL,
|
|
157
|
+
source_task_id VARCHAR(255) NOT NULL,
|
|
158
|
+
session_id VARCHAR(255) NOT NULL,
|
|
159
|
+
owner VARCHAR(255),
|
|
160
|
+
batch_id VARCHAR(255) NOT NULL,
|
|
161
|
+
tool_call_id VARCHAR(255) NOT NULL,
|
|
162
|
+
-- leg_key: 车3 §3.2′ 身份轴——sha256(resume checkpoint token) 的 hex(64 字符),首腿存空串
|
|
163
|
+
-- (空串是首腿的真值,不是缺席,故 NOT NULL)。MySQL 侧 twin 逐字同形。
|
|
164
|
+
leg_key VARCHAR(64) NOT NULL,
|
|
165
|
+
parent_tool_call_id VARCHAR(255),
|
|
166
|
+
-- bound_input_hash(车5 §9 C2)——MySQL twin 逐字同形(宽度/语义见那侧注)。
|
|
167
|
+
bound_input_hash VARCHAR(255),
|
|
168
|
+
state VARCHAR(32) NOT NULL,
|
|
169
|
+
-- clay 批 TINYINT(1) 归一先例:PG 侧统一 SMALLINT 0/1(此前本家族用过 BOOLEAN,已裁弃).
|
|
170
|
+
provisional SMALLINT NOT NULL DEFAULT 0,
|
|
171
|
+
version INT NOT NULL DEFAULT 0,
|
|
172
|
+
decision VARCHAR(16),
|
|
173
|
+
decision_actor TEXT,
|
|
174
|
+
decision_note TEXT,
|
|
175
|
+
decided_at_ms BIGINT,
|
|
176
|
+
denied_reason VARCHAR(64),
|
|
177
|
+
gate_token VARCHAR(512),
|
|
178
|
+
gate_bound_call_id VARCHAR(255),
|
|
179
|
+
gate_bound_input_hash VARCHAR(255),
|
|
180
|
+
idempotency_key VARCHAR(255),
|
|
181
|
+
card_json TEXT NOT NULL,
|
|
182
|
+
schema_version INT NOT NULL,
|
|
183
|
+
expires_at_ms BIGINT NOT NULL,
|
|
184
|
+
created_at_ms BIGINT NOT NULL,
|
|
185
|
+
updated_at_ms BIGINT NOT NULL,
|
|
186
|
+
PRIMARY KEY (ask_id),
|
|
187
|
+
-- (task_id, idempotency_key) 复合唯一——MySQL twin 逐字同形(改动理由见那侧注)。
|
|
188
|
+
CONSTRAINT uq_approval_ask_idem UNIQUE (task_id, idempotency_key)
|
|
189
|
+
)`);
|
|
190
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_ask_task_state ON ${APPROVAL_ASKS_TABLE} (task_id, state)`);
|
|
191
|
+
// 车3 §5.1:sync 腿开流重放读口(listPendingBySession)的支撑索引 —— MySQL twin 的
|
|
192
|
+
// `KEY idx_ask_session_state (session_id, state)` 同形。
|
|
193
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_ask_session_state ON ${APPROVAL_ASKS_TABLE} (session_id, state)`);
|
|
194
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_ask_batch ON ${APPROVAL_ASKS_TABLE} (batch_id)`);
|
|
195
|
+
await q(`CREATE INDEX IF NOT EXISTS idx_ask_state ON ${APPROVAL_ASKS_TABLE} (state)`);
|
|
196
|
+
}
|
|
197
|
+
const ASK_COLS = "ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, " +
|
|
198
|
+
"provisional, version, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, " +
|
|
199
|
+
"gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, schema_version, expires_at_ms, " +
|
|
200
|
+
"created_at_ms, updated_at_ms";
|
|
201
|
+
const BATCH_COLS = "batch_id, task_id, state, bound_ask_id, version, created_at_ms, updated_at_ms";
|
|
202
|
+
/** 唯一键冲突的方言判别(checkpoint-store-sql.ts 同一对常量)——`decideAsk` 用它把 `(task_id,
|
|
203
|
+
* idempotency_key)` 撞键翻成 typed 的 `idempotency_conflict` 臂,不让驱动错误对象漏给消费层。 */
|
|
204
|
+
const DUP_ENTRY = 1062; // MySQL/TiDB ER_DUP_ENTRY
|
|
205
|
+
const PG_UNIQUE_VIOLATION = "23505";
|
|
206
|
+
/** JSON 列的读出:**本层只做存取,不解释**——返回类型即 `unknown`,把「这是什么形」的责任留给消费方。
|
|
207
|
+
* 两个 JSON 列的 schema 属主都不在本车:`decision_actor` = design/171 ActorAssertion 形(车4 回决端点
|
|
208
|
+
* 消费,届时按宪法 [2704] 立 schema 后 safeParse);`card_json` = 172 §3.1 呈卡帧载荷(车3 wire 面属主)。
|
|
209
|
+
* 故此处刻意不铸内域类型(无 as),持久层对这两列保持透明字节存取语义。 */
|
|
210
|
+
function parseJsonColumn(v) {
|
|
211
|
+
return JSON.parse(String(v));
|
|
212
|
+
}
|
|
213
|
+
function toBool(v) {
|
|
214
|
+
return Boolean(Number(v));
|
|
215
|
+
}
|
|
216
|
+
function numOrNull(v) {
|
|
217
|
+
return v === null || v === undefined ? null : Number(v);
|
|
218
|
+
}
|
|
219
|
+
function strOrNull(v) {
|
|
220
|
+
return v === null || v === undefined ? null : String(v);
|
|
221
|
+
}
|
|
222
|
+
function mapAskRow(r) {
|
|
223
|
+
return {
|
|
224
|
+
askId: String(r.ask_id),
|
|
225
|
+
taskId: String(r.task_id),
|
|
226
|
+
sourceTaskId: String(r.source_task_id),
|
|
227
|
+
sessionId: String(r.session_id),
|
|
228
|
+
owner: strOrNull(r.owner),
|
|
229
|
+
batchId: String(r.batch_id),
|
|
230
|
+
toolCallId: String(r.tool_call_id),
|
|
231
|
+
legKey: String(r.leg_key),
|
|
232
|
+
parentToolCallId: strOrNull(r.parent_tool_call_id),
|
|
233
|
+
boundInputHash: strOrNull(r.bound_input_hash),
|
|
234
|
+
state: String(r.state),
|
|
235
|
+
provisional: toBool(r.provisional),
|
|
236
|
+
version: Number(r.version),
|
|
237
|
+
decision: strOrNull(r.decision) ?? null,
|
|
238
|
+
decisionActor: r.decision_actor == null ? null : parseJsonColumn(r.decision_actor),
|
|
239
|
+
decisionNote: strOrNull(r.decision_note),
|
|
240
|
+
decidedAtMs: numOrNull(r.decided_at_ms),
|
|
241
|
+
deniedReason: strOrNull(r.denied_reason),
|
|
242
|
+
gateToken: strOrNull(r.gate_token),
|
|
243
|
+
gateBoundCallId: strOrNull(r.gate_bound_call_id),
|
|
244
|
+
gateBoundInputHash: strOrNull(r.gate_bound_input_hash),
|
|
245
|
+
idempotencyKey: strOrNull(r.idempotency_key),
|
|
246
|
+
cardJson: parseJsonColumn(r.card_json),
|
|
247
|
+
schemaVersion: Number(r.schema_version),
|
|
248
|
+
expiresAtMs: Number(r.expires_at_ms),
|
|
249
|
+
createdAtMs: Number(r.created_at_ms),
|
|
250
|
+
updatedAtMs: Number(r.updated_at_ms),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function mapBatchRow(r) {
|
|
254
|
+
return {
|
|
255
|
+
batchId: String(r.batch_id),
|
|
256
|
+
taskId: String(r.task_id),
|
|
257
|
+
state: String(r.state),
|
|
258
|
+
boundAskId: strOrNull(r.bound_ask_id),
|
|
259
|
+
version: Number(r.version),
|
|
260
|
+
createdAtMs: Number(r.created_at_ms),
|
|
261
|
+
updatedAtMs: Number(r.updated_at_ms),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** 动态 SET 列清单(`transitionAsk`/`resolveProvisional` 用):只收 patch 里真出现的字段,`decisionActor`
|
|
265
|
+
* 按内容面 JSON 编码器序列化。`updatedAtMs` 不在这里——调用方把它拼进固定尾列(所有转移恒推进它)。 */
|
|
266
|
+
function patchColumns(patch, json) {
|
|
267
|
+
const out = [];
|
|
268
|
+
if (patch.decision !== undefined)
|
|
269
|
+
out.push(["decision", patch.decision]);
|
|
270
|
+
if (patch.decisionActor !== undefined)
|
|
271
|
+
out.push(["decision_actor", patch.decisionActor == null ? null : json(patch.decisionActor)]);
|
|
272
|
+
if (patch.decisionNote !== undefined)
|
|
273
|
+
out.push(["decision_note", patch.decisionNote]);
|
|
274
|
+
if (patch.decidedAtMs !== undefined)
|
|
275
|
+
out.push(["decided_at_ms", patch.decidedAtMs]);
|
|
276
|
+
if (patch.deniedReason !== undefined)
|
|
277
|
+
out.push(["denied_reason", patch.deniedReason]);
|
|
278
|
+
if (patch.gateToken !== undefined)
|
|
279
|
+
out.push(["gate_token", patch.gateToken]);
|
|
280
|
+
if (patch.gateBoundCallId !== undefined)
|
|
281
|
+
out.push(["gate_bound_call_id", patch.gateBoundCallId]);
|
|
282
|
+
if (patch.gateBoundInputHash !== undefined)
|
|
283
|
+
out.push(["gate_bound_input_hash", patch.gateBoundInputHash]);
|
|
284
|
+
if (patch.provisional !== undefined)
|
|
285
|
+
out.push(["provisional", patch.provisional ? 1 : 0]);
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
/** 第 n 个(1-based)绑定参数的占位符文本。TiDB 用位置序 `?`(n 被忽略);PG 需要显式编号 `$n`——
|
|
289
|
+
* 两种转移(transitionAsk/resolveProvisional)的列集合按 patch 内容动态变化,固定的两段式 SQL 文本
|
|
290
|
+
* 写不出来,因此占位符本身也必须按方言现场生成(与 background-agent-store-sql.ts 的 guard 占位符
|
|
291
|
+
* 构造同精神:方言差异仍然显式——分支在这一个函数里,不是被某个跨方言的抽象吞掉)。 */
|
|
292
|
+
function ph(dialect, n) {
|
|
293
|
+
return dialect === "tidb" ? "?" : `$${n}`;
|
|
294
|
+
}
|
|
295
|
+
/** 双方言 `ApprovalAskStore`。见文件头注的 CAS 铁则 + 方言差异清单。 */
|
|
296
|
+
export class SqlApprovalAskStore {
|
|
297
|
+
db;
|
|
298
|
+
constructor(db) {
|
|
299
|
+
this.db = db;
|
|
300
|
+
}
|
|
301
|
+
q(tidb, pg) {
|
|
302
|
+
return this.db.dialect === "tidb" ? tidb : pg;
|
|
303
|
+
}
|
|
304
|
+
json(value) {
|
|
305
|
+
return dialectJsonEncoder(this.db.dialect)(value);
|
|
306
|
+
}
|
|
307
|
+
/** 唯一键冲突判别(方言差异显式,checkpoint-store-sql.ts 同形):mysql2 挂 `errno`,node-pg 挂 SQLSTATE `code`。 */
|
|
308
|
+
isDupKey(e) {
|
|
309
|
+
return this.db.dialect === "tidb" ? e?.errno === DUP_ENTRY : e?.code === PG_UNIQUE_VIOLATION;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* 🔴 失败臂的回读**必须走当前这条 `conn`**,不许调 `this.getAsk`/`this.getBatch`(codex 复审 F1,
|
|
313
|
+
* 2026-08-06 真缺陷,池大小=1 的钉上实测挂死 25s)。原因:那两个读口走**池级** `this.db.query`,而
|
|
314
|
+
* 此刻本方法还攥着自己的事务连接(要到 `finally` 才 release)——于是「回读在等一条新连接,而唯一
|
|
315
|
+
* 那条连接要等回读返回才释放」= 确定性死锁。池大于 1 时它退化成「同时走到失败臂的并发数 ≥ 池大小
|
|
316
|
+
* 就整池饿死」,是同一个缺陷的更难复现形,不是另一个问题。
|
|
317
|
+
*
|
|
318
|
+
* 调用点纪律:**先 `conn.rollback()` 再调这两个**。回滚之后连接回到 autocommit,这条 SELECT 自成
|
|
319
|
+
* 一个事务 ⇒ 读到的是最新已提交视图(正是失败臂要如实上报的东西),不受本事务快照的影响——两方言
|
|
320
|
+
* 在这一点上同义,不必再纠结 InnoDB 读视图与 TiDB start_ts 的差别。
|
|
321
|
+
*/
|
|
322
|
+
async getAskOn(conn, askId) {
|
|
323
|
+
const { rows } = await conn.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE ask_id = ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE ask_id = $1`), [askId]);
|
|
324
|
+
return rows[0] ? mapAskRow(rows[0]) : null;
|
|
325
|
+
}
|
|
326
|
+
/** {@link getAskOn} 的批侧同形(同一条纪律:失败臂回读走本连接)。 */
|
|
327
|
+
async getBatchOn(conn, batchId) {
|
|
328
|
+
const { rows } = await conn.query(this.q(`SELECT ${BATCH_COLS} FROM ${APPROVAL_BATCHES_TABLE} WHERE batch_id = ?`, `SELECT ${BATCH_COLS} FROM ${APPROVAL_BATCHES_TABLE} WHERE batch_id = $1`), [batchId]);
|
|
329
|
+
return rows[0] ? mapBatchRow(rows[0]) : null;
|
|
330
|
+
}
|
|
331
|
+
async ensureAsk(row) {
|
|
332
|
+
const now = row.createdAtMs;
|
|
333
|
+
const conn = await this.db.connect();
|
|
334
|
+
try {
|
|
335
|
+
await conn.begin();
|
|
336
|
+
await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_BATCHES_TABLE} (batch_id, task_id, state, version, created_at_ms, updated_at_ms) VALUES (?, ?, 'OPEN', 0, ?, ?)`, `INSERT INTO ${APPROVAL_BATCHES_TABLE} (batch_id, task_id, state, version, created_at_ms, updated_at_ms) VALUES ($1, $2, 'OPEN', 0, $3, $4) ON CONFLICT (batch_id) DO NOTHING`), [row.batchId, row.taskId, now, now]);
|
|
337
|
+
// 🔴 `idempotency_key` **不在 INSERT 列清单里**(车4 §12-E 双写者收口):该列已归回决专用,只有
|
|
338
|
+
// `decideAsk` 赢 CAS 时写它,铸行恒 NULL。留在这里的 NULL 字面量就是这条纪律的落笔处。
|
|
339
|
+
await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASKS_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, version, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
|
|
340
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASKS_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, version, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
|
|
341
|
+
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $11, $12, $13, $14, $15) ON CONFLICT (ask_id) DO NOTHING"), [
|
|
342
|
+
row.askId,
|
|
343
|
+
row.taskId,
|
|
344
|
+
row.sourceTaskId,
|
|
345
|
+
row.sessionId,
|
|
346
|
+
row.owner ?? null,
|
|
347
|
+
row.batchId,
|
|
348
|
+
row.toolCallId,
|
|
349
|
+
row.legKey,
|
|
350
|
+
row.parentToolCallId ?? null,
|
|
351
|
+
row.boundInputHash ?? null,
|
|
352
|
+
this.json(row.cardJson),
|
|
353
|
+
row.schemaVersion,
|
|
354
|
+
row.expiresAtMs,
|
|
355
|
+
now,
|
|
356
|
+
now,
|
|
357
|
+
]);
|
|
358
|
+
await conn.commit();
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
try {
|
|
362
|
+
await conn.rollback();
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
/* ignore */
|
|
366
|
+
}
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
conn.release();
|
|
371
|
+
}
|
|
372
|
+
const out = await this.getAsk(row.askId);
|
|
373
|
+
if (!out)
|
|
374
|
+
throw new Error(`ensureAsk: row vanished after insert (askId=${row.askId})`);
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
async transitionAsk(askId, from, to, patch) {
|
|
378
|
+
// machine 层先判(非法转移=编程错误,throw 而非返回 false——设计定稿 §4 原话)。
|
|
379
|
+
if (!canAskTransition(from, to)) {
|
|
380
|
+
throw new Error(`approval-ask-store.transitionAsk: illegal ask transition ${from} -> ${to} (askId=${askId})`);
|
|
381
|
+
}
|
|
382
|
+
const dialect = this.db.dialect;
|
|
383
|
+
const dyn = patchColumns(patch, (v) => this.json(v));
|
|
384
|
+
const cols = ["state", ...dyn.map(([c]) => c), "updated_at_ms"];
|
|
385
|
+
const vals = [to, ...dyn.map(([, v]) => v), patch.updatedAtMs];
|
|
386
|
+
let n = 0;
|
|
387
|
+
const setSql = cols.map((c) => `${c} = ${ph(dialect, ++n)}`).join(", ");
|
|
388
|
+
const askIdPh = ph(dialect, ++n);
|
|
389
|
+
const fromPh = ph(dialect, ++n);
|
|
390
|
+
// `version = version + 1`(恒变列,MySQL 同值 UPDATE affectedRows=0 坑——见文件头注 CAS 铁则②)。
|
|
391
|
+
const res = await this.db.query(`UPDATE ${APPROVAL_ASKS_TABLE} SET ${setSql}, version = version + 1 WHERE ask_id = ${askIdPh} AND state = ${fromPh}`, [...vals, askId, from]);
|
|
392
|
+
return res.affected === 1;
|
|
393
|
+
}
|
|
394
|
+
async decideAsk(askId, batchId, decision) {
|
|
395
|
+
if (decision.idempotencyKey != null)
|
|
396
|
+
assertIdempotencyKeyShape(decision.idempotencyKey); // F2 卫生门:进事务前拒
|
|
397
|
+
const now = Date.now();
|
|
398
|
+
const conn = await this.db.connect();
|
|
399
|
+
try {
|
|
400
|
+
await conn.begin();
|
|
401
|
+
// ① 批必须还 OPEN(自增写 version 保证 affected 语义在 CLIENT_FOUND_ROWS 关闭下仍=1;=0 ⇒ 批已封)。
|
|
402
|
+
const batchRes = await conn.query(this.q(`UPDATE ${APPROVAL_BATCHES_TABLE} SET version = version + 1, updated_at_ms = ? WHERE batch_id = ? AND state = 'OPEN'`, `UPDATE ${APPROVAL_BATCHES_TABLE} SET version = version + 1, updated_at_ms = $1 WHERE batch_id = $2 AND state = 'OPEN'`), [now, batchId]);
|
|
403
|
+
if (batchRes.affected !== 1) {
|
|
404
|
+
await conn.rollback();
|
|
405
|
+
return { ok: false, reason: "batch_closed", row: await this.getAskOn(conn, askId) };
|
|
406
|
+
}
|
|
407
|
+
// ② ask 必须还 STREAM_PENDING;单赢者 CAS。回决幂等键随决议**同一条语句**落列(车4 §12-E):
|
|
408
|
+
// 分两步写会开一个「已 DECIDED 但键还没落」的窗口,同 key 重试在窗口内读不到键 ⇒ 会去打第二次
|
|
409
|
+
// CAS(输)再报 409,把一次合法回放变成冲突。同一 UPDATE ⇒ 键与终态同生共死。
|
|
410
|
+
let askRes; // 显式标注:`let x;` 是 evolving-any,本仓按强类型用
|
|
411
|
+
try {
|
|
412
|
+
askRes = await conn.query(this.q(`UPDATE ${APPROVAL_ASKS_TABLE} SET state='DECIDED', decision=?, decision_actor=?, decision_note=?, decided_at_ms=?, idempotency_key=?, version=version+1, updated_at_ms=? WHERE ask_id=? AND state='STREAM_PENDING'`, `UPDATE ${APPROVAL_ASKS_TABLE} SET state='DECIDED', decision=$1, decision_actor=$2, decision_note=$3, decided_at_ms=$4, idempotency_key=$5, version=version+1, updated_at_ms=$6 WHERE ask_id=$7 AND state='STREAM_PENDING'`), [decision.decision, decision.decisionActor == null ? null : this.json(decision.decisionActor), decision.decisionNote ?? null, now, decision.idempotencyKey ?? null, now, askId]);
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
// `(task_id, idempotency_key)` 撞键 = 同一 key 已被本 task 内**另一条** ask 的回决占用。这是调用方
|
|
416
|
+
// 键管理的问题(不是 CAS 竞争),翻成 typed 臂交给端点译 409——方言 errno/SQLSTATE 不出店。
|
|
417
|
+
if (this.isDupKey(err)) {
|
|
418
|
+
await conn.rollback();
|
|
419
|
+
return { ok: false, reason: "idempotency_conflict", row: await this.getAskOn(conn, askId) };
|
|
420
|
+
}
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
if (askRes.affected !== 1) {
|
|
424
|
+
await conn.rollback();
|
|
425
|
+
return { ok: false, reason: "ask_not_pending", row: await this.getAskOn(conn, askId) };
|
|
426
|
+
}
|
|
427
|
+
await conn.commit();
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
try {
|
|
431
|
+
await conn.rollback();
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
/* ignore */
|
|
435
|
+
}
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
conn.release();
|
|
440
|
+
}
|
|
441
|
+
const row = await this.getAsk(askId);
|
|
442
|
+
if (!row)
|
|
443
|
+
throw new Error(`decideAsk: row vanished after commit (askId=${askId})`);
|
|
444
|
+
return { ok: true, row };
|
|
445
|
+
}
|
|
446
|
+
async expireAsk(askId, batchId) {
|
|
447
|
+
const now = Date.now();
|
|
448
|
+
const conn = await this.db.connect();
|
|
449
|
+
try {
|
|
450
|
+
await conn.begin();
|
|
451
|
+
// ⓪ 锁序不变量(真双库 §6-4b 死锁实抓,2026-08-05):凡同一事务触碰批行+ask 行者,**一律批行先**
|
|
452
|
+
// (decideAsk/bindBatch/abortBatch 皆批先;expireAsk 原是 ask 先 ⇒ 与 decideAsk 构成 AB-BA,
|
|
453
|
+
// TiDB 悲观锁当场 `Deadlock found`)。这里用无谓词的 version 自增先取批行锁作序列化点——
|
|
454
|
+
// 带 state 谓词的转移放后面(谓词不符时部分引擎不留锁,序列化点会失效)。ask CAS 输时整个
|
|
455
|
+
// 事务回滚,这次自增一并撤销,无痕。
|
|
456
|
+
const lockRes = await conn.query(this.q(`UPDATE ${APPROVAL_BATCHES_TABLE} SET version=version+1 WHERE batch_id=?`, `UPDATE ${APPROVAL_BATCHES_TABLE} SET version=version+1 WHERE batch_id=$1`), [batchId]);
|
|
457
|
+
if (lockRes.affected !== 1) {
|
|
458
|
+
await conn.rollback(); // 批行不存在 = ensureAsk 从未为它建批(编程错误形,如实不赢)
|
|
459
|
+
return { won: false, voidedSiblings: [] };
|
|
460
|
+
}
|
|
461
|
+
// ① 中选:ask STREAM_PENDING → PARKING(单赢者;输 = 已被回决/取消,整体放弃)。
|
|
462
|
+
const askRes = await conn.query(this.q(`UPDATE ${APPROVAL_ASKS_TABLE} SET state='PARKING', version=version+1, updated_at_ms=? WHERE ask_id=? AND state='STREAM_PENDING'`, `UPDATE ${APPROVAL_ASKS_TABLE} SET state='PARKING', version=version+1, updated_at_ms=$1 WHERE ask_id=$2 AND state='STREAM_PENDING'`), [now, askId]);
|
|
463
|
+
if (askRes.affected !== 1) {
|
|
464
|
+
await conn.rollback();
|
|
465
|
+
return { won: false, voidedSiblings: [] };
|
|
466
|
+
}
|
|
467
|
+
// ② 批 OPEN → ROUTING_UNBOUND(已是 ROUTING_UNBOUND = 兄弟先到,affected=0 也算成功续走③——不检查
|
|
468
|
+
// affected,批状态无论如何这一刻都已经是 ROUTING_UNBOUND 或更前进)。
|
|
469
|
+
await conn.query(this.q(`UPDATE ${APPROVAL_BATCHES_TABLE} SET state='ROUTING_UNBOUND', version=version+1, updated_at_ms=? WHERE batch_id=? AND state='OPEN'`, `UPDATE ${APPROVAL_BATCHES_TABLE} SET state='ROUTING_UNBOUND', version=version+1, updated_at_ms=$1 WHERE batch_id=$2 AND state='OPEN'`), [now, batchId]);
|
|
470
|
+
// ③ 兄弟撤卡:PG 用 RETURNING 单语句拿名单;TiDB 无 RETURNING,同事务先 SELECT 后 UPDATE。
|
|
471
|
+
let voidedSiblings;
|
|
472
|
+
if (this.db.dialect === "pg") {
|
|
473
|
+
const res = await conn.query(`UPDATE ${APPROVAL_ASKS_TABLE} SET state='VOID', version=version+1, updated_at_ms=$1 WHERE batch_id=$2 AND state='STREAM_PENDING' AND ask_id<>$3 RETURNING ask_id`, [now, batchId, askId]);
|
|
474
|
+
voidedSiblings = res.rows.map((r) => String(r.ask_id));
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
// 🔴 `FOR UPDATE` + 按选出的 id 列表更新(车5 同病修,与 `bindBatch` 的 PARKING 臂逐字同形——
|
|
478
|
+
// 那里的完整论证见该处注):裸 SELECT 是**非锁定读**,它与随后那条宽谓词 UPDATE 不是同一个行集
|
|
479
|
+
// (`transitionAsk`/`decideAsk` 不走批锁,可以在两条语句之间把某个兄弟搬进/搬出 STREAM_PENDING)。
|
|
480
|
+
// 搬进的会被 UPDATE 改成 VOID 却**不在**返回名单里(撤卡帧漏发 ⇒ 壳上留一张已作废的卡),搬出的
|
|
481
|
+
// 会在名单里却没被改(撤卡帧误发)。PG 的 RETURNING 与 InMemory 都返回「真正被改的那一组」,
|
|
482
|
+
// 所以裸读会造出一个**只在 MySQL 方言上**成立的协议分歧。锁序:本方法已先取批行锁(步⓪),
|
|
483
|
+
// 此处再取 ask 行锁,与文件头注的「批先 ask 后」一致。
|
|
484
|
+
const sel = await conn.query(`SELECT ask_id FROM ${APPROVAL_ASKS_TABLE} WHERE batch_id=? AND state='STREAM_PENDING' AND ask_id<>? FOR UPDATE`, [batchId, askId]);
|
|
485
|
+
voidedSiblings = sel.rows.map((r) => String(r.ask_id));
|
|
486
|
+
if (voidedSiblings.length > 0) {
|
|
487
|
+
const inList = voidedSiblings.map(() => "?").join(", ");
|
|
488
|
+
await conn.query(`UPDATE ${APPROVAL_ASKS_TABLE} SET state='VOID', version=version+1, updated_at_ms=? WHERE ask_id IN (${inList}) AND state='STREAM_PENDING'`, [
|
|
489
|
+
now,
|
|
490
|
+
...voidedSiblings,
|
|
491
|
+
]);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
await conn.commit();
|
|
495
|
+
return { won: true, voidedSiblings };
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
try {
|
|
499
|
+
await conn.rollback();
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
/* ignore */
|
|
503
|
+
}
|
|
504
|
+
throw err;
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
conn.release();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async bindBatch(batchId, askId, gate) {
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
const conn = await this.db.connect();
|
|
513
|
+
try {
|
|
514
|
+
await conn.begin();
|
|
515
|
+
// bind-once:批 ROUTING_UNBOUND → ROUTING_BOUND + bound_ask_id(WHERE bound_ask_id IS NULL 是第二道
|
|
516
|
+
// 保险——state 谓词已经把并发者挡在外面,这里双保险防「同一批被 bind 两次」哪怕未来状态机被误改)。
|
|
517
|
+
const batchRes = await conn.query(this.q(`UPDATE ${APPROVAL_BATCHES_TABLE} SET state='ROUTING_BOUND', bound_ask_id=?, version=version+1, updated_at_ms=? WHERE batch_id=? AND state='ROUTING_UNBOUND' AND bound_ask_id IS NULL`, `UPDATE ${APPROVAL_BATCHES_TABLE} SET state='ROUTING_BOUND', bound_ask_id=$1, version=version+1, updated_at_ms=$2 WHERE batch_id=$3 AND state='ROUTING_UNBOUND' AND bound_ask_id IS NULL`), [askId, now, batchId]);
|
|
518
|
+
if (batchRes.affected !== 1) {
|
|
519
|
+
// 判别式的信息来源 = 批行的真实态。🔴 回读**放在 rollback 之后、走池上的独立读**,不在本事务内读:
|
|
520
|
+
// TiDB 的事务快照按 start_ts 取,同事务内的普通 SELECT 可能读到 BEGIN 时刻的旧值(InnoDB 的读视图
|
|
521
|
+
// 规则又与它不同)——两方言在这一点上不等价,而本车恰恰要靠这个值分 ABORTED / ROUTING_BOUND 两条
|
|
522
|
+
// **处置相反**的臂。事务已回滚(本事务除了那条 affected=0 的 CAS 什么也没写),池上重读是干净的
|
|
523
|
+
// 已提交视图,双方言同义。
|
|
524
|
+
await conn.rollback();
|
|
525
|
+
const cur = await this.getBatchOn(conn, batchId);
|
|
526
|
+
if (!cur)
|
|
527
|
+
return { ok: false, batchState: "MISSING" };
|
|
528
|
+
return { ok: false, batchState: cur.state, ...(cur.boundAskId !== null ? { boundAskId: cur.boundAskId } : {}) };
|
|
529
|
+
}
|
|
530
|
+
// 中选 ask PARKING → PARKED(带 gate 三件)。
|
|
531
|
+
const askRes = await conn.query(this.q(`UPDATE ${APPROVAL_ASKS_TABLE} SET state='PARKED', gate_token=?, gate_bound_call_id=?, gate_bound_input_hash=?, version=version+1, updated_at_ms=? WHERE ask_id=? AND state='PARKING'`, `UPDATE ${APPROVAL_ASKS_TABLE} SET state='PARKED', gate_token=$1, gate_bound_call_id=$2, gate_bound_input_hash=$3, version=version+1, updated_at_ms=$4 WHERE ask_id=$5 AND state='PARKING'`), [gate.gateToken, gate.gateBoundCallId ?? null, gate.gateBoundInputHash ?? null, now, askId]);
|
|
532
|
+
if (askRes.affected !== 1) {
|
|
533
|
+
await conn.rollback();
|
|
534
|
+
// ask 不在 PARKING(被别的竞争者收走)⇒ 整事务回滚,批**没有**被绑上。如实报批的回滚后真值:
|
|
535
|
+
// 它仍是 ROUTING_UNBOUND(上一步的 CAS 谓词已经证明了这一点,连同本次自增一起撤销)。
|
|
536
|
+
return { ok: false, batchState: "ROUTING_UNBOUND" };
|
|
537
|
+
}
|
|
538
|
+
// 其余 PARKING 兄弟 → VOID,**名单随成功臂回传**(车5 §8 A-4):撤卡帧的 `askIds` 就是它。
|
|
539
|
+
// 读法与 expireAsk 逐字同形:PG `RETURNING` 单语句;TiDB 无 RETURNING ⇒ 同事务先 SELECT 后 UPDATE。
|
|
540
|
+
let voidedSiblings;
|
|
541
|
+
if (this.db.dialect === "pg") {
|
|
542
|
+
const res = await conn.query(`UPDATE ${APPROVAL_ASKS_TABLE} SET state='VOID', version=version+1, updated_at_ms=$1 WHERE batch_id=$2 AND state='PARKING' AND ask_id<>$3 RETURNING ask_id`, [now, batchId, askId]);
|
|
543
|
+
voidedSiblings = res.rows.map((r) => String(r.ask_id));
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
// 🔴 `FOR UPDATE` + 按选出的 id 逐条更新(codex 复审 F2,2026-08-06):裸 SELECT 是非锁定读,
|
|
547
|
+
// 它与随后那条宽谓词 UPDATE **不是**同一个行集——`transitionAsk` 不走批锁,可以在两条语句之间
|
|
548
|
+
// 把某个兄弟搬进/搬出 PARKING。搬进的会被 UPDATE 改成 VOID 却**不在**返回名单里(撤卡帧漏发 ⇒
|
|
549
|
+
// 壳上留一张已作废的卡),搬出的会在名单里却没被改(撤卡帧误发)。PG 的 RETURNING 与 InMemory
|
|
550
|
+
// 都返回「真正被改的那一组」,所以裸读会造出一个**只在 MySQL 方言上**成立的协议分歧。
|
|
551
|
+
// 修法:锁定读把行集钉死,再只更新这批 id ⇒ 返回名单恒等于被改集合,三形同义。
|
|
552
|
+
// 锁序:本方法已先取批行锁(上面那条 CAS),此处再取 ask 行锁,与文件头注的「批先 ask 后」一致。
|
|
553
|
+
const sel = await conn.query(`SELECT ask_id FROM ${APPROVAL_ASKS_TABLE} WHERE batch_id=? AND state='PARKING' AND ask_id<>? FOR UPDATE`, [batchId, askId]);
|
|
554
|
+
voidedSiblings = sel.rows.map((r) => String(r.ask_id));
|
|
555
|
+
if (voidedSiblings.length > 0) {
|
|
556
|
+
const inList = voidedSiblings.map(() => "?").join(", ");
|
|
557
|
+
await conn.query(`UPDATE ${APPROVAL_ASKS_TABLE} SET state='VOID', version=version+1, updated_at_ms=? WHERE ask_id IN (${inList}) AND state='PARKING'`, [now, ...voidedSiblings]);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
await conn.commit();
|
|
561
|
+
return { ok: true, voidedSiblings };
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
try {
|
|
565
|
+
await conn.rollback();
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
/* ignore */
|
|
569
|
+
}
|
|
570
|
+
throw err;
|
|
571
|
+
}
|
|
572
|
+
finally {
|
|
573
|
+
conn.release();
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async abortBatch(batchId) {
|
|
577
|
+
const now = Date.now();
|
|
578
|
+
const conn = await this.db.connect();
|
|
579
|
+
try {
|
|
580
|
+
await conn.begin();
|
|
581
|
+
const batchRes = await conn.query(this.q(`UPDATE ${APPROVAL_BATCHES_TABLE} SET state='ABORTED', version=version+1, updated_at_ms=? WHERE batch_id=? AND (state='OPEN' OR state='ROUTING_UNBOUND')`, `UPDATE ${APPROVAL_BATCHES_TABLE} SET state='ABORTED', version=version+1, updated_at_ms=$1 WHERE batch_id=$2 AND (state='OPEN' OR state='ROUTING_UNBOUND')`), [now, batchId]);
|
|
582
|
+
if (batchRes.affected !== 1) {
|
|
583
|
+
await conn.rollback();
|
|
584
|
+
return [];
|
|
585
|
+
}
|
|
586
|
+
// 只撤 STREAM_PENDING(设计定稿字面:「整批 STREAM_PENDING→VOID」)——已 DECIDED 的兄弟不受影响
|
|
587
|
+
// (§3.0 原话),PARKING/PARKED/DENIED 同样不在这条 UPDATE 的谓词里,保持不动。
|
|
588
|
+
let voided;
|
|
589
|
+
if (this.db.dialect === "pg") {
|
|
590
|
+
const res = await conn.query(`UPDATE ${APPROVAL_ASKS_TABLE} SET state='VOID', version=version+1, updated_at_ms=$1 WHERE batch_id=$2 AND state='STREAM_PENDING' RETURNING ask_id`, [now, batchId]);
|
|
591
|
+
voided = res.rows.map((r) => String(r.ask_id));
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
// 🔴 同 `expireAsk`/`bindBatch` 的锁定读改造(车5 同病修):非锁定读的名单与宽谓词 UPDATE 的
|
|
595
|
+
// 行集不是同一个 ⇒ 撤卡帧漏发/误发,且分歧只在 MySQL 方言上成立。锁序:批行锁(上一条 CAS)
|
|
596
|
+
// 已取,此处再取 ask 行锁。
|
|
597
|
+
const sel = await conn.query(`SELECT ask_id FROM ${APPROVAL_ASKS_TABLE} WHERE batch_id=? AND state='STREAM_PENDING' FOR UPDATE`, [batchId]);
|
|
598
|
+
voided = sel.rows.map((r) => String(r.ask_id));
|
|
599
|
+
if (voided.length > 0) {
|
|
600
|
+
const inList = voided.map(() => "?").join(", ");
|
|
601
|
+
await conn.query(`UPDATE ${APPROVAL_ASKS_TABLE} SET state='VOID', version=version+1, updated_at_ms=? WHERE ask_id IN (${inList}) AND state='STREAM_PENDING'`, [now, ...voided]);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
await conn.commit();
|
|
605
|
+
return voided;
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
try {
|
|
609
|
+
await conn.rollback();
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
/* ignore */
|
|
613
|
+
}
|
|
614
|
+
throw err;
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
conn.release();
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* 🔴 排序键是 `updated_at_ms`(车5 §8 D-4;codex 三轮补抓:原先按 `created_at_ms` 排,`deferReconcile`
|
|
622
|
+
* 就是**空转**的)。这条读口与 `deferReconcile` 是一对:收敛器每轮取前 `limit` 条,判不出终局的行用
|
|
623
|
+
* `deferReconcile` 推 `updated_at_ms` 把自己排到队尾,下一轮新行才轮得到。若这里仍按建行时间排,
|
|
624
|
+
* 推 `updated_at_ms` 对顺序毫无影响 ⇒ 一旦 `limit` 被若干长驻 PARKING 行占满,每轮扫到的永远是同一批,
|
|
625
|
+
* 后来的审批被无限饿死(§5 旋钮那句「超出下轮接着扫」也就不成立)。
|
|
626
|
+
* 次级键 `created_at_ms` 只为**确定性**:同毫秒的行不至于在两次扫描间乱序(分页/限流下的稳定序)。
|
|
627
|
+
*
|
|
628
|
+
* 🔴 配套纪律(§9 C5):任何 TTL 判据一律量 immutable 的 `createdAtMs`/`expiresAtMs`,**绝不量
|
|
629
|
+
* `updatedAtMs`**——它被队列轮转每轮刷新,量它的 TTL 永不到期。
|
|
630
|
+
*/
|
|
631
|
+
async listByState(state, limit) {
|
|
632
|
+
const { rows } = await this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE state = ? ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE state = $1 ORDER BY updated_at_ms ASC, created_at_ms ASC LIMIT $2`), [state, limit]);
|
|
633
|
+
return rows.map(mapAskRow);
|
|
634
|
+
}
|
|
635
|
+
async listPendingByTask(taskId, signal) {
|
|
636
|
+
if (signal?.aborted === true)
|
|
637
|
+
throw abortedReadError("listPendingByTask");
|
|
638
|
+
const { rows } = await withReadSignal(this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE task_id = ? AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE task_id = $1 AND state = 'STREAM_PENDING' ORDER BY created_at_ms ASC`), [taskId]), signal, "listPendingByTask");
|
|
639
|
+
return rows.map(mapAskRow);
|
|
640
|
+
}
|
|
641
|
+
async listPendingBySession(sessionId, owner, signal) {
|
|
642
|
+
if (signal?.aborted === true)
|
|
643
|
+
throw abortedReadError("listPendingBySession");
|
|
644
|
+
// 租户门的两段谓词文本(见接口注):`owner = ?` 对 NULL 恒 UNKNOWN ⇒ 无租户身份的部署形必须走
|
|
645
|
+
// `owner IS NULL`,否则这条读口在那些部署上恒回零行(一条静默失效的重放面,比报错更难发现)。
|
|
646
|
+
const { rows } = await withReadSignal(this.db.query(owner === null
|
|
647
|
+
? this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner IS NULL ORDER BY created_at_ms ASC`)
|
|
648
|
+
: this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE session_id = ? AND state = 'STREAM_PENDING' AND owner = ? ORDER BY created_at_ms ASC`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE session_id = $1 AND state = 'STREAM_PENDING' AND owner = $2 ORDER BY created_at_ms ASC`), owner === null ? [sessionId] : [sessionId, owner]), signal, "listPendingBySession");
|
|
649
|
+
return rows.map(mapAskRow);
|
|
650
|
+
}
|
|
651
|
+
async getAsk(askId) {
|
|
652
|
+
const { rows } = await this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE ask_id = ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE ask_id = $1`), [
|
|
653
|
+
askId,
|
|
654
|
+
]);
|
|
655
|
+
return rows[0] ? mapAskRow(rows[0]) : null;
|
|
656
|
+
}
|
|
657
|
+
async getByIdempotencyKey(taskId, key) {
|
|
658
|
+
assertIdempotencyKeyShape(key); // F2 卫生门:坏形键不做注定方言分歧的查询
|
|
659
|
+
// 谓词跟 UNIQUE `(task_id, idempotency_key)` 逐字对齐 ⇒ 至多一行,且天然带租户门(见接口注)。
|
|
660
|
+
const { rows } = await this.db.query(this.q(`SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE task_id = ? AND idempotency_key = ?`, `SELECT ${ASK_COLS} FROM ${APPROVAL_ASKS_TABLE} WHERE task_id = $1 AND idempotency_key = $2`), [taskId, key]);
|
|
661
|
+
return rows[0] ? mapAskRow(rows[0]) : null;
|
|
662
|
+
}
|
|
663
|
+
async deferReconcile(askId, expectedState, expectedVersion, nowMs) {
|
|
664
|
+
// 纯 touch:**不碰 state**(见接口注——同态转移在状态机表上无出边,这里刻意走另一扇门),只推
|
|
665
|
+
// `updated_at_ms` 与恒变的 `version`。`AND state=? AND version=?` 双谓词 = 两副本同扫时的单赢者门。
|
|
666
|
+
const res = await this.db.query(this.q(`UPDATE ${APPROVAL_ASKS_TABLE} SET updated_at_ms = ?, version = version + 1 WHERE ask_id = ? AND state = ? AND version = ?`, `UPDATE ${APPROVAL_ASKS_TABLE} SET updated_at_ms = $1, version = version + 1 WHERE ask_id = $2 AND state = $3 AND version = $4`), [nowMs, askId, expectedState, expectedVersion]);
|
|
667
|
+
return res.affected === 1;
|
|
668
|
+
}
|
|
669
|
+
async resolveProvisional(askId, from, to, patch) {
|
|
670
|
+
// 🔴 故意不经 canAskTransition:这是设计定稿 §3.0/§4 明说的「provisional 行的版本化补偿」——终态→终态
|
|
671
|
+
// 的例外通道,机器表把终态定义为「无出边」,这里走的是另一扇门(WHERE provisional=1 AND state=from)。
|
|
672
|
+
const dialect = this.db.dialect;
|
|
673
|
+
const dyn = patchColumns(patch, (v) => this.json(v));
|
|
674
|
+
const cols = ["state", "provisional", ...dyn.map(([c]) => c), "updated_at_ms"];
|
|
675
|
+
const vals = [to, 0, ...dyn.map(([, v]) => v), patch.updatedAtMs];
|
|
676
|
+
let n = 0;
|
|
677
|
+
const setSql = cols.map((c) => `${c} = ${ph(dialect, ++n)}`).join(", ");
|
|
678
|
+
const askIdPh = ph(dialect, ++n);
|
|
679
|
+
const provPh = ph(dialect, ++n);
|
|
680
|
+
const fromPh = ph(dialect, ++n);
|
|
681
|
+
const res = await this.db.query(`UPDATE ${APPROVAL_ASKS_TABLE} SET ${setSql}, version = version + 1 WHERE ask_id = ${askIdPh} AND provisional = ${provPh} AND state = ${fromPh}`, [...vals, askId, 1, from]);
|
|
682
|
+
return res.affected === 1;
|
|
683
|
+
}
|
|
684
|
+
async deleteByTask(taskId) {
|
|
685
|
+
await this.db.query(this.q(`DELETE FROM ${APPROVAL_ASKS_TABLE} WHERE task_id = ?`, `DELETE FROM ${APPROVAL_ASKS_TABLE} WHERE task_id = $1`), [taskId]);
|
|
686
|
+
await this.db.query(this.q(`DELETE FROM ${APPROVAL_BATCHES_TABLE} WHERE task_id = ?`, `DELETE FROM ${APPROVAL_BATCHES_TABLE} WHERE task_id = $1`), [taskId]);
|
|
687
|
+
}
|
|
688
|
+
async getBatch(batchId) {
|
|
689
|
+
const { rows } = await this.db.query(this.q(`SELECT ${BATCH_COLS} FROM ${APPROVAL_BATCHES_TABLE} WHERE batch_id = ?`, `SELECT ${BATCH_COLS} FROM ${APPROVAL_BATCHES_TABLE} WHERE batch_id = $1`), [batchId]);
|
|
690
|
+
return rows[0] ? mapBatchRow(rows[0]) : null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** MySQL-protocol(TiDB)绑定。 */
|
|
694
|
+
export class TiDBApprovalAskStore extends SqlApprovalAskStore {
|
|
695
|
+
constructor(pool) {
|
|
696
|
+
super(mysqlDriver(pool));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/** PostgreSQL 绑定。 */
|
|
700
|
+
export class PgApprovalAskStore extends SqlApprovalAskStore {
|
|
701
|
+
constructor(pool) {
|
|
702
|
+
super(pgDriver(pool));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
//# sourceMappingURL=approval-ask-store-sql.js.map
|