@sema-agent/server 7.1.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 +3 -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/org-memory.d.ts +8 -5
- package/dist/boot/org-memory.js +23 -12
- package/dist/boot/reapers.d.ts +34 -0
- package/dist/boot/reapers.js +198 -23
- package/dist/boot/resolve-spec.d.ts +16 -1
- package/dist/boot/resolve-spec.js +104 -46
- package/dist/config-center/facade.d.ts +1 -1
- package/dist/config-center/facade.js +1 -1
- package/dist/config-center/http-client.d.ts +19 -0
- package/dist/config-center/http-client.js +81 -32
- package/dist/config-types.d.ts +76 -7
- package/dist/config.d.ts +1 -0
- package/dist/config.js +134 -4
- package/dist/elicitation.d.ts +4 -0
- package/dist/elicitation.js +2 -2
- package/dist/http/routes/capabilities.js +18 -2
- package/dist/http/routes/runs.d.ts +1 -0
- package/dist/http/routes/runs.js +548 -16
- package/dist/http/routes/tasks.js +190 -12
- package/dist/http/server.d.ts +1 -1
- package/dist/http/server.js +124 -8
- 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/leader/wire.js +4 -0
- package/dist/main.js +7 -4
- package/dist/org-memory-admission.d.ts +2 -1
- package/dist/org-memory-admission.js +10 -4
- package/dist/parked-decide.js +5 -2
- 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 +18 -3
- package/dist/question.js +20 -6
- 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/runtime-governance.d.ts +11 -4
- package/dist/runtime-governance.js +16 -5
- 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
|
@@ -9,7 +9,7 @@ const SUMMARY_COLS = "handle, owner, session_scoped, session_id, parent_session_
|
|
|
9
9
|
* 类型签名逐字核对过),只是 record_json 全量对象序列化里随手带上的普通字段,不需要专用列——
|
|
10
10
|
* record_json 的写法是 `json(stored)`(stored = `{...record, handle, scope, rev}` 整个对象展开),
|
|
11
11
|
* 任何 record 上有的字段(含 writerEpoch)天然随之落盘/读回,无需逐字段维护投影列表。 */
|
|
12
|
-
const PROJ_COLS = "scope, owner, session_scoped, session_id, parent_session_id, root_session_id, session_anchor, name, agent_type, status, spawned_at_ms, updated_at_ms, settled_at_ms, usage_json, rev, parked_checkpoint_token, park_claim_id, record_json";
|
|
12
|
+
const PROJ_COLS = "scope, owner, session_scoped, session_id, parent_session_id, root_session_id, session_anchor, name, agent_type, status, spawned_at_ms, updated_at_ms, settled_at_ms, usage_json, rev, parked_checkpoint_token, park_claim_id, admitted_org_scopes, admitted_org_write_scope, record_json";
|
|
13
13
|
const PROJ_N = PROJ_COLS.split(", ").length; // F5: WHERE 占位符序号从列数派生,列序变更单点同步
|
|
14
14
|
function assertScope(record) {
|
|
15
15
|
if (record.scope === undefined || record.scope === "") {
|
|
@@ -43,6 +43,16 @@ function projParams(stored, json, text) {
|
|
|
43
43
|
stored.rev,
|
|
44
44
|
stored.parkedCheckpointToken ?? null,
|
|
45
45
|
stored.parkClaimId ?? null,
|
|
46
|
+
// core 5.14.0 #22([2854] 提货单⑤):每条腿在 injector-ready 栅上写下自己被裁定的 org 准入结果。
|
|
47
|
+
// 🔴 三态必须区分,这是 core 契约试剂盒硬钉的那条:**缺席(undefined)≠ 空数组**。
|
|
48
|
+
// · 字段缺席 ⇒ 列 NULL(「没有记录」)。老行免回填,读空即零准入 fail-closed。
|
|
49
|
+
// · `[]` / `null` ⇒ 列上是 JSON "[]" / 真 NULL 写域 —— 「裁定过,结果是什么都没有」。
|
|
50
|
+
// 两列是**写侧投影**(真源恒是 record_json,`get()` 只读它)——语义上不可能漂移:缺席的键根本
|
|
51
|
+
// 不进 JSON.stringify,所以「缺席读回缺席」由真源结构保证,不靠这两列。
|
|
52
|
+
// 📌 记账(诚实清单):本仓今天**没有**读这两列的消费者;它们存在是为了让 org 治理审计能按列
|
|
53
|
+
// 查询(「哪些后台子代拿到过 org:acme 的写权」),不必全表扫 JSON。谁要接,先上黑板对表。
|
|
54
|
+
stored.admittedOrgScopes !== undefined ? json(stored.admittedOrgScopes) : null,
|
|
55
|
+
stored.admittedOrgWriteScope !== undefined ? stored.admittedOrgWriteScope : null,
|
|
46
56
|
json(stored),
|
|
47
57
|
];
|
|
48
58
|
}
|
|
@@ -171,6 +181,12 @@ export async function ensureTiDBBackgroundAgentSchema(pool) {
|
|
|
171
181
|
-- 行状态原子求值,不能只靠 record_json blob(那要求先读后写,读写之间就是 CAS 本要堵的竞态窗)。
|
|
172
182
|
parked_checkpoint_token VARCHAR(190) NULL,
|
|
173
183
|
park_claim_id VARCHAR(190) NULL,
|
|
184
|
+
-- core 5.14.0 #22:该腿被裁定的 org 准入结果(写侧投影,真源恒是 record_json)。
|
|
185
|
+
-- NULL = 字段缺席(「没有记录」,老行天然如此,免回填);JSON "[]" = 裁定过、结果为空。
|
|
186
|
+
-- admitted_org_write_scope 的 NULL 兼表「缺席」与「裁定为无写域」——两者在 core 侧都落 fail-closed
|
|
187
|
+
-- 的零写权,列上不必再分,真源(record_json)里仍然分得清 undefined 与 null。
|
|
188
|
+
admitted_org_scopes TEXT NULL,
|
|
189
|
+
admitted_org_write_scope VARCHAR(190) NULL,
|
|
174
190
|
record_json LONGTEXT NOT NULL,
|
|
175
191
|
PRIMARY KEY (scope_key, handle),
|
|
176
192
|
KEY idx_bga_anchor (scope_key, session_anchor, spawned_at_ms),
|
|
@@ -201,6 +217,9 @@ export async function ensurePgBackgroundAgentSchema(q) {
|
|
|
201
217
|
-- core 1.383([1565] / design/153 件3a)ε(TiDB 同案注):updateIf 的 CAS 守卫专用列
|
|
202
218
|
parked_checkpoint_token VARCHAR(190),
|
|
203
219
|
park_claim_id VARCHAR(190),
|
|
220
|
+
-- core 5.14.0 #22(TiDB 同案注:三态与「写侧投影」口径见 MySQL 孪生的行内注)。
|
|
221
|
+
admitted_org_scopes TEXT,
|
|
222
|
+
admitted_org_write_scope VARCHAR(190),
|
|
204
223
|
record_json TEXT NOT NULL,
|
|
205
224
|
PRIMARY KEY (scope_key, handle)
|
|
206
225
|
)`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Pool as MySqlPool } from "mysql2/promise";
|
|
2
2
|
import type { Pool as PgPool } from "pg";
|
|
3
|
-
import { type Checkpoint, type CheckpointGate, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type ResumeOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor } from "@sema-agent/core";
|
|
3
|
+
import { type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor } from "@sema-agent/core";
|
|
4
4
|
import { type SqlDriver } from "./sql-driver.js";
|
|
5
5
|
/**
|
|
6
6
|
* design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
|
|
@@ -67,6 +67,42 @@ export interface PendingCheckpoint {
|
|
|
67
67
|
* matches what the operator queue serves; oversized payloads collapse to a marker rather than bloating the row.
|
|
68
68
|
* Exported for the LOCAL twin (local-checkpoint-store.ts) so the operator-queue projection can't drift. */
|
|
69
69
|
export declare function boundedToolInput(args: unknown): unknown;
|
|
70
|
+
/**
|
|
71
|
+
* 🔴 #151 车5 §9 C4:`findCheckpointCandidatesForAsk` 的 typed 候选行。刻意只有对账收敛器判据 1 真需要的
|
|
72
|
+
* 五件——**没有 token 之外的任何凭据面**,`token` 本身是 resume 凭据、只在服务端内部流转(同
|
|
73
|
+
* `findPendingTokenBySession`:它从不出网,§12-C「checkpointToken 从不外发」)。
|
|
74
|
+
*/
|
|
75
|
+
export interface CheckpointAskCandidate {
|
|
76
|
+
token: string;
|
|
77
|
+
/** `checkpoint.status` 列(权威;`pending` | `resolved` | `expired`)。不做过滤,由收敛器判活性。 */
|
|
78
|
+
status: string;
|
|
79
|
+
createdAtMs: number;
|
|
80
|
+
/** 该 park 绑定的 tool call(= `pendingAction.toolCallId`)。`unparseable` 行为 null。 */
|
|
81
|
+
boundCallId: string | null;
|
|
82
|
+
/** 服务端铸的入参摘要——判据 1 的第二道硬等式(与 `approval_asks.bound_input_hash` 相等才算命中)。 */
|
|
83
|
+
boundInputHash: string | null;
|
|
84
|
+
/** 在场 ⇒ 这行读不出(blob 版本超前 / JSON 坏)。收敛器视同不匹配;**从不是** false,缺席即可读。 */
|
|
85
|
+
unparseable?: true;
|
|
86
|
+
}
|
|
87
|
+
/** `pending_steer` 列承载的两个 CheckpointState 字段(= core `appendPendingSteer` / `readPendingSteerQueue`
|
|
88
|
+
* 的入参形)。列是**唯一**权威(suspend 时写的 blob 从不带它们)。 */
|
|
89
|
+
type SteerColumnState = Pick<CheckpointState, "pendingSteer" | "pendingSteerQueue">;
|
|
90
|
+
/** 读列结果。`lossy` = 列里有东西但**没能完整读出来**(JSON 坏 / 条目形不认)。两个调用点对它的处置
|
|
91
|
+
* 刻意相反,见各自的旁注:`get()` 容忍(坏列不许把一条挂起的 run 变成不可 resume),`setPendingSteer`
|
|
92
|
+
* fail-loud(往一份读不全的队列上追加 = 把别人 park 的指令悄悄写没,正是 #147 要消灭的那件事)。 */
|
|
93
|
+
interface SteerColumnRead {
|
|
94
|
+
state: SteerColumnState;
|
|
95
|
+
lossy: boolean;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 两个 steering 列 → `{pendingSteer?, pendingSteerQueue?}`(= core `appendPendingSteer`/`readPendingSteerQueue`
|
|
99
|
+
* 的入参形)。**两列各管一形**,不是一列两形:
|
|
100
|
+
* · `pending_steer`(旧列,本服务只读不写)—— 裸单座 `{text, trusted}`。来源有二:队列化之前的存量行,
|
|
101
|
+
* 以及**滚动升级窗里仍在跑的旧副本**写下的座。core 的 `readPendingSteerQueue` 把它折成 member 0。
|
|
102
|
+
* · `pending_steer_queue`(新列)—— `PendingSteerEntry[]`。
|
|
103
|
+
* 任一列有内容却读不出 ⇒ `lossy`(两个调用点对它的处置见 {@link SteerColumnRead})。
|
|
104
|
+
*/
|
|
105
|
+
export declare function readPendingSteerColumns(seatRaw: unknown, queueRaw: unknown): SteerColumnRead;
|
|
70
106
|
/** Dual-dialect durable CheckpointStore. See the file header for the dialect-delta ledger. */
|
|
71
107
|
export declare class SqlCheckpointStore implements CheckpointStore {
|
|
72
108
|
protected readonly db: SqlDriver;
|
|
@@ -123,15 +159,30 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
123
159
|
*/
|
|
124
160
|
reopen(token: CheckpointToken, scope: string, reason: ReopenReason): Promise<boolean>;
|
|
125
161
|
/**
|
|
126
|
-
* design/80 D-A (durable steering)
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
162
|
+
* design/80 D-A (durable steering) —— **core 5.14.0(#147 BREAKING)起是「追加进一条有界有序队列」**,
|
|
163
|
+
* 不再是「盖掉单座」。两个人同时给一条挂起的 run 转向,旧语义会让第二条**静默销毁**第一条(一条没人
|
|
164
|
+
* 知道丢了的操作员指令);新语义是两条都在,resume 按 `seq` 全数投递。
|
|
165
|
+
*
|
|
166
|
+
* 语义与边界**全部铸在 core**,本 store 一个都不自铸(契约原文:`appendPendingSteer` is "the shared
|
|
167
|
+
* append every backend must build its next queue with: bounds + `seq` mint live there, not once per store"):
|
|
168
|
+
* · `validatePendingSteer` —— 每个 impl 内部必跑的 fail-closed 不变式(M2,接入审计 2026-07-08):内容
|
|
169
|
+
* 越狱/控制字符 `steering.invalid_content`;**未知字段现在是 REFUSE 而不是丢弃**(领先一个版本的
|
|
170
|
+
* producer 必须知道自己的新字段活不过这条挂起的腿);缺席的 `inputId` 在这里铸(uuidv7)。
|
|
171
|
+
* · `appendPendingSteer` —— 铸 `seq`、判 `inputId` 幂等(同 id 同载荷 = no-op 返回 true;同 id **不同**
|
|
172
|
+
* 载荷 = `steering.duplicate_input_id`,吞掉它就等于把队列要消灭的那种静默丢失原样请回来)、
|
|
173
|
+
* 执行两条上界(条数 + 48000 字节整信封),越界一律 `steering.queue_full` fail-loud,**绝不驱逐**
|
|
174
|
+
* 一条已被接受的指令。
|
|
175
|
+
*
|
|
176
|
+
* 🔴 从「一条无条件 UPDATE」变成「读-改-写」,中间那道窗必须自己有 CAS:两个并发 steer 读到同一份
|
|
177
|
+
* 队列、后写者整个盖掉前者 —— 就是 #147 要消灭的静默丢失换了个位置复发。守卫是 `pending_steer_rev`
|
|
178
|
+
* **专用整数列**(不是拿旧列值比:本表无显式 COLLATE,*_ci 排序规则会把只差大小写的两份信封判等 ⇒
|
|
179
|
+
* CAS 假命中;也不是复用 resolve 的 `rev`:bump 它会让并发 resume 的 `resolve(expect)` 白白落败)。
|
|
180
|
+
* CAS 输了就重读重算(队列只有 3 个位子,争用者天然极少);给足冗余仍全败 ⇒ **抛**,不返回 false ——
|
|
181
|
+
* `false` 在调用方的语义是「没有 pending 行可转向」(HTTP 409「已不再挂起」),把争用报成那个是撒谎。
|
|
182
|
+
*
|
|
183
|
+
* 返回:`true` = 落在一条活的 pending 行上(含幂等重放);`false` = 没有该 (token,scope) 的 pending 行。
|
|
130
184
|
*/
|
|
131
|
-
setPendingSteer(token: CheckpointToken, scope: string, steer:
|
|
132
|
-
text: string;
|
|
133
|
-
trusted: boolean;
|
|
134
|
-
}): Promise<boolean>;
|
|
185
|
+
setPendingSteer(token: CheckpointToken, scope: string, steer: PendingSteerInput): Promise<boolean>;
|
|
135
186
|
/**
|
|
136
187
|
* CAS-expire a SINGLE checkpoint by token (core 1.71 `CheckpointStore.expire`, for `TaskStream.destroy`'s
|
|
137
188
|
* suspended-worker reap — core [R33] {delta}). `pending → expired`, returns the CAS winner (true = we
|
|
@@ -199,6 +250,29 @@ export declare class SqlCheckpointStore implements CheckpointStore {
|
|
|
199
250
|
* can only resolve within its own tenant. Returns null if there is no pending checkpoint for that session/scope.
|
|
200
251
|
*/
|
|
201
252
|
findPendingTokenBySession(sessionId: string, scope?: string): Promise<CheckpointToken | null>;
|
|
253
|
+
/**
|
|
254
|
+
* 🔴 #151 车5 §9 C4 —— 流内审批对账收敛器判据 1 的**窄读口**(店面承载;收敛器本体不在本车)。
|
|
255
|
+
*
|
|
256
|
+
* 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
|
|
257
|
+
* 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
|
|
258
|
+
* 谓词精确查——`(scope, session_id, tool_call_id, created_at ≥ sinceMs)` 这组条件下的行数天然极小,
|
|
259
|
+
* 一次全量返回,结构上没有分页假阴性。
|
|
260
|
+
*
|
|
261
|
+
* 三条口径,逐条都是判据:
|
|
262
|
+
* - **`scope` 必填**(不是 optional):对账是跨腿读,租户门是读口自己的责任,不靠调用方记得过滤
|
|
263
|
+
* (同 `listPendingBySession` 姿势;`resolve`/`reap` 的 scope enforced 同族)。
|
|
264
|
+
* - **不按 status 过滤**:已 `resolved`/`expired` 的行照样返回。后到的 resume 会把行翻成 resolved,
|
|
265
|
+
* 若只看 pending 就会把「park 成功过」这个既成事实读成「没 park 过」⇒ 又是一条假阴性 DENIED
|
|
266
|
+
* (对账三约束③)。park 成功是事实,gate 的活性由 `status` 表达,交给收敛器自己判。
|
|
267
|
+
* - **单行读不出 ⇒ 标记返回,不抛**:`unparseable` 的行由收敛器视同「不匹配」,一条坏行不许打断整段
|
|
268
|
+
* 扫描(§8 C-6)。什么算读不出:blob 的 `version` 超出本 build 支持(`get()` 那条前向兼容门在这里
|
|
269
|
+
* 不能 throw,否则一条超前行会让整个 session 的对账永久卡死)、或 blob JSON 坏。
|
|
270
|
+
*
|
|
271
|
+
* 匹配是两段的:`tool_call_id` **列**是 `put()` 从 `pendingAction.toolCallId` 盖下来的权威投影,命中它
|
|
272
|
+
* 的行零解析即候选;列为 NULL 的行(无工具动作的 park,或列存在之前的旧行)才回落去解 blob——解得出且
|
|
273
|
+
* 相等才算候选,解不出就标 `unparseable`。
|
|
274
|
+
*/
|
|
275
|
+
findCheckpointCandidatesForAsk(scope: string, sessionId: string, toolCallId: string, sinceMs: number): Promise<CheckpointAskCandidate[]>;
|
|
202
276
|
/**
|
|
203
277
|
* The owner SCOPE of a session's pending checkpoint (the multi-tenant key === the owner principal in the
|
|
204
278
|
* BFF/non-operator flow, the same key listPending filters by). For the /decide owner-gate: a non-operator
|
|
@@ -251,4 +325,5 @@ export declare class PgCheckpointStore extends SqlCheckpointStore {
|
|
|
251
325
|
info?(msg: string, meta?: unknown): void;
|
|
252
326
|
});
|
|
253
327
|
}
|
|
328
|
+
export {};
|
|
254
329
|
//# sourceMappingURL=checkpoint-store-sql.d.ts.map
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL centrally in pg-pool.ts (neither store creates tables).
|
|
28
28
|
*/
|
|
29
29
|
import { createHash } from "node:crypto";
|
|
30
|
-
import { CheckpointError, validatePendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
|
|
30
|
+
import { CheckpointError, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
|
|
31
31
|
import { redactDeep } from "../trace/redact.js";
|
|
32
32
|
import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
|
|
33
33
|
import { mysqlDriver, pgDriver, dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
@@ -76,8 +76,154 @@ export function boundedToolInput(args) {
|
|
|
76
76
|
return { truncated: true, bytes: json.length };
|
|
77
77
|
return redacted;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* checkpoint blob → `pendingAction.toolCallId`。
|
|
81
|
+
*
|
|
82
|
+
* 🔴 三态、**不是**两态(真双库实跑抓到的缺陷,2026-08-06):「读不出」与「读出来了、但这条 park 本来就
|
|
83
|
+
* 没有 toolCallId」是两件事,压成同一个 `undefined` 会把每一条 `plan_review`/`task_done`/`resource_limit`
|
|
84
|
+
* 腿(core 的 `PendingAction` 联合里三个成员结构上就没有 toolCallId)都误报成坏行。前者应标 `unparseable`
|
|
85
|
+
* 交给收敛器当「不确定」,后者是**确定的不匹配**——干净地不是候选。
|
|
86
|
+
* - `{ readable: false }` —— JSON 坏 / 形状根本不是对象:真的读不出。
|
|
87
|
+
* - `{ readable: true, toolCallId: null }` —— 读出来了,这条 park 无工具动作:确定不匹配。
|
|
88
|
+
* - `{ readable: true, toolCallId: "…" }` —— 读出来了,拿去比。
|
|
89
|
+
*
|
|
90
|
+
* 逐层 `typeof` 收窄,不做裸 `as` 断言:blob 是持久层读回来的 `unknown`,是信任边界(宪法 [2704])。
|
|
91
|
+
*/
|
|
92
|
+
function pendingActionToolCallId(blob) {
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = typeof blob === "string" ? JSON.parse(blob) : blob;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { readable: false };
|
|
99
|
+
}
|
|
100
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
101
|
+
return { readable: false };
|
|
102
|
+
if (!("pendingAction" in parsed))
|
|
103
|
+
return { readable: true, toolCallId: null, boundInputHash: null, kind: null }; // 无 pendingAction 的 park:确定不匹配
|
|
104
|
+
const pa = parsed.pendingAction;
|
|
105
|
+
if (typeof pa !== "object" || pa === null)
|
|
106
|
+
return { readable: false };
|
|
107
|
+
const kind = "kind" in pa ? pa.kind : null;
|
|
108
|
+
if (kind !== null && typeof kind !== "string")
|
|
109
|
+
return { readable: false };
|
|
110
|
+
if (!("toolCallId" in pa))
|
|
111
|
+
return { readable: true, toolCallId: null, boundInputHash: null, kind }; // plan_review / task_done / resource_limit 腿
|
|
112
|
+
const id = pa.toolCallId;
|
|
113
|
+
if (typeof id !== "string")
|
|
114
|
+
return { readable: false };
|
|
115
|
+
// 🔴(属主兜底轮 F1)带 toolCallId 却挂着词表外的 kind = 结构漂移,不是「读得出」——这条 blob 声称
|
|
116
|
+
// 自己是一次工具审批 park(有 callId)又否认(kind 不是 tool_approval)。让它带着匹配的 callId 混进
|
|
117
|
+
// 健康候选,等于给「外部改库 / schema 偏斜」开一条错配 PARK 的路。词表判据窄取 "tool_approval":
|
|
118
|
+
// 其余合法 kind(plan_review 等)在 core 的类型里结构上**没有** toolCallId,走不到这里。
|
|
119
|
+
if (kind !== "tool_approval")
|
|
120
|
+
return { readable: false };
|
|
121
|
+
// hash 只做逐层收窄,不在这层与列比对(比对是调用处的列/blob 一致性门)。缺席 = null(旧行/无 hash 形)。
|
|
122
|
+
const bih = "boundInputHash" in pa ? pa.boundInputHash : null;
|
|
123
|
+
if (bih !== null && typeof bih !== "string")
|
|
124
|
+
return { readable: false };
|
|
125
|
+
return { readable: true, toolCallId: id, boundInputHash: bih, kind };
|
|
126
|
+
}
|
|
79
127
|
const DUP_ENTRY = 1062; // MySQL/TiDB ER_DUP_ENTRY
|
|
80
128
|
const PG_UNIQUE_VIOLATION = "23505";
|
|
129
|
+
function readActorAssertion(v) {
|
|
130
|
+
if (typeof v !== "object" || v === null)
|
|
131
|
+
return undefined;
|
|
132
|
+
const id = "id" in v ? v.id : undefined;
|
|
133
|
+
const hostAsserted = "hostAsserted" in v ? v.hostAsserted : undefined;
|
|
134
|
+
if (typeof id !== "string" || typeof hostAsserted !== "boolean")
|
|
135
|
+
return undefined;
|
|
136
|
+
const issuer = "issuer" in v ? v.issuer : undefined;
|
|
137
|
+
return { id, hostAsserted, ...(typeof issuer === "string" ? { issuer } : {}) };
|
|
138
|
+
}
|
|
139
|
+
/** 队列化之前的**裸单座**形(`{text,trusted}`)—— 旧行原样躺在库里,core 的 `readPendingSteerQueue`
|
|
140
|
+
* 会把它折成 member 0,所以读出来照原样保留、不改写、不迁移。 */
|
|
141
|
+
function readLegacySeat(v) {
|
|
142
|
+
if (typeof v !== "object" || v === null)
|
|
143
|
+
return undefined;
|
|
144
|
+
const text = "text" in v ? v.text : undefined;
|
|
145
|
+
const trusted = "trusted" in v ? v.trusted : undefined;
|
|
146
|
+
if (typeof text !== "string" || typeof trusted !== "boolean")
|
|
147
|
+
return undefined;
|
|
148
|
+
return { text, trusted };
|
|
149
|
+
}
|
|
150
|
+
/** 队列条目的逐层窄读(持久层读回的是 `unknown`,是信任边界 —— 禁裸 `as`)。四个必备键任一不合形 ⇒
|
|
151
|
+
* 整条不认(返回 undefined,由调用方判 lossy);`actor`/`priority` 可选,不合形即缺席。 */
|
|
152
|
+
function readQueueEntry(v) {
|
|
153
|
+
if (typeof v !== "object" || v === null)
|
|
154
|
+
return undefined;
|
|
155
|
+
const text = "text" in v ? v.text : undefined;
|
|
156
|
+
const trusted = "trusted" in v ? v.trusted : undefined;
|
|
157
|
+
const seq = "seq" in v ? v.seq : undefined;
|
|
158
|
+
const inputId = "inputId" in v ? v.inputId : undefined;
|
|
159
|
+
if (typeof text !== "string" || typeof trusted !== "boolean")
|
|
160
|
+
return undefined;
|
|
161
|
+
if (typeof seq !== "number" || !Number.isFinite(seq))
|
|
162
|
+
return undefined;
|
|
163
|
+
if (typeof inputId !== "string" || inputId === "")
|
|
164
|
+
return undefined;
|
|
165
|
+
const priority = "priority" in v ? v.priority : undefined;
|
|
166
|
+
const actor = readActorAssertion("actor" in v ? v.actor : undefined);
|
|
167
|
+
return {
|
|
168
|
+
text,
|
|
169
|
+
trusted,
|
|
170
|
+
seq,
|
|
171
|
+
inputId,
|
|
172
|
+
...(actor !== undefined ? { actor } : {}),
|
|
173
|
+
...(priority === "now" || priority === "next" || priority === "later" ? { priority } : {}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 两个 steering 列 → `{pendingSteer?, pendingSteerQueue?}`(= core `appendPendingSteer`/`readPendingSteerQueue`
|
|
178
|
+
* 的入参形)。**两列各管一形**,不是一列两形:
|
|
179
|
+
* · `pending_steer`(旧列,本服务只读不写)—— 裸单座 `{text, trusted}`。来源有二:队列化之前的存量行,
|
|
180
|
+
* 以及**滚动升级窗里仍在跑的旧副本**写下的座。core 的 `readPendingSteerQueue` 把它折成 member 0。
|
|
181
|
+
* · `pending_steer_queue`(新列)—— `PendingSteerEntry[]`。
|
|
182
|
+
* 任一列有内容却读不出 ⇒ `lossy`(两个调用点对它的处置见 {@link SteerColumnRead})。
|
|
183
|
+
*/
|
|
184
|
+
export function readPendingSteerColumns(seatRaw, queueRaw) {
|
|
185
|
+
let lossy = false;
|
|
186
|
+
const parse = (raw) => {
|
|
187
|
+
if (raw === null || raw === undefined)
|
|
188
|
+
return undefined;
|
|
189
|
+
if (typeof raw !== "string")
|
|
190
|
+
return raw;
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(raw);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
lossy = true;
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
const seatParsed = parse(seatRaw);
|
|
200
|
+
const seat = seatParsed === undefined ? undefined : readLegacySeat(seatParsed);
|
|
201
|
+
if (seatParsed !== undefined && seat === undefined)
|
|
202
|
+
lossy = true;
|
|
203
|
+
const queueParsed = parse(queueRaw);
|
|
204
|
+
let queue;
|
|
205
|
+
if (queueParsed !== undefined) {
|
|
206
|
+
if (!Array.isArray(queueParsed))
|
|
207
|
+
lossy = true;
|
|
208
|
+
else {
|
|
209
|
+
queue = [];
|
|
210
|
+
for (const item of queueParsed) {
|
|
211
|
+
const entry = readQueueEntry(item);
|
|
212
|
+
if (entry === undefined)
|
|
213
|
+
lossy = true;
|
|
214
|
+
else
|
|
215
|
+
queue.push(entry);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
state: { ...(seat !== undefined ? { pendingSteer: seat } : {}), ...(queue !== undefined ? { pendingSteerQueue: queue } : {}) },
|
|
221
|
+
lossy,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/** 读-改-写的 CAS 重试上限。队列本身只有 `MAX_PENDING_STEER_ENTRIES`(= 3)个位子,再多就 fail-loud,
|
|
225
|
+
* 所以真实争用者数量天然很小;给足冗余后仍然全败 ⇒ 是异常,必须 fail-loud(见 setPendingSteer 尾注)。 */
|
|
226
|
+
const STEER_CAS_ATTEMPTS = 8;
|
|
81
227
|
/** Dual-dialect durable CheckpointStore. See the file header for the dialect-delta ledger. */
|
|
82
228
|
export class SqlCheckpointStore {
|
|
83
229
|
db;
|
|
@@ -136,7 +282,7 @@ export class SqlCheckpointStore {
|
|
|
136
282
|
}
|
|
137
283
|
}
|
|
138
284
|
async get(token) {
|
|
139
|
-
const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer FROM checkpoint WHERE token = $1"), [token]);
|
|
285
|
+
const { rows } = await this.db.query(this.q("SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue FROM checkpoint WHERE token = ?", "SELECT version, status, checkpoint, outcome, reopen_reason, rev, pending_steer, pending_steer_queue FROM checkpoint WHERE token = $1"), [token]);
|
|
140
286
|
const r = rows[0];
|
|
141
287
|
if (!r)
|
|
142
288
|
return null;
|
|
@@ -163,9 +309,22 @@ export class SqlCheckpointStore {
|
|
|
163
309
|
cp.rev = r.rev == null ? 0 : Number(r.rev); // legacy/pre-migration rows ⇒ 0 (the .d.ts "Absent ⇒ legacy 0")
|
|
164
310
|
// design/80 D-A: overlay the parked steer off the AUTHORITATIVE column (set by setPendingSteer AFTER the
|
|
165
311
|
// suspend-time blob was written) so core injects it on resume. The blob never carries it.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
312
|
+
// core 5.14.0(#147):列现在承载**两个**字段 —— 队列本体 + 旧行折下来的裸单座。两者都要盖上去,
|
|
313
|
+
// core 的 `readPendingSteerQueue` 才能把「legacy 座 = member 0,其余按 seq」拼成完整投递序。
|
|
314
|
+
// 🔴 坏列在这里**只丢 steer、不抛**:resume 是这条挂起的 run 唯一的出路,为一条读不出的转向把整条
|
|
315
|
+
// run 变成不可 resume 是错误的失败方向(写侧相反,见 setPendingSteer)。
|
|
316
|
+
const steer = readPendingSteerColumns(r.pending_steer, r.pending_steer_queue);
|
|
317
|
+
if (steer.lossy) {
|
|
318
|
+
// 「静默」到此为止:读不出的部分确实丢了(投递腿不能为一条坏列把整条 run 变成不可 resume),
|
|
319
|
+
// 但它必须**留痕** —— 丢的是操作员指令,而 #147 的全部意义就是这类丢失不许无声。
|
|
320
|
+
this.logger?.info?.("checkpoint_pending_steer_unreadable", { tokenFp: tokenFingerprint(token) });
|
|
321
|
+
}
|
|
322
|
+
if (cp.state) {
|
|
323
|
+
if (steer.state.pendingSteer !== undefined)
|
|
324
|
+
cp.state.pendingSteer = steer.state.pendingSteer;
|
|
325
|
+
if (steer.state.pendingSteerQueue !== undefined)
|
|
326
|
+
cp.state.pendingSteerQueue = steer.state.pendingSteerQueue;
|
|
327
|
+
}
|
|
169
328
|
// Diagnostic: log what the REAL runner.resume store.get returns for the remote
|
|
170
329
|
// workspaceHandle (the isolated round-trip test can't prove the real-path get). undefined here ⇒ store/get
|
|
171
330
|
// drops it; present ⇒ core's 910→614 threading drops it. Cheap, on the resume read-path only.
|
|
@@ -227,20 +386,61 @@ export class SqlCheckpointStore {
|
|
|
227
386
|
return res.affected === 1;
|
|
228
387
|
}
|
|
229
388
|
/**
|
|
230
|
-
* design/80 D-A (durable steering)
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
389
|
+
* design/80 D-A (durable steering) —— **core 5.14.0(#147 BREAKING)起是「追加进一条有界有序队列」**,
|
|
390
|
+
* 不再是「盖掉单座」。两个人同时给一条挂起的 run 转向,旧语义会让第二条**静默销毁**第一条(一条没人
|
|
391
|
+
* 知道丢了的操作员指令);新语义是两条都在,resume 按 `seq` 全数投递。
|
|
392
|
+
*
|
|
393
|
+
* 语义与边界**全部铸在 core**,本 store 一个都不自铸(契约原文:`appendPendingSteer` is "the shared
|
|
394
|
+
* append every backend must build its next queue with: bounds + `seq` mint live there, not once per store"):
|
|
395
|
+
* · `validatePendingSteer` —— 每个 impl 内部必跑的 fail-closed 不变式(M2,接入审计 2026-07-08):内容
|
|
396
|
+
* 越狱/控制字符 `steering.invalid_content`;**未知字段现在是 REFUSE 而不是丢弃**(领先一个版本的
|
|
397
|
+
* producer 必须知道自己的新字段活不过这条挂起的腿);缺席的 `inputId` 在这里铸(uuidv7)。
|
|
398
|
+
* · `appendPendingSteer` —— 铸 `seq`、判 `inputId` 幂等(同 id 同载荷 = no-op 返回 true;同 id **不同**
|
|
399
|
+
* 载荷 = `steering.duplicate_input_id`,吞掉它就等于把队列要消灭的那种静默丢失原样请回来)、
|
|
400
|
+
* 执行两条上界(条数 + 48000 字节整信封),越界一律 `steering.queue_full` fail-loud,**绝不驱逐**
|
|
401
|
+
* 一条已被接受的指令。
|
|
402
|
+
*
|
|
403
|
+
* 🔴 从「一条无条件 UPDATE」变成「读-改-写」,中间那道窗必须自己有 CAS:两个并发 steer 读到同一份
|
|
404
|
+
* 队列、后写者整个盖掉前者 —— 就是 #147 要消灭的静默丢失换了个位置复发。守卫是 `pending_steer_rev`
|
|
405
|
+
* **专用整数列**(不是拿旧列值比:本表无显式 COLLATE,*_ci 排序规则会把只差大小写的两份信封判等 ⇒
|
|
406
|
+
* CAS 假命中;也不是复用 resolve 的 `rev`:bump 它会让并发 resume 的 `resolve(expect)` 白白落败)。
|
|
407
|
+
* CAS 输了就重读重算(队列只有 3 个位子,争用者天然极少);给足冗余仍全败 ⇒ **抛**,不返回 false ——
|
|
408
|
+
* `false` 在调用方的语义是「没有 pending 行可转向」(HTTP 409「已不再挂起」),把争用报成那个是撒谎。
|
|
409
|
+
*
|
|
410
|
+
* 返回:`true` = 落在一条活的 pending 行上(含幂等重放);`false` = 没有该 (token,scope) 的 pending 行。
|
|
234
411
|
*/
|
|
235
412
|
async setPendingSteer(token, scope, steer) {
|
|
236
|
-
// M2(接入审计 2026-07-08):契约把 validatePendingSteer 定为【每个 impl 内部】必跑的 fail-closed 不变式
|
|
237
|
-
// ("EVERY CheckpointStore.setPendingSteer impl runs [it] so the reject is IDENTICAL across the in-memory and
|
|
238
|
-
// durable backends")——core 自家 File/InMemory/Pg 三 impl 全有;此前这里裸写 UPDATE=靠调用方自觉(HTTP 层
|
|
239
|
-
// 三处前置校验只是碰巧全覆盖,下一个内部调用方不一定记得)。clean = 冻结 {text,trusted} 两字段拷贝
|
|
240
|
-
// (调用方对象上的多余字段绝不落库,契约同款)。
|
|
241
413
|
const clean = validatePendingSteer(steer); // throws CheckpointError(steering.invalid_content) — per-impl 一致拒绝
|
|
242
|
-
|
|
243
|
-
|
|
414
|
+
for (let attempt = 0; attempt < STEER_CAS_ATTEMPTS; attempt++) {
|
|
415
|
+
const { rows } = await this.db.query(this.q("SELECT status, pending_steer, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = ? AND scope = ?", "SELECT status, pending_steer, pending_steer_queue, pending_steer_rev FROM checkpoint WHERE token = $1 AND scope = $2"), [token, scope]);
|
|
416
|
+
const row = rows[0];
|
|
417
|
+
if (!row || row.status !== "pending")
|
|
418
|
+
return false; // 没有活的 pending 行(与旧的 CAS 落空同义)
|
|
419
|
+
const read = readPendingSteerColumns(row.pending_steer, row.pending_steer_queue);
|
|
420
|
+
if (read.lossy) {
|
|
421
|
+
// 🔴 写侧与 get() 的失败方向刻意相反:往一份读不全的队列上追加,写回时就会把读不出的那部分
|
|
422
|
+
// **永久抹掉** —— 那正是 #147 存在的理由(静默丢一条已 park 的操作员指令)。宁可这一次转向失败。
|
|
423
|
+
throw new Error(`the parked steering queue on checkpoint ${tokenFingerprint(token)} is unreadable — refusing to append onto it ` +
|
|
424
|
+
`(appending would silently drop the entries this build cannot parse)`);
|
|
425
|
+
}
|
|
426
|
+
const before = read.state.pendingSteerQueue?.length ?? 0;
|
|
427
|
+
const next = appendPendingSteer(read.state, clean); // throws steering.queue_full / steering.duplicate_input_id
|
|
428
|
+
// 幂等重放(同 inputId 同载荷):core 原样奉还既有队列 ⇒ 一个字都不用写,也不必烧一个 rev。
|
|
429
|
+
if (next.length === before)
|
|
430
|
+
return true;
|
|
431
|
+
const rev = Number(row.pending_steer_rev ?? 0);
|
|
432
|
+
// 🔴 只写队列列 —— `pending_steer`(旧单座列)本服务**从不写**:滚动升级窗里旧副本对那一列是无条件
|
|
433
|
+
// 覆盖写,两边写同一列 = 互相清零(codex 复审 F3)。分列之后旧副本的座与新队列共存,读侧合并。
|
|
434
|
+
const res = await this.db.query(this.q("UPDATE checkpoint SET pending_steer_queue = ?, pending_steer_rev = pending_steer_rev + 1 " +
|
|
435
|
+
"WHERE token = ? AND scope = ? AND status = 'pending' AND pending_steer_rev = ?", "UPDATE checkpoint SET pending_steer_queue = $1, pending_steer_rev = pending_steer_rev + 1 " +
|
|
436
|
+
"WHERE token = $2 AND scope = $3 AND status = 'pending' AND pending_steer_rev = $4"), [this.json(next, "pending steer queue"), token, scope, rev]);
|
|
437
|
+
if (res.affected > 0)
|
|
438
|
+
return true;
|
|
439
|
+
// CAS 输了:另一个 steer 抢先追加(或这行刚被 resolve/expire)⇒ 重读重算。下一轮的 SELECT 会
|
|
440
|
+
// 分辨这两种情形:行还 pending 就再追加一次,不 pending 就返回 false。
|
|
441
|
+
}
|
|
442
|
+
throw new Error(`setPendingSteer lost the pending_steer_rev CAS ${STEER_CAS_ATTEMPTS} times on checkpoint ${tokenFingerprint(token)} — ` +
|
|
443
|
+
`refusing to report it as "no pending checkpoint" (that would silently discard an operator instruction)`);
|
|
244
444
|
}
|
|
245
445
|
/**
|
|
246
446
|
* CAS-expire a SINGLE checkpoint by token (core 1.71 `CheckpointStore.expire`, for `TaskStream.destroy`'s
|
|
@@ -377,6 +577,87 @@ export class SqlCheckpointStore {
|
|
|
377
577
|
: await this.db.query(this.q("SELECT token FROM checkpoint WHERE session_id=? AND status='pending' LIMIT 1", "SELECT token FROM checkpoint WHERE session_id=$1 AND status='pending' LIMIT 1"), [sessionId]);
|
|
378
578
|
return rows[0] ? String(rows[0].token) : null;
|
|
379
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* 🔴 #151 车5 §9 C4 —— 流内审批对账收敛器判据 1 的**窄读口**(店面承载;收敛器本体不在本车)。
|
|
582
|
+
*
|
|
583
|
+
* 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」:
|
|
584
|
+
* 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成
|
|
585
|
+
* 谓词精确查——`(scope, session_id, tool_call_id, created_at ≥ sinceMs)` 这组条件下的行数天然极小,
|
|
586
|
+
* 一次全量返回,结构上没有分页假阴性。
|
|
587
|
+
*
|
|
588
|
+
* 三条口径,逐条都是判据:
|
|
589
|
+
* - **`scope` 必填**(不是 optional):对账是跨腿读,租户门是读口自己的责任,不靠调用方记得过滤
|
|
590
|
+
* (同 `listPendingBySession` 姿势;`resolve`/`reap` 的 scope enforced 同族)。
|
|
591
|
+
* - **不按 status 过滤**:已 `resolved`/`expired` 的行照样返回。后到的 resume 会把行翻成 resolved,
|
|
592
|
+
* 若只看 pending 就会把「park 成功过」这个既成事实读成「没 park 过」⇒ 又是一条假阴性 DENIED
|
|
593
|
+
* (对账三约束③)。park 成功是事实,gate 的活性由 `status` 表达,交给收敛器自己判。
|
|
594
|
+
* - **单行读不出 ⇒ 标记返回,不抛**:`unparseable` 的行由收敛器视同「不匹配」,一条坏行不许打断整段
|
|
595
|
+
* 扫描(§8 C-6)。什么算读不出:blob 的 `version` 超出本 build 支持(`get()` 那条前向兼容门在这里
|
|
596
|
+
* 不能 throw,否则一条超前行会让整个 session 的对账永久卡死)、或 blob JSON 坏。
|
|
597
|
+
*
|
|
598
|
+
* 匹配是两段的:`tool_call_id` **列**是 `put()` 从 `pendingAction.toolCallId` 盖下来的权威投影,命中它
|
|
599
|
+
* 的行零解析即候选;列为 NULL 的行(无工具动作的 park,或列存在之前的旧行)才回落去解 blob——解得出且
|
|
600
|
+
* 相等才算候选,解不出就标 `unparseable`。
|
|
601
|
+
*/
|
|
602
|
+
async findCheckpointCandidatesForAsk(scope, sessionId, toolCallId, sinceMs) {
|
|
603
|
+
const { rows } = await this.db.query(this.q("SELECT token, status, created_at, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
|
|
604
|
+
"WHERE scope=? AND session_id=? AND created_at>=? AND (tool_call_id=? OR tool_call_id IS NULL) ORDER BY created_at ASC", "SELECT token, status, created_at, tool_call_id, bound_input_hash, version, checkpoint FROM checkpoint " +
|
|
605
|
+
"WHERE scope=$1 AND session_id=$2 AND created_at>=$3 AND (tool_call_id=$4 OR tool_call_id IS NULL) ORDER BY created_at ASC"), [scope, sessionId, sinceMs, toolCallId]);
|
|
606
|
+
const out = [];
|
|
607
|
+
for (const r of rows) {
|
|
608
|
+
const base = {
|
|
609
|
+
token: String(r.token),
|
|
610
|
+
status: String(r.status),
|
|
611
|
+
createdAtMs: Number(r.created_at),
|
|
612
|
+
boundInputHash: r.bound_input_hash == null ? null : String(r.bound_input_hash),
|
|
613
|
+
};
|
|
614
|
+
// 🔴 前向兼容门必须在**任何**命中路径之前(codex 复审 F3,2026-08-06 真缺陷):版本超前的行是
|
|
615
|
+
// `get()` 明确拒读的行,而 `tool_call_id` 列命中与否跟能不能读懂 blob 毫无关系。若让列的快路径
|
|
616
|
+
// 绕过这道门,收敛器就会把它当健康候选去 `bindBatch` ⇒ ask 被钉成 **PARKED + 一张本进程读不出的
|
|
617
|
+
// checkpoint 的 resume 坐标**,而 PARKED 不可回滚。判据只能是「宁可不命中,绝不错配」(§8 D-2)。
|
|
618
|
+
// 门在这里**不抛**(见头注):标 unparseable 交给收敛器当「不确定」,单行不打断整段扫描。
|
|
619
|
+
if (Number(r.version) > MAX_SUPPORTED_CHECKPOINT_VERSION) {
|
|
620
|
+
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
// 🔴 blob 可读性同样门在**所有**命中路径之前(codex 确认轮补抓):`tool_call_id` 列长得对,不代表
|
|
624
|
+
// 这张 checkpoint 读得出来——列是 put() 时的投影,blob 才是 resume 真正要吃的东西。让「列命中」
|
|
625
|
+
// 跳过可读性检查,等于给「blob 损坏 / schema 偏斜」留了一条与 F3 完全相同的不可逆后果:
|
|
626
|
+
// 收敛器把 ask 钉成 PARKED + 这张 checkpoint 的坐标,而后续 `get()` 必然失败。
|
|
627
|
+
// 读口的契约只有一条——**读不出就标记**,没有「有一列长得对就豁免」的例外。
|
|
628
|
+
const derived = pendingActionToolCallId(r.checkpoint);
|
|
629
|
+
if (!derived.readable) {
|
|
630
|
+
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const col = r.tool_call_id == null ? null : String(r.tool_call_id);
|
|
634
|
+
// 列与 blob **互相矛盾** ⇒ 判别不出谁对,标 unparseable(「宁可不命中,绝不错配」,§8 D-2)。
|
|
635
|
+
// 🔴 矛盾的定义是「列说得出、而 blob 不同意」——同时覆盖两形:blob 给了**别的** callId,以及
|
|
636
|
+
// blob 说这条 park **根本没有工具动作**(`toolCallId === null`)。后者一样是漂移(列声称有一次
|
|
637
|
+
// 工具调用、blob 否认),早先只判「两边都非空且不等」会把它漏成健康候选。
|
|
638
|
+
// 现网 put() 把列与 blob 同源同刻写下,矛盾只可能来自外部改库或 schema 偏斜——正是该保守的场合。
|
|
639
|
+
if (col !== null && col !== derived.toolCallId) {
|
|
640
|
+
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
// 🔴 bound_input_hash 的列/blob 一致性门(属主兜底轮 F1)——与上面 toolCallId 的矛盾门同款:
|
|
644
|
+
// 返回给收敛器的 hash 是「identity ∧ hash 双等」硬谓词的**输入**,列被外部改成漂移值(乃至恰好
|
|
645
|
+
// 改成另一只 ask 的 hash)时,只信列 = 把双等谓词架空成单等。两边都在场且不等 ⇒ unparseable;
|
|
646
|
+
// 列 NULL 而 blob 有值 ⇒ 用 blob 值补齐(旧行回落,与 toolCallId 的「列 NULL 回落 blob」同规)。
|
|
647
|
+
if (base.boundInputHash !== null && derived.boundInputHash !== null && base.boundInputHash !== derived.boundInputHash) {
|
|
648
|
+
out.push({ ...base, boundCallId: null, unparseable: true });
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const effectiveHash = base.boundInputHash ?? derived.boundInputHash;
|
|
652
|
+
// 列在场时以列为准(权威投影);列为 NULL 的旧行回落用 blob 解出来的值。
|
|
653
|
+
const effective = col ?? derived.toolCallId;
|
|
654
|
+
if (effective === toolCallId)
|
|
655
|
+
out.push({ ...base, boundInputHash: effectiveHash, boundCallId: effective });
|
|
656
|
+
// 解得出、但不是这只 ask 的 callId(或这条 park 本来就没有工具动作)⇒ **确定**不是候选:
|
|
657
|
+
// 既不返回也不标坏行(标坏行会让收敛器把一条明确的「不匹配」当成「不确定」)。
|
|
658
|
+
}
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
380
661
|
/**
|
|
381
662
|
* The owner SCOPE of a session's pending checkpoint (the multi-tenant key === the owner principal in the
|
|
382
663
|
* BFF/non-operator flow, the same key listPending filters by). For the /decide owner-gate: a non-operator
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Checkpoint, type CheckpointSummary, type CheckpointToken, type ReopenReason, type ResolveExpectation, type ResumeOutcome } from "@sema-agent/core";
|
|
1
|
+
import { type Checkpoint, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ReopenReason, type ResolveExpectation, type ResumeOutcome } from "@sema-agent/core";
|
|
2
2
|
import { type PendingCheckpoint } from "./checkpoint-store-sql.js";
|
|
3
3
|
export interface LocalCheckpointStoreOptions {
|
|
4
4
|
/** taskId join for the pending card (the local FileRunStore's `getActiveTaskId`). Absent ⇒ `taskId: null`. */
|
|
@@ -31,10 +31,11 @@ export declare class LocalCheckpointStore {
|
|
|
31
31
|
get(token: CheckpointToken): Promise<Checkpoint | null>;
|
|
32
32
|
resolve(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): Promise<boolean>;
|
|
33
33
|
reopen(token: CheckpointToken, scope: string, reason: ReopenReason): Promise<boolean>;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
/** core 5.14.0(#147):入参是 `PendingSteerInput`(text/trusted + 可选 actor/inputId/priority),不是
|
|
35
|
+
* 两键单座 —— 形参窄成 `{text,trusted}` 会**静默丢掉**署名与幂等键(TS 的方法参数是双变的,窄形不
|
|
36
|
+
* 报错,只是把三个可选键从类型上抹掉,调用点再也传不进来)。队列语义与边界全在被包的 core
|
|
37
|
+
* `FileCheckpointStore` 里,本 wrapper 只做转发。 */
|
|
38
|
+
setPendingSteer(token: CheckpointToken, scope: string, steer: PendingSteerInput): Promise<boolean>;
|
|
38
39
|
expire(token: CheckpointToken, scope: string): Promise<boolean>;
|
|
39
40
|
reap(scope: string, cutoff: number): Promise<number>;
|
|
40
41
|
listByScope(scope: string): Promise<CheckpointSummary[]>;
|
|
@@ -133,6 +133,10 @@ export class LocalCheckpointStore {
|
|
|
133
133
|
reopen(token, scope, reason) {
|
|
134
134
|
return this.inner.reopen(token, scope, reason);
|
|
135
135
|
}
|
|
136
|
+
/** core 5.14.0(#147):入参是 `PendingSteerInput`(text/trusted + 可选 actor/inputId/priority),不是
|
|
137
|
+
* 两键单座 —— 形参窄成 `{text,trusted}` 会**静默丢掉**署名与幂等键(TS 的方法参数是双变的,窄形不
|
|
138
|
+
* 报错,只是把三个可选键从类型上抹掉,调用点再也传不进来)。队列语义与边界全在被包的 core
|
|
139
|
+
* `FileCheckpointStore` 里,本 wrapper 只做转发。 */
|
|
136
140
|
setPendingSteer(token, scope, steer) {
|
|
137
141
|
return this.inner.setPendingSteer(token, scope, steer);
|
|
138
142
|
}
|
package/dist/plugins/pg-pool.js
CHANGED
|
@@ -35,6 +35,7 @@ import { ensureSchema as ensureBreakerStateSchema } from "./breaker-state-sql.js
|
|
|
35
35
|
import { ensurePgOutcomeLedgerSchema as ensureOutcomeLedgerSchema } from "./outcome-ledger-sql.js";
|
|
36
36
|
import { ensureSchema as ensureImageIndexSchema } from "./image-index-sql.js";
|
|
37
37
|
import { ensurePgImageBakeSchema as ensureImageBakeSchema } from "./image-bake-store-sql.js";
|
|
38
|
+
import { ensurePgApprovalAskSchema } from "./approval-ask-store-sql.js";
|
|
38
39
|
export const PG_SCHEMA_STATEMENTS = [
|
|
39
40
|
`CREATE TABLE IF NOT EXISTS task_run (
|
|
40
41
|
task_id VARCHAR(64) NOT NULL,
|
|
@@ -93,7 +94,13 @@ export const PG_SCHEMA_STATEMENTS = [
|
|
|
93
94
|
terminal_at BIGINT,
|
|
94
95
|
gate_kind VARCHAR(32),
|
|
95
96
|
bound_input_hash VARCHAR(190),
|
|
97
|
+
-- pending_steer / pending_steer_queue / pending_steer_rev:队列化后的 durable steering(core 5.14.0
|
|
98
|
+
-- #147)。三列的语义、「为什么队列另起一列而不是覆盖旧列」(滚动升级窗里旧副本的无条件覆盖写)、
|
|
99
|
+
-- 48000 字节预算、以及「为什么另起一个整数 rev 列而不拿 JSON 旧值当 CAS 守卫」的完整由来见 MySQL
|
|
100
|
+
-- 孪生(tidb-pool.ts 同三列的行内注)。PG text 无长度上限,预算由 core 侧统一执法。
|
|
96
101
|
pending_steer TEXT,
|
|
102
|
+
pending_steer_queue TEXT,
|
|
103
|
+
pending_steer_rev BIGINT NOT NULL DEFAULT 0,
|
|
97
104
|
risk_descriptor TEXT,
|
|
98
105
|
PRIMARY KEY (token)
|
|
99
106
|
)`,
|
|
@@ -334,6 +341,10 @@ export async function ensurePgSchema(pool) {
|
|
|
334
341
|
await ensureOutcomeLedgerSchema(client);
|
|
335
342
|
await ensureImageIndexSchema(client);
|
|
336
343
|
await ensureImageBakeSchema(client);
|
|
344
|
+
// #151 流内审批协议持久层(MySQL twin 走 tidb-pool 的 SCHEMA_STATEMENTS 展开)。该 ensure 收的是裸
|
|
345
|
+
// `PgQueryFn`(而非兄弟们收的 client 形),故在这里绑一层 —— DDL 仍然跑在**同一条** client 上,
|
|
346
|
+
// advisory lock 的 session 语义不受影响(见本函数头注:路由到别的池连接会静默作废那把锁)。
|
|
347
|
+
await ensurePgApprovalAskSchema((text, params) => client.query(text, params));
|
|
337
348
|
}
|
|
338
349
|
finally {
|
|
339
350
|
if (locked) {
|