/** * [ref](黑板 [ref]②,cli [ref] 认领消费)—— 引擎**附着租约自退**。 * * ## 防的是什么 * * 壳自 spawn 引擎的本机形里,壳正常退出之后引擎**继续活着**:占端口、占库连接池、占模型配额,而且 * 用户已经离开了、再没有人会关心它。[ref] 的 parent 监视只覆盖「生我的那个 pid 没了」这一形,而 * **多壳共享一只引擎**时它结构性地问错了问题 —— 首壳退了 peer 还在用,监视父 pid 要么误杀要么永不触发。 * 本件问的是那个真正的问题:**还有没有人附着**。 * * ## S-306:自退律只剩这一条(硬 breaking) * * `SEMA_PARENT_PID` 看门狗的 drain 臂**整条删**了:父死只是把引擎标成**孤儿**(`parent_watch_orphaned`), * 孤儿走的是**本腿这一条**判据,只有一处不同 —— 窗长从 {@link EngineLeaseDeps.lingerMs} 换成 * {@link EngineLeaseOrphanLane.lingerMs}(`ENGINE_ORPHAN_LINGER_MS`,默认 2s)。于是: * · 规则集 **−1**(「父死即退」那条判据没了),参数 +1(孤儿窗); * · 「有人附着 / 有 run 在飞就不退」这条结构性保护**对孤儿同样成立** —— cli 拒绝 S-292 的那一形 * (A 起引擎、B 按 engine.port 复用、A 正常退)不会再在 B 的 turn 中间把引擎拆掉; * · 装配判据随之变成 `parentPid 在场 ∨ engineAutoExit`;`ENGINE_AUTO_EXIT` 关着的 owned 引擎窗长 * 是 **`Infinity`** —— 父还活着就**永不自退**(与 S-306 前逐字同),父一死才由孤儿窗接手。 * * ## 机制(逐条都是契约) * * 1. **opt-in**:`ENGINE_AUTO_EXIT` 与 `SEMA_PARENT_PID` **都**缺席 ⇒ 整件不装配(`boot/shutdown.ts` * 连本函数都不调)。服务器部署形逐字零变化 —— 一台 k8s 上的 replica 半夜没人用不是「该退了」, * 是**正常的低谷**。壳 spawn 形由壳在 spawn 时显式注入。**部署级旋钮无条件生效**:它不派生自任何 * 客户端表态(本仓 operator-knob 铁律,[ref] 同病两犯史),两只 linger 键的坏值校验也不挂在 * `ENGINE_AUTO_EXIT` / `SEMA_PARENT_PID` 的表态上。 * 2. **自退判据 = 三合取,且必须持续满 `linger`**(`linger` = 孤儿态 `orphan.lingerMs`,否则 `lingerMs`): * · 附着租约集空(`attachedStreams() === 0`); * · 无在飞 run(`inflight() === 0` —— **与 /health 的 `inflight` 同一只 getter**,不另立第二判据); * · HTTP 触点静默(`now() - lastActivityAt() >= lingerMs`),**且**最后一条附着流的**离场** * 也已满窗(`now() - lastAttachEndedAt() >= lingerMs` —— codex R1-F1,见该 dep 的注)。 * 任何一条不满足即把计时清零(连续性判据,不是累计)。 * 3. **为什么 HTTP 触点是独立的第三条**(不是冗余):纯轮询壳(零 SSE,每 2s 一次 `GET /v1/sessions`) * 的请求是毫秒级的,而本腿 15s 采样一拍 —— 采样点几乎必然落在两次轮询之间,`attached` 与 * `inflight` **同时读到 0**。只看那两条的实现会在一只**正被使用**的引擎底下把它关掉。租约的 * 建立面按设计就是「首个 HTTP/SSE 接触」,不只是 SSE。 * 4. **退出 = 走 `drain`**,即 `boot/shutdown.ts` 里 SIGTERM 用的那一条 `drainThenShutdown`:拒新提交 * (503+Retry-After)→ 候在途腿跑完 → `hardShutdown`。in-flight run 的结算 / park 语义与人工停机 * **逐字一致**。本模块自己**从不** `process.exit`。 * 5. **crash-last 判别位**([ref] ④「flush 判别位标 auto-exit 非崩溃」):走上面那条受控退, * `hardShutdown` 一进来就 `clearCrashLast` ⇒ 遗言文件被删 ⇒「文件在场 ⇔ 上次退出=崩溃」这条契约 * 对自退**自动成立**。刻意**不**另写一条 `reason:"auto-exit"` 的遗言行:那会把该文件的语义从 * 「崩溃判别位」稀释成「上次退出记录」,而运维读它就是为了判前者(见 `crash-last.ts` 顶注)。 * 自退的可观察面是 `engine_lease_auto_exit` + 随后的 `draining_started` 两行日志。 * 6. **interval 必须 unref**:自退检查腿不得成为「进程本该退出却退不掉」的理由(与 drain tick 刻意 * 不 unref 的取向相反 —— 那一只 tick 就是停机驱动,这一只只是观察者)。**恒一只 interval**: * 孤儿态不另起第二只定时器(两只定时器 = 两条自退律,正是本件要消灭的那件事)。 * 9. **拍频由窗推导,不是第二个旋钮**(S-306):`tickMs = min(`{@link ENGINE_LEASE_TICK_MS}`, 本腿可能 * 执行的每一个窗)`。理由是顶注 8 那句话的逆命题 —— 实际窗 `[linger, linger+tickMs)` 里,采样滞后 * 不得压过窗本身:15s 一拍去执行一个 2s 的孤儿窗,「2s」这句承诺就只是个装饰。所以装了孤儿车道 * (= 壳 spawn 形)的引擎按 2s 采样,没装的(纯服务器形)仍是 15s —— 存量部署一拍都没多跳。 * 7. **装配即起算**:armed 那一刻若已空闲,计时**当场**开始。覆盖的是「壳 spawn 了引擎,自己却在 * 第一次接触之前就崩了」这一形 —— 那只引擎一辈子没有过租约,正是本件要收的孤儿。 * 8. **实际窗 = `[lingerMs, lingerMs + tickMs)`**(采样腿的固有滞后,成文而非隐藏):最后一条租约是在 * **两拍之间**灭的,本腿要到下一拍才看见,空闲计时从**那一拍**起表。⇒ 真实等待比 `lingerMs` 多出 * 0~1 拍。方向是**保守**的(等得更久,不会更早),所以不修 —— 要修就得让 SSE 关闭点反向通知本腿, * 那是把一只自洽的观察者改成有状态的被通知者,不值当。运维读数以 `engine_lease_auto_exit.idleForMs` * 为准(它报的是真实空闲时长,不是 `lingerMs`)。 * * ## 已知收窄(成文,现版不收) * * · **parked-待赎回 run 不入判据**:设计稿 ② 原文含「无 running/parked-待赎回 run」,本版**只看 * running**。理由 = 廉价读口不存在:parked 是 store 里的**durable 行**,数它必须发 SQL,而本腿是一只 * 每 15s 无限期跑下去的定时器 —— 为它引入周期性 SQL 轮询,代价压过收益。**bg run 在跑不退**那一臂 * 已由 `inflight()` 结构性守住;parked 行是 durable 的,引擎退掉不丢:下次起来仍可赎回(spawn 形里 * 「下次」就是壳的下一次 spawn)。伤害面 = 一条 parked run 的赎回被推迟到下次启动,不是丢失。 * 这是**允许的收窄,不是静默**:有了廉价 parked 计数口(或本腿改成事件驱动)之后应当补上。 * · **活过父 run 的后台子代理不入判据**(codex R1 [high] 验真后**如实登记**,不是本件引入:S-306 之前 * 父死走的是**当场** drain,而 `drainThenShutdown` 读的是同一只 `inflight()` ⇒ 同一个缺口,只是那时 * 连这 2s 都没有)。`inflight()` 的口径是**本副本的 HTTP 腿 ∪ leader 腿**(`http/server.ts` 的 * `inflightRuns`/`steerableRuns`/两只计数器),而 core 侧 `backgroundScope:"session"` 的子代理可以 * **活过**生它的那条 HTTP 腿 —— 那之后它在本腿眼里是 0。伤害面 = 壳没了且没人附着时,一条还在跑的 * 后台子代理会随引擎的受控退被中断(它的账本行照常落终态,不是静默丢)。收口需要一只**廉价**的 * 「本副本活跃子代理数」读口(与 parked 那条同族:要么 core 给计数,要么本腿改事件驱动),归属 * `inflight()` 的属主而不是本腿 —— 在那之前这条收窄**成文在此**,不许再被读成「bg 全被守住」。 * · **`/health` 探针不算触点**:`lastActivityAt` 的属主(`http/server.ts` 的 `noteActivity`)按既有契约 * 把 `/health` 与 `/metrics*` 排除在活性时钟之外(常态采样噪音不得把空闲窗永远撑开)。⇒ 一只**只** * 轮询 `/health` 的壳不构成租约。这与 SIGHUP 空闲窗的判据同源,刻意保持一致而非在本腿另开一份。 */ import type { ParentOrphanReason } from "../parent-watch.js"; /** 自退检查拍频的**上限**。**不开 env 旋钮**(与 `PARENT_WATCH_INTERVAL_MS` 同纪律:有真需求再议, * 防旋钮增殖);实际拍频由顶注 9 的推导取更小者。 */ export declare const ENGINE_LEASE_TICK_MS = 15000; /** * 孤儿态读数(装配层把看门狗的 `onOrphaned` 落成这一笔)。 * * 两个字段必须**同一笔**给出,不拆成两只 getter:拆开之后「已经孤儿了但 reason 还没写」是一个可达的 * 中间态,而它恰好落在自退这条承重路径上。 */ export interface EngineOrphanState { /** 成孤儿的时刻(**单调**钟,与本模块的 `now` 同域)。 */ since: number; /** 哪一支判的(看门狗的闭集词表)。 */ reason: ParentOrphanReason; } /** * 孤儿车道 —— `SEMA_PARENT_PID` 在场才装(缺席 ⇒ 本字段整条缺席,孤儿态**结构上**不可达)。 * * 为什么窗长与读口**捆在一起**而不是两只平行 dep:平行时「孤儿窗配着、却永远不会成孤儿」是一个 * 可表达的状态,拍频推导(顶注 9)会据此把一台纯服务器形的引擎无故提速到 2s 一拍。捆在一起之后, * 「本腿会不会执行孤儿窗」就是一句**类型**能回答的话。 */ export interface EngineLeaseOrphanLane { /** 孤儿态窗长(`config.engineOrphanLingerMs`,默认 2s)。 */ lingerMs: number; /** 活读(与 `attachedStreams`/`inflight` 同姿势 —— 本腿的每一只输入都是 pull getter,没有被通知者)。 * `null` = 父还活着。 */ read: () => EngineOrphanState | null; } /** 自退缘由闭集:`"idle"` = 没人用了(owned 态);另两个词 = 看门狗判的孤儿那两支。 * `orphan === (reason !== "idle")` 是不变量 —— 两者在**同一处**铸出(见 {@link createEngineLeaseTracker}), * 不存在各算一次的可能。 */ export type EngineLeaseExitReason = "idle" | ParentOrphanReason; /** * 自退那一刻的**判据读数**(一个对象,三个消费者:`engine_lease_auto_exit` 日志行、数据根上的 * `exit-last.json` 留痕、以及 drain 本身)。一处铸出 ⇒ 日志与留痕永远不会各说各话。 */ export interface EngineLeaseExitReport { /** 父已经不在了那一路退的吗(壳的 exit watch 读这一键把 pid 归入**预期**退出)。 */ orphan: boolean; reason: EngineLeaseExitReason; /** 真实空闲时长(不是配置的窗长 —— 见顶注 8)。 */ idleForMs: number; attached: number; inflight: number; /** 本次**生效**的窗长(孤儿态 = 孤儿窗)。 */ lingerMs: number; /** 成孤儿到自退之间隔了多久;非孤儿退出时键缺席(不编 0)。 */ orphanedForMs?: number; } export interface EngineLeaseDeps { /** 活附着数 = 本副本当前**打开着的 SSE 流**条数(`drainState.attachedStreams`,由 `createHttpServer` 自赋)。 */ attachedStreams: () => number; /** 在飞腿数 —— **`/health` 的 `inflight` 同一只 getter**(`drainState.inflight`)。不自建第二套计数: * 两套计数迟早分叉,而分叉的那一侧恰好是「以为没 run 了」就把引擎关掉。 */ inflight: () => number; /** 最后一次**非探针** HTTP 触点的单调时刻(`drainState.lastActivityAt`)。 * ⚠️ 时基契约:必须与本模块的 `now` 同为 `performance.now()` 单调域(`server.ts` 的打点即该域)—— * 混入 `Date.now()` 会让 `now() - lastActivityAt` 差出 epoch 量级,静默判据恒真。 */ lastActivityAt: () => number; /** 最后一条**附着流断开**的单调时刻(`drainState.lastAttachEndedAt`)。 * * 🔴 codex 交叉复审 R1-F1(验真后修):`attachedStreams` 是**采样**面,`lastActivityAt` 记的是 * **入场**(admission)时刻 —— 两者合起来仍漏一个形:一条**整个生命周期都落在两拍之间**的流 * (活得比拍频短)从没被任何一拍看见,于是 `idleSince` 保持着这条流出现**之前**的旧值,而 * 「已经没人附着满 lingerMs」这句承诺可以提前至多一整拍兑现。补这条**离场**触点之后,判据变成 * 精确的「距最后一次附着结束已满 lingerMs」。缺席(0)= 本进程还没有过附着流结束。 */ lastAttachEndedAt: () => number; /** 已有停机属主(`closing || draining`)时本腿站下:一次正在进行的优雅 drain 不该被「也没人附着了」 * 升级成第二信号硬停(那会斩掉 in-flight leg)。 */ isStopped: () => boolean; /** 自退出口 = SIGTERM 那条 graceful drain + 自退留痕(见顶注 4/5;装配层接线)。判据读数随调用递出, * 日志那一行由本模块打 —— 两者同一个 report 对象。 */ selfExit: (report: EngineLeaseExitReport) => void; /** owned 态(父还活着 / 没被监视)下三合取**持续**满这么久才触发(`config.engineLingerMs`,默认 60s)。 * **`Infinity`** = 永不自退(`ENGINE_AUTO_EXIT` 关着的 owned 引擎;窗永远追不上 ⇒ 无需第二条分支)。 */ lingerMs: number; /** 孤儿车道(`SEMA_PARENT_PID` 在场才给)。缺席 ⇒ 这台引擎不可能成孤儿,窗恒为 `lingerMs`。 */ orphan?: EngineLeaseOrphanLane; log: (event: string, fields: Record) => void; /** 检查拍频 seam(测试)。缺省 = 顶注 9 的推导(`min(ENGINE_LEASE_TICK_MS, 本腿可能执行的每个窗)`)。 */ tickMs?: number; /** 时钟 seam,缺省 `() => performance.now()`(**单调**域)。用 `Date.now()` 会让 NTP/手动跳变吞掉或 * 拉长 linger 窗 —— 前跳把窗直接跳过去 = 在一只还有人用的引擎底下提前关停。 */ now?: () => number; } /** 装配并**立刻**起自退检查(装配即 `engine_lease_armed`)。返回停机链用的 `stop()`。 */ export declare function createEngineLeaseTracker(deps: EngineLeaseDeps): { stop(): void; }; //# sourceMappingURL=engine-lease.d.ts.map