@sema-agent/server 7.29.0 → 7.30.0-rc.1

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.
Files changed (52) hide show
  1. package/USAGE.md +20 -5
  2. package/dist/boot/governance-seams.d.ts +13 -0
  3. package/dist/boot/governance-seams.js +21 -1
  4. package/dist/boot/retention-lane.d.ts +206 -0
  5. package/dist/boot/retention-lane.js +279 -0
  6. package/dist/boot/shutdown.d.ts +8 -0
  7. package/dist/boot/shutdown.js +21 -2
  8. package/dist/config-types.d.ts +22 -1
  9. package/dist/config-types.js +6 -1
  10. package/dist/config.d.ts +15 -1
  11. package/dist/config.js +43 -2
  12. package/dist/http/routes/capabilities.js +30 -0
  13. package/dist/http/routes/memory-policy.d.ts +13 -0
  14. package/dist/http/routes/memory-policy.js +79 -2
  15. package/dist/http/routes/retention-ops.d.ts +36 -0
  16. package/dist/http/routes/retention-ops.js +190 -0
  17. package/dist/http/server.js +14 -0
  18. package/dist/main.js +74 -0
  19. package/dist/memory-scope.d.ts +5 -3
  20. package/dist/memory-scope.js +6 -8
  21. package/dist/observability/fail-open.d.ts +4 -0
  22. package/dist/observability/fail-open.js +4 -0
  23. package/dist/plugins/caching-session-store.d.ts +11 -1
  24. package/dist/plugins/caching-session-store.js +13 -0
  25. package/dist/plugins/checkpoint-store-sql.d.ts +3 -0
  26. package/dist/plugins/checkpoint-store-sql.js +4 -0
  27. package/dist/plugins/local-checkpoint-store.d.ts +3 -0
  28. package/dist/plugins/local-checkpoint-store.js +4 -0
  29. package/dist/plugins/local-session-store.d.ts +5 -0
  30. package/dist/plugins/local-session-store.js +6 -0
  31. package/dist/plugins/memory-engine-pg.js +22 -10
  32. package/dist/plugins/memory-engine-tidb.js +22 -11
  33. package/dist/plugins/memory-key-guards.d.ts +27 -4
  34. package/dist/plugins/memory-key-guards.js +57 -6
  35. package/dist/plugins/memory-sync-store-pg.js +5 -0
  36. package/dist/plugins/memory-sync-store-tidb.js +5 -0
  37. package/dist/plugins/pg-pool.js +4 -0
  38. package/dist/plugins/pg-session-storage.d.ts +2 -0
  39. package/dist/plugins/pg-session-storage.js +3 -0
  40. package/dist/plugins/retention-lane-store-sql.d.ts +254 -0
  41. package/dist/plugins/retention-lane-store-sql.js +236 -0
  42. package/dist/plugins/retention-store-sql.d.ts +463 -0
  43. package/dist/plugins/retention-store-sql.js +788 -0
  44. package/dist/plugins/store-backend.d.ts +20 -0
  45. package/dist/plugins/store-backend.js +7 -0
  46. package/dist/plugins/tidb-pool.js +5 -0
  47. package/dist/plugins/tidb-session-store.d.ts +3 -0
  48. package/dist/plugins/tidb-session-store.js +4 -0
  49. package/dist/plugins/tool-result-store-sql.d.ts +3 -0
  50. package/dist/plugins/tool-result-store-sql.js +4 -0
  51. package/dist/run-local.js +16 -0
  52. package/package.json +1 -1
package/USAGE.md CHANGED
@@ -667,11 +667,26 @@ curl -N http://<host>:8090/v1/tasks/stream -H 'content-type: application/json' \
667
667
  > 治理拍),锁上会让**每一条**任务在引擎门口被拒,而那把锁本就无物可守(策略已是部署所有、请求放松不了);
668
668
  > ②`mcp` + 部署自己配了 MCP 服务器——部署的服务器走同一个字段,锁上同样每条腿必拒。两者都在**启动期**点名拒,
669
669
  > 不留给运维在每条任务上猜。
670
- > - `RETENTION_MAX_AGE_DAYS=<非负数>`:托管留存期**声明**。⚠️ **不要读成「数据会按期删」**:本版没有任何 store
671
- > 声明托管留存能力、也没有留存调度面,所以这根旋钮目前只做两件事——喂引擎的启动期能力校验(把
672
- > `retentionPolicy` 一并写进 `LOCKED_CONFIG_KEYS` store 不能删 ⇒ **拒启**,杜绝「策略锁着、数据永存」),
673
- > 以及在配了策略却无执行面时打一条启动 warn(`retention_policy_not_executed`)。真执行(调度/重试/副本协调/
674
- > 逐行审计)是后续批。
670
+ > - `RETENTION_MAX_AGE_DAYS=<非负数>`:托管留存**视界**(天;`0` 合法=立即可删)。坏值(非数/负数/设了但为空)
671
+ > 一律拒启——删数据的旋钮不许 NaN 流通。**它只是"保留多久"这句声明**;真正按它删数据的是下面两根。
672
+ > - `RETENTION_SWEEP_INTERVAL_SEC=<非负整数>`(默认 `0` = **关**;上限 `2147483` 24.8 ):
673
+ > 托管留存 **sweep lane** 的节律。`>0` 才起这条腿。越上限拒启(`setInterval` 的 32 位 delay 帽:溢出会被
674
+ > Node 静默重置成 1ms,把一次"偶尔扫一遍"变成近乎连续的破坏性事务流)
675
+ > 🔴 **半配置一律拒启**(不看有没有上锁):`>0` 而 ①没配 `RETENTION_MAX_AGE_DAYS`(没有视界就算不出 cutoff)、
676
+ > 或 ②后端不是 SQL(`DB_BACKEND=mysql|pg`;local/文件后端诚实声明 `retention:"none"`,没有任何东西会删行)
677
+ > ⇒ 启动期点名拒。**多副本安全**:互斥靠库内单行 **sweep 租约**(fencing token 随每一条破坏性审计行走),
678
+ > 与 `LEADER_ENABLED` **无关**——不必先开 leader 面。
679
+ > - `RETENTION_MODE=audit-only|enforce`(默认 `audit-only`):灰度档。`audit-only` = lane 照跑照判、每个域记一条
680
+ > `audit_only` 审计行(三个候选计数),**一条破坏性方法都不调**;`enforce` = 真删。闭集,拼错的词拒启
681
+ > (静默落回默认档 = 运维以为在删而其实没删)。**回滚边界 = 翻回 `audit-only`**(已删不可逆),所以先在
682
+ > `audit-only` 上读几天审计行确认命中集无误再翻。
683
+ >
684
+ > **配套的 operator 面**(全部 `operator-only`,见 `OPERATOR_PRINCIPALS`):
685
+ > - `PUT /v1/ops/retention/holds/:domain` / `DELETE …` —— 按域下/解 **legal hold**(冻结期该域整体跳过;
686
+ > 空路径段 `…/holds/` 寻址**无主桶**,即单用户部署里那唯一的域)。放置与解除各写一条审计行,
687
+ > 且**状态变更与审计行同一个事务**(不存在"解冻了但账上没有")。
688
+ > - `GET /v1/ops/retention/audit?domain=&limit=&before=` —— 审计读面(keyset 分页,游标形同名册面)。
689
+ > - 能力位 `capabilities.retention` = `{mode, maxAgeDays}`(lane 开着)或 `null`(关着)。
675
690
 
