/** * [ref] 修方向 1(黑板 [ref] 定谳)—— `/v1/fleet/stream` 连接时快照要并入的**近期终态 workflow 行窗**。 * * ## 它修什么 * 引擎重启后 boot 扫描判死前世 `running` run,`publishWorkflow`(终帧)与 `removeWorkflow` 在**同一同步 * 调用栈内**背靠背执行;fleet bus 是纯内存 EventEmitter、无重放,`snapshot()` 只读活跃 Map ⇒ **重启之后 * 才连上的客户端连增量带快照都结构性看不见那一行**(cli 七轮正控实证)。durable 真源一直是对的 * (`GET /v1/workflows` 可达),缺的只是把它接到面板这一路上——所以这里是 **pull**:读同一份 durable * store,**不碰 bus 的活跃 Map**(活行的单写者不变量、增量帧语义逐字不动)。 * * ## 五条界(任何一条失手都只是少几行历史 ⇒ 整体 F 类 fail-open,登记 tag,不静默) * · 窗长 {@link FLEET_SNAPSHOT_TERMINAL_WINDOW_MS} · 行数 {@link FLEET_SNAPSHOT_TERMINAL_MAX_ROWS} * · 扫描面 {@link FLEET_SNAPSHOT_TERMINAL_SCAN_LIMIT} · 读预算 {@link FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS} * · 熔断冷却 {@link FLEET_SNAPSHOT_TERMINAL_COOLDOWN_MS} * * ## 为什么本模块有进程级可变状态(路由域刻意保持零状态,故不放在 `routes/fleet.ts`) * 三件都长在同一个事实上——**`WorkflowRunStore` 契约没有取消面**,`Promise.race` 只结束等待、撤不回 * 已发出的查询:合流(重连风暴不按连接数放大,R1-H2)、合流窗有限(不把陈旧结果发给后来者、也不让 * 一条永不 settle 的读把后续连接全卡住,R2-H1/R3-M2)、熔断冷却(挂死的后端不被每周期继续加压, * R3-H1)。真解是店侧查询超时/取消 —— 要动契约与四个后端,已作移交项。 * * ## scope * `WorkflowRunStore` 契约**没有跨 scope 枚举**(见 `orchestration/workflow-notify-journal.ts` 头注), * 所以窗恒按**调用者自己的 principal scope** 读:fleet-wide 观察者看到的是「全租户活行 + 自己 scope 的 * 终态窗」。这是诚实的不对称(读不到的东西不编),方向安全(绝不多给);真行的租户/会话可见性仍由 * 路由的 `visW` 统一执法,本模块不自行放行。 */ import { type WorkflowRunStore } from "@sema-agent/core"; import { type FleetWorkflowRow } from "./fleet-bus.js"; /** * 窗长 —— 10 分钟,**不做旋钮**。 * * 它覆盖的是一段具体的人机时长:「引擎重启 → 客户端重连 → 人看一眼面板上刚才那条 workflow 怎么了」。 * 比它短会漏掉重连慢一步的壳(即本 issue 的病灶);比它长就变成「拿 SSE 快照当历史列表」——而历史面 * 本来就有真源(`GET /v1/workflows`:durable、可分页、带 scope/session 过滤),不该在这里长出第二个。 * 两侧边界都由语义定死,旋钮只会让部署方去调一个没有正确取值的数;fleet 面现有零旋钮,从之。 */ export declare const FLEET_SNAPSHOT_TERMINAL_WINDOW_MS: number; /** 同一快照里终态行的行数上限(窗长之外的第二道界)。快照是面板首屏,不是历史列表——超出的部分归 * `GET /v1/workflows`。 */ export declare const FLEET_SNAPSHOT_TERMINAL_MAX_ROWS = 20; /** * 每个终态**扫描面**的行数(店侧下推的 limit)。它比 {@link FLEET_SNAPSHOT_TERMINAL_MAX_ROWS} 大是有 * 具体病灶的(codex R1-M3,红先复现):店侧分页按 `createdAt DESC` 排(core 契约 + 两个 SQL 实现皆然), * 而本窗的判据是**结束时刻**——一条跑了三天、刚刚才结束的 workflow(正是用户此刻要看的那条)会被 20 条 * 更晚创建的行挤出首页。留这段余量是在现有契约内能给的最好答案;彻底解法是店侧加一个 `ended_at_ms DESC` * 的窄查询(要过真双库门,已作移交项记在发车说明里)。 * * 残余(如实记档):同 scope 同一终态下,若有超过本值条**更晚创建**的行,那条"早创建、刚结束"的行仍会 * 被挤掉。此时用户仍可从 `GET /v1/workflows` 看到真相——丢的只是面板首屏的一行。 */ export declare const FLEET_SNAPSHOT_TERMINAL_SCAN_LIMIT = 100; /** durable 读的时间预算。超时=**放弃这段增益**(记 F 类 fail-open),绝不让一次慢查询把整条 SSE 握手 * 拖住——面板少一条历史行是难看,连不上流是坏掉。 */ export declare const FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS = 2000; /** * (原 `FLEET_SNAPSHOT_TERMINAL_COALESCE_MS` 已删)⚖️ 「同时只有一支探针」与「结果别太陈旧」的取舍 * —— codex R3-M2 与 R4-H1 是两轮方向相反的 finding,这里成文定案。 * * 后来者**加入**在飞的那支读,只要它还在自己的预算之内({@link FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS})。 * · 曾短暂改成 250ms 的窄窗(想压小"采到"与"交付"的时差):实测判据下更坏 —— 慢而健康的店(比如 * 1.9s)配错峰重连,会变成每 250ms 起一批新读(2 次 list + ≤20 点读),而熔断要等某个调用方 2s * 超时才开;单飞的意义被自己抵消(R4-H1)。 * · 保留的代价:刚好在探针尾巴上加入的调用方,可能拿到一份最多约"两倍预算"之前采到的行集(R3-M2) * —— 后果是这条连接首屏少一条**刚刚**结束的行(且只有跨副本翻转才补不上帧),与本模块整体的 F 类 * 姿态同级;而 R4-H1 的后果是给一个已经很慢的库继续加压。取小害。 * 过了预算的在飞读 = 已被放弃的一代(等待方全走光了),不再加入:那一代由熔断收尾,新调用方在冷却期 * 外开新一代。 */ /** * 熔断冷却期:一次读失败/超预算之后,同 (store, scope) 在这段时间内**不再发起新读**(直接放弃这段增益, * 记 F 类 fail-open)。 * * 为什么必须有(codex R3-H1,红先复现 4→2):`WorkflowRunStore` 契约没有取消面 —— 发出去的查询撤不回。 * 后端挂死时,只按"复用窗过期就再起一次"办,等于每个周期给那个已经挂死的后端再加两笔永不回来的活, * 连接不断则永远堆下去(池/内存/驱动队列迟早耗尽),这正是"故障期被自己的重试放大"那一族。冷却期把 * 新增速率钉在「每 scope 每 10s 至多一次探针」,并且**自愈**:冷却一过,下一条连接就是一次真实探测。 * 残余(如实记档):挂死那几笔仍在后端占着;真解=店侧查询超时/取消(要动契约与四个后端,移交项)。 */ export declare const FLEET_SNAPSHOT_TERMINAL_COOLDOWN_MS = 10000; /** fleet 行上的 `status` 是自由字符串(wire 面容忍未知词),而"是不是终态"必须与上面那张**同一张** * 闭集表说同一句话 —— 供路由判断一条 workflow 行帧是否终态(不做裸 cast:词表就是判据)。 */ export declare function isTerminalWorkflowRowStatus(status: string): boolean; export declare function seedTerminalWorkflowRow(row: FleetWorkflowRow): void; /** 测试缝:清 seed 缓存(与 clearTerminalWindowCooldownForTest 同姿势,生产零调用)。 */ export declare function clearSeededTerminalRowsForTest(): void; export declare function recentTerminalWorkflowRows(store: WorkflowRunStore | undefined, scope: string | null, pullScope?: string): Promise; /** * test-only:把熔断冷却**提前**到现在(等价于"冷却期已过"),**不动**在飞读的登记。 * * 为什么只清冷却:冷却期是墙钟(10s),测试不该真等;而"在飞读的复用是否会把毒 promise 传给后来者" * 恰恰是要被测的行为,清掉它就等于把待测对象删了。生产路径不引用本函数(与 * `resetFailOpenRecorderForTest` 同一姿势:测试面显式,不给生产留旁路)。 */ export declare function clearTerminalWindowCooldownForTest(store: WorkflowRunStore, scope: string): void; /** test-only:这个 store 目前还记着几个 scope(codex R4-M2 的空闲销号有没有真的销)。 */ export declare function trackedTerminalWindowScopesForTest(store: WorkflowRunStore): number; /** * test-only:下一次开流会不会**合流到**当前这代在飞的读(与 {@link sharedRead} 同一份判据,不另抄常数)。 * * 为什么需要它:挂死的读永不 settle,那一代会一直留在表上、只被复用窗按龄挡掉。测试要断言"后端恢复后 * 的下一次开流读得到行",前置条件恰恰是"毒代已过复用窗"——那条前置条件必须**等出来**(轮询本谓词), * 不能靠"上一条连接自己吃满预算 + 一次客户端往返"凑出的那几毫秒墙钟余量(实测 8~28ms,既无因果保证 * 也无下界 ⇒ 负载敏感偶发红)。读到的是快照:表里没有这一 scope 就是"不会合流"。 * * ⚠️ 刻意**不走** `scopeState`(它会顺手建表项)——观测口不许改被观测的状态(`trackedTerminalWindow * ScopesForTest` 钉的空闲销号计数会被它污染)。 */ export declare function terminalWindowJoinsInFlightForTest(store: WorkflowRunStore, scope: string): boolean; //# sourceMappingURL=fleet-terminal-window.d.ts.map