/** * 墙钟阶跃守卫 —— 给「按墙钟绝对 deadline 做终局判定」的清算腿加一道**单调钟佐证**。 * * 病灶(test [ref] 场景①,libfaketime 真实构造,宿主真钟全程未碰):审批 SLA=45s,把进程内墙钟 * **前拨 1 小时**,真实只等了约 14 秒审批卡就被 D-D deny-sweep 判超时自动拒绝。链条是清楚的: * `checkpoint.deadline` 是 core 在 `put` 时按**当时的墙钟**铸的绝对时刻(必须是墙钟——它要跨进程 * 重启存活,单调基准一重启就没了),而 sweep 每拍拿 `Date.now()` 去比 `deadline <= cutoff`。墙钟凭空 * 前跳 J 毫秒 ⇒ 判定钟凭空多走 J 毫秒 ⇒ 人还没走开,窗就没了。 * * [ref] core 半场三定性把判定半场划给本仓:`totalWaitMs` 的墙钟**记账**是设计如此(要跨 durable * resume 延续),不改形;**判定**侧可以在进程内用单调钟做窗口佐证,与账面字段互不牵连。本模块就是那道佐证。 * * ## 判据(为什么是「宽限一个完整 SLA」) * 直觉修法是把 cutoff 改成 `now - 累计前跳量`。它不成立:core 在跳变**之后**铸的 deadline 用的是**新** * 墙钟,拿旧墙钟去比 ⇒ 新卡凭空多活 J 毫秒,而 J 可以是任意大。补偿量若衰减,cutoff 又会以 2 倍速前进。 * 两条路都把一个有界缺陷换成一个无界缺陷。 * * 本模块改判**宽限**:检出一次前跳后,按**单调钟**静默 `grace = slaMs` 毫秒不做判定。 * * 🔴 **宽限量是一个完整 SLA,不是 `min(J, S)`**(codex 交叉复审 R1 [high] 三,验真后修 —— 第一版正是 * `min(J, S)`,而那条「J < S 时宽限恰好补平损失」的证明**是错的**)。错在哪:宽限只是**推迟**判定, * 它消不掉墙钟从此**永久领先** J 毫秒这件事。反例(逐字复算):S=45s,gate 在真实 t=0 铸出(deadline = * W0+45s),真实 t=1s 时前跳 J=10s。`min(J,S)`=10s ⇒ 宽限到真实 t=11s;此后墙钟恒为 `W0+t+10`, * 于是 deadline 在 **真实 t=35s** 就被判到期 —— 仍旧少给了整整 10 秒。 * * 取满一个 S 才成立,证明是:宽限结束的时刻 t_g = t_jump + S(单调钟量)。 * · 跳变**之前或当时**就在等的 gate:它在 t_g 之前不可能被判,故真实等待 ≥ S ✔ * · 跳变**之后**在 t_x 铸出的 gate:deadline 用的是跳变后的钟 ⇒ 它按墙钟到期的时刻恰好对应真实 * t_x + S(墙钟与单调钟从跳变后起同速),宽限只可能把它再往后推,绝不会提前 ✔ * 即**任一审批席都拿得到不少于一个完整 SLA 的真实时间**;单次阶跃下判定最多被推迟一个 S。 * * 🔴 **「有界且一次性自清」只对单次阶跃成立**(codex 交叉复审 R2 [high] 五,验真后修 —— 第一版把这句话 * 写成了无条件的,那是错的):每次前跳都把窗延到「此刻 + 一个 S」,所以**只要每个 S 内再跳一次**,窗就 * 永远续下去,deny 腿被静默关停 —— 而它是一条 **fail-closed** 腿,永久不跑比跑早了更坏。 * 收口 = 一串连续跳变的**累计宽限总上限**(缺省 `2 × S`:一个 S 覆盖有证明的单次阶跃,第二个 S 容一串 * 短促跳变即 NTP 连续大步修正的真实形);撞上上限就**响亮**恢复判定(`onGraceCapped`),此后宁可按一个 * 不稳的钟判,也不让 fail-closed 腿永远不跑。绝对兜底始终另有一层:`checkpoint.terminal_at_ms` 由别的 * reap 腿执行、不经本守卫。 * * ## 方向与响亮度([ref] 安全轴) * 宽限只会**推迟**一个 fail-closed 动作(deny),绝不会放行任何东西 —— 方向安全。但它必须**响亮**: * 检出即 warn(带跳变量与宽限量),宽限期内每拍 info。绝对兜底仍在:`checkpoint.terminal_at_ms` * (put 时写死的绝对上限)由另一条 reap 腿执行,不经本守卫 ⇒ 悬挂不可能无限。 * * 后拨(墙钟相对单调钟**倒退**)不触发宽限:它只会让 deadline 显得更远 ⇒ 判定被推迟,已经是安全方向。 * 仍然 warn —— 一次没人知道的钟倒退是运维事实,不是可以吞掉的噪声。 */ /** 判定为「阶跃」的漂移阈值。低于它的漂移是 tick 抖动 / 事件循环延迟 / NTP 缓变(≤500ppm,60s 拍上仅 30ms), * 不是拨表。误判的代价只是**推迟**一次 deny ≤ 宽限量,所以宁可取得低一些。 */ export declare const DEFAULT_CLOCK_JUMP_THRESHOLD_MS = 2000; /** `slaMs` 缺省(部署没配 `APPROVAL_TIMEOUT_SEC` 时)—— 与 `tool-approval.ts` 的 * `DEFAULT_APPROVAL_TTL_MS` 同值同义:一次审批交互的量级。 */ export declare const DEFAULT_CLOCK_JUMP_GRACE_MS: number; export interface WallClockJumpGuard { /** * 每次清算前调用一次,传入本次清算**要用的那个墙钟读数**(判定基准本身,不是另取一次 `Date.now()` * ——否则守卫看的钟和判定用的钟不是同一个)。 * * @returns `true` = 本拍可按墙钟判定;`false` = 刚检出前跳,单调宽限未走完,本拍**整拍让开**。 */ admit(wallNowMs: number): boolean; } export interface WallClockJumpGuardOptions { /** 宽限上界 = 被保护窗口的 SLA(审批腿=`approvalTimeoutSec * 1000`);缺省 {@link DEFAULT_CLOCK_JUMP_GRACE_MS}。 */ slaMs?: number; /** 单调钟 seam。缺省 `performance.now()` —— 本仓既定单调域(server.ts/shutdown.ts/sighup-idle.ts 同源)。 */ monotonic?: () => number; /** 墙钟 seam,**只用于构造当拍取首个锚点**(判定仍用 `admit` 的入参)。缺省 `Date.now`。 * codex R2 [high] 五:没有它,`lastWall` 要等第一次 `admit` 才有值 ⇒ 构造之后、首拍之前的那次前跳 * **测不出来**,而那正是「进程刚起来、pending gate 已在库里」的真实窗口。 */ now?: () => number; /** 阈值 seam;缺省 {@link DEFAULT_CLOCK_JUMP_THRESHOLD_MS}。 */ jumpThresholdMs?: number; /** 一串连续跳变累计宽限的**总上限**,自本串第一次跳变起算;缺省 `2 × slaMs`(理由见 `admit` 内注)。 */ maxTotalGraceMs?: number; /** 检出阶跃:`jumpMs > 0` = 前拨(触发宽限),`< 0` = 后拨(只留痕,不宽限)。 */ onJump?: (info: { jumpMs: number; graceMs: number; }) => void; /** 宽限期内每拍一次。 */ onSuppressed?: (info: { remainingMs: number; }) => void; /** 累计宽限撞上总上限、守卫**强制恢复判定**时一次(响亮放行,不是悄悄放行)。 */ onGraceCapped?: (info: { totalGraceMs: number; }) => void; } export declare function createWallClockJumpGuard(opts?: WallClockJumpGuardOptions): WallClockJumpGuard; //# sourceMappingURL=wall-clock-jump-guard.d.ts.map