import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { type ExecutionOutcomeRecordWord, type GateOutcome, type Checkpoint, type CheckpointGate, type CheckpointState, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type PendingSteerInput, type ResumeOutcome, type ResolvedOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor, type StoreDurability, type StoreFidelity } from "@sema-agent/core"; import { type RuleOfferProjection } from "../approval-card.js"; import { type SqlDialect, type SqlDriver } from "./sql-driver.js"; export { approvalPayloadFingerprint } from "./approval-payload-fingerprint.js"; /** * [ref] D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending * checkpoint's lifetime, stamped at put() into `terminal_at_ms` INDEPENDENT of the per-approval `deadline`. The * SLA-timer (D-D) does the fine, per-gate-kind resolve-deny; THIS coarse backstop ensures even a pending row * with a NULL `deadline` (no approval TTL configured) is eventually GC'd if the SLA service dies — closing a * forever-leak of a never-resolved suspension (the current `reap`/`reapExpired` only catch non-NULL deadlines). * Generous (default 30d) so it never pre-empts a legitimately long-lived human/irreversible_ask gate; * env-overridable. Clamped ≥1min so a misconfig can't expire live suspensions instantly. */ export declare const TERMINAL_BACKSTOP_MS: number; /** * [ref] D-D (cross-review fix): the crash-safe `terminal_at_ms` backstop must fall STRICTLY AFTER any SLA * `deadline`, never AT it. `terminal_at_ms = max(createdAt+backstop, deadline)` made the two coincide whenever an * operator tuned APPROVAL_TERMINAL_BACKSTOP_MS at/below the SLA — and reapExpired's terminal_at_ms-branch (which * has NO gate_kind filter) then abort-EXPIRED a human/irreversible_ask gate in the SAME tick the deny-sweep * wanted to gracefully DENY it, racing it away. Adding this grace to the deadline term guarantees the deny-sweep * at least this window of clean ticks before the absolute backstop can fire. Far smaller than the backstop, so * it never meaningfully delays the eventual crash-safe GC. */ export declare const TERMINAL_GRACE_MS = 3600000; /** * The capability token IS the resume credential (token-as-auth) — anyone who reads it can impersonate a * resume, so it must never reach the logs (which fan out to a log-aggregation pipeline). For the diagnostic * resume-read-path log we emit a stable, non-reversible fingerprint instead, enough to correlate get↔resolve. */ export declare function tokenFingerprint(token: string): string; /** Non-secret view of a pending checkpoint for the operator approval queue — deliberately NO token. */ export interface PendingCheckpoint { sessionId: string; scope: string; toolName: string | null; toolCallId: string | null; /** [ref] D-1: the decision-action binding fields the operator/portal must ECHO back on /decide so the * resume binds to the EXACT action they saw (a stale view is then rejected fail-closed PRE-CAS). `boundCallId` * === `toolCallId` (the pending tool call); `boundInputHash` is the server-minted opaque hash of the shown * input (NEVER recomputed by the portal — echoed verbatim). Surfacing them here is what makes the D-1 TOCTOU * guard REACHABLE by the portal (without it the portal can only do the unbound legacy fallback). * 🔴 [ref]②: ABSENT (key omitted, never `null`) on a park with no tool action (plan_review / * resource_limit / task_done) or a pre-D-1 legacy row ⇒ the consumer falls back to the unbound resolve. * The wire contract (§1) is "an absent nullable field is OMITTED, not null" — a `null` here invited a * consumer to treat the key's PRESENCE as "there is a binding" and echo `null` back into /decide. */ boundCallId?: string; boundInputHash?: string; /** 🔴 [ref]③ (additive): the checkpoint's gate kind — the operator queue mixes tool approvals with * plan_review / resource_limit / task_done parks (all of which carry `toolName: null`), so a durable-recovery * consumer needs this to ROUTE a row to the right wire (`/decide` vs `/plan_review` vs `/resume`). Absent * only for a legacy SQL row suspended before the `gate_kind` column existed. */ gateKind?: CheckpointGate["kind"]; /** The suspended run's taskId (task_active join) — the key to the run/trace ("view task context"). */ taskId: string | null; /** The pending tool call's args (post-hook), redacted + bounded — for tool_approval the write payload, for a * durable AskUserQuestion the question itself. Lets the approval card render WITHOUT an N+1 trace.turns * fetch. `null` for pre-migration rows (suspended before the column existed) ⇒ consumer falls back. */ input: unknown; createdAt: number; deadline: number | null; /** [ref] riskDescriptor inbox: core's INERT triage descriptor ({severity 1-5, axes, toolName, redacted * summary, touchedPaths}) for human/irreversible_ask gates. listPending returns the queue severity-sorted * (DESC) so the supervisor inbox triages high-risk asks first. `null` for gates with no descriptor. */ riskDescriptor: RiskDescriptor | null; /** * [ref]-2 / [ref]② 起役;core 5.58.0([ref])换 OFFER 判别联合形:这条 park 的「不再询问」 * offers —— core 在 mint 这道门时挂到 `pendingAction.ruleOffers` 上的那一份(core * `checkpoint-store.d.ts` 明写与同步腿的 `AskRequest.ruleOffers` 是**同一条契约**:序即契约—— * whole-string exact `single` 在场时恒 index 0、`batch` 恒末位,core 契约基数 ≤2 —— server 侧执法帽 * 是 `MAX_RULE_OFFERS`=4 的容忍余量,两个数的分工见该常量顶注)。 * * 🔴 **本腿的下标不是 core 的 offer index,别把它当选择键**(5.58 复审 F-3,与契约文同批改准)。 * core 的契约原话是:丢掉不认识的成员时**必须保住每个留下来成员的原始 wire 下标**,做不到的消费端 * 「must suppress its persistence actions entirely (fail toward asking)」。本腿走的是**后者** —— * {@link boundedRuleOffers} 对形不合的 offer 是**逐条丢弃并压紧**的(一条坏 offer 不该让另一条真 offer * 消失,这条不变量本身有它自己的红格),压紧就意味着中间剔除会让后面的下标整体前移。之所以站得住, * 是因为**本腿没有兑付口**:durable 回决体(`/v1/approvals/:id/decide`)不带规则位,选择键一次都没有 * 用到本腿的下标。⇒ **硬条款**:哪天 durable 腿真要开兑付位,先把「原始下标随行」显式加进本投影 * (或按 core 的另一半整格不投),而不是默默拿压紧后的下标当选择键。 * 与**同步腿**刻意分家:`buildRuleLaneMaterial` 那侧是**纯前缀截、零逐条丢弃**,所以活卡帧 / * `card_json` / 重放帧上的下标**恒等于** core 的 offer index —— 兑付真发生在那条腿上。 * * 🔴 **混舰队旧行(pre-5.58 的 `ruleSuggestions` 数组,无 `kind` 判别位)**:按新 Raw 形逐条不合 * ⇒ 整格落「无供给」——与 [ref] 升级单的舰队条款一致(旧建议面丢失只影响展示,ask 安全面照常; * pending 审批短命,重触发即得新形卡)。**不做兼容读**(schema stale=重问,core 同款纪律)。 * * 🔴 **无供给 ≠ 丢失**(本键存在的全部理由):同步腿的卡带这一格(`card_json` 里逐字在),而耐久腿 * (park 行 → `GET /v1/approvals`)此前**根本没投**,于是同一张卡走 durable 路时「不再询问」那一格 * 恒空 —— 消费端只能读成「本部署不供候选」。本键把 core 真给的那份带上 wire,缺席从此是**真的**没有 * 候选(规则车道没武装 / 命令说不出规则 / 这只 ask 规则清不掉),而不是投影层漏了。 * * 🔴 **只在为真时在场**:core 不给 ⇒ 键**缺席**(不铸 `null`、不铸 `[]`)——与 `boundCallId`/`gateKind` * 同一条 wire 纪律([ref]②「absent nullable field is OMITTED, not null」)。存量行(本列存在之前 * park 的)读回同样是缺席,与「这只 ask 没有候选」在消费端读法一致:本卡无此选项。 * * 🔴 **展示用,永不参与 resume**:core 侧同款定性(「never read by the resume path, and never a rule by * itself」)。本仓耐久腿今天也没有兑付口(`/v1/approvals/:id/decide` 的体不带规则位),所以它是**分诊 * 材料**不是承诺 —— 消费端渲这一格时要按「同副本活卡腿才兑得动」读(同 `ApprovalCardSchema.ruleOffers` * 顶注那条射程说明)。 */ ruleOffers?: readonly RuleOfferProjection[]; /** * [ref]①([ref]② / [ref];core 5.60.0 [ref]):**bidi 在场位**的耐久运维队列投影 —— 这条 park 行的 * 执行载荷里含至少一个 DIRECTIONAL 格式控制符(人眼读到的顺序与真正执行的字节顺序可能不同)。 * * 值**完全由 core 供**:park mint 时 core 写在 `pendingAction`(tool_approval 臂)上的那一位 * (`PendingAction.hasBidiControls`,core [ref]),`put()` 在铸行 * 这一刻反范式成 `checkpoint.has_bidi_controls` 列(同行 `tool_input` / `bound_input_hash` / `gate_kind` / * `risk_descriptor` / `rule_suggestions` 五列同一条「铸行时一格、读口零 blob」先例),server 不重算。 * * 🔴 `true` **或缺席,恒不写 `false`/`null`**(core d.ts 的 OMIT 契约逐字点名 BFF:「never null, never * present-but-undefined, never false」):缺席 = **没检出**(干净 / core 的有界扫描没够着 / 本列诞生前 * park 的老行),消费端**禁**读成「已确认干净」。与 `/v1/assistant/inbox` 那面的同名键同一份语义 * (那面由 `summarizeCheckpoint` 对老行做投影期回填;本面读列不读 blob,老行恒缺席 —— 两面的缺席 * 集合因此不完全相等,但两面对「缺席」的读法逐字相同)。同步腿(`tool_approval` 帧 / `approval_request` * 卡)的对应键是 `inputHasBidi`(E-14),名字刻意不同:那一位是 server 对帧自身序列化后的 args 算的。 * * 展示/分诊用,永不参与 resume / gate / CAS(core 同款 ECHO-ONLY 定性)。 */ hasBidiControls?: true; /** * [ref]([ref]② core 点名 / [ref] 认领):**内容问句分型键** —— `"content_ask"` 当这条 tool_approval * park 门住的是保留工具 AskUserQuestion(问用户的问题,不是副作用工具),消费端(cli/壳审批面)据它 * 渲问句 UI 而不用嗅探 `toolName`。词与判据 = core `summarizeCheckpoint` 的同一行规则(行上 core 铸的 * `toolName` 的**全函数**派生;词属主 `approval-content-kind.ts`,parity 钉看漂移)——**不是** * `hasBidiControls` 那类「server 不重算」的扫描位:派生 ≠ 重扫。 * 🔴 `"content_ask"` 或**缺席**([ref]② OMIT 纪律,恒不铸 `null`/`false`):缺席 = 非内容问句门 * (普通工具门 / 非工具 park / toolName 缺席的存量行)。展示/分诊用,永不参与 resume / gate / CAS。 * 两条 durable 读面(`GET /v1/approvals` 行 + `/stream` `pending` 帧)经同一投影同时携带;live 腿的 * 同名键在 `LivePendingRow.contentKind`(同一词属主)。 */ contentKind?: "content_ask"; } /** * [ref]([ref] / DEBTS [ref]):session 键读口(`findPendingTokenBySession` / `peekPendingScope`)的**行绑定**。 * * 病灶(本仓自己两处成文:`server.ts` mintFreshRun 腿 409 段、`active-run-conflict.ts` `checkpointId` 顶注): * `checkpoint` 表**没有** per-session pending 唯一约束 —— 同一 session 下 `task_done` 纯 park(行已终局、 * `task_active` claim 已释放)与下一条 run 的门 park **合法并存**;而两条读口此前是**无序 `LIMIT 1`**,同 * session 两条 pending 时选中哪一条由存储引擎的扫描序决定:同一情形两次调用可能答出两张不同的门,路由层 * 的 owner 门(`peekPendingScope`)与 `resumeCheckpoint` 的载入(`findPendingTokenBySession`)更可能各读到 * 一条([ref] codex R3 揭的 tokenless TOCTOU 同源;[ref] 在取用点加了行级属主复核 = 下游兜底,本件是源头)。 * * 为什么不是加唯一约束:那两条行的并存是**设计内**的形(见上),约束会把新 run 的 park `put()` 当场打死 * (core 的 `CheckpointStore.put` 契约不允许);且 MySQL-protocol 侧没有部分唯一索引,TiDB 上要靠生成列 * 绕,两方言分歧成本高于收益。 * * 修法 = **读口带 gate 坐标 + 显式序**: * · `gateKinds` 在场 ⇒ 只认这些门型的 pending 行(每条 resume 腿本来就只服务一种门型 —— decide 只解 * 审批门、wake 只解 `task_done`、preempt 只解 `resource_limit`、plan_review 只解 `plan_review`;把 * 这条已成文的守卫**前移到读口**,选错行这件事结构上不再发生)。**空数组 = 什么都不认**(显式 `1=0`, * 不静默退化成「任意门型」)。缺席 ⇒ 任意门型(存在性读口:boot reclaim / reaper probe / 断连竞态卫兵)。 * · 无论绑不绑,序恒为 `created_at_ms DESC, token DESC`(**最新优先**,主键兜平局):同一情形下每次读 * 都答同一行;两条读口共用同一段谓词 + 同一段序({@link SqlCheckpointStore.pendingRowQuery} 单一属主), * 路由门与载入点结构上选中同一行。最新优先的理由:并存形里**老**的那条恒是终局后的 `task_done` 纯 park, * 活 run 的门在它之后铸。 * * SQL twin 与 LOCAL twin 同判(local 侧按 `cp.gate.kind` 过滤、按 `createdAt`/token 同序取最新)。 */ export interface PendingRowBinding { /** gate 坐标:只认这些门型的 pending 行(core `CheckpointGate["kind"]` 闭集)。缺席 ⇒ 任意门型。 */ gateKinds?: readonly CheckpointGate["kind"][]; /** * 并存候选里取哪一端(缺省 `"newest"`)。两个取向各有一个**结构性**的消费者,别混用: * · `"newest"`(缺省)—— 「活 run 的门」族:并存形里老的那条恒是终局 task_done 纯 park,活 run 的门 * 在它之后铸 ⇒ 最新即正解; * · `"oldest"` —— **跨副本收敛**族(wake-mint 赢家复核 + wake 消费腿;codex 审批面轮 R2-[medium], * 验真后修):收敛要的是一个**与自己的铸行时刻无关**的不动点。最新优先下「后铸者恒自选」——两副本 * 并发铸 task_done park 时,后铸的 B 复核必选中刚铸的自己,先铸的 A 若在 B 落盘前复核也选中自己, * 两行双双留存、消息被拆到两张卡上。最老优先没有这个洞:**后铸者复核时先铸行必然已在**(它比自己老 * ⇒ 选中它、expire 自己),先铸者无论何时复核都选中自己 ⇒ 不动点唯一。消费腿(/wake)取同端, * 赢家卡与被消费卡恒同一张(FIFO 顺带成立)。 */ pick?: "newest" | "oldest"; } /** [ref]:一条**仍持有 claim 的 run** 可能停在的全部门型(= 除 `task_done` 外全部;派生自上表,不手抄)。 * 给「按 run 行动」的读口用:cancel / steer 的 park 落点、409 `pendingGate` 材料 —— 这些腿手里有 run 行, * 而 run 行一旦 parked 就绝不是 `task_done`,绑上它就把同 session 那条终局 park 排除在候选之外。 */ export declare const LIVE_RUN_GATE_KINDS: readonly CheckpointGate["kind"][]; /** [ref]:终局后纯 park 的门型(wake 腿 / wake-mint 赢家复核只认它)。 */ export declare const TERMINAL_PARK_GATE_KINDS: readonly CheckpointGate["kind"][]; /** * [ref]:LOCAL twin 与测试共用的**同序判据**——「候选集里哪一条是读口该答的那条」。与 SQL 的 * `ORDER BY created_at_ms {DESC|ASC}, token {DESC|ASC} LIMIT 1` 逐字同义(token 列两方言都是二进制序 —— * MySQL `utf8mb4_bin` / PG `COLLATE "C"`,token 是 ASCII ⇒ JS 字符串比较与之同序)。 * 返回 `true` 表示 `a` 应排在 `b` 前面(缺省最新优先;`pick:"oldest"` 时最老优先,取向语义见 * {@link PendingRowBinding.pick})。 */ export declare function pendingRowPrecedes(a: { createdAt: number; token: string; }, b: { createdAt: number; token: string; }, pick?: "newest" | "oldest"): boolean; /** * `checkpoint` 表上**本仓后加、且被无条件引用**的列册 —— 升级前置探针({@link assertCheckpointColumnSchema}) * 的唯一数据源。一行一列:列名 + 这一列不在时**运行期**先坏在哪(拒启文案逐字用它,所以运维读到的是 * 「我会先在哪里挂」,不是一句泛泛的 schema 不齐)。 * * 🔴 为什么是一张表而不是每列一个 `assert*ColumnSchema` 函数(S-160 归一):[ref]① 那次是一列一函数, * 本批 [ref] 又要加两列 —— 照抄就是第二、第三份三十行的同形代码,而它们**唯一的差别只有列名与那句话**。 * 探测法(恒空 `WHERE 1=0` 读 + 方言缺列码)、失败方向(只在**可证**缺列时才给删表指路)、恢复动作 * (DROP TABLE checkpoint 的代价交代)三者对整张表是同一条,所以规则只留一条,列变成数据:下一次加列 * 只加一行,不再多一条规则。 */ export declare const CHECKPOINT_ADDED_COLUMNS: ReadonlyArray<{ column: string; breaksAt: string; }>; /** * [ref]① 升级前置断言 —— **拒启**,不是 warn(形照 [ref] `assertApprovalAskRuleMaterialSchema` / [ref] / * [ref] 三条先例;codex 店面轮 R1-[high] 采纳 —— 其引证的「MIGRATION.md 常设策略」经亲验不存在,采纳的 * 依据是本仓 assert*Schema 探针族的真先例与失败方向本身)。S-160 起判据对 {@link CHECKPOINT_ADDED_COLUMNS} * **整张列册**跑(见那张表的头注:归一的理由)。 * * 病:本仓不发 `ALTER TABLE`(SCHEMA POLICY:改列就改 CREATE + 删库重建)。一台**没删表**就升上来的 * 部署,`CREATE TABLE IF NOT EXISTS` 对存量 `checkpoint` 是空操作 —— 后加的列不在,而写口与读口 * **无条件**引用它们。没有这道断言,服务照常过启动与健康检查,直到运行期才响,且首撞点在用户的 * 审批 / resume 路径上。 * * 判据 = **能力探测**(恒空的 `WHERE 1=0` 读)+ **方言的缺列错误码**({@link isMissingColumnError}, * 判据属主 `sql-errors.ts`);其余错误**原样 rethrow**,一个字都不加工 —— 一句破坏性的「去 DROP TABLE」 * 指路比它要挡的缺陷更贵([ref] 两轮收窄的结论逐字继承)。 * * 位置:与三条先例同款,不在 `ensureSchema` 里(那条通道的契约是「只发 CREATE」),放在 boot 的 * store 装配段、路由装配之前;**无条件**跑(与 [ref] tool_result 探针同档)——checkpoint 表在两方言的 * 中央 DDL 里恒在,SQL 后端一开它就是 durable park/审批队列的承载面,不存在「协议不上场」的豁免形。 */ export declare function assertCheckpointColumnSchema(query: (sql: string) => Promise<{ rows: Record[]; }>, dialect: SqlDialect): Promise; /** * `pendingAction.ruleOffers` 的**铸行侧**投影(mint 面,与 {@link boundedToolInput} 同位同待遇)。 * * 三件事,一件都不能少: * 1. **结构窄读**(宪法 [ref] 边界必 schema / 禁裸 as-cast):blob 里的这一格是 `unknown`(旧 core / * 坏行 / 未来加字段都可能)。形不合的**整条丢弃**(不是整只丢弃)——一条坏 offer 不该让另一条真 offer 消失。 * 判据按 `kind` 判别联合分臂合形(`RuleOfferRawSchema` 两臂各自 **strip 形** `.strip()`, * 多余键**剥掉**、且不拒收 —— 重扫二轮更正:旧注写 `.loose()`,而 zod 4 的 loose = passthrough, * 未知键会**留在** `parsed.data` 里,与「剥掉」相反)。上游 additive 加一键是 core 的常规做法,拿 strict 去窄读会让那一次加字段把整只 * offer 静默清零 —— 「有供给」当场变「无供给」,与本键的存在理由正相反(顶注第三段)。 * 2. **脱敏**:offer 文本是从**命令原文**铸出来的(`Bash(curl -H "Authorization: Bearer …")` 这种 exact * 形拼得出秘密),而 `GET /v1/approvals` 是**跨租户可见**的运维队列。落列前过 `redactSecrets` —— * 与同行 `tool_input` 在 mint 就 `redactDeep` 是**同一条**纪律([ref] 件4 把 `shadowedRule` 修在读面 * 是因为那条路的行早已落库、改铸点救不了存量;本键是**新**列,没有存量行要救,修在铸点更彻底: * 秘密从此连**落库**都不落,不是落了库再在两条读面各洗一遍)。 * 3. **脱敏之后**才截长(codex 交叉复审 [medium],验真后修):`redactSecrets` 会**变长**,先按限长校验 * 原文再脱敏会产出超限文本,而 SQL 腿有**回读**(列 → 本函数再跑一遍)、LOCAL 腿没有 ⇒ 同一份素材 * 两个后端给出不同卡面。顺序反过来之后本函数对自己的输出**幂等**,两条腿同形。完整论证见 * `RuleOfferRawSchema` 顶注。 * 4. **限基数**:core 契约今天 ≤2,这里按 {@link MAX_RULE_OFFERS}(=4,server 侧容忍帽,两个数的 * 分工逐字见该常量顶注)截;`batch.rules` 成员另按 {@link MAX_RULE_OFFER_BATCH_MEMBERS}(=8)截 —— * 一条被改坏/未来放宽的上游不该把运维队列的一行撑爆。两个截都是**前缀截**(保序)。 * ⚠️ **但本函数整体不保原始下标**:第 1 条的「坏的逐条丢」会压紧数组 ⇒ 中间剔除后下标前移。 * 本腿因此是**纯展示腿**,下标不得当选择键 —— 硬条款与它站得住的理由逐字见 * {@link PendingCheckpoint.ruleOffers} 顶注(同步腿相反:那侧零逐条丢弃,下标恒等于 core 的 offer index)。 * 🔴 **成员帽扣在判形之前**(5.58 复审 F-2):见 {@link preboundBatchMembers} —— offer 条数那一维由 * {@link MAX_RULE_OFFER_SCAN} 管,成员那一维在那之前是**无界**的。 * 🔴 **扣预算在合形之后**(合并码重扫):旧序先 `slice` 再判形,于是 ≥MAX 条坏 offer 能把唯一的真 * offer 挤出预算,读数是 `undefined` —— 与上面第 1 条自证的不变量(坏的丢、好的照投)正相反。扫描窗 * {@link MAX_RULE_OFFER_SCAN} 仍是有界的:一条被改坏成上万条的行不该让每 3s 一拍的读路径去逐条判形。 * * 🔴 `opts.countRedactions`:**两条腿都照常脱敏,只有回读腿不计数**(合并码重扫;codex 复审第一轮 * 指出「回读不脱敏」会把纵深防御一起关掉,验真后按这条收)。`redactSecrets` 对自己的输出是**值幂等、 * 计数不幂等**(URL-userinfo / JSON 秘密字段两条 pattern 会命中自己的输出并再计一次),而回读腿正是 * `/v1/approvals/stream` 每 3s 一拍的热路径 ⇒ 每条订阅每行每拍给 `redactions_applied_total` +1,把那个 * 用来识别「过度脱敏 / pattern 从不触发」的指标灌成不可读。关掉的是**计数**:重扫仍在,手改过的行 / * 将来某条写路径漏了脱敏,读面照样兜住(运维队列跨租户可见,少脱一次的代价永远不能省)。 * * 返回 `undefined` = **无供给**(键不落列):core 没给、给了空数组、或整只形不合。 */ export declare function boundedRuleOffers(raw: unknown, opts?: { countRedactions?: boolean; }): RuleOfferProjection[] | undefined; /** Redact + size-bound a pending tool call's args for persistence/serving. JSON-roundtrips so the stored shape * matches what the operator queue serves; oversized payloads collapse to a marker rather than bloating the row. * Exported for the LOCAL twin (local-checkpoint-store.ts) so the operator-queue projection can't drift. */ export declare function boundedToolInput(args: unknown): unknown; /** * 🔴 [ref] 车5 §9 C4:`findCheckpointCandidatesForAsk` 的 typed 候选行。刻意只有对账收敛器判据 1 真需要的 * 五件——**没有 token 之外的任何凭据面**,`token` 本身是 resume 凭据、只在服务端内部流转(同 * `findPendingTokenBySession`:它从不出网,§12-C「checkpointToken 从不外发」)。 */ export interface CheckpointAskCandidate { token: string; /** `checkpoint.status` 列(权威;`pending` | `resolved` | `expired`)。不做过滤,由收敛器判活性。 */ status: string; createdAtMs: number; /** * 🔴 和解三元组的第一维([ref] 件1,黑板 [ref]③①)—— checkpoint 自己带的 `sourceTaskId`(core 在 * suspendAsk 铸行时恒填该腿的 `sessionId`)。**没有对应的列**,只能从 blob 窄读;读不出/缺席 = null。 * * 为什么必须进候选集:读口按 `(scope, session_id, tool_call_id)` 查,而委派子代那条腿的 ask 落行时 * `session_id` 记的是**投递上下文**(根会话),park 却发生在**子代自己**的 sessionId 上 —— 只靠 * (toolCallId, hash) 两维,一条根腿的 park 会被一只子代的 ask 认领(摘要相同的两次同 args 调用完全 * 正常)。三元组全同才算同一件事。 */ sourceTaskId: string | null; /** 该 park 绑定的 tool call(= `pendingAction.toolCallId`)。`unparseable` 行为 null。 */ boundCallId: string | null; /** 服务端铸的入参摘要——判据 1 的第二道硬等式(与 `approval_ask.bound_input_hash` 相等才算命中)。 */ boundInputHash: string | null; /** 在场 ⇒ 这行读不出(blob 版本超前 / JSON 坏)。收敛器视同不匹配;**从不是** false,缺席即可读。 */ unparseable?: true; } /** * [ref] 件1 —— legacy `/v1/approvals/:sessionId/decide` 腿的**幂等回放**判别行(窄读,非凭据面)。 * * 刻意**不带 token**:回放只需要「这条已决的行归谁、绑的是哪一次调用、判词是什么」三件,而 token 是 * resume 凭据(§12-C「checkpointToken 从不外发」;`CheckpointAskCandidate` 带它是因为收敛器要拿它去 * `bindBatch`,本口没有那个用途 ⇒ 不给)。 */ export interface DecidedApprovalRecord { /** 行的属主 scope 列(`encodeCheckpointScope` 的产物:principal 或匿名哨兵 `"_"`)。回放属主门的输入。 */ scope: string; /** 行上的 D-1 入参摘要(`bound_input_hash` 列);pre-D-1 行为 null。 */ boundInputHash: string | null; /** * 这条已决行的**工具审批判词**,或 `null` = **判别不出**(codex R1-[medium] 四的收敛形): * outcome 读不出 / 没有 winner / winner 绑的是**另一个** callId / 判词是 REVIEW 门那三个词。 * * 🔴 `null` **不是**「无所谓」:调用方必须把它当「这条命中的行我读不懂」⇒ **整次回放拒绝**。 * 一条读不懂的已决行与一条判词相反的已决行在风险上同级 —— 都意味着这只 callId 上的持久事实 * 不是本次回放能如实复述的。 */ decision: "approve" | "deny" | null; /** * 判词**之外**的决议载荷指纹(codex R2-[high] 一,验真后补):`updatedInput` / `answer` / `reason` * 三件都在 core 的持久 winner 里,而**同判词不等于同决议** —— 「approve + 编辑后的实参 A」与 * 「approve + 实参 B」是两次不同的放行,只比判词就会把后者谎报成「你的请求已被处理」(而 B 从未执行)。 * 调用方按**同一份请求重发**的语义要求它与本次请求的同三件逐字相等。 * `null` = 算不出(载荷序列化不了)⇒ 与 `decision: null` 同判:整次拒绝。 * 铸法见 {@link approvalPayloadFingerprint}(两条车道共用同一只,免得指纹口径漂)。 */ payloadFp: string | null; /** * S-160(core 7.7.0 [ref])—— 这条已决行上**已决动作最后被怎么处置**的结算记录,`null` = **未知**。 * * 🔴 缺席只有一个含义:未知,**绝不是**「放行过」。`decision` 说的是决定(人批了没有),这一格说的是 * 那次决定在 resume 腿上执行时的处置(部署自己的复核可能在 post-CAS 把人批过的、改过参数的调用否掉, * 于是 `decision: "approve"` 与 denied/policy 的处置**同时**在一行上成立)。[ref] 的病正是「流断之后 * 从行重建这条 run 的消费者只读得到前半句」——decide 幂等回放正是那种消费者。 * * 铸法 = core 的 {@link executionVerdict} **单口**(两条车道共用),不许任何读点自己判列的在场性。 */ executionOutcome: GateOutcome | null; } /** * [ref] 件1 —— {@link SqlCheckpointStore.findDecidedApprovalsForBinding} 的扫描上界。 * * 🔴 它**同时是判据**,不只是一道 LIMIT:调用方按「命中集里每条判词都一致才回放」判,而一次被截断的 * 集合可能把分歧藏在第 N+1 行 ⇒ 消费点约定「**回满 = 判别不出**」,整次拒绝回放(回落 404)。现网正常形 * 恒 0 或 1 行,回满只可能是数据异常。 */ export declare const DECIDED_REPLAY_SCAN_CAP = 8; /** * [ref] 件1 —— core 的 `ResolvedOutcome` → 本腿(工具审批 decide)的二值词表。**闭集穷举**([ref]): * core 哪天给 `ResolvedOutcome.decision` 加员,下面的 `never` 当场编译期红,而不是让一个新判词被 * 静默折成 `deny` 或漏成「没有决议」。 * * 三条判据,缺一即 `null`(= 不回放,调用方逐字回落修前 404 —— **绝不**把 not-found 洗成成功): * ① 有 winner —— `resource_limit` / `wake` / `task_done` 的 resolve 不记 winner(core 契约),那些行 * 不是「一次人给的工具审批」,没有可回放的判词; * ② winner 绑的正是这次请求回显的那个 `boundCallId` —— 身份第二锚(列谓词是第一锚),两锚同意才算同一件事; * ③ 判词属于**工具审批**那两个词(`allow`/`deny`)—— `approve`/`reject`/`edit` 是 REVIEW 门 * (plan_review / dry_run_review)的判词,那些门的决议入口不是本腿,回放它们等于跨门作答。 */ export declare function approvalDecisionOfWinner(winner: ResolvedOutcome | undefined, boundCallId: string): "approve" | "deny" | null; /** * checkpoint blob → `pendingAction.toolCallId`。 * * 🔴 三态、**不是**两态(真双库实跑抓到的缺陷,2026-08-06):「读不出」与「读出来了、但这条 park 本来就 * 没有 toolCallId」是两件事,压成同一个 `undefined` 会把每一条 `plan_review`/`task_done`/`resource_limit` * 腿(core 的 `PendingAction` 联合里三个成员结构上就没有 toolCallId)都误报成坏行。前者应标 `unparseable` * 交给收敛器当「不确定」,后者是**确定的不匹配**——干净地不是候选。 * - `{ readable: false }` —— JSON 坏 / 形状根本不是对象:真的读不出。 * - `{ readable: true, toolCallId: null }` —— 读出来了,这条 park 无工具动作:确定不匹配。 * - `{ readable: true, toolCallId: "…" }` —— 读出来了,拿去比。 * * 逐层 `typeof` 收窄,不做裸 `as` 断言:blob 是持久层读回来的 `unknown`,是信任边界(宪法 [ref])。 */ export declare function pendingActionToolCallId(blob: unknown): { readable: true; toolCallId: string | null; boundInputHash: string | null; kind: string | null; sourceTaskId: string | null; } | { readable: false; }; /** `pending_steer_queue` 列承载的 CheckpointState 字段(= core `appendPendingSteer` / `readPendingSteerQueue` * 的入参形)。列是**唯一**权威(suspend 时写的 blob 从不带它)。 */ type SteerColumnState = Pick; /** 读列结果。`lossy` = 列里有东西但**没能完整读出来**(JSON 坏 / 条目形不认)。两个调用点对它的处置 * 刻意相反,见各自的旁注:`get()` 容忍(坏列不许把一条挂起的 run 变成不可 resume),`setPendingSteer` * fail-loud(往一份读不全的队列上追加 = 把别人 park 的指令悄悄写没,正是 [ref] 要消灭的那件事)。 */ interface SteerColumnRead { state: SteerColumnState; lossy: boolean; } /** * steering 列 → `{pendingSteerQueue?}`(= core `appendPendingSteer`/`readPendingSteerQueue` 的入参形)。 * **一列一形**:`pending_steer_queue` 存 `PendingSteerEntry[]`。 * * 🔴 7.74.0 起只剩这**一**列。队列化之前的裸单座列 `pending_steer` 随它的席位一起退役了(core 7.17.0 * [ref] d:引擎对带席的行 PRE-CAS 拒 `seat_retired`),存量列由启动期的一次性迁移删掉 * (`plugins/one-time-migrations.ts`:列上还有非空值 ⇒ **响亮拒启**,清空后 `DROP COLUMN`)。 * ⇒ 这里刻意**没有**第二条读腿:双读臂的寿命就是那一次迁移,不是永远。 * * 列有内容却读不出 ⇒ `lossy`(两个调用点对它的处置见 {@link SteerColumnRead})。 */ export declare function readPendingSteerQueueColumn(queueRaw: unknown): SteerColumnRead; /** Dual-dialect durable CheckpointStore. See the file header for the dialect-delta ledger. */ export declare class SqlCheckpointStore implements CheckpointStore { protected readonly db: SqlDriver; protected readonly logger?: { info?(msg: string, meta?: unknown): void; } | undefined; /** [ref] 车1:托管留存声明。读法与「为什么 SQL 店答 managed 而三方法的实现体在 `retention-store-sql.ts`」 * 逐字见 {@link MANAGED_RETENTION}(本店的 `checkpoint`/`checkpoint_ctx` 两表由那只聚合店按期清)。 */ readonly retention: import("@sema-agent/core").RetentionDeclaration; /** * `CheckpointStore.durability` 声明([ref] 欠账,[ref] 件5)—— 行落在 MySQL-protocol / PostgreSQL 的 * `checkpoint` 表里,进程重启、副本轮换、整机重建都不丢 ⇒ `"durable"`,如实。 * * 🔴 为什么这一格空着是有代价的:core 的 `resolveDeclaredDurability` 把**缺席**折成 `"process-local"` * (它不能替一个没表态的店猜),于是静态装配面对每一个真持久部署都读出 `process_local`, * `GET /v1/diagnostics/wiring` 的 park 车道读数与启动自检的那条警告都因此不可信 —— 而 park 正是流内 * 审批协议的降级目的地,「重启后还赎不赎得回」是运维必须能一眼看见的事。声明是店自己的责任,不是 * 消费侧靠 backend.kind 猜出来的。 */ readonly durability: StoreDurability; /** * `CheckpointStore.fidelity` 声明(core 5.17.0 [ref] 提货批 [ref])——**如实按介质判**:本店把整个 * checkpoint 经 {@link SqlCheckpointStore.json} 编码进一个 JSON 列(TiDB 逐字文本 / PG lossless * `::jsonb` 协议信封),读侧 `parseJson` 还原 ⇒ 能扛过 round-trip 的只有 JSON 值域,`"json"`。 * * 🔴 为什么必须显式写、哪怕缺席也折向 json:core 的 `resolveDeclaredFidelity` 对缺席是 fail-closed * (读 json),所以沉默不会立刻错——但沉默**表达不出**「我核对过我的介质就是这个宽度」。park 铸行 * 的 args / preview / 风险描述 / `boundInputHash` 全部从这一格算出的投影铸;哪天这四行编码里任何 * 一处改了介质(换存储格式、换列类型),声明在场才有东西可以红,沉默那格只会安静地按错宽度铸出 * 一份「审批人看到的 ≠ 盘上躺着的 ≠ resume 执行的」。同 `durability` 的 [ref] 件5 教训:表态是店 * 自己的责任,不是消费侧靠 backend.kind 猜。 * * ⚠️ **已知残余(不是遮掩,是这两个词表达不了的那一格)**:PG 臂比 `"json"` 字面**略窄** —— * {@link pgProtocolJsonStringify} 对含 NUL / lone surrogate 的串 fail-loud 拒绝(R4-H1 有意裁定: * 复核面必须与真执行的 args 在 NUL 那一位上一致,「悄悄清洗再存」是不可接受的那一支),而 core 的 * json 宽度收下这些码位。`StoreFidelity` 的闭集只有 `"structured-clone" | "json"`,没有第三个词能说 * 「json 减去本介质存不下的码位」——声明 `"structured-clone"` 是大得多的谎,所以 `"json"` 仍是两者 * 里唯一诚实的选择。后果有界且 fail-closed:core 5.17.0 起 park 铸行失败会把 cause 带到 gate、追加 * 到 fallback 的 deny 上,这条 args 退回**同步门**由人判(不静默漏批、不挂死)。边界钉在 * `wiring-governance-operator.test.ts` 的 [ref] 组;已上报上游求一个能表达该宽度的词。 */ readonly fidelity: StoreFidelity; /** * `CheckpointStore.redecision` 声明(core 5.22.0 F-012 L2/L3;[ref] clay 裁放行)——**如实按能力判**: * 本店的 {@link SqlCheckpointStore.reopen} 是真 CAS 实现(`resolved`→`pending` 原子翻回 + `reopen_reason` * 权威列,[ref] D-1 的两条重开腿都走它),声明 `reopen: true` 是读数不是抬举。core 的沙箱准入 * 模式(pre-flight 要求 `redecision.reopen === true`,declaration never duck-typing——方法在场不算数) * 由这一格武装:Kata 腿(`capabilities.isolation` 真声明)+ durable park 部署下,`sandbox_local` ask * 自动放行并以 `permission.sandbox_admitted` durable 事件披露(缺席披露=core 侧变异恰红)。 * `validatingLease` **不声明**:本店没有 durable validating-lease 纪律,不承诺没有的东西。 * 翻向钉:`wiring-governance-operator.test.ts`(修前恰红,与本声明同 commit)。 */ readonly redecision: { readonly reopen: true; }; /** * `CheckpointStore.execution` 声明(core 7.7.0 [ref];S-160)——**必填**,且必须恰是 `{ outcome: true }`: * 店在此承诺 {@link SqlCheckpointStore.recordExecutionOutcome} 是契约描述的那条真 CAS(谓词、四词、 * 冲突抛),且 {@link SqlCheckpointStore.reopen} 认这条记录。与 `durability`/`fidelity`/`redecision` * 三格同一条「声明而非鸭子判」姿势,区别只在这一格**不可缺席**:缺席的店会被 core 在 store 席的 * 第一次读(`resolveCheckpointStore`)以 `config.invalid_checkpoint_store` 拒启 —— 那是契约的正确行为, * 因为一台记不下「已决动作最后被怎么处置」的部署,它手里每一条 resolved 行在这条轴上都永远读不出来。 */ readonly execution: { readonly outcome: true; }; constructor(db: SqlDriver, logger?: { info?(msg: string, meta?: unknown): void; } | undefined); /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */ private q; /** JSON column binding. TiDB: plain `JSON.stringify`, stored verbatim. PG: `pgProtocolJsonStringify` — * ordinary `JSON.stringify` that **refuses** (throws `PgUnstorableError`) when the value carries a code * point PG cannot hold (NUL / lone surrogate); the `::jsonb` at the call site is just the bind cast, not * an escaping layer. * * "LOSSLESS" in R4-H1 means exactly **"never silently lossy"**, not "encodes everything": the contrast is * with `pgSanitizeText`, the lossy U+FFFD-scarring path used for CONTENT faces. An approval row is not a * content face — the operator's review surface has to agree with the executed args AT the NUL position, so * scrubbing the byte and storing the scrubbed row is the unacceptable arm; refusing loudly is the chosen one. * That refusal is why {@link SqlCheckpointStore.fidelity} carries a documented residual (see it). */ private json; /** 判据属主 = `sql-errors.ts`([ref] P1-①;旧形 tidb 臂只认 errno ⇒ 只带 `code` 的 dup 会把 * `checkpoint.already_exists` 这条 typed 契约错误退化成裸驱动对象上抛)。 */ private isDupKey; /** 第 n 个(1-based)绑定参数的占位符文本 —— 与 approval-ask-store-sql.ts 的 `ph` 同形:谓词按 binding * 内容动态变化,固定的两段式 SQL 文本写不出来,占位符本身也得按方言现场生成(分支仍在这一处显式)。 */ private ph; /** * [ref]:两条 session 键读口的**唯一**谓词/序属主(理由全文见 {@link PendingRowBinding} 顶注)。 * `findPendingTokenBySession` 与 `peekPendingScope` 都从这里拿 SQL —— 路由层的 owner 门与载入点因此 * 结构上选中同一行(修前是两段各写各的无序 `LIMIT 1`)。 */ private pendingRowQuery; /** Create-once. core mints the token (`mintCheckpointToken`) and calls this during suspend. */ put(token: CheckpointToken, cp: Checkpoint): Promise; get(token: CheckpointToken): Promise; /** * Atomic CAS: pending → resolved, scoped. Returns true for the single winner, false if already * resolved/expired (AlreadyResolved → caller no-ops, never re-executes). `outcome` is persisted atomically * with the flip (R20 #1 — a future v2 resumable-resume can recover a crash-after-CAS; v1 runner write-only). * * [ref] D-1 (atomicity): the winner-persist (the full `outcome` is stored; the winner * {boundCallId,decision,updatedInput?} is derived from it on read) + the `rev` bump happen ATOMICALLY in the * SAME UPDATE as the pending→resolved CAS — one statement, no get→write window. When the caller passes the * `rev` it observed at get() (ResolveExpectation), the CAS additionally requires the LIVE rev to still equal * it: ANY intervening resolve/reopen cycle bumps rev, so a cycle in the get→resolve window loses the CAS * (returns false → core re-inspects → checkpoint.reopened_concurrently). Omitting `expect` keeps the legacy * unconditional CAS (a first resume of a never-reopened checkpoint is unconstrained by rev). * 🔴 reopen_reason = NULL on a winning resolve — parity with the core reference InMemoryCheckpointStore * (MINOR-12: "a freshly-resolved row carries no stale reopen reason"). Without this, an env_failed reopen * followed by a correct winner-replay resolve leaves reopen_reason='env_failed' stale on the now-resolved * row; a later DUPLICATE resume of that token would hit core's pre-CAS env_failed winner-replay guard * against the stale reason and throw checkpoint.reopen_revote (which the service classifies RETRIABLE → * re-parks the run suspended) instead of the terminal checkpoint.already_resolved — stranding the run. */ resolve(token: CheckpointToken, scope: string, outcome: ResumeOutcome, expect?: ResolveExpectation): Promise; /** * Inverse of `resolve` (core `CheckpointStore.reopen`): CAS `resolved → pending`. Returns the CAS winner * (true = was resolved → now pending again). Compensates a post-CAS env-restore failure * (`resume.env_failed`): `resumeStream` consumes the checkpoint BEFORE `resumeVM`, so a restore failure * would otherwise strand the suspended work as `resolved` + unfindable (`findPendingTokenBySession` only * matches `pending`). Reopening lets a retry re-resume the SAME work. * * [ref] D-1 (reopen-by-reason): the `reason` distinguishes the two triggers (both fire at core's * runtask.ts) so the re-resume can validate per reason (§3 inv #1) — `env_failed` = the action is still * valid, the re-resume MUST replay the persisted winner (core rejects a different decision with * `checkpoint.reopen_revote`); `tool_unavailable` = the action could not run and may now be stale, so a * FRESH decision is allowed (P-7). We RECORD the reason on the row and bump `rev` (so a concurrent resume's * resolve CAS loses — atomicity). 🔴 We DELIBERATELY leave the persisted `outcome` UNTOUCHED (NOT stale junk * as a pre-D-1 reopen treated it): `resolvedOutcome` derives from it on get(), and an `env_failed` re-resume * is validated against it — clearing it would defeat the winner-replay invariant. */ reopen(token: CheckpointToken, scope: string, reason: ReopenReason): Promise; /** * core 7.7.0 [ref](S-160)—— 记录**已决动作在 resume 腿上最后被怎么处置**:那一次调用的 `tool_end` 帧 * 携带的 {@link GateOutcome}。契约(答案闭集四词 / 幂等 / 冲突抛 / 一个字都不碰 status·rev·outcome· * reopen_reason)的**判词逻辑一行都不自铸**:全部消费 core 导出的 * {@link executionRecordDisposition} —— 它是每个后端共用的那一份「CAS 谓词 + 幂等/冲突两臂」, * 本店只留 commit 那一半(一条 `UPDATE … WHERE`)。 * * 形 = **先读后 CAS**(与 {@link SqlCheckpointStore.setPendingSteer} 同款,理由同款):判词是**按行读** * 出来的(要分辨 already_recorded / conflict / not_resolved / absent 四种,SQL 的 affected 只会告诉你 * 「没打中」),所以读一次交给 core 判,再把 commit 那一半压回一条带完整谓词的 UPDATE。两条语句之间那道窗 * 由 UPDATE 自己的谓词兜住:CAS 落空就重读重判(下一轮的读会分辨出到底是谁抢先了)。给足冗余仍全败 ⇒ * **抛**,绝不返回一个四词之一 —— 那等于替一次没发生的写编一个答案。 * * 🔴 `execution_outcome` 坏 cell 在这里与 `get()` 同款 **抛**(strict parse):静默当缺席会让一份不同的 * 记录覆盖已经在案的那份,而记录物理不可变正是这条契约的全部意义。 */ recordExecutionOutcome(token: CheckpointToken, scope: string, gate: GateOutcome): Promise; /** * [ref] D-A (durable steering) —— **core 5.14.0([ref] BREAKING)起是「追加进一条有界有序队列」**, * 不再是「盖掉单座」。两个人同时给一条挂起的 run 转向,旧语义会让第二条**静默销毁**第一条(一条没人 * 知道丢了的操作员指令);新语义是两条都在,resume 按 `seq` 全数投递。 * * 语义与边界**全部铸在 core**,本 store 一个都不自铸(契约原文:`appendPendingSteer` is "the shared * append every backend must build its next queue with: bounds + `seq` mint live there, not once per store"): * · `validatePendingSteer` —— 每个 impl 内部必跑的 fail-closed 不变式(M2,接入审计 2026-07-08):内容 * 越狱/控制字符 `steering.invalid_content`;**未知字段现在是 REFUSE 而不是丢弃**(领先一个版本的 * producer 必须知道自己的新字段活不过这条挂起的腿);缺席的 `inputId` 在这里铸(uuidv7)。 * · `appendPendingSteer` —— 铸 `seq`、判 `inputId` 幂等(同 id 同载荷 = no-op 返回 true;同 id **不同** * 载荷 = `steering.duplicate_input_id`,吞掉它就等于把队列要消灭的那种静默丢失原样请回来)、 * 执行两条上界(条数 + 48000 字节整信封),越界一律 `steering.queue_full` fail-loud,**绝不驱逐** * 一条已被接受的指令。 * * 🔴 从「一条无条件 UPDATE」变成「读-改-写」,中间那道窗必须自己有 CAS:两个并发 steer 读到同一份 * 队列、后写者整个盖掉前者 —— 就是 [ref] 要消灭的静默丢失换了个位置复发。守卫是 `pending_steer_rev` * **专用整数列**(不是拿旧列值比:整数按构造唯一,而长 JSON 文本的相等性取决于排序规则/字段序/空白与 * 转义形,任一处判等松一点就是一次 CAS 假命中 = 悄悄丢一条 steer。[ref] A11 更正:本注原先的理由写的是 * "本表无显式 COLLATE,*_ci 会把只差大小写的两份信封判等",那半已不成立——`checkpoint` 现在钉了 * `COLLATE utf8mb4_bin`/PG 侧 `COLLATE "C"`;结论不变,理由回到上面那条更根本的; * 也不是复用 resolve 的 `rev`:bump 它会让并发 resume 的 `resolve(expect)` 白白落败)。 * CAS 输了就重读重算(队列只有 3 个位子,争用者天然极少);给足冗余仍全败 ⇒ **抛**,不返回 false —— * `false` 在调用方的语义是「没有 pending 行可转向」(HTTP 409「已不再挂起」),把争用报成那个是撒谎。 * * 返回:`true` = 落在一条活的 pending 行上(含幂等重放);`false` = 没有该 (token,scope) 的 pending 行。 */ setPendingSteer(token: CheckpointToken, scope: string, steer: PendingSteerInput): Promise; /** * CAS-expire a SINGLE checkpoint by token (core 1.71 `CheckpointStore.expire`, for `TaskStream.destroy`'s * suspended-worker reap — core [R33] {delta}). `pending → expired`, returns the CAS winner (true = we * expired it; false = already resolved/expired). Same UPDATE as `reap`, keyed by **token** not deadline. * It races the SAME `pending` row as `resolve`, so the two are mutually exclusive: destroy() must `expire` * (fence) BEFORE destroying the env — if a concurrent resume already won `resolve`, expire returns false and * destroy leaves the env for that resume; if expire wins, the resume's resolve fails → never resume-to-dead-env. * Uses `expired` (not a `resolve`-deny) so a CANCELLED checkpoint never pollutes resolved-count / outcome. */ expire(token: CheckpointToken, scope: string): Promise; /** Interface reap: CAS-expire pending checkpoints in `scope` past `cutoff`. Returns count. */ reap(scope: string, cutoff: number): Promise; /** * GLOBAL sweep for the service's per-replica TTL reaper (expiry isn't tenant- * sensitive — only `resolve` is scoped). Idempotent across replicas (DB serializes; no election). Returns count. * Called with `cutoff = Date.now()` (deadline/terminal_at_ms are ABSOLUTE epoch-ms), so it expires any pending row * whose per-approval `deadline` OR its [ref] D-1 §3-inv#3 `terminal_at_ms` crash-safe backstop has passed — * the latter closes the forever-leak of a pending row with a NULL `deadline` (no approval TTL was configured). * * [ref] D-D: the deadline-branch EXPIRES (≈ abort) every kind EXCEPT a tool-approval human/irreversible_ask * gate — those are resolve-DENIED gracefully by the SLA deny-sweep (listExpiredApprovalGates → * resumeCheckpoint(deny)), so expiring them here too would race + lose the graceful denial. 🔴 A durable * AskUserQuestion ALSO mints gate.kind='human' (no question-specific kind in core) — but DENYING a question is * incoherent (the model gets a "denied" tool-result, not an answer), so it is carved BACK INTO the expire path * (COALESCE(tool_name,'')='AskUserQuestion') to abort-expire on timeout instead. Legacy rows (gate_kind NULL) * stay on the expire path. The terminal_at_ms-branch is the crash-safe backstop for ANY kind (incl. a human gate * whose deny-resume keeps failing) — it always abort-expires past the absolute cap (which is now STRICTLY after * the deadline, so it never races the deny-sweep at the deadline instant). */ reapExpired(cutoff: number): Promise; /** * [ref] D-D (SLA-timer): the sessions of pending human/irreversible_ask checkpoints past their `deadline` * (the per-approval SLA). The reaper resolve-DENIES each (re-resume with decision=deny → the model continues * with the denial) — graceful, vs the abort that expire() gives resource_limit/needs_review. Excludes legacy * rows (gate_kind NULL — they predate the split and fall through to reapExpired's expire path). LIMIT bounds * the per-tick deny-resume work (each is a model run). Scope is read off the COMMITTED row (invariant #4). * 🔴 EXCLUDES AskUserQuestion gates (which also mint gate.kind='human'): denying a question is incoherent — * the model would get a "denied" tool-result instead of an answer — so a timed-out durable question falls to * reapExpired's abort-expire path instead of this graceful-deny path. */ listExpiredApprovalGates(cutoff: number, limit?: number): Promise>; /** Pending checkpoints for the operator queue — NON-secret fields only (never the token). `taskId` is * joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its * session claim, so the join is live for every pending row; null only in pathological windows). */ /** * S-160 显式记账 —— **本口与 {@link SqlCheckpointStore.listByScope} 都不投执行判词,这是结构性的不是遗漏**: * 两口的谓词都硬 `status='pending'`,而执行记录的 CAS 谓词是 `status='resolved' AND execution_outcome IS NULL` * ⇒ 一条 pending 行**按构造**不可能带记录;反向也封死了(记过的行 `reopen` 拒,永远回不到 pending)。 * 所以这两条读面上「没有 `executionOutcome`」不是「缺席=未知」,而是「这一格在这条读面上不存在」。 * 真正需要它的读面是**已决行**的那一条:`findDecidedApprovalsForBinding`(decide 幂等回放),见那里。 */ listPending(scope?: string): Promise; /** * [ref] #2 (scheduler seam, core 1.105): a scope's PENDING checkpoints as lightweight * {@link CheckpointSummary} rows in ONE query — the cross-task triage view a supervisor scheduler needs * (gate kind + risk severity + budget spent + deadline per suspended task), no N+1 `get`s. The projection is * core's shared {@link summarizeCheckpoint} run over the persisted blob (the `checkpoint` column = the same full * {@link Checkpoint} `get()` parses), so this stays byte-identical to core's InMemory/Pg/File impls. Order = * created_at_ms ASC; callers (the inbox/scheduler) sort by severity. The COLUMN `status` is authoritative (the blob * is the suspend-time snapshot), so it overrides the blob's status before the summary is derived. * * LIMIT bounds the fan-out (review w16yqkkxv): a triage view never needs more than a few — 500 is a generous * ceiling that still protects memory/latency if a scope ever accumulates pathologically many pending gates. */ listByScope(scope: string): Promise; /** * Map a session (the non-secret handle the operator decides by) → its pending checkpoint token, INTERNALLY. * `scope` is enforced so an operator can only resolve within its own tenant. Returns null if there is no * pending checkpoint for that session/scope. * * 🔴 [ref]([ref]):「A suspended session has exactly one pending checkpoint」这句旧注**不成立**(同 session * 下 `task_done` 纯 park 与下一条 run 的门 park 合法并存,表上无唯一约束),旧形无序 `LIMIT 1` 因此不确定。 * 现形 = `binding.gateKinds` 门型绑定 + 显式序(最新优先,token 兜平局),谓词/序的唯一属主 * {@link SqlCheckpointStore.pendingRowQuery};语义与三态全文见 {@link PendingRowBinding} 顶注。 * `scope` 传 `""`(空串)历史上与缺席同义(旧形 `scope ?` 真值判),这里逐字保留。 */ findPendingTokenBySession(sessionId: string, scope?: string, binding?: PendingRowBinding): Promise; /** * 🔴 [ref] 车5 §9 C4 —— 流内审批对账收敛器判据 1 的**窄读口**(店面承载;收敛器本体不在本车)。 * * 「这条 PARKING 的 ask 究竟 park 成了哪张 checkpoint?」的唯一读法。为什么不是「按 session 翻历史页」: * 分页宽读会漏匹配,而漏匹配在收敛器那侧的后果是**假阴性 ⇒ 落一条不可逆的 DENIED**。所以这里改成 * 谓词精确查——`(scope, session_id, tool_call_id, created_at_ms ≥ sinceMs)` 这组条件下的行数天然极小, * 一次全量返回,结构上没有分页假阴性。 * * 三条口径,逐条都是判据: * - **`scope` 必填**(不是 optional):对账是跨腿读,租户门是读口自己的责任,不靠调用方记得过滤 * (同 `listPendingBySession` 姿势;`resolve`/`reap` 的 scope enforced 同族)。 * - **不按 status 过滤**:已 `resolved`/`expired` 的行照样返回。后到的 resume 会把行翻成 resolved, * 若只看 pending 就会把「park 成功过」这个既成事实读成「没 park 过」⇒ 又是一条假阴性 DENIED * (对账三约束③)。park 成功是事实,gate 的活性由 `status` 表达,交给收敛器自己判。 * - **单行读不出 ⇒ 标记返回,不抛**:`unparseable` 的行由收敛器视同「不匹配」,一条坏行不许打断整段 * 扫描(§8 C-6)。什么算读不出:blob 的 `version` 超出本 build 支持(`get()` 那条前向兼容门在这里 * 不能 throw,否则一条超前行会让整个 session 的对账永久卡死)、或 blob JSON 坏。 * * 匹配是两段的:`tool_call_id` **列**是 `put()` 从 `pendingAction.toolCallId` 盖下来的权威投影,SQL 谓词 * 先按它(或 NULL)收窄;列为 NULL 的行(无工具动作的 park,或列存在之前的旧行)靠解 blob 补判——解得出 * 且相等才算候选,解不出就标 `unparseable`。 * 🔴 **没有「列命中即零解析」的快路径**(原注写过,已作废,别照它优化):函数体对**每一行**无条件解 * blob,原因有二 ——(a) 前向兼容门与 blob 可读性门必须门在**所有**命中路径之前(codex F3 + 确认轮: * 列长得对不代表 blob 读得出,放行一条读不出的行去 `bindBatch` 会把 ask 钉成 PARKED + 一张本进程读不出 * 的 resume 坐标,而 PARKED 不可回滚);(b) `[ref]` 之后 blob 顶层的 `sourceTaskId` 是和解三元组的第一 * 维,列命中行结构上也必须解 blob 才拿得到它。 */ findCheckpointCandidatesForAsk(scope: string, sessionId: string, toolCallId: string, sinceMs: number): Promise; /** * [ref] 件1 —— 「这条会话上的这一次工具调用,**是不是已经被决过了**?」的窄读口(legacy `/decide` * 腿的幂等回放判别器;返回形见 {@link DecidedApprovalRecord})。 * * 为什么必须有这一口(而不是复用 `findPendingTokenBySession`):后者硬 `status='pending'`,首决之后 * 恒 null ⇒ 重试与「压根没有这条审批」在 wire 上同形(404)。而合规客户端的重试体里**没有** * checkpointToken(它只回显二元组),所以既有的 `approval_stale` 409 那条判别路对它结构性不可达。 * * 四条口径,逐条都是判据: * · **谓词 = (session_id, tool_call_id, status='resolved')**:`tool_call_id` 列是 `put()` 从 * `pendingAction.toolCallId` 盖下来的权威投影,也正是 listPending 交给客户端回显的 `boundCallId` * ⇒ 客户端手里那把与库里这一列同源。`(session_id, status)` 是既有索引 `idx_checkpoint_session_status` * 的前缀,不新增索引面。 * · **只认 `resolved`**:`expired`(窗到期被 reaper 抹掉)**不是**一次决议 —— 把它回放成 * 「已决」等于把「没人来得及答」谎报成「有人答过」。那一形照旧落 404(诚实缺席)。 * · **`decided_at_ms IS NOT NULL`**:与 respond 腿 `decidedRowIsAuthoritative` 的合取项③同判据 * (「这条决议真的被结算腿落过」的印记);顺带消掉 MySQL/PG 对 `ORDER BY … DESC` 里 NULL 排序 * 方向不同这个方言差(MySQL NULL 最后、PG NULL 最前)—— 谓词先滤掉,排序就不含 NULL。 * · **不带 scope 谓词、由调用方判属主**:与本类 `findPendingTokenBySession` 同姿势(读口不做租户 * 判决,判决在调用点)。回放腿据返回的 `scope` 做与 pending 那道**逐字同判**的属主门,非属主 * 拿到的仍是 404(不给存在性谕示)。 * * 🔴 **返回的是全部命中行,不是「最近那一条」**(codex R1-[medium] 四,验真后改向):本口原先按 * `decided_at_ms DESC LIMIT 1` 取「最近一次决议」,而 local 孪生手里根本没有这个时间戳(core 的 * `Checkpoint` 上没有 decidedAt 字段),它只能按 `createdAt` 排 —— 两条车道于是可能对**同一批行**给出 * **不同**的判词(创建序与决议序相反时)。判据因此改成一条与排序**无关**的:调用方要求**全部命中行 * 判词一致**才回放,分歧即拒。同毫秒并列、两方言 NULL 排序差、跨车道排序键不同 —— 三个问题一起消失, * 而现网正常形(恒 0 或 1 行)一个字节不变。上限 8 行足够:超过它的形已经不是「重试」而是数据异常。 * * 坏 `outcome` cell **不抛**:回放是一条锦上添花的腿,让一条读不出的 JSON 把一次 decide 重试变成 500 * 比 404 更坏。留痕后交出 `decision: null`(= 判别不出),由调用方按「整次拒绝」处置。 */ findDecidedApprovalsForBinding(sessionId: string, boundCallId: string): Promise; /** * The owner SCOPE of a session's pending checkpoint (the multi-tenant key === the owner principal in the * BFF/non-operator flow, the same key listPending filters by). For the /decide owner-gate: a non-operator * caller may only decide a checkpoint in their OWN scope. Returns `undefined` (no pending row), `null` * (anonymous/dev session — open), or the owner scope string. */ /** * [ref]:**按 token** 的 scope 轻读 —— `undefined`(无该行)/ `null`(匿名/开放)/ 属主 scope 串。 * * 为什么必须有这一口(codex [ref] r1-[medium],验真后修):`peekPendingScope` 是**按 session** 的读,答的 * 是「此刻绑定下最新的那一行」;而 `get(token)` 读的是**那一个** token。表无 per-session pending 唯一 * 约束、两读又非事务 ⇒ 并发轮换窗内两者可以是不同的行。于是「按 session 轻读出的属主」不能拿来当 * 「可否把 `get(token)` 的失败细节回显给这位调用方」的准入位 —— 那正是 [ref] 要封的存在性 oracle 在 * TOCTOU 面上的复活形。本口把属主证明与**将要载入的那个 token** 原子绑定(同一 `WHERE token = ?`)。 * * 刻意**不带** `status='pending'` 谓词:判据是「这一行是不是你的」,与它此刻处在哪一态无关(`get()` * 自己也是裸 `WHERE token = ?`);加了反而在「刚被并发消费」的窗里把属主自己的诊断吞掉。 * 只 SELECT `scope` 一列 ⇒ 版本闸/坏 blob 都影响不到它(那是 `get()` 读 `version`/`checkpoint` 的事)。 */ peekScopeByToken(token: CheckpointToken): Promise; peekPendingScope(sessionId: string, binding?: PendingRowBinding): Promise; /** Upsert the resume rebuild inputs for a session — written at SUBMIT (durable mode on) and, since S-433 F2, * once per resume leg at its CAS win point (`DriveResumeArgs.resumeCtx`). Unconditional per `session_id`: * the ordering fence lives at the CALLER's CAS win, not in a version column here. */ putCtx(sessionId: string, ctx: unknown): Promise; getCtx(sessionId: string): Promise; /** * GC ctx rows whose lifecycle is over (bound to the checkpoint, don't leak): * a session with NO pending checkpoint, NO active run, AND not touched since `cutoff`. * * The extra `task_active` guard closes a multi-gate window: between a resume's CAS (pending→resolved) and * the task hitting its NEXT gate (writing a new pending checkpoint), the session has no pending checkpoint — * yet `putCtx` stamps `updated_at_ms` at submit and — since S-433 F2 (server 7.86.0) — once more on EVERY * resume leg, at the moment that leg wins the resume CAS (`DriveResumeArgs.resumeCtx`; before that fix only * the plan approval's mode write-back re-stamped it, and it did so AFTER acceptance). Both extra writes only * ever REFRESH the stamp, so they lengthen a ctx's life and touch none of the three guards — the direction is * "keep it longer", never "reap it sooner". So the ctx could be older than `cutoff` and was being * reaped, making the next resume 409 ("resume context missing"). `task_active` holds the session lock for * the WHOLE submit→last-resume-complete span (running OR suspended), so excluding sessions with an active * claim keeps the ctx exactly as long as a run still needs it, while still GC'ing it once the run is terminal. */ reapCtx(cutoff: number): Promise; /** * E21 (§0.5 session delete) — purge ALL checkpoint state for one session: the durable checkpoint rows * (suspended-task gates) AND their service-side resume-context rows (checkpoint_ctx). Keyed on the * `session_id` column both tables carry. Idempotent (returns the count of `checkpoint` rows removed). * Unconditional by design: a session being deleted has no resumable future, so any pending gate is moot. * * `owner` is a single-DB-fleet defense-in-depth guard (adversarial-review MEDIUM). Neither table carries an * owner column, so the guard threads through `session_meta.owner` via an EXISTS sub-select (null-safe compare: * `<=>` on TiDB, `IS NOT DISTINCT FROM` on PG). The purge coordinator runs this BEFORE deleting session_meta, * so the meta row (with its authoritative owner) is still present here — a wrong-owner caller's EXISTS fails * and purges nothing. `owner=null` (legacy/dev, never threaded by the route) keeps the historical * unconditional behaviour via the null-safe match. * * 🔴 Placeholder ARITY differs, not just the syntax: `?` cannot be reused, so the TiDB arm binds `sessionId` * TWICE (row key + guard sub-select) while PG reuses `$1`. */ deleteBySession(sessionId: string, owner: string | null): Promise; } /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */ export declare class TiDBCheckpointStore extends SqlCheckpointStore { constructor(pool: MySqlPool, logger?: { info?(msg: string, meta?: unknown): void; }); } /** PostgreSQL binding — historical class name + ctor shape preserved. */ export declare class PgCheckpointStore extends SqlCheckpointStore { constructor(pool: PgPool, logger?: { info?(msg: string, meta?: unknown): void; }); } //# sourceMappingURL=checkpoint-store-sql.d.ts.map