/** * [ref] 车2 —— 托管留存的 **sweep lane**(设计稿 `docs/DESIGN-270-retention-lane.md` v1.3 §3/§4/§7)。 * * ── 为什么是独立文件、独立定时器,而不是折进 `startReapers()`(设计稿 §3 首条)─────────────────── * `boot/reapers.ts` 里那二十条腿全都是**容忍多副本**的幂等清理:每台副本各扫各的,重复扫一遍只是浪费。 * 本 lane 相反 —— 它按「三方法依赖序」跑一段**有状态**的序列(expire → deleteSessions → deleteOrphans), * 并给每一轮打一个 fencing token 写进不可变审计。这条约束(单执行者)混进那个 tick 会被稀释成 * 「反正大家都幂等」,而幂等救不了「两个副本各写一半审计、token 交错」。所以:自己的定时器、自己的 * durable 租约、自己的失败隔离。 * * ── 互斥 = durable lease,**不是** `LEADER_ENABLED`(设计稿 §3,codex F1 亲验坐实)─────────────── * 仓内 `leaderEnabled` 是纯布尔配置门(gate 端点与腿注册),零选举/零租约/零 fencing —— 每台配 true 的 * 副本都会「自认 leader」。lane 与它**解耦**:租约自持互斥,operator 不必先开 leader 面。 * * ── 时序契约(逐条都是判据,别重排)──────────────────────────────────────────────────────────── * ① 每 tick 开头抢/续租(`lease.acquire`)。抢不到 ⇒ **本 tick 零写、零调用、静默返回**(不是错误: * "别人在跑"是正常的多副本稳态,每 tick 打一条 warn 只会把日志变成噪音)。 * ② 抢到 ⇒ 枚举域;**对每个 domain,在动它之前**复核**并续**租约(`lease.renew`,一条 CAS)。复核点在**每 domain 前** * 而不是每 tick 前,这一格是承重的:一轮 sweep 的时长随域数增长,而租约 TTL 是固定的 2×interval —— * 只在轮首复核等于「开轮时还在,那这一轮剩下的几百个域都算数」,那正是丢租之后继续删数据的形。 * ③ 模式分家(§4):`audit-only` 只调 `previewRetention` 记 `audit_only` 行,**一条破坏性方法都不调**; * `enforce` 才调三方法。两支在代码里**物理分开**(不是同一段带 `if` 的调用序)—— 自审判据「grep 三个 * 方法名只出现在 enforce 臂」靠的就是这个形。 * ④ 单 domain 失败 ⇒ 记 `failed` 行 + **继续下一 domain**(一个坏租户不卡住整轮);同 domain 连败 * ≥ {@link RETENTION_SWEEP_STUCK_THRESHOLD} ⇒ `retention_sweep_stuck` 指标 + **一次性** warn。 * * ── 破坏性审计行的属主(§6 F5)──────────────────────────────────────────────────────────────── * `expired_checkpoints`/`deleted_sessions`/`deleted_tool_results` 三行由**店**在删除的同一个 DB 事务里写 * (车1 已实现)。lane **不补记**它们 —— 补记 = 「删了但审计没落地」的窗口重新打开。receipt 在本文件里 * 只做两件事:二次确认(日志/指标)与「本轮到底动了多少」的读数。 */ import type { RetentionPolicy, RetentionReceipt } from "@sema-agent/core"; import type { RetentionMode } from "../config-types.js"; /** 同 domain 连续失败多少次算「卡住了」(fail-loud 的阈值)。`5` 与 reaper 家族的 * `REAPER_FAILURE_WARN_THRESHOLD` 同值同理由:一次抖动(一次重连、一次超时)正是容错要吸收的, * 只有**连败**才越过「可诊断」这条线。 */ export declare const RETENTION_SWEEP_STUCK_THRESHOLD = 5; /** 租约 TTL = 本倍数 × sweep 间隔(设计稿 §3)。`2` = 允许一轮跑满一个完整间隔还有余量,再多就该让位。 */ export declare const RETENTION_LEASE_TTL_FACTOR = 2; /** * lane 消费的**执行器**面 —— core `ManagedRetentionCapability` 的三方法 + 车1 的非契约窄读口 * `previewRetention`(audit-only 档的读数源;core 契约无 count 面)+ 声明位。 * * 🔴 三方法的入参是 core 契约形的**超集**(多一个 `audit`):车1 的店在运行期对缺席的审计上下文 * fail-closed 抛错,所以这里把它写进类型 —— 让「忘了传」变成编译错,而不是一次运行期的破坏性调用被拒。 */ export interface RetentionExecutor { readonly retention?: "managed" | "none"; listRetentionDomains(): Promise; previewRetention(input: { domain: string; cutoffMs: number; }): Promise<{ checkpoints: number; sessions: number; toolResults: number; }>; expireCheckpoints(input: RetentionDestructiveInput): Promise; deleteExpiredSessions(input: RetentionDestructiveInput): Promise; deleteOrphanToolResults(input: RetentionDestructiveInput): Promise; } /** 破坏性调用的入参(判定时点的三格随行 —— 理由逐字见车1 `RetentionAuditContext` 的头注)。 */ export interface RetentionDestructiveInput { domain: string; cutoffMs: number; audit: { mode: RetentionMode; policyDays: number; fencingToken: number | null; }; } /** 非破坏性审计行的写口(lane 侧属主的那五个词)。 */ export interface RetentionAuditSink { append(row: { domain: string; action: "audit_only" | "skipped_legal_hold" | "failed" | "hold_placed" | "hold_released"; mode: RetentionMode; policyDays: number; fencingToken: number | null; deleted: number; skipped: number; tombstones: number; error?: string; }): Promise; } /** sweep 租约的窄口(真身 = `plugins/retention-lane-store-sql.ts` 的 `SqlRetentionLaneStore`)。 */ export interface RetentionLeaseHandle { acquire(): Promise<{ held: true; fencingToken: number; } | { held: false; }>; /** * 「本轮的执行权还在我手上吗」**并同时续租** —— 每 domain 前调一次(见文件头时序契约②)。 * `false` = 不再属于我 ⇒ 当场中止本轮。 * * 🔴 名字是 `renew` 而不是 `stillMine`(codex R1-[high] 之后改的):它**有写副作用**,叫一个纯读的 * 名字会让下一个读者以为可以随便多调几次。合成一条 CAS 的两个理由(原子性 + 长轮次的续租机会) * 逐字见 `plugins/retention-lane-store-sql.ts` 的同名方法。 */ renew(fencingToken: number): Promise; release(): Promise; } /** 日志/指标座(窄到真实消费面 —— 测试用两行字面量驱动同一段生产代码)。 */ export interface RetentionLaneLogger { info(msg: string, meta?: unknown): void; warn(msg: string, meta?: unknown): void; } export interface RetentionLaneMetrics { inc(name: string, labels?: Record, by?: number): void; } /** 一轮 sweep 的全部依赖(纯口,零 `ServiceConfig` —— 一轮的行为不该随一个 120 键的对象漂)。 */ export interface RetentionSweepCtx { policy: RetentionPolicy; mode: RetentionMode; executor: RetentionExecutor; audit: RetentionAuditSink; lease: RetentionLeaseHandle; logger: RetentionLaneLogger; metrics: RetentionLaneMetrics; now(): number; /** 可选的 hold 预检(§5 v1.3:**省调优化,不承重** —— 承重判在店事务内)。缺席 ⇒ 不预检。 */ holdInForce?(domain: string): Promise; /** * 同 domain 的连败计数(**跨轮存活**:调用方持有它)。一轮内新建等于永远数不过 1 —— 与 * `createThrottledReaperCatch` 必须建在 `setInterval` 之外是同一条理由。 */ streaks?: Map; } /** 一轮的读数(给日志与判据用;lane 自己不做任何决策依赖它)。 */ export interface RetentionSweepOutcome { /** 本轮真正处理完的 domain 数(丢租中止时 = 中止之前那些)。 */ ranDomains: number; /** 枚举出来的 domain 总数(抢不到租时为 0 —— 那时连枚举都不做)。 */ totalDomains: number; /** 本轮是否因为**丢租**中止。 */ lostLease: boolean; /** 本轮是否拿到了执行权(false = 别人在跑,零写)。 */ held: boolean; /** 本轮的 fencing token(未持租时 undefined)。 */ fencingToken?: number; } /** * lane **自持的** boot 不变式(设计稿 §7,codex F3)—— 与 core 的 `assertRetentionCapability` 是两道门, * 各答各的问题: * · core 那道问的是「**锁着**的策略会不会盖在一只删不了的店上」——只在 locked 时说话; * · **本道**问的是「lane 开着的时候,它真的能删吗」——**不看 locked 状态**。 * * 为什么后者必须存在:core 的门在 `locked=false` 时整条放行,于是 * ① `RETENTION_SWEEP_INTERVAL_SEC>0` 而 policy 缺席 = 没有有效 cutoff 的 sweep(每 tick 什么都算不出来, * 读数一路 0,看起来像"没有候选"); * ② unlocked policy × 一只 `"none"` 的店照样过检 —— 部署于是**广告了一个不执行的留存面**:能力位说 * `{mode,maxAgeDays}`、审计表也在长,而那只店的行谁都没在删。 * 两者都在**启动期**拒(安全控件不得半开),不是让运维从一台"启动成功、什么也没删"的机器上猜。 * * @param locked 只进文案(让拒启信息能说清"你锁没锁都一样拒");**判据本身不读它** —— 这一格的存在 * 就是为了让"不看 locked"这句话在签名上可见,而不是靠注释声明。 */ export declare function assertRetentionLaneWirable(input: { intervalSec: number; policy: RetentionPolicy | undefined; executor: RetentionExecutor | undefined; stores: ReadonlyArray<{ name: string; store: { retention?: "managed" | "none"; } | object | undefined; }>; locked?: boolean; }): void; /** * 跑**一轮** sweep(纯函数式的一拍:自己不排期、不建定时器)。导出是判据面的需要 —— * 一个只能靠真定时器推进的 lane 要么让判据睡真时间,要么让判据去证一个假时钟。 */ export declare function runRetentionSweepOnce(ctx: RetentionSweepCtx): Promise; /** `startRetentionLane` 的装配面 = 一轮的依赖 + 节律。 */ export interface RetentionLaneCtx extends RetentionSweepCtx { intervalSec: number; } /** * 起飞后的 lane 控制器(`create*` 形:有行为有状态)。 * * 🔴 `stop()` 为什么必须**可等**(codex 交叉复审 R1-[high],验真后加):`clearInterval` 只挡住**新** tick, * 它不等当前这一轮。旧形的收尾链停表之后紧跟着就让租 ⇒ 新副本立刻接租,而旧副本还在删同一个域的 * 数据 —— 一个由**正常** SIGTERM/SIGINT 稳定触发的双执行者窗口(比任何竞态都好复现)。 * 正确的次序是:停表 → **等在飞那一轮落地** → 让租 → 关连接池。 */ export interface RetentionLaneController { /** 停表并**等**在飞那一轮落地(幂等;从不抛 —— 收尾链不该被一条清理腿打断)。 */ stop(): Promise; /** 立刻起一轮(**判据面**:让 stop 的语义可以在不睡真时间、也不换假时钟的前提下被证)。 */ runNow(): void; } /** * 起 lane,返回控制器(`undefined` = lane 关着,**根本没建**定时器)。 * * 三条与 reaper 家族同款的纪律: * · `unref()` 紧跟 `setInterval` —— 定时器绝不可持住进程; * · **重入守卫**:一轮的时长随域数与库延迟增长,越过 interval 就会叠罗汉压同一批行(邻居五腿同形)。 * ⚠️ 守卫的代价是「一轮里只有轮首那一次续租机会」——所以每 domain 前那条 `renew` 是承重的,不是装饰; * · 一轮内部的任何异常都在轮内被吞掉并记账(`runRetentionSweepOnce` 的 per-domain catch),这里再兜一层 * 是为了「租约抢占本身失败」这种**轮外**故障 —— 它不该变成一条 unhandled rejection 把进程带走。 */ export declare function startRetentionLane(ctx: RetentionLaneCtx): RetentionLaneController | undefined; //# sourceMappingURL=retention-lane.d.ts.map