import type { AskRow, ApprovalAskStore } from "./plugins/approval-ask-store-sql.js"; import type { BatchState } from "./approval-ask-machine.js"; import type { CheckpointAskCandidate } from "./plugins/checkpoint-store-sql.js"; import { type RunRecord } from "./plugins/store-contracts.js"; import type { Logger } from "./observability/logger.js"; import type { Metrics } from "./observability/metrics.js"; import { type DenyReason, type VoidReason } from "./approval-deny-reasons.js"; import { type ApprovalRevokeFrame } from "./approval-card.js"; /** 判据表 v2 的一次判定结果(纯数据;执行器按 kind 分派到店面的一次 CAS)。 */ export type ReconcileAction = { kind: "bind"; gate: { gateToken: string; gateBoundCallId: string | null; gateBoundInputHash: string | null; }; } | { kind: "deny"; reason: DenyReason; } | { kind: "void"; reason: VoidReason; } | { kind: "hold"; }; /** {@link decideReconcileAction} 的入参(全部是**已经读好的事实**——纯函数不碰 IO)。 */ export interface ReconcileInput { ask: AskRow; /** 判据 1 的候选集(读口已按 scope+session+toolCallId+sinceMs 精确查);无 checkpoint 面 ⇒ 空数组。 */ candidates: readonly CheckpointAskCandidate[]; /** 出处 run 行;`null` = 无行(被 reap / 从未建 / adhoc 腿)。 */ run: RunRecord | null; /** 批行当前态;`null` = 本轮未探(还没走到需要它的臂)。`"MISSING"` = 批行不存在。 */ batchState: BatchState | "MISSING" | null; /** 批的中选者(`bound_ask_id`);`ROUTING_BOUND` 臂靠它分「中选的是我」与「中选的是兄弟」两种处置。 * 缺席/未知 = `null`(与 `batchState: null` 同栏:未探到就不据它下判)。 */ batchBoundAskId?: string | null; nowMs: number; adhocGraceMs: number; orphanTtlMs: number; /** `false` ⇒ 跳过判据 1(本轮已经试过 `bindBatch` 且失败,降级续判 ②③④⑤)。缺省 `true`。 */ allowBind?: boolean; } /** * 判据 1 的一次**分类**结果([ref] 件1;纯数据)。 * * 为什么不只回「命中 / 不命中」:黑板 [ref]/[ref] 两帖把「两侧摘要不等」拆成了**四种成因**,处置各不 * 相同 —— 只有一种是真的「两个值不一样」,其余三种根本只有一侧(或零侧)铸过摘要。压成同一个 * `undefined` 会让运维看着一条「不匹配」去查一件从没发生过的事。 */ export type GateMatchOutcome = /** 三元组身份 ∧ 摘要双等 —— 判据 1 命中。 */ { kind: "match"; candidate: CheckpointAskCandidate; } /** 祖先冻结 approver 层 fold 中途的那一次铸造:其后还可能有 rewrite,两侧**本不该等**([ref]③)。 */ | { kind: "ancestor_fold_mint"; } /** * 单铸路径 —— **不是** mismatch([ref]③②): * - `ask_only`:纯 sync 腿(同轮结算,永无 checkpoint 行)/ 再审批链(2 次 ask 铸造、0 次 checkpoint); * - `checkpoint_only`:durable-first 干净 args(ask 侧 0 次铸造,checkpoint 铸出); * - `neither`:字符串模式 `onAsk` + durableApproval(两侧都没铸)。 */ | { kind: "single_mint"; side: "ask_only" | "checkpoint_only" | "neither"; } /** * 三元组身份对得上、两侧摘要都在场却**不等** —— 唯一的真 mismatch。成因是**部署自伤**而非不当调用 * ([ref]②:hook/policy 把自己仍持引用的活对象在两铸点之间改了,或每读返回新值的有状态 getter)。 * 处置 = 留痕不拒:计数 + 一条 warn,行照 ②③④⑤ 走(判据 1 不命中的既有 fail-safe 路径不变)。 */ | { kind: "hash_mismatch"; candidates: number; } /** 三元组身份就对不上(不是摘要的事):别的 sourceTaskId / 别的 callId / 早于本 ask 的 park。 */ | { kind: "identity_miss"; }; /** * 这只 ask 是不是**祖先冻结 approver 层**在委派 fold 中途铸的那一份([ref] 件1④,黑板 [ref]③)。 * * 判别位是**产品自己发的**,不是外部约定:core 的 `withDelegationProvenance` 只包**子代自己那条缝**, * 祖先层那次走未包装的原函数 ⇒ `AskRequest.delegation` 在不在,就是「这一份是哪一层发的」。落到行上, * `delegation.parentToolCallId` 是必填字段,铸行时逐字透传进 `parent_tool_call_id` 列 ⇒ **列非 NULL * ⇔ delegation 在场**(不需要新列,也不需要回 `req` 里再读一次)。 * * 第二维 `sourceTaskId !== sessionId` 把「根腿」摘出去:根腿的 `sourceTaskId` 恒等于会话锚(core 给 * `AskRequest.sourceTaskId` 填的就是该腿 sessionId),它根本不在任何 fold 里,`parent_tool_call_id` * 为 NULL 是它的常态而不是信号。 * * 命中 ⇒ 退出硬相等(判据 1 结构上不命中,行落 ②③④⑤)。实测(test AI 围栏)checkpoint 存的摘要**逐字 * 等于子代自己那条缝**、只不等于祖先层那一次 —— 所以收窄到这一层,子代自己的缝照常参与。 */ export declare function isAncestorFoldMint(ask: AskRow): boolean; /** * 判据 1 的**硬谓词**(纯函数,§9 C2 + §8 D-2;[ref] 件1 起返回分类而不是布尔)。 * * 逐条: * - 祖先层 fold 中途铸点 ⇒ 直接退出(见 {@link isAncestorFoldMint}); * - `unparseable` 候选直接出局(读不出 ⇒ 不确定 ⇒ 不命中); * - **身份三元组**:`sourceTaskId` **相等** ∧ `toolCallId` **相等** ∧ 时间维的**因果下界** * `candidate.createdAtMs >= ask.createdAtMs`(⚠️ 第三维**不是等式** —— 把它读成等式会让合法的 park * 几乎命不中,行随后落 ②③⑤ 被误判)—— 摘要**不是**身份([ref]③①: * 同 args 的两次调用摘要天然相同,只靠摘要硬相等会把第二次投递并进第一次的票);任一维在候选侧 * 缺席(读不出的 blob 没有 `sourceTaskId`)即身份不成立; * - **hash 双等**:两侧都必须在场且相等,任一侧缺席一律不 bind;禁「能取到时才比」的可选谓词。 * 归因分两级(顺序即代码顺序):身份这一层先判 —— 同身份候选为空**且**候选集非空 ⇒ `identity_miss`; * 走到 hash 这一层才把缺席记成**单铸** `single_mint`(见 {@link GateMatchOutcome})。 * * 多候选时的取舍:优先 `status === "pending"`(活着的那张 gate),否则取最早的一条(读口按 * `created_at_ms ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到 * 一个还能被 resume 的坐标上,对壳更有用。 */ export declare function classifyGateMatch(ask: AskRow, candidates: readonly CheckpointAskCandidate[]): GateMatchOutcome; /** {@link classifyGateMatch} 的布尔面(判据 1 命中即返回那条候选)。分类信息由调用方按需另取。 */ export declare function selectGateCandidate(ask: AskRow, candidates: readonly CheckpointAskCandidate[]): CheckpointAskCandidate | undefined; /** 判据表 v2 的判定(纯函数;顺序 = 设计稿 §9 尾的五臂汇总,注见文件头)。 */ export declare function decideReconcileAction(input: ReconcileInput): ReconcileAction; /** 收敛器一次 tick 的产出(可观测 + 测试断言面)。 */ export interface ReconcileStats { /** 段一扫到的 `PARKING` 行数。 */ scanned: number; /** * 🔴 判据 1 **结构上无法命中**的行数(`bound_input_hash` 列为 NULL ⇒ 双等硬谓词永远不成立)。 * * 存在理由(codex 交叉复审 C1,2026-08-06 验真):设计稿 §9 C2 的前提是「`AskRequest.boundInputHash` * core 侧已在场」——对**当时**树上的 core 5.13.0 不成立(`AskRequest` 没有这个字段,铸行侧无值可存), * 于是判据 1 恒不命中、PARKING 行只会落 ②③④⑤。这个计数把那片盲区变成**可观测量**而不是静默行为 * (下面每 tick 一条 warn),并被写成协议开关翻真的前置判据之一。 * * ✅ **该前置判据已销账**([ref] 翻真验证车,2026-08-07 亲验 core 5.15.0):字段在 * `core/tool-policy.d.ts`,真码 `core/tool-policy.js` 每一只 ask 都填 —— * 判据 1 现在会真命中(钉:test/stream-approval-on-e2e.test.ts 件4 末条,同构两行只差 hash 一位, * 有 hash 的 bind 成 PARKED、无 hash 的进本计数)。 * * 🔴 **本字段与它的 warn 都不删**:语义从「结构性盲区」变成「边缘异常」—— 翻真之后一条非零读数说明 * 有 ask 的铸行侧没拿到 hash(旧版 core 的存量行 / 回滚形 / 未来某条不经 `resolveAsk` 的新路径), * 那正是运维需要当场看见的东西。恒零才是健康态,不是这条计数没用了。 */ unmatchableNoHash: number; /** * 🔴 判据 1 的**真** mismatch 行数([ref] 件1③,黑板 [ref]②):三元组身份对得上、两侧摘要都在场却不等。 * * 成因裁定 = **部署自伤,不是不当调用**:core 已证在真正产生两次铸造的弧上摘要恒等,两条分歧路径都要求 * 部署自己交出活对象(hook/policy 返回己持引用后在两铸点之间突变;或每读返回新值的有状态 getter)。 * 所以处置是**留痕不拒**:计一笔 + 一条 warn,行照 ②③④⑤ 的既有 fail-safe 走 —— 判据 1 不命中本来 * 就不会产生终态 denial(约束②),这里不新增任何拒绝语义。 */ hashMismatch: number; /** * 单铸路径行数([ref] 件1②)——**不计入** {@link hashMismatch}。三形分列: * `askOnly` 纯 sync / 再审批链;`checkpointOnly` durable-first 干净 args;`neither` 两侧都没铸。 * 它们都是**结构上只有一侧(或零侧)有摘要**,把它们读成「比过了、不匹配」是把没发生的事记成异常。 */ singleMintAskOnly: number; singleMintCheckpointOnly: number; singleMintNeither: number; /** * 祖先冻结 approver 层 fold 中途铸点([ref] 件1④):退出硬相等,不是 mismatch 也不是单铸。 * * 🔴 **今天恒零,而且是结构性的**(扫描P2,2026-08-12 亲读验真;登记而不假称已解):读口 * `findCheckpointCandidatesForAsk` 按 `session_id = ask.sessionId` 查,`session_id` 列写的是 * `cp.sessionId`,而 core 每个 checkpoint 铸点都填 `sourceTaskId: sessionId` * (`runner/prepare-task.js` 四处;`checkpoint-store.d.ts` 逐字「= sessionId at the mint」)⇒ 读口 * 返回的候选恒满足 `c.sourceTaskId === ask.sessionId`;而判据 1 命中要 `c.sourceTaskId === * ask.sourceTaskId`,祖先层否决要 `ask.sourceTaskId !== ask.sessionId` —— 两者互斥,这一臂在生产 * 接线下一次也不会被返回。 * * 所以**别把零读数当健康信号**(与 {@link unmatchableNoHash} 的「恒零才是健康态」正相反:那条能非零, * 这条不能)。根因是本文件 `reconcileOne` 里已登记的读口 session 维缺口(委派腿的候选压根进不来), * 修它需要自带判据 + 真 checkpoint 店的宿主/子代双 session 端到端钉,不在扫描批的口径内。 * 否决臂**不删**:它是纯减法的安全臂,读口一修好就重新承重。结构事实的钉: * `test/approval-reconciler.test.ts` 的「扫描P2 ④ 结构钉」(翻转钉——读口修好那天它会变红)。 */ ancestorFoldMint: number; parked: number; denied: number; voided: number; /** 本轮没有产生终态的行:判据 3 的保持,**以及**判据 2/4/5 判出了终态但那条 CAS 干净地输的行 * (别的副本先收走了它)——两者对本轮的意义相同:行没被本实例改动,下轮按新态重判。 */ held: number; /** 单行判定/写入抛错被隔离掉的行数(一条坏行不许打断整段扫描)。 */ failed: number; /** 段一/段二因**墙钟预算**用尽而提前收工的段数(0/1/2)。>0 ⇒ 依赖在退化,剩余行留给下一轮 * (见 {@link RECONCILE_SEGMENT_BUDGET_MS})。 */ budgetExhausted: number; /** 段二:被代打 `expireAsk` 的孤儿 `STREAM_PENDING` 行数(赢 CAS 的)。 */ orphansExpired: number; } /** 撤卡帧的投递面(逐 ctx 分发:durable 腿落账本、sync 腿 live,见 `ApprovalRevokeFrame` 顶注)。 * 收敛器不认识 SSE 也不认识账本,只交给这个口(broker 反查活 ctx,各 ctx 自带自己的写口)。 */ export type ApprovalRevokeEmitter = (frame: ApprovalRevokeFrame, target: { owner: string | null; sessionId: string; taskId: string; }) => void; /** 判据 1 的读口(窄接口,不绑具体 checkpoint 店实现——local 车道的店没有这个面,装配点传 undefined)。 */ export interface ReconcileCheckpointPort { findCheckpointCandidatesForAsk(scope: string, sessionId: string, toolCallId: string, sinceMs: number): Promise; } /** 判据 2/4 的读口(窄接口,同上)。 */ export interface ReconcileRunPort { getRun(taskId: string): Promise; } export interface ApprovalReconcilerDeps { askStore: ApprovalAskStore; /** 缺席 ⇒ 判据 1 恒不命中(候选集恒空),行落 ②③④⑤ —— 诚实降级,不是「假装没 checkpoint 就该 DENY」。 */ checkpoints?: ReconcileCheckpointPort | undefined; /** 缺席 ⇒ `run` 恒 `null`,判据 2 不成立;durable 腿因此只可能被 ⑤ 兜底(不会被误判 DENIED)。 */ runs?: ReconcileRunPort | undefined; logger: Logger; metrics?: Metrics | undefined; /** 每段每 tick 的行数上限(`STREAM_APPROVAL_RECONCILE_BATCH`)。 */ batchLimit: number; /** 孤儿代打的宽限(`STREAM_APPROVAL_PENDING_GRACE_MS`)——活属主的窗到期竞争者常态必胜, * reaper 只在超过它之后才补位(§8 D-1.1:否则 reaper 会跟活属主抢,制造挂死面)。 */ pendingGraceMs: number; adhocGraceMs: number; orphanTtlMs: number; emitRevoke?: ApprovalRevokeEmitter | undefined; } /** {@link createApprovalReconciler} 返回的活对象。 */ export interface ApprovalReconciler { /** 跑一轮(两段:PARKING 判据表 + 孤儿 STREAM_PENDING 代打)。**永不抛**——单行错误隔离在行内, * 段级错误由调用方(reaper 腿的 throttled catch)兜。 */ runOnce(nowMs: number): Promise; } export declare function createApprovalReconciler(deps: ApprovalReconcilerDeps): ApprovalReconciler; //# sourceMappingURL=approval-reconciler.d.ts.map