676
691
  > **⚠️ hook-wired 部署里,parked 后台子代可能赎回不了(常态,不是升级窗口)。**
677
692
  > 引擎 5.19.0 起,一个任务的 **PreToolUse screening 面下延管辖它委派出去的子代**,于是 hook-wired 父
@@ -71,6 +71,19 @@ export interface GovernanceSeamsCtx {
71
71
  name: string;
72
72
  store: object | undefined;
73
73
  }>;
74
+ /**
75
+ * #270 车1 —— **本 build 是否真的接了留存执行器**(sweep lane;车2 的 `startRetentionLane()`)。
76
+ * 缺席 = `false`。
77
+ *
78
+ * 🔴 为什么这一位必须存在(codex 对抗复审 R2-[critical],验真后修):core 的
79
+ * `assertRetentionCapability` 只读**店的声明位**。#270 车1 让三只 SQL 店如实声明了 `"managed"`
80
+ * (它们的行确实由托管留存删),于是那道门从此**放行** locked policy —— 可执行器还在车2 手里。
81
+ * 两者之间的窗口如果什么都不做,后果正是本仓最忌的那种:部署自述「策略已受管」、审计与能力面
82
+ * 也这么说,而**没有任何东西在删数据**;车1 之前那条 `retention_policy_not_executed` 的响亮 warn
83
+ * 还会因为"有 managed 店在场"而**静音**。
84
+ * ⇒ 判据改成问「有没有东西真会删」,而不是「有没有店说自己被管」。车2 接上 lane 时把这一位传 true。
85
+ */
86
+ retentionExecutorWired?: boolean;
74
87
  }
