/** * [ref] 件 S-4 —— **running 态跨片用量投影**的数据面(`GET /v1/runs/:id` 的 `crossSliceUsage`)。 * * 【为什么需要它】S-3 把三条跨片总额旋钮(`RESOURCE_SUSPEND_TOTAL_TOKENS` / `_BUDGET_USD` / * `_MAX_SLICES`)供到了 `TaskSpec.resourceSuspend`,core 那本跨片账(`ResourceLedger`)也确实在记 —— * 但**跑的过程中 wire 上一个数都读不到**。运维第一次知道「这条 run 逼近过上限」是在**爆窗那一刻** * (行翻 `suspended` + `resource_limit` gate),想提前介入无读面。[ref] 的 75 万 token 就是这样烧完的。 * * 【两半各自的真源(刻意不自建第三本账)】 * · **上限半**:本模块的进程内登记册 —— 记的是**这条 run 真正铸给 core 的那只 `resourceSuspend`** * (装配点 `boot/resolve-spec.ts` 的 `resourceSuspendOptIn`),不是读时现取 env。差别是承重的: * ① verify/cascade 腿被 `resourceSuspendOptIn` **整只排除**(opt-in 不成立)⇒ 登记册里没有它 ⇒ * 整键缺席,绝不显示一个没人执行的天花板;② 滚动改 env 之后,已在跑的 run 报的仍是它自己那份。 * · **已耗半**:run 自己的 durable 账本行 `model_usage`(`trace/project.ts` 的 `aggregateModelUsage`)—— * 与 `TaskStats.modelUsage` 回声**同一份账**,append-only、跨 slice、跨进程(每条腿只追加自己的 * delta,和跨越 park 边界)。**本模块不累加任何东西**:读时求和,不落第二本账(两本账必漂移)。 * * 【与 core `ResourceLedger` 的关系(诚实登记,别读成「就是那本账」)】core 执法读的是挂在 * `resource_limit` checkpoint 行上的 `ResourceLedger`,它对本仓在 running 期**结构性不可达**(没有 * 「按 taskId 找 checkpoint」的面,而且跑的时候那行是 `resolved` 的)。两边量的是**同一个量**: * core 的 `spentTokens` 轴 = 每片 `TaskStats.tokens`(= Σ 每次调用 `usage.totalTokens`,**不含**委派子代, * 子代在 `stats.nested`),而 `model_usage` 行正是同一批 `brain.call` 的逐 turn delta、同样只收顶层 run。 * 已知差:① **滞后至多一个 turn**(delta 在 turn 边界 drain,进行中的那一 turn 还没入账);② 账本行 * 写失败(F 类)会少计。⇒ 本投影是**分诊/预警**读面,**不是**任何门/CAS/resume 判定的输入。 * * 【登记册的语义边界(与 `turn-activity.ts` 的 `msSinceLastActivity` 逐字同族)】 * · **同副本 best-effort**:只活在本进程。跨副本 poll(run 跑在别的副本)或本副本重启后 ⇒ 读不到 ⇒ * **整键诚实缺席**。缺席 = 「本副本无法证明这条 run 有跨片窗」,不是「它没有窗」——绝不铸零上限 * (`0` 在 core 那边是**合法且极紧**的上限,与「没配」两义必须可判别,S-3 顶注同一条纪律)。 * · 不落库、不进 SQL 面。有界性由插入序近似 LRU 兜底(CAP 条,每条 ≈ 100B)。 * · **新世代必须清**:session purge 后客户端合法复用自带 taskId 重提交 —— {@link recordResourceWindow} * 每次先删后写,于是「新腿没窗」不会读到旧腿的窗(`turn-activity` codex S1-F2 同款陷阱)。 * * 【已声明的代价:每次 poll 一次全量账本读(codex 交叉复审 R2-[medium],如实登记、本批不修)】 * {@link buildCrossSliceUsage} 要对**全部** `model_usage` 行求和 ⇒ 调用点(`routes/runs.ts` 的 poll)在 * 窗已登记时每拍读一次 `getEvents(taskId, 0)`,工作量随「轮询次数 × 已积累事件数」增长。三条限幅是**结构性** * 的,不是「应该很少见」:① **只有登记过窗的 run 才读**(没开三旋钮的部署、以及 verify/cascade 腿,一次都不读); * ② 只在 `running` 且非 stale 时读;③ 同一条路上早已存在同形读(终局 + 配了 infra 价目表时的 `needCost` 臂, * 同样是 `getEvents(taskId, 0)`)—— 本批加的是**跑动期**这一档,不是新病种。 * 🔴 真正的收口在**存储面**(不在本模块):给 run store 加一条「累计用量 + resource_limit park 计数」的 * 聚合投影/游标,让 poll 的工作量与账本长度解耦。缓存一份读时结果是**不采纳**的方向——那正是本模块顶注 * 拒绝的「第二本账」(它会与账本漂移,且缺席语义会被缓存住)。 */ import type { TaskSpec } from "@sema-agent/core"; import type { RunEvent } from "./plugins/store-contracts.js"; /** 这条 run 铸给 core 的**跨片上限**(三轴各自可缺席;至少一根在场本记录才存在)。 * $ 轴用 micro-USD —— 与 core `ResourceLedger.totalBudgetMicroUsd` 同单位、整数,避免浮点累积误差 * (core 自己的换算就是 `Math.round(totalBudgetUsd * 1e6)`,此处逐字同式)。 */ export interface ResourceWindowTotals { totalTokens?: number; totalBudgetMicroUsd?: number; maxSlices?: number; } /** wire 上的投影体(`GET /v1/runs/:id` 顶层 `crossSliceUsage`)。**三轴各自成对**:上限键不在 ⇒ 它那根 * 的已耗键也不在(没有天花板就没有「逼近天花板」这件事可读)。`spentMicroUsd` 另有一条缺席理由 —— * unpriced 部署下用量行不带 `costMicroUsd` = **未知**,折 0 会谎报「还没花钱」([ref] 未知传染)。 */ export interface CrossSliceUsage { totalTokens?: number; spentTokens?: number; totalBudgetMicroUsd?: number; spentMicroUsd?: number; maxSlices?: number; sliceCount?: number; } /** * 登记这条 run 的跨片窗(装配点 = 三个 `createRun` 成功分支,与 `clearTurnActivity` 同位同理由)。 * * `rs` 就是 `TaskSpec.resourceSuspend` 本身 —— 缺席、或三根总额一根都没有(`resourceSuspendOptIn` 只在 * `> 0` 时才铸键,所以「有键」即「有真上限」)⇒ **不留记录**(并清掉同 taskId 的旧世代残留)。 */ export declare function recordResourceWindow(taskId: string, rs: TaskSpec["resourceSuspend"] | undefined): void; /** * **续跑腿**的登记(与 {@link recordResourceWindow} 分家,codex 交叉复审 R2-[high] 验真后修)。 * * 🔴 三根旋钮在续跑腿上的下场**不同**,所以登记法也必须不同(S-3 已成文的上游契约,此处是它的读面孪生): * · `totalTokens` / `totalBudgetUsd` —— core **冻在账本上**(`debitLedger` 逐字 `prior ?? total`)。 * 续跑腿的 spec 里那两个值是**当期 env 现读**的,与这条链真正在被执法的值**可以不同**(运维在两片 * 之间把 500k 改成 1M:core 仍按 500k 停,而现读值会让人以为还剩一倍余量)。⇒ **一律不取**: * 已有记录就保留(那是首片登记的、也就是被冻住的那份),没有记录就诚实缺这一轴。 * · `maxSlices` —— core **不冻结**,每片现读当期部署值去比账本上的 `sliceCount`。⇒ **取当期值**才是真的。 * * 缺席语义不变:两条 totals 缺、`maxSlices` 也缺 ⇒ 不留记录(整键缺席)。 */ export declare function recordResumeResourceWindow(taskId: string, rs: TaskSpec["resourceSuspend"] | undefined): void; /** * 本副本记得的跨片窗;无记录 ⇒ `undefined`(缺席语义见顶注:「无法证明」,不是「没有窗」)。 * * 🔴 **读命中会刷新插入序**(codex 交叉复审 R1-[high] 的逐出半场,验真后修):终局的 run **不主动清** * (与 `turn-activity.ts` 同理由:主动清会制造「done 已打点、行还没翻终态」窗口里的假缺席),于是纯 * 写序 FIFO 下,一条长跑的 run 会被它之后的 8192 条**早已终局**的记录挤掉 —— 恰好挤掉唯一还需要这份 * 数据的那一条。改成「按读刷新」= 真 LRU:被 poll 的(=活着且有人看的)那条永远是最年轻的。 * 代价如实:本函数因此**有副作用**(只改顺序、不改值),调用点是 poll 路径,幂等无碍。 */ export declare function readResourceWindow(taskId: string): ResourceWindowTotals | undefined; /** 测试隔离用(生产路径不调 —— 终局不主动清,理由与 `turn-activity.ts` 同:清反而制造假缺席窗口)。 */ export declare function clearResourceWindow(taskId: string): void; /** * 投影体的**纯**构造:上限来自登记册,已耗来自这条 run 的 durable 账本行。 * * 三轴的已耗读法: * · `spentTokens` = 全账本 `model_usage` 行逐模型四分量求和(input MISS + output + cacheRead + cacheWrite * = 与 `usage-analytics` 读侧同口径,也就是 core `stats.tokens` 量的那个总量)。零行 = 真的还没记到 * 账 ⇒ `0`(而不是缺席:token 轴没有「未知」这一格,append-only 保证它单调不减)。 * · `spentMicroUsd` = 同样逐模型求和;**任一模型的成本未知 ⇒ 整个和未知 ⇒ 键缺席** * (`aggregateModelUsage` 已按模型做了未知传染,这里只要看键在不在)。 * · `sliceCount` = 账本上 `resource_limit` park 行的条数 —— core 的 `ResourceLedger.sliceCount` 正是在 * 每次 resource 挂起时 +1,而每一次那样的挂起在本仓都恰好落一行 `suspended{gate.kind:"resource_limit"}` * (审批 park / plan_review park 的 gate.kind 不同,不计)。 */ export declare function buildCrossSliceUsage(totals: ResourceWindowTotals, events: readonly RunEvent[]): CrossSliceUsage | undefined; //# sourceMappingURL=resource-window.d.ts.map