/** * [ref](docs/DESIGN-261-fleet-liveness-reconcile.md v1.2,四方零驳定稿)—— fleet 总线**存活对账腿**。 * * ## 它修什么(§0 病灶机制链) * fleet 总线是进程内纯内存聚合(`fleet-bus.ts` 头注自陈 NOT durable / NOT cross-replica),行的**唯一** * 离场路径是发布方主动打 `onTerminal`/`onChildTerminal`。发布方没走到终态臂(run 事件环异常中断、引擎 * 被壳 reuse 而旧 run 对象已死、BCE 终态通知丢失),行就**永久僵在** `tasks` Map 里 —— 此后每个订阅方 * (含 snapshot 新连接)看到一条永远 "running" 的幽灵行。壳侧只能靠 overlay 防御(cli 1.0.74 过渡件)。 * * ## 核心裁定:**不造第二个判死者**(§1) * 本腿**不判死**。它只做两件事: * (a) 副本本地活体登记面(`http/server.ts` 的 `inflightRuns` ∪ `steerableRuns`,经 * {@link FleetReconcilerDeps.isLiveOnThisReplica} 探针注入 —— **不搬 Map**)在场 ⇒ 行活,跳过; * (b) 登记面无此 taskId ⇒ 读 durable run 行,把 durable **已有的判决**投影到总线上。 * 判死的属主始终是 durable 侧既有机制(心跳 → reaper finalize → 行翻终态);本腿是**投影腿**, * 对 durable **恒只读**({@link FleetReconcileRunPort} 这只窄口连写方法都没有 —— 单写者不变量由类型执法)。 * * `msSinceLastActivity` **显式排除**:它是「turn 卡死」诊断维,把它当退休输入会把真忙的后台 run 扫下 * 面板,方向错。卡死的 run 行**该**留着(它确实在占资源),诊断走 `GET /v1/runs/:id`。 * * ## 对账矩阵(§1;每周期对 bus 的顶层 run 行逐行判) * | 登记面 | durable 行 | 处置 | * |---|---|---| * | 在场 | 任意 | **跳过**(含 turn 卡死形) | * | 无 | 终局 | **投影退休**:终态 `task` 帧(durable 真状态 + `retiredBy:"reconcile"` + 被退代际)→ `task_remove`,**结算级** | * | 无 | `running`(本副本 claim / 他副本 claim) | **陈述级退场**:`task_remove` + `removeReason:"orphaned"`,**恒不造终态** | * | 无 | 行不存在 | 陈述级退场(见下方"静默一周期"的收窄) | * * 🔴 **对"行不存在"这一格的收窄**(实施稿承重,设计稿 §3 的「拿不准的恒答最弱主张」同源):bus 上 * 一条 `parentId` 缺席的行**不一定**是顶层 run 行 —— BCE 车道的 `a*` 子代行在父链接尚未解析成功时 * 同样不发 `parentId`(`rowTags` 的 R6 臂),两者在总线上**结构同形**。而 `getRun(a*)` 必然 miss。 * 若照矩阵当周期就退场,一条**活着的**长寿 session-scoped bg 子代会被扫下面板。所以这一格额外要求 * 「**静默满一个对账周期**」(本腿订阅总线自记每行的最后帧时刻):还在出帧 = 活着的证据。 * durable 行**在场**的那三格没有这个歧义(`getRun` 命中 = 它就是一条 run 行),故照矩阵即时处置。 * * ## 子代行(§1) * 随父:父行退休/退场时**同扫**(与 `onTerminal` 的 removal 集同姿势)。子代**不读 durable** —— * bg 子代的 durable 真源在 core 侧 registry,server 不越界重判。无父可循的孤儿子行(父行已不在 bus) * 按「bus 内滞留超一个对账周期且无新帧」陈述级退场。 * * ## workflow 行(§1) * 只做同款「durable 终局投影」([ref] 的 snapshot pull 腿 + reapers 的 workflow recover 腿已在场), * **不加新判据**:活行恒不动,没有陈述级退场这一形。 * * ## 🔴 S-450:候补窗的升级**由事实驱动**,周期臂降为兜底 * 陈述级退场(`orphanExit`)恒不造终态、行进候补窗;把它升级成真终局此前**只**发生在下一个对账周期的 * ① 臂 ⇒ 成对终帧最晚 {@link FLEET_RECONCILE_INTERVAL_MS} + 预算抖动才到面板,而消费方 * (`@sema-agent/client-core` 的面板投影)对「行缺席」按 30s 结算 —— 两侧各自正确,合成的是「面板先结算 * 成缺席、一分钟后又闪回一帧终局」。**定时器在错误的一侧**:补偿的触发条件是**事实**(durable 行翻终局), * 却挂在**周期**上。现在多一条事件腿({@link FleetReconcilerDeps.subscribeRunTerminal},源头 = * `observability/run-terminal.ts` 的**唯一铸点** `notifyRunTerminal` —— 三只 store 的 CAS **赢**分支各调一次): * `setTerminal` 落地的同一 tick 就去读。 * **提示只带 `taskId`,事实仍只有 durable 一个属主** —— 两个触发源走同一串判据 * (`getRun` → `isTerminalRunStatus` → `upgradeWatched`),提速的是「什么时候去读」而不是「读什么」。 * 升级动作**只有一份**(`upgradeWatched`),幂等闸 = `watch.delete(id)` 的返回值。 * **两处仍走周期臂,如实**:① 终局由**别的副本**写(本副本收不到进程内提示);② **批量 reaper** * (`reapStale` 一族是整批 UPDATE、拿不到 id,不经 `setTerminal`)—— 这两族**与本件存在之前的行为逐字等同**。 * 上界成文在契约与 DEPLOY-PREREQS:「单副本经写口的终局:落地即升级;其余:最迟一个对账周期」。 * * 🔴 **补发帧推一次,不设回执、不设重放队列**(主会话 09-18 定,设计稿 §3.5;test [ref] 提问的答案)。 * 理由:fleet 面是**投影**(bus 帧 + SSE,没有 durable 队列),给它加「待取 / 回执」就是造第二本账 * (与「不铸并行账本」同一条纪律);而终局的**真源**本来就可寻址、可重读 —— 顶层 run 走 * `GET /v1/runs/:id`,后台代理 `a*` 走 `GET /v1/runs/:id/tasks/:handle`(父 run 终结后仍可读)。 * ⇒ 消费方的闭环义务(契约附录 H 与发车帖点名):**回收一条「缺席」行之前、或收到某 id 的帧而行已不在时, * 按 id 向 durable 面补读一次**;读到终局以它为准,404 = 行已被 retention 清,如实渲「结果已不可得」。 * 静默丢弃迟到帧是消费方的病,不是本件要用 wire 机制兜的。server 侧只保证**留痕**:见下方 * `upgradeWatched` 的 `fleet_rows_reconciled_total{disposition="retired_deferred", trigger=…}` —— * 「补发发生过」在 server 侧可查;**接收端有没有收到不是 server 能知道的事,不假装知道**。 * * ## 五条界(任何一条失手都只是幽灵行多活一个周期 ⇒ 整体 F 类 fail-open,登记 tag,不静默) * · 节律 {@link FLEET_RECONCILE_INTERVAL_MS} · 每周期读上限 {@link FLEET_RECONCILE_MAX_ROWS} * · 读预算 {@link FLEET_RECONCILE_READ_BUDGET_MS} · 熔断冷却 {@link FLEET_RECONCILE_COOLDOWN_MS} * · 待终局候补窗 {@link FLEET_RECONCILE_WATCH_MAX} × {@link FLEET_RECONCILE_WATCH_TTL_MS} * 任何读错/超预算 ⇒ `recordFailOpen("server.fleet.reconcile-read-failed")` + 熔断 + 本周期跳过。 * * ## 为什么整套常数都不做旋钮 * 与 `fleet-terminal-window.ts` 同一论证:两侧边界都由语义定死(比周期短 = 幽灵行仍在面板上,比周期长 = * 拿对账腿当历史列表),旋钮只会让部署方去调一个没有正确取值的数;fleet 面现有零旋钮,从之。 */ import { type WorkflowRun, type WorkflowRunStore } from "@sema-agent/core"; import type { Logger } from "../observability/logger.js"; import type { Metrics } from "../observability/metrics.js"; import { type RunRecord } from "../plugins/store-contracts.js"; import { type RunTerminalRecord } from "../observability/run-terminal.js"; import { type FleetEventBus } from "./fleet-bus.js"; /** 对账节律 —— 60s 档。比它短 = 每分钟给 durable 加一批点读却换不来更快的面板真相(幽灵行的时间尺度 * 是"发布方已经死了",分钟级);比它长 = 壳的引擎温切门(cli engineSwapGate 读活行集)被幽灵行按住 * 太久。不做旋钮,理由见模块头。 */ export declare const FLEET_RECONCILE_INTERVAL_MS = 60000; /** 每周期的**点读上限**。超出的行留到下一周期(顺序稳定 = bus 的插入序),不把一次扫描变成全表点读风暴。 */ export declare const FLEET_RECONCILE_MAX_ROWS = 200; /** 一个周期的 durable 读**时间预算**。超时 = 放弃这一轮(记 F 类 fail-open),行留在面板上。 * 与 `FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS` 同值同理由:慢查询不该把整条腿卡住。 */ export declare const FLEET_RECONCILE_READ_BUDGET_MS = 2000; /** 熔断冷却:一次读失败/超预算之后,这段时间内**不再发起任何读**。`RunStore`/`WorkflowRunStore` 契约 * 都没有取消面(发出去的查询撤不回),挂死的后端不该被每周期继续加压。冷却一过自动重探(自愈)。 */ export declare const FLEET_RECONCILE_COOLDOWN_MS = 60000; /** 陈述级退场之后的**待终局候补窗**行数上限(§1 矩阵的「终局候 durable 翻转后由下一周期投影」半场: * 行已不在 bus,故补发终态 `task` 帧 + remove 的**成对形**)。有界:超出即不再收新条目。 */ export declare const FLEET_RECONCILE_WATCH_MAX = 200; /** 候补窗的存活时长。reaper 的孤儿宽限窗之外仍没翻终局的行 = 这条腿等不到了(它本来也只是投影), * 丢弃并不损失真相:`GET /v1/runs/:id` 永远是那条 run 的真源。 */ export declare const FLEET_RECONCILE_WATCH_TTL_MS: number; /** durable run 行的**只读**窄口(单写者不变量的类型执法面:这只接口上没有任何写方法, * 所以"对账腿顺手改了一行"在本仓是编译期不可能,而不是靠注释约束)。 */ export interface FleetReconcileRunPort { getRun(taskId: string): Promise; } /** durable workflow 行的只读窄口(同上;`WorkflowRunStore` 的 `get` 一面。`null` 在返回类型里是因为 * core 契约的 `get` 用 `null` 表"没这一行",两种缺席一并容忍)。 */ export interface FleetReconcileWorkflowPort { get(id: string): Promise; } export interface FleetReconcilerDeps { bus: FleetEventBus; /** * 副本本地**活体谓词**(§4 件1 的"探针注入形"):`taskId` 是否在本副本的在飞/可 steer 登记面里。 * 由 `http/server.ts` 交出 `(taskId) => boolean`,**不搬 Map** —— 登记面的属主仍是 server, * 对账腿只问一个是非题(搬 Map 会造出第二份活性判据,而两份判据必漂移)。 */ isLiveOnThisReplica: (taskId: string) => boolean; /** * S-450 测试缝:「run 行 CAS 赢了翻终局」的订阅口。**生产恒缺席** —— 缺席时本腿自己去 * `observability/run-terminal.ts` 的**唯一铸点**登记(那口是进程级的自由函数,与 `recordFailOpen` * 同姿势:真正的铸点在没有任何句柄的深处)。给它一个注入形只是为了让用例能在一台假铸点上跑。 * * 🔴 **跨副本上界照旧、如实**:终局由**别的副本**写(对方的 reaper / 续跑腿)⇒ 本副本的进程内铸点 * 不会响,升级帧仍走周期臂。本版**不**引入跨副本总线(那是 leader run store 一族的独立设计)。 */ subscribeRunTerminal?: (fn: (rec: RunTerminalRecord) => void) => () => void; runs?: FleetReconcileRunPort; workflows?: FleetReconcileWorkflowPort | WorkflowRunStore; /** 本副本 id(与 `RunRecord.instanceId` 同域)。只进日志/读数 —— 两条 `running` 臂的**处置相同** * (都是陈述级退场),分不分得出 claim 属谁不改变行为,故它不是判据。 */ instanceId?: string; logger?: Logger; metrics?: Metrics; /** 测试缝:单调/可控时钟(默认 `Date.now`)。 */ now?: () => number; } /** 一个周期的读数(测试与遥测面;不上 wire)。 */ export interface FleetReconcileTally { /** 本周期判过的顶层候选行数(含跳过的)。 */ scanned: number; /** 登记面在场而跳过的行数。 */ skippedLive: number; /** durable 说 park(suspended / needs_review)而跳过的行数 —— 行还活着,不是孤儿(codex R1-F1)。 */ skippedParked: number; /** durable 终局 ⇒ 投影退休的行数(结算级)。 */ retired: number; /** 陈述级退场的行数(顶层 + 孤儿子行)。 */ orphaned: number; /** 随父同扫的子行数。 */ childrenSwept: number; /** durable 终局 ⇒ 投影退休的 workflow 行数。 */ workflowsRetired: number; /** 本周期实际发出的 durable 点读次数。 */ reads: number; /** 本周期因熔断整轮未跑。 */ cooling: boolean; /** 本周期踩了 F 类兜底(读失败/超预算)。 */ failOpen: boolean; } /** 对账腿的活对象(有状态:静默钟 + 候补窗 + 熔断 ⇒ `create*` 而非 `build*`)。 */ export interface FleetReconciler { runOnce(nowMs: number): Promise; /** 解除对总线的订阅(收尾用;定时器由调用方 `clearInterval`)。 */ stop(): void; } export declare function createFleetReconciler(deps: FleetReconcilerDeps): FleetReconciler; /** * 起对账定时器(60s 档,`unref`)+ 重入守卫。返回 handle 供收尾段 `clearInterval`。 * * ⚠️ 装配点必须在 `createHttpServer` **之后** —— 活体谓词是 server 造出来的 * ({@link FleetReconcilerDeps.isLiveOnThisReplica});这也是本腿不并进 `boot/reapers.ts` 那只 tick 的 * 唯一原因(reaper 段按「位置即契约」必须在 server 之前建)。 * * 重入守卫照 reapers 的四腿先例:本腿时长随 bus 行数与库延迟增长,短周期下无守卫会逐周期叠罗汉压同一批行。 */ export declare function startFleetReconciler(deps: FleetReconcilerDeps): { timer: NodeJS.Timeout; reconciler: FleetReconciler; }; //# sourceMappingURL=fleet-reconciler.d.ts.map