75
88
  /**
76
89
  * 件C 的装配相容性判据(纯,可单测)——见文件头「两条判据」。
@@ -132,11 +132,13 @@ export function createGovernanceSeams(ctx) {
132
132
  }
133
133
  // 件D:留存期。先跑 core 的启动期校验(策略值本身 + 锁着时的店能力),再补一条本仓特有的诚实 warn。
134
134
  const retentionPolicy = config.retentionPolicy;
135
- assertRetentionCapability({ policy: retentionPolicy, locked: lockedKeys.has("retentionPolicy"), stores: retentionStores });
135
+ const locked = lockedKeys.has("retentionPolicy");
136
+ assertRetentionCapability({ policy: retentionPolicy, locked, stores: retentionStores });
136
137
  if (retentionPolicy !== undefined) {
137
138
  // 声明位的读法与 core 的校验口**同一个结构型**(`RetentionDeclaring`)——不自造一个平行的形状,
138
139
  // 否则「core 认为这店 managed / 我方认为不是」这种分歧会静默存在。
139
140
  const managed = retentionStores.filter((s) => s.store?.retention === "managed");
141
+ const executorWired = ctx.retentionExecutorWired === true;
140
142
  if (managed.length === 0) {
141
143
  logger.warn("retention_policy_not_executed", {
142
144
  maxAgeDays: retentionPolicy.maxAgeDays,
@@ -145,6 +147,24 @@ export function createGovernanceSeams(ctx) {
145
147
  "engine for the startup capability check and will become effective when a managed-retention store + scheduler land.",
146
148
  });
147
149
  }
150
+ else if (!executorWired) {
151
+ // 🔴 #270 车1(codex R2-[critical] 验真后修):**店说 managed ≠ 有人在删**。
152
+ // · locked ⇒ **拒启**:core 的门因为声明位而放行了,可这台机上没有执行器 —— 那正是它文案里
153
+ // 「policy locked, data immortal」的实况,只是伪装成了合规。拒启保住的是车1 之前的**同一个**
154
+ // 行为(那时店声明 none ⇒ core 直接拒),不是新增的严格。
155
+ // · 未锁 ⇒ **响亮 warn**(不拒启):未锁的策略本就是一句声明,但它此前那条 warn 会被「有 managed
156
+ // 店在场」静音,于是最需要说话的那一刻反而没声音。
157
+ const detail = `retention policy is configured (maxAgeDays=${retentionPolicy.maxAgeDays}) and ${managed.length} wired store(s) declare ` +
158
+ `retention:"managed", but THIS BUILD WIRES NO RETENTION EXECUTOR (the sweep lane) — nothing enumerates domains or calls ` +
159
+ `the destructive store methods, so no data is deleted on the policy's account. A "managed" declaration is a statement ` +
160
+ `about who OWNS deleting those rows, not proof that a scheduler is running.`;
161
+ if (locked) {
162
+ throw new Error(`${detail} Refusing to start with the retentionPolicy key LOCKED: a locked policy over a deployment that does not ` +
163
+ `actually delete is exactly the "policy locked, data immortal" posture the engine's capability check exists to ` +
164
+ `prevent. Unlock the key, remove the policy, or run a build whose retention sweep lane is wired.`);
165
+ }
166
+ logger.warn("retention_policy_not_executed", { maxAgeDays: retentionPolicy.maxAgeDays, note: detail });
167
+ }
148
168
  }
149
169
  return { compliancePostureResolver, lockedConfig, retentionPolicy, lockedKeys };
150
170
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * #270 车2 —— 托管留存的 **sweep lane**(设计稿 `docs/DESIGN-270-retention-lane.md` v1.3 §3/§4/§7)。
3
+ *
4
+ * ── 为什么是独立文件、独立定时器,而不是折进 `startReapers()`(设计稿 §3 首条)───────────────────
5
+ * `boot/reapers.ts` 里那二十条腿全都是**容忍多副本**的幂等清理:每台副本各扫各的,重复扫一遍只是浪费。
6
+ * 本 lane 相反 —— 它按「三方法依赖序」跑一段**有状态**的序列(expire → deleteSessions → deleteOrphans),
7
+ * 并给每一轮打一个 fencing token 写进不可变审计。这条约束(单执行者)混进那个 tick 会被稀释成
8
+ * 「反正大家都幂等」,而幂等救不了「两个副本各写一半审计、token 交错」。所以:自己的定时器、自己的
9
+ * durable 租约、自己的失败隔离。
10
+ *
11
+ * ── 互斥 = durable lease,**不是** `LEADER_ENABLED`(设计稿 §3,codex F1 亲验坐实)───────────────
12
+ * 仓内 `leaderEnabled` 是纯布尔配置门(gate 端点与腿注册),零选举/零租约/零 fencing —— 每台配 true 的
13
+ * 副本都会「自认 leader」。lane 与它**解耦**:租约自持互斥,operator 不必先开 leader 面。
14
+ *
15
+ * ── 时序契约(逐条都是判据,别重排)────────────────────────────────────────────────────────────
16
+ * ① 每 tick 开头抢/续租(`lease.acquire`)。抢不到 ⇒ **本 tick 零写、零调用、静默返回**(不是错误:
17
+ * "别人在跑"是正常的多副本稳态,每 tick 打一条 warn 只会把日志变成噪音)。
18
+ * ② 抢到 ⇒ 枚举域;**对每个 domain,在动它之前**复核**并续**租约(`lease.renew`,一条 CAS)。复核点在**每 domain 前**
19
+ * 而不是每 tick 前,这一格是承重的:一轮 sweep 的时长随域数增长,而租约 TTL 是固定的 2×interval ——
20
+ * 只在轮首复核等于「开轮时还在,那这一轮剩下的几百个域都算数」,那正是丢租之后继续删数据的形。
21
+ * ③ 模式分家(§4):`audit-only` 只调 `previewRetention` 记 `audit_only` 行,**一条破坏性方法都不调**;
22
+ * `enforce` 才调三方法。两支在代码里**物理分开**(不是同一段带 `if` 的调用序)—— 自审判据「grep 三个
23
+ * 方法名只出现在 enforce 臂」靠的就是这个形。
24
+ * ④ 单 domain 失败 ⇒ 记 `failed` 行 + **继续下一 domain**(一个坏租户不钉死整轮);同 domain 连败
25
+ * ≥ {@link RETENTION_SWEEP_STUCK_THRESHOLD} ⇒ `retention_sweep_stuck` 指标 + **一次性** warn。
26
+ *
27
+ * ── 破坏性审计行的属主(§6 F5)────────────────────────────────────────────────────────────────
28
+ * `expired_checkpoints`/`deleted_sessions`/`deleted_tool_results` 三行由**店**在删除的同一个 DB 事务里写
29
+ * (车1 已实现)。lane **不补记**它们 —— 补记 = 「删了但审计没落地」的窗口重新打开。receipt 在本文件里
30
+ * 只做两件事:二次确认(日志/指标)与「本轮到底动了多少」的读数。
31
+ */
32
+ import type { RetentionPolicy, RetentionReceipt } from "@sema-agent/core";
33
+ import type { RetentionMode } from "../config-types.js";
34
+ /** 同 domain 连续失败多少次算「卡住了」(fail-loud 的阈值)。`5` 与 reaper 家族的
35
+ * `REAPER_FAILURE_WARN_THRESHOLD` 同值同理由:一次抖动(一次重连、一次超时)正是容错要吸收的,
36
+ * 只有**连败**才越过「可诊断」这条线。 */
37
+ export declare const RETENTION_SWEEP_STUCK_THRESHOLD = 5;
38
+ /** 租约 TTL = 本倍数 × sweep 间隔(设计稿 §3)。`2` = 允许一轮跑满一个完整间隔还有余量,再多就该让位。 */
39
+ export declare const RETENTION_LEASE_TTL_FACTOR = 2;
40
+ /**
41
+ * lane 消费的**执行器**面 —— core `ManagedRetentionCapability` 的三方法 + 车1 的非契约窄读口
42
+ * `previewRetention`(audit-only 档的读数源;core 契约无 count 面)+ 声明位。
43
+ *
44
+ * 🔴 三方法的入参是 core 契约形的**超集**(多一个 `audit`):车1 的店在运行期对缺席的审计上下文
45
+ * fail-closed 抛错,所以这里把它写进类型 —— 让「忘了传」变成编译错,而不是一次运行期的破坏性调用被拒。
46
+ */
47
+ export interface RetentionExecutor {
48
+ readonly retention?: "managed" | "none";
49
+ listRetentionDomains(): Promise<readonly string[]>;
50
+ previewRetention(input: {
51
+ domain: string;
52
+ cutoffMs: number;
53
+ }): Promise<{
54
+ checkpoints: number;
55
+ sessions: number;
56
+ toolResults: number;
57
+ }>;
58
+ expireCheckpoints(input: RetentionDestructiveInput): Promise<RetentionReceipt>;
59
+ deleteExpiredSessions(input: RetentionDestructiveInput): Promise<RetentionReceipt>;
60
+ deleteOrphanToolResults(input: RetentionDestructiveInput): Promise<RetentionReceipt>;
61
+ }
62
+ /** 破坏性调用的入参(判定时点的三格随行 —— 理由逐字见车1 `RetentionAuditContext` 的头注)。 */
63
+ export interface RetentionDestructiveInput {
64
+ domain: string;
65
+ cutoffMs: number;
66
+ audit: {
67
+ mode: RetentionMode;
68
+ policyDays: number;
69
+ fencingToken: number | null;
70
+ };
71
+ }
72
+ /** 非破坏性审计行的写口(lane 侧属主的那五个词)。 */
73
+ export interface RetentionAuditSink {
74
+ append(row: {
75
+ domain: string;
76
+ action: "audit_only" | "skipped_legal_hold" | "failed" | "hold_placed" | "hold_released";
77
+ mode: RetentionMode;
78
+ policyDays: number;
79
+ fencingToken: number | null;
80
+ deleted: number;
81
+ skipped: number;
82
+ tombstones: number;
83
+ error?: string;
84
+ }): Promise<void>;
85
+ }
86
+ /** sweep 租约的窄口(真身 = `plugins/retention-lane-store-sql.ts` 的 `SqlRetentionLaneStore`)。 */
87
+ export interface RetentionLeaseHandle {
88
+ acquire(): Promise<{
89
+ held: true;
90
+ fencingToken: number;
91
+ } | {
92
+ held: false;
93
+ }>;
94
+ /**
95
+ * 「本轮的执行权还在我手上吗」**并同时续租** —— 每 domain 前调一次(见文件头时序契约②)。
96
+ * `false` = 不再属于我 ⇒ 当场中止本轮。
97
+ *
98
+ * 🔴 名字是 `renew` 而不是 `stillMine`(codex R1-[high] 之后改的):它**有写副作用**,叫一个纯读的
99
+ * 名字会让下一个读者以为可以随便多调几次。合成一条 CAS 的两个理由(原子性 + 长轮次的续租机会)
100
+ * 逐字见 `plugins/retention-lane-store-sql.ts` 的同名方法。
101
+ */
102
+ renew(fencingToken: number): Promise<boolean>;
103
+ release(): Promise<void>;
104
+ }
105
+ /** 日志/指标座(窄到真实消费面 —— 测试用两行字面量驱动同一段生产代码)。 */
106
+ export interface RetentionLaneLogger {
107
+ info(msg: string, meta?: unknown): void;
108
+ warn(msg: string, meta?: unknown): void;
109
+ }
110
+ export interface RetentionLaneMetrics {
111
+ inc(name: string, labels?: Record<string, string>, by?: number): void;
112
+ }
113
+ /** 一轮 sweep 的全部依赖(纯口,零 `ServiceConfig` —— 一轮的行为不该随一个 120 键的对象漂)。 */
114
+ export interface RetentionSweepCtx {
115
+ policy: RetentionPolicy;
116
+ mode: RetentionMode;
117
+ executor: RetentionExecutor;
118
+ audit: RetentionAuditSink;
119
+ lease: RetentionLeaseHandle;
120
+ logger: RetentionLaneLogger;
121
+ metrics: RetentionLaneMetrics;
122
+ now(): number;
123
+ /** 可选的 hold 预检(§5 v1.3:**省调优化,不承重** —— 承重判在店事务内)。缺席 ⇒ 不预检。 */
124
+ holdInForce?(domain: string): Promise<boolean>;
125
+ /**
126
+ * 同 domain 的连败计数(**跨轮存活**:调用方持有它)。一轮内新建等于永远数不过 1 —— 与
127
+ * `createThrottledReaperCatch` 必须建在 `setInterval` 之外是同一条理由。
128
+ */
129
+ streaks?: Map<string, number>;
130
+ }
131
+ /** 一轮的读数(给日志与判据用;lane 自己不做任何决策依赖它)。 */
132
+ export interface RetentionSweepOutcome {
133
+ /** 本轮真正处理完的 domain 数(丢租中止时 = 中止之前那些)。 */
134
+ ranDomains: number;
135
+ /** 枚举出来的 domain 总数(抢不到租时为 0 —— 那时连枚举都不做)。 */
136
+ totalDomains: number;
137
+ /** 本轮是否因为**丢租**中止。 */
138
+ lostLease: boolean;
139
+ /** 本轮是否拿到了执行权(false = 别人在跑,零写)。 */
140
+ held: boolean;
141
+ /** 本轮的 fencing token(未持租时 undefined)。 */
142
+ fencingToken?: number;
143
+ }
144
+ /**
145
+ * lane **自持的** boot 不变式(设计稿 §7,codex F3)—— 与 core 的 `assertRetentionCapability` 是两道门,
146
+ * 各答各的问题:
147
+ * · core 那道问的是「**锁着**的策略会不会盖在一只删不了的店上」——只在 locked 时说话;
148
+ * · **本道**问的是「lane 开着的时候,它真的能删吗」——**不看 locked 状态**。
149
+ *
150
+ * 为什么后者必须存在:core 的门在 `locked=false` 时整条放行,于是
151
+ * ① `RETENTION_SWEEP_INTERVAL_SEC>0` 而 policy 缺席 = 没有有效 cutoff 的 sweep(每 tick 什么都算不出来,
152
+ * 读数一路 0,看起来像"没有候选");
153
+ * ② unlocked policy × 一只 `"none"` 的店照样过检 —— 部署于是**广告了一个不执行的留存面**:能力位说
154
+ * `{mode,maxAgeDays}`、审计表也在长,而那只店的行谁都没在删。
155
+ * 两者都在**启动期**拒(安全控件不得半开),不是让运维从一台"启动成功、什么也没删"的机器上猜。
156
+ *
157
+ * @param locked 只进文案(让拒启信息能说清"你锁没锁都一样拒");**判据本身不读它** —— 这一格的存在
158
+ * 就是为了让"不看 locked"这句话在签名上可见,而不是靠注释声明。
159
+ */
160
+ export declare function assertRetentionLaneWirable(input: {
161
+ intervalSec: number;
162
+ policy: RetentionPolicy | undefined;
163
+ executor: RetentionExecutor | undefined;
164
+ stores: ReadonlyArray<{
165
+ name: string;
166
+ store: {
167
+ retention?: "managed" | "none";
168
+ } | object | undefined;
169
+ }>;
170
+ locked?: boolean;
171
+ }): void;
172
+ /**
173
+ * 跑**一轮** sweep(纯函数式的一拍:自己不排期、不建定时器)。导出是判据面的需要 ——
174
+ * 一个只能靠真定时器推进的 lane 要么让判据睡真时间,要么让判据去证一个假时钟。
175
+ */
176
+ export declare function runRetentionSweepOnce(ctx: RetentionSweepCtx): Promise<RetentionSweepOutcome>;
177
+ /** `startRetentionLane` 的装配面 = 一轮的依赖 + 节律。 */
178
+ export interface RetentionLaneCtx extends RetentionSweepCtx {
179
+ intervalSec: number;
180
+ }
181
+ /**
182
+ * 起飞后的 lane 控制器(`create*` 形:有行为有状态)。
183
+ *
184
+ * 🔴 `stop()` 为什么必须**可等**(codex 对抗复审 R1-[high],验真后加):`clearInterval` 只挡住**新** tick,
185
+ * 它不等当前这一轮。旧形的收尾链停表之后紧跟着就让租 ⇒ 新副本立刻接租,而旧副本还在删同一个域的
186
+ * 数据 —— 一个由**正常** SIGTERM/SIGINT 稳定触发的双执行者窗口(比任何竞态都好复现)。
187
+ * 正确的次序是:停表 → **等在飞那一轮落地** → 让租 → 关连接池。
188
+ */
189
+ export interface RetentionLaneController {
190
+ /** 停表并**等**在飞那一轮落地(幂等;从不抛 —— 收尾链不该被一条清理腿打断)。 */
191
+ stop(): Promise<void>;
192
+ /** 立刻起一轮(**判据面**:让 stop 的语义可以在不睡真时间、也不换假时钟的前提下被证)。 */
193
+ runNow(): void;
194
+ }
195
+ /**
196
+ * 起 lane,返回控制器(`undefined` = lane 关着,**根本没建**定时器)。
197
+ *
198
+ * 三条与 reaper 家族同款的纪律:
199
+ * · `unref()` 紧跟 `setInterval` —— 定时器绝不可持住进程;
200
+ * · **重入守卫**:一轮的时长随域数与库延迟增长,越过 interval 就会叠罗汉压同一批行(邻居五腿同形)。
201
+ * ⚠️ 守卫的代价是「一轮里只有轮首那一次续租机会」——所以每 domain 前那条 `renew` 是承重的,不是装饰;
202
+ * · 一轮内部的任何异常都在轮内被吞掉并记账(`runRetentionSweepOnce` 的 per-domain catch),这里再兜一层
203
+ * 是为了「租约抢占本身失败」这种**轮外**故障 —— 它不该变成一条 unhandled rejection 把进程带走。
204
+ */
205
+ export declare function startRetentionLane(ctx: RetentionLaneCtx): RetentionLaneController | undefined;
206
+ //# sourceMappingURL=retention-lane.d.ts.map
@@ -0,0 +1,279 @@
1
+ import { recordFailOpen } from "../observability/fail-open.js"; // #157:上报臂的登记式兜底(F 类,census 第 28 行)
2
+ /** 同 domain 连续失败多少次算「卡住了」(fail-loud 的阈值)。`5` 与 reaper 家族的
3
+ * `REAPER_FAILURE_WARN_THRESHOLD` 同值同理由:一次抖动(一次重连、一次超时)正是容错要吸收的,
4
+ * 只有**连败**才越过「可诊断」这条线。 */
5
+ export const RETENTION_SWEEP_STUCK_THRESHOLD = 5;
6
+ /** 租约 TTL = 本倍数 × sweep 间隔(设计稿 §3)。`2` = 允许一轮跑满一个完整间隔还有余量,再多就该让位。 */
7
+ export const RETENTION_LEASE_TTL_FACTOR = 2;
8
+ /** 一天的毫秒数(cutoff 换算;本地常量,避免为一个数字引一整只时间工具)。 */
9
+ const DAY_MS = 86_400_000;
10
+ /**
11
+ * lane **自持的** boot 不变式(设计稿 §7,codex F3)—— 与 core 的 `assertRetentionCapability` 是两道门,
12
+ * 各答各的问题:
13
+ * · core 那道问的是「**锁着**的策略会不会盖在一只删不了的店上」——只在 locked 时说话;
14
+ * · **本道**问的是「lane 开着的时候,它真的能删吗」——**不看 locked 状态**。
15
+ *
16
+ * 为什么后者必须存在:core 的门在 `locked=false` 时整条放行,于是
17
+ * ① `RETENTION_SWEEP_INTERVAL_SEC>0` 而 policy 缺席 = 没有有效 cutoff 的 sweep(每 tick 什么都算不出来,
18
+ * 读数一路 0,看起来像"没有候选");
19
+ * ② unlocked policy × 一只 `"none"` 的店照样过检 —— 部署于是**广告了一个不执行的留存面**:能力位说
20
+ * `{mode,maxAgeDays}`、审计表也在长,而那只店的行谁都没在删。
21
+ * 两者都在**启动期**拒(安全控件不得半开),不是让运维从一台"启动成功、什么也没删"的机器上猜。
22
+ *
23
+ * @param locked 只进文案(让拒启信息能说清"你锁没锁都一样拒");**判据本身不读它** —— 这一格的存在
24
+ * 就是为了让"不看 locked"这句话在签名上可见,而不是靠注释声明。
25
+ */
26
+ export function assertRetentionLaneWirable(input) {
27
+ if (input.intervalSec <= 0)
28
+ return; // lane 关 ⇒ 本门整条不判(缺省姿态,#210「缺席不在律内」)
29
+ const lockNote = input.locked === true ? " (the retentionPolicy key is LOCKED, but this gate refuses either way)" : "";
30
+ if (input.policy === undefined) {
31
+ throw new Error(`RETENTION_SWEEP_INTERVAL_SEC=${input.intervalSec} arms the managed-retention sweep lane, but RETENTION_MAX_AGE_DAYS is not ` +
32
+ `configured — the lane would run with NO horizon to compute a cutoff from, enumerate domains every tick and delete nothing, ` +
33
+ `while the capability surface advertises a retention face that does not exist${lockNote}. Set RETENTION_MAX_AGE_DAYS, or set ` +
34
+ `RETENTION_SWEEP_INTERVAL_SEC=0 to leave the lane off.`);
35
+ }
36
+ if (input.executor === undefined) {
37
+ throw new Error(`RETENTION_SWEEP_INTERVAL_SEC=${input.intervalSec} arms the managed-retention sweep lane, but this deployment wires NO retention ` +
38
+ `executor — the destructive store methods live on the SQL backends only (DB_BACKEND=mysql|pg); the local/file backend honestly ` +
39
+ `declares retention:"none" and nothing would delete a row${lockNote}. Run a SQL backend, or set RETENTION_SWEEP_INTERVAL_SEC=0.`);
40
+ }
41
+ // 三方法俱全:声明位说 `managed` 不等于方法在场(一个第三方 store 可以只写声明)。逐个点名缺的那个,
42
+ // 因为运维要能从这句话直接查到是哪一面没接上。
43
+ // 逐个方法名走**闭集常量**而不是断言成 `Record<string, unknown>`:`executor[m]` 在 `m` 是键联合时
44
+ // 类型上就是「那几个方法的联合」,`typeof … !== "function"` 直接判得了 —— 一处宽松断言都不需要,
45
+ // 而且契约面加一个方法时这张表漏补是**编译红**(`satisfies` 那一行),不是运行期漏检。
46
+ const executor = input.executor;
47
+ const CONTRACT_METHODS = ["listRetentionDomains", "previewRetention", "expireCheckpoints", "deleteExpiredSessions", "deleteOrphanToolResults"];
48
+ for (const m of CONTRACT_METHODS) {
49
+ if (typeof executor[m] !== "function") {
50
+ throw new Error(`the wired retention executor is missing \`${m}\` — a store that declares retention:"managed" PROMISES the whole ` +
51
+ `ManagedRetentionCapability contract, and the sweep lane calls every method of it${lockNote}. Refusing to start with a ` +
52
+ `half-implemented retention face.`);
53
+ }
54
+ }
55
+ // 每只被 lane 消费的店都必须诚实声明 managed。`store: undefined` 的行跳过(那一面这台压根没装,
56
+ // 与 core 的同款判据逐字同姿势 —— 缺席的店不是"不删",它是"没有行")。
57
+ const notManaged = input.stores.filter((s) => s.store !== undefined && s.store.retention !== "managed").map((s) => s.name);
58
+ if (notManaged.length > 0) {
59
+ throw new Error(`RETENTION_SWEEP_INTERVAL_SEC=${input.intervalSec} arms the managed-retention sweep lane, but these wired store(s) do NOT declare ` +
60
+ `retention:"managed": ${notManaged.join(", ")}${lockNote}. Their rows would be advertised as governed by the retention policy ` +
61
+ `while nothing deletes them. Wire a backend whose stores are managed, or set RETENTION_SWEEP_INTERVAL_SEC=0.`);
62
+ }
63
+ }
64
+ /**
65
+ * 跑**一轮** sweep(纯函数式的一拍:自己不排期、不建定时器)。导出是判据面的需要 ——
66
+ * 一个只能靠真定时器推进的 lane 要么让判据睡真时间,要么让判据去证一个假时钟。
67
+ */
68
+ export async function runRetentionSweepOnce(ctx) {
69
+ const claim = await ctx.lease.acquire();
70
+ if (!claim.held)
71
+ return { ranDomains: 0, totalDomains: 0, lostLease: false, held: false };
72
+ const fencingToken = claim.fencingToken;
73
+ const policyDays = ctx.policy.maxAgeDays;
74
+ const cutoffMs = ctx.now() - policyDays * DAY_MS;
75
+ const streaks = ctx.streaks;
76
+ const domains = await ctx.executor.listRetentionDomains();
77
+ let ranDomains = 0;
78
+ let lostLease = false;
79
+ for (const domain of domains) {
80
+ // ── 丢租即停:复核在**每 domain 前**(文件头时序契约②)。中止后不写任何"我中止了"的审计行 ——
81
+ // 那一行会带着一个已经不属于我们的 fencing token 落进不可变账。
82
+ if (!(await ctx.lease.renew(fencingToken))) {
83
+ lostLease = true;
84
+ ctx.logger.warn("retention_sweep_lease_lost", {
85
+ fencingToken,
86
+ processedDomains: ranDomains,
87
+ totalDomains: domains.length,
88
+ note: "the sweep lease is no longer ours (another replica took it, or our own lease expired because this round outran 2x the sweep interval); aborting the round mid-way. Nothing further is touched; the next holder re-enumerates from scratch (every leg is idempotent).",
89
+ });
90
+ break;
91
+ }
92
+ try {
93
+ // hold 预检(§5 v1.3:**省调优化,不承重**)。它省掉的是「一个冻结的大租户每轮仍然跑三条跨表
94
+ // 事务」;承重的判据在店事务内的哨兵行锁上,所以这里读到 false 而事务内读到 true 是**合法**的
95
+ // (那时店自己零删全 skip)。
96
+ if (ctx.holdInForce !== undefined && (await ctx.holdInForce(domain))) {
97
+ await ctx.audit.append({ domain, action: "skipped_legal_hold", mode: ctx.mode, policyDays, fencingToken, deleted: 0, skipped: 0, tombstones: 0 });
98
+ ranDomains++;
99
+ streaks?.set(domain, 0);
100
+ continue;
101
+ }
102
+ // ── 模式分家(§4)。两支**物理分开**:破坏性方法名只出现在 enforce 那一支里。
103
+ if (ctx.mode === "audit-only") {
104
+ await sweepDomainAuditOnly(ctx, { domain, cutoffMs, policyDays, fencingToken });
105
+ }
106
+ else {
107
+ await sweepDomainEnforce(ctx, { domain, cutoffMs, policyDays, fencingToken });
108
+ }
109
+ ranDomains++;
110
+ streaks?.set(domain, 0);
111
+ }
112
+ catch (err) {
113
+ ranDomains++;
114
+ await recordDomainFailure(ctx, { domain, policyDays, fencingToken, err });
115
+ }
116
+ }
117
+ return { ranDomains, totalDomains: domains.length, lostLease, held: true, fencingToken };
118
+ }
119
+ /** audit-only 支(§4):**一条破坏性方法都不调**,只把候选计数记进一行 `audit_only`。 */
120
+ async function sweepDomainAuditOnly(ctx, d) {
121
+ const p = await ctx.executor.previewRetention({ domain: d.domain, cutoffMs: d.cutoffMs });
122
+ // 三候选数进 receipt 的三列(设计稿 §6 逐字:「audit_only 行 = preview 三数」)。列名沿用
123
+ // deleted/skipped/tombstones 而不是另开三列:同一张追加表两套列语义会让读它的 SQL 必须先分支 action。
124
+ await ctx.audit.append({
125
+ domain: d.domain,
126
+ action: "audit_only",
127
+ mode: "audit-only",
128
+ policyDays: d.policyDays,
129
+ fencingToken: d.fencingToken,
130
+ deleted: p.checkpoints,
131
+ skipped: p.sessions,
132
+ tombstones: p.toolResults,
133
+ });
134
+ }
135
+ /**
136
+ * enforce 支(§3):三方法按**依赖序** —— 先让过期的 checkpoint 过门(它是会话树的一条活引用腿),
137
+ * 再删会话树(它落的墓碑是孤儿结果的域归属证据),最后清孤儿结果。倒过来任何一步都会让下一步少删一批,
138
+ * 靠"下一拍自然收敛"补 —— 那是把一个可以现在做对的顺序换成一个延迟。
139
+ *
140
+ * 🔴 破坏性审计行**不在这里写**(§6 F5:店在删除同事务内已写)。receipt 只做二次确认与日志。
141
+ */
142
+ async function sweepDomainEnforce(ctx, d) {
143
+ const input = {
144
+ domain: d.domain,
145
+ cutoffMs: d.cutoffMs,
146
+ audit: { mode: "enforce", policyDays: d.policyDays, fencingToken: d.fencingToken },
147
+ };
148
+ const checkpoints = await ctx.executor.expireCheckpoints(input);
149
+ const sessions = await ctx.executor.deleteExpiredSessions(input);
150
+ const toolResults = await ctx.executor.deleteOrphanToolResults(input);
151
+ const touched = checkpoints.deleted + sessions.deleted + toolResults.deleted;
152
+ if (touched > 0) {
153
+ ctx.metrics.inc("retention_rows_swept_total", { domain: "" }, touched); // 域不进标签(高基数);域维在审计表上
154
+ ctx.logger.info("retention_swept", {
155
+ domain: d.domain,
156
+ fencingToken: d.fencingToken,
157
+ checkpoints: checkpoints.deleted,
158
+ sessions: sessions.deleted,
159
+ toolResults: toolResults.deleted,
160
+ skipped: checkpoints.skipped + sessions.skipped + toolResults.skipped,
161
+ tombstones: checkpoints.tombstones + sessions.tombstones + toolResults.tombstones,
162
+ });
163
+ }
164
+ }
165
+ /**
166
+ * 单 domain 失败的记账(§3:`failed` 行 + 继续下一 domain;连败 ≥ 阈值 ⇒ 指标 + 一次性 warn)。
167
+ *
168
+ * 🔴 整段**总不抛**:它是 catch 臂里的收尾,一个 poison error(`Symbol.toPrimitive` 抛)或一条抛异常的
169
+ * 日志/审计写口若从这里逃出去,就会打断 for 循环、饿死后面每一个 domain —— 那正是 per-domain 隔离
170
+ * 存在的理由的反面(reapers.ts 的 per-scope 循环踩过同一形,注释在彼)。
171
+ */
172
+ async function recordDomainFailure(ctx, d) {
173
+ // poison error(抛异常的 `Symbol.toPrimitive`/`toString`)⇒ 落一句**自陈**的占位文案而不是空转:
174
+ // 审计行里写着「原因取不出来」比写着 "unknown error" 诚实,读账的人据此知道该去看进程日志。
175
+ let message;
176
+ try {
177
+ message = d.err instanceof Error ? d.err.message : String(d.err);
178
+ }
179
+ catch {
180
+ message = "<error object whose toString() itself threw — see the process log for the raw rejection>";
181
+ }
182
+ /** 审计行落地了吗 —— 进下面那条 warn 的 meta。**不是装饰**:审计写不进去时,`retention_audit` 里
183
+ * 会缺这一条 `failed` 行,而运维只有从日志里读到这一位才知道"账本自己也断了"。 */
184
+ let auditRowWritten = true;
185
+ try {
186
+ await ctx.audit.append({
187
+ domain: d.domain,
188
+ action: "failed",
189
+ mode: ctx.mode,
190
+ policyDays: d.policyDays,
191
+ fencingToken: d.fencingToken,
192
+ deleted: 0,
193
+ skipped: 0,
194
+ tombstones: 0,
195
+ error: message.slice(0, 2000),
196
+ });
197
+ }
198
+ catch {
199
+ // 审计写口自己坏了(库不可达 —— 通常就是上一步失败的同一个原因)。下面那条 warn 仍要发出去,
200
+ // 并且**带上这一位**:否则「审计表里没有这条 failed 行」看起来会像"这一轮根本没跑到这个域"。
201
+ auditRowWritten = false;
202
+ }
203
+ const streaks = ctx.streaks;
204
+ const streak = (streaks?.get(d.domain) ?? 0) + 1;
205
+ streaks?.set(d.domain, streak);
206
+ try {
207
+ ctx.logger.warn("retention_sweep_domain_failed", { domain: d.domain, fencingToken: d.fencingToken, consecutiveFailures: streak, auditRowWritten, err: message });
208
+ if (streak >= RETENTION_SWEEP_STUCK_THRESHOLD) {
209
+ ctx.metrics.inc("retention_sweep_stuck", {}, 1);
210
+ // 一次性:阈值一到就打一条、并把计数归零,于是**仍然坏着**的域每再连败一个完整阈值才报一次
211
+ // (不是首次之后永远静音,也不是每轮刷屏)——与 `createThrottledReaperCatch` 逐字同形。
212
+ ctx.logger.warn("retention_sweep_stuck", {
213
+ domain: d.domain,
214
+ consecutiveFailures: streak,
215
+ lastError: message,
216
+ note: "this retention domain has failed its sweep for RETENTION_SWEEP_STUCK_THRESHOLD consecutive rounds — its data is NOT being retained per policy. Check retention_audit rows with action='failed' for this domain.",
217
+ });
218
+ streaks?.set(d.domain, 0);
219
+ }
220
+ }
221
+ catch {
222
+ // 上报**自己**抛了(poison error 的 toString / 一条抛异常的 logger transport)。这里绝不能让它逃出去:
223
+ // 逃出去会打断 per-domain 循环、饿死后面每一个域 —— 而 per-domain 隔离正是这条腿存在的理由
224
+ // (reapers.ts 的 per-scope 循环踩过同一形)。走登记过的 F 类兜底口,于是这条臂有计数、有探针行、
225
+ // 有一次性 warn,而不是一次无痕的沉默(#157 / FAIL-OPEN-CENSUS 第 28 行)。
226
+ recordFailOpen("server.retention.sweep-report-dropped", `domain=${d.domain}`);
227
+ }
228
+ }
229
+ /**
230
+ * 起 lane,返回控制器(`undefined` = lane 关着,**根本没建**定时器)。
231
+ *
232
+ * 三条与 reaper 家族同款的纪律:
233
+ * · `unref()` 紧跟 `setInterval` —— 定时器绝不可持住进程;
234
+ * · **重入守卫**:一轮的时长随域数与库延迟增长,越过 interval 就会叠罗汉压同一批行(邻居五腿同形)。
235
+ * ⚠️ 守卫的代价是「一轮里只有轮首那一次续租机会」——所以每 domain 前那条 `renew` 是承重的,不是装饰;
236
+ * · 一轮内部的任何异常都在轮内被吞掉并记账(`runRetentionSweepOnce` 的 per-domain catch),这里再兜一层
237
+ * 是为了「租约抢占本身失败」这种**轮外**故障 —— 它不该变成一条 unhandled rejection 把进程带走。
238
+ */
239
+ export function startRetentionLane(ctx) {
240
+ if (ctx.intervalSec <= 0)
241
+ return undefined;
242
+ const streaks = ctx.streaks ?? new Map(); // 跨轮存活:必须建在 setInterval **之外**
243
+ /** 在飞那一轮(`undefined` = 空闲)。它同时是重入守卫与 `stop()` 的等待对象 —— 一个变量两用刻意为之:
244
+ * 两个变量必然在某次改动后不同步,而不同步的方向是「stop 以为没人在跑」。 */
245
+ let inFlight;
246
+ let stopped = false;
247
+ const runOnce = () => {
248
+ if (stopped || inFlight !== undefined)
249
+ return;
250
+ inFlight = runRetentionSweepOnce({ ...ctx, streaks })
251
+ .then(() => undefined)
252
+ .catch((err) => {
253
+ try {
254
+ ctx.logger.warn("retention_sweep_round_failed", { err: err instanceof Error ? err.message : String(err) });
255
+ }
256
+ catch {
257
+ // 同 `recordDomainFailure` 的上报臂:一条抛异常的 logger transport 不许把这条 `.catch` 变成
258
+ // 一个 floating rejection(Node 默认处理下会带走整个进程)。
259
+ recordFailOpen("server.retention.sweep-report-dropped", "round");
260
+ }
261
+ })
262
+ .finally(() => {
263
+ inFlight = undefined;
264
+ });
265
+ };
266
+ const timer = setInterval(runOnce, ctx.intervalSec * 1000);
267
+ timer.unref?.();
268
+ return {
269
+ runNow: runOnce,
270
+ stop: async () => {
271
+ stopped = true;
272
+ clearInterval(timer);
273
+ // 等在飞那一轮落地。`inFlight` 自己已经把所有异常吞掉了(上面的 catch),所以这里不会抛;
274
+ // 再挂一条 catch 是因为收尾链绝不该被一条清理腿打断 —— 哪怕是被将来某次改动引入的新抛点。
275
+ await inFlight?.catch(() => undefined);
276
+ },
277
+ };
278
+ }
279
+ //# sourceMappingURL=retention-lane.js.map
@@ -16,6 +16,7 @@
16
16
  import { Runner, type RunnerDeps } from "@sema-agent/core";
17
17
  import type { ServiceConfig } from "../config.js";
18
18
  import type { createHttpServer } from "../http/server.js";
19
+ import type { RetentionLaneController } from "./retention-lane.js";
19
20
  import type { Logger } from "../observability/logger.js";
20
21
  import type { startOtlpExporter } from "../observability/otel-exporter.js";
21
22
  import type { CostQuota } from "../observability/cost-quota.js";
@@ -36,6 +37,13 @@ export interface ShutdownCtx {
36
37
  stop(): void;
37
38
  };
38
39
  };
40
+ /** #270 车2:托管留存 sweep lane 的**控制器**(`undefined` = lane 关着,压根没建定时器)。与 `reaper`
41
+ * 同列文件头契约 3,但严格更强:那些腿只要停表就够了,本腿的 tick 打的是跨十余张表的**破坏性**事务
42
+ * ⇒ 必须 `await stop()` 等在飞那一轮真正落地,**然后**才让租、才关连接池(见 hardShutdown 内的三步注)。 */
43
+ retentionLane: RetentionLaneController | undefined;
44
+ /** #270 车2:主动让租(把 sweep 租约的到期时间归零)—— 优雅停机后另一副本不必等满一个 TTL 才接手。
45
+ * best-effort:一次失败只意味着"下一位等 TTL",绝不阻塞停机。缺席 = lane 关着。 */
46
+ releaseRetentionLease: (() => Promise<void>) | undefined;
39
47
  otelExporter: ReturnType<typeof startOtlpExporter> | undefined;
40
48
  breakerState: ReturnType<BreakerStateStore["startRefresh"]> | undefined;
41
49
  costQuota: CostQuota | CostQuotaStore | undefined;