@sema-agent/server 3.5.0 → 3.5.2

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.
package/dist/budget.js CHANGED
@@ -131,6 +131,31 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
131
131
  if (principal)
132
132
  costQuota?.add(principal, e.costMicroUsd);
133
133
  }
134
+ // ┌─ #59 裁定([2052]① cli 报案 → 黑板 [2073] 立案;本块是该工单的落点,勿删) ─────────────────┐
135
+ // 病灶报案:下面三处 `e.costMicroUsd ?? 0`(本行 + fleetUsage + fleetLease)把「成本未知」记成
136
+ // 「免费」,与 `pricingConfigured=false` 的部署组合 ⇒ 全部账目假 0。
137
+ //
138
+ // 亲读上游后的**事实纠偏**:core 在 brain.call 帧上**恒发数字、从不缺席**——
139
+ // runtask.ts `usageCostMicroUsd(...)` 返回 `number`(内部 `computeCostMicroUsd` 恒返回 number),
140
+ // 帧上 `costMicroUsd: turnCostMicroUsd` 是**无条件**键;无价目表时 `modelCostToPricing(undefined)`
141
+ // 产出全零单价 ⇒ 每次调用如实算出 **0**。
142
+ // 所以「0 = 免费」与「0 = 没配价目表」的歧义**产生在 core**,server 收到的信息里根本不含这个区分:
143
+ // 把这三处改成透传缺席**治不了根**(缺席永不到达),只会在 wire/SDK 面开一个今天零 producer 的可缺键。
144
+ // `?? 0` 因此定性为**类型面守卫**(core 契约把该键声明为 optional),不是语义折零点。
145
+ //
146
+ // 三问:
147
+ // · 谁需要 —— 消费端要区分真零/未知(cli 在 CC 对照里正是靠「诚实缺席不编造」赢的那一点)。
148
+ // · 谁受伤 —— (a) 聚合面遇缺席:usage-analytics 与 aggregateModelUsage 此前把 DB JSON 当全必填做裸
149
+ // 算术,一行缺键就 NaN/字符串毒掉整窗(#59 同批已修,红先复现在
150
+ // test/usage-analytics.test.ts + test/model-usage.test.ts);(b) 历史行:已按 0 记账的行无法回溯
151
+ // 区分,只能靠部署位读回。
152
+ // · 补偿 —— 第一级 = `capabilities.pricingConfigured`(main.ts:801,已在):false ⇒ 消费端把任何 0
153
+ // 渲染成「未知」而非「免费」;SDK `ModelUsageDelta.costMicroUsd` 的文档逐字同源。第二级(行级缺席)
154
+ // 需要 core 先在源头消歧 —— **工单挂 core**:无价目表时别为该调用编 0(缺席该键,或带一位
155
+ // priced=false),到货后本处三行同批改透传,聚合面已经接得住。
156
+ // 现状语义(本批测试钉死,勿静默改):core 发 0 ⇒ server **如实透传 0**,消歧走部署位;
157
+ // 缺席(今日无 producer)⇒ 记 0,不抛不 NaN。
158
+ // └──────────────────────────────────────────────────────────────────────────────────────────────┘
134
159
  // E8 (shell-host): per-task × per-model usage for the `TaskStats.modelUsage` echo. `record` is a NO-OP unless
135
160
  // e.taskId is a REGISTERED top-level run (the leak fence — sub-task/throwaway taskIds are never registered, so
136
161
  // they neither leak nor enter the echo; their spend is in TaskStats.nested). Drained at turn boundaries into the
@@ -16,7 +16,13 @@ export type FleetTaskStatus = "queued" | "running" | "waiting" | "stopping" | "a
16
16
  * shell-local render state the renderer composes; the wire carries the data that TRANSITIONS). */
17
17
  export interface FleetTaskRow {
18
18
  id: string;
19
- name: string;
19
+ /** 显示名。**缺席不铸**([2070]③ cli 定谳,server 3.5.1):没有真名时本键**不发**。
20
+ * ⚠️ 两代语义留档 —— 3.5.0 及以前,三处在无真名时把**行 id** 顶到本位(匿名子代 tick 铸子代 uuid、
21
+ * `onChildTerminal` 合成终帧铸 a\* handle、`publishTask` 懒铸底座铸行 id),于是人眼看到一串乱码
22
+ * (clay 亲报的「子代名乱码」形),消费端也**分不清**「它就叫这个」与「我们不知道它叫什么」。
23
+ * 3.5.1 起后者 = 键缺席,显示占位归客户端渲(cli 已收敛 `'(unnamed)'`)。同一条纪律见 [2053]
24
+ * costUSD:诚实缺席 > 编造。(SDK `FleetTaskRow.name` 同步放宽 —— 见发车工单。) */
25
+ name?: string;
20
26
  description?: string;
21
27
  /** §K-7 (shell ASK 2026-06-28; byte-aligned to `@sema-agent/sdk` 0.0.39 `FleetTaskRow.agentType`/`agentName`) — a
22
28
  * SHORT agent-TYPE label so the picker renders like CC 187 (`Explore`/`general-purpose`/`code-review`) instead of
@@ -41,7 +41,9 @@ export class FleetEventBus {
41
41
  /** Upsert a task row (MERGE onto any existing row by `id`) + fan out the merged row. Publishers may send a
42
42
  * partial `{ id, tokens }` etc.; the row keeps its other fields. */
43
43
  publishTask(delta) {
44
- const merged = { ...(this.tasks.get(delta.id) ?? { id: delta.id, name: delta.id, status: "running" }), ...delta };
44
+ // [2070]③:懒铸底座**不带 name** —— 一条只有 `{id,…}` 的首帧此前会把行 id 铸成显示名,而这里
45
+ // 恰恰是「谁都没给过名字」的位置。真名后到时照常 MERGE 上行(见本文件末的 [2070]③ 第 4 条钉)。
46
+ const merged = { ...(this.tasks.get(delta.id) ?? { id: delta.id, status: "running" }), ...delta };
45
47
  this.tasks.set(delta.id, merged);
46
48
  this.emit({ type: "task", row: merged, ts: this.now() });
47
49
  }
@@ -157,16 +159,16 @@ export function fleetRunPublisher(bus, run) {
157
159
  // core 1.147 subagent usage tick (per-turn, cumulative) → a CHILD fleet row nested under the run.
158
160
  const cid = childId(ev.taskId);
159
161
  children.add(cid);
160
- // BC-2 (core 1.151): use the subagent's sanitized display `name` (taskName/agent-type) for the child row;
161
- // fall back to the taskId when absent (an anonymous subagent — core deliberately omits name rather than leak
162
- // the delegated objective). The row IDENTITY stays `cid` (runId+taskId); only the display label improves.
162
+ // BC-2 (core 1.151): use the subagent's sanitized display `name` (taskName/agent-type) for the child row.
163
+ // [2070]③:name 缺席时**不铸**(此前回落 taskId=子代 uuid,人眼乱码)——行 IDENTITY 一直是 `cid`
164
+ // (runId+taskId),显示名与身份是两件事,没有显示名就让客户端渲占位。
163
165
  // [WF2-A] redact the subagent display name for PARITY with the top-level run name (fleetRunLabels): core
164
166
  // sanitizes it for RENDERING (ANSI/NUL strip + truncate) but does NOT secret-redact, so a secret-shaped
165
167
  // taskName would leak verbatim on the child row without this.
166
168
  // §K-7: `ev.name` IS the subagent TYPE (BC-2: taskName / selected agent-type) → ALSO surface it as
167
169
  // `agentType` (the picker's short-label field), not only the display `name`. Absent name (anonymous
168
- // subagent) → no agentType (the row falls back to name=taskId). One redacted value (name === agentType).
169
- const childName = redactSecrets(ev.name ?? ev.taskId);
170
+ // subagent) → 两位一起缺席(name/agentType 同源同命)One redacted value (name === agentType).
171
+ const childName = ev.name !== undefined ? redactSecrets(ev.name) : undefined;
170
172
  if (!childStartedAt.has(cid))
171
173
  childStartedAt.set(cid, Date.now());
172
174
  if (ev.parentToolCallId)
@@ -174,7 +176,7 @@ export function fleetRunPublisher(bus, run) {
174
176
  // [1415]③ 点亮半场:core 1.354 起 settle 时补终态 tick(status 值域 additive 扩 completed|failed;
175
177
  // 此前恒 "running"=行为逐位不变)——workflow 子的 uuid 行终于有完成态可见(running→removed 的
176
178
  // [1414]#3 形就此闭)。
177
- bus.publishTask({ id: cid, name: childName, ...(ev.name ? { agentType: childName } : {}), parentId: run.runId, scope: run.scope, ...sessionField, status: tickStatus(ev.status), tokens: sumTokens(ev.usage), ...(() => { const t = toolUsesOf(ev.usage); return t !== undefined ? { toolUses: t } : {}; })(), elapsedMs: Date.now() - childStartedAt.get(cid) });
179
+ bus.publishTask({ id: cid, ...(childName !== undefined ? { name: childName, agentType: childName } : {}), parentId: run.runId, scope: run.scope, ...sessionField, status: tickStatus(ev.status), tokens: sumTokens(ev.usage), ...(() => { const t = toolUsesOf(ev.usage); return t !== undefined ? { toolUses: t } : {}; })(), elapsedMs: Date.now() - childStartedAt.get(cid) });
178
180
  }
179
181
  else if (ev.type === "tool_start" && ev.toolName) {
180
182
  // BC-1 (clay 2026-06-27): the top-level run row's `description` = LIVE ACTIVITY (the tool now running), NOT
@@ -212,12 +214,13 @@ export function fleetRunPublisher(bus, run) {
212
214
  const parentId = ev.parentTaskId && ev.parentTaskId !== run.rootTaskId ? childId(ev.parentTaskId) : run.runId;
213
215
  // [WF2-A] redact the forwarded subagent name for parity with the top-level run name (see onEvent above).
214
216
  // §K-7: ev.name IS the subagent type → also surface it as `agentType` (picker short label). Reuse the value.
215
- const fwdName = redactSecrets(ev.name ?? ev.taskId);
217
+ // [2070]③ onEvent 臂:无 name name/agentType 两位一起缺席(不拿 taskId 冒充显示名)
218
+ const fwdName = ev.name !== undefined ? redactSecrets(ev.name) : undefined;
216
219
  if (!childStartedAt.has(cid))
217
220
  childStartedAt.set(cid, Date.now());
218
221
  if (ev.parentToolCallId)
219
222
  childByToolCall.set(ev.parentToolCallId, cid);
220
- bus.publishTask({ id: cid, name: fwdName, ...(ev.name ? { agentType: fwdName } : {}), parentId, scope: run.scope, ...sessionField, status: tickStatus(ev.status), tokens: sumTokens(ev.usage), ...(() => { const t = toolUsesOf(ev.usage); return t !== undefined ? { toolUses: t } : {}; })(), elapsedMs: Date.now() - childStartedAt.get(cid) }); // [1415]③ 同上;[1748] toolUses 同 bg lane 口径
223
+ bus.publishTask({ id: cid, ...(fwdName !== undefined ? { name: fwdName, agentType: fwdName } : {}), parentId, scope: run.scope, ...sessionField, status: tickStatus(ev.status), tokens: sumTokens(ev.usage), ...(() => { const t = toolUsesOf(ev.usage); return t !== undefined ? { toolUses: t } : {}; })(), elapsedMs: Date.now() - childStartedAt.get(cid) }); // [1415]③ 同上;[1748] toolUses 同 bg lane 口径
221
224
  },
222
225
  onChildTerminal(taskId, status, altId, toolUseId) {
223
226
  // A background subagent/bash CHILD settles — task_progress never emits a terminal tick,
@@ -251,9 +254,11 @@ export function fleetRunPublisher(bus, run) {
251
254
  const liveCids = candidateCids.filter((c) => children.has(c));
252
255
  const hadRow = liveCids.length > 0;
253
256
  // 都没活行 = bash 无 tick 形:按首候选合成瞬态终帧(建即离,诚实且无害)。
257
+ // [2070]③:合成帧**不铸 name** —— 这条「瞬态」帧会被壳的终态留存池渲 60s,顶上去的 a\* handle
258
+ // 在那 60s 里就是用户看到的名字。父链接/租户/会话三位照发(它们是归属事实,不是显示名)。
254
259
  for (const cid of hadRow ? liveCids : [childId(taskId)]) {
255
260
  const startedAtChild = childStartedAt.get(cid);
256
- bus.publishTask({ id: cid, ...(children.has(cid) ? {} : { name: taskId, parentId: run.runId, scope: run.scope, ...sessionField }), status: fleetStatus, ...(startedAtChild !== undefined ? { elapsedMs: Date.now() - startedAtChild } : {}) });
261
+ bus.publishTask({ id: cid, ...(children.has(cid) ? {} : { parentId: run.runId, scope: run.scope, ...sessionField }), status: fleetStatus, ...(startedAtChild !== undefined ? { elapsedMs: Date.now() - startedAtChild } : {}) });
257
262
  bus.removeTask(cid);
258
263
  children.delete(cid);
259
264
  childStartedAt.delete(cid);
@@ -1,6 +1,7 @@
1
1
  import { canonicalToolName } from "@sema-agent/core";
2
2
  import { principalFrom, verifyDirectDoorProof, MAX_APPROVAL_REASON_CHARS } from "../../security.js";
3
3
  import { redactedPreview } from "../../trace/redact.js";
4
+ import { fleetRunLabels } from "../../fleet/fleet-bus.js"; // [2069]④ §3 行展示名与 fleet 行同源(见用处的注)
4
5
  import { sleep } from "../sse-log.js";
5
6
  import { sendJson, sendError, sseHeaders } from "../send.js";
6
7
  import { gatedPrincipal, explicitOperatorOk, isOperator } from "../principal-gate.js";
@@ -227,7 +228,24 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
227
228
  // ③ 补偿=(a) 行**不消失**——它仍在本列表里带真实 `status`,「所有还停着的任务」由 status 回答;
228
229
  // (b) failSuspendedWithExpiredCheckpoint 三个 run store 全实装、每 tick 无条件跑(不受
229
230
  // APPROVAL_TIMEOUT_SEC 开关),窗口由 REAP_INTERVAL_SEC 封顶后收敛成 failed。
230
- return { taskId: r.taskId, sessionId: r.sessionId, status: r.status, needsAttention: !!gate, gate, createdAt: r.createdAt, updatedAt: r.updatedAt };
231
+ // [2069]④(cli 2026-07-30):additive 展示位。行此前只有 taskId/status/needsAttention,面板
232
+ // 渲不出「这是哪个任务」。源 = run 行自己的 `objectivePreview`(写入时已脱敏 + ~120 截断)——
233
+ // **不**走 §2 inbox 那套 per-row `getCtx`:本视图头注逐字承诺「ONE query, no N+1」,为两个
234
+ // 展示位把它变成 N+1 是拿契约换排版。
235
+ // · `name` 经 `fleetRunLabels()`,与 fleet 行**同一个函数** ⇒ 同一个 run 在两个面板里名字一致
236
+ // (两处各写一遍必然漂),顺带白拿 [1414]④ 的控制帧形保护(`<task-notification…>` 给中性名)。
237
+ // · `description` = 未截断原值,**仅在与 name 不同时才发** —— 相同就是同一句话印两遍
238
+ // (BC-1 在 fleet 行上杀掉的 objective-shown-twice)。
239
+ // · 两键 OMIT-when-absent(无 preview ⇒ 都不发,占位归客户端),同批 [2070]③ 同一条纪律。
240
+ // 钉:test/assistant-tasks-display-keys.test.ts。
241
+ const preview = r.objectivePreview?.trim() ? r.objectivePreview : undefined;
242
+ const label = preview !== undefined ? fleetRunLabels(preview).name : undefined;
243
+ return {
244
+ taskId: r.taskId, sessionId: r.sessionId, status: r.status, needsAttention: !!gate, gate,
245
+ ...(label !== undefined ? { name: label } : {}),
246
+ ...(preview !== undefined && label !== preview ? { description: preview } : {}),
247
+ createdAt: r.createdAt, updatedAt: r.updatedAt,
248
+ };
231
249
  })
232
250
  .sort((a, b) => Number(b.needsAttention) - Number(a.needsAttention) || (b.gate?.severity ?? 0) - (a.gate?.severity ?? 0) || (b.gate?.spentMicroUsd ?? 0) - (a.gate?.spentMicroUsd ?? 0));
233
251
  sendJson(res, 200, { tasks });
package/dist/main.js CHANGED
@@ -808,22 +808,41 @@ async function main() {
808
808
  runDenySweep = server.denyExpiredApprovals;
809
809
  // [1934]:绑址旋钮 + 无鉴权自收窄(理由见 resolveBindHost 顶注)。undefined = Node 默认全接口。
810
810
  const bindHost = resolveBindHost(config);
811
- await new Promise((resolve) => (bindHost ? server.listen(config.port, bindHost, resolve) : server.listen(config.port, resolve)));
812
- logger.info("listening", {
813
- port: config.port,
814
- bindHost: bindHost ?? "0.0.0.0/::(all interfaces)",
815
- ...(bindHost === "127.0.0.1" && !config.bindHost
816
- ? { note: "auto-narrowed to loopback: write face is unauthenticated (ALLOW_UNAUTHED_WRITES with no service token). Set BIND_HOST explicitly to override." }
817
- : {}),
818
- });
819
811
  // [2062]③ HOST 继承暗通道告警:zsh 常把 HOST 设成机器名,`{...process.env}` 起底的壳会静默继承给
820
812
  // 引擎——bind 面被 shell 环境暗配而部署者不知情(HOST=机器名解析到 LAN 口时=静默扩暴露面)。
813
+ // 🔴 [2067]③ 次序承重:必须在 listen **之前**打——HOST=不可解析名时 listen 直接 ENOTFOUND 崩溃,
814
+ // 恰是「继承 HOST 把 boot 弄崩」这个最需要解释的场景;warn 在 listen 后=诊断在崩溃里缺席,用户只见
815
+ // 裸 getaddrinfo 栈(cli 真机取证)。
821
816
  if (config.bindHostSource === "HOST") {
822
817
  logger.warn("bind_host_from_HOST_env", {
823
818
  bindHost,
824
819
  note: "the bind address came from the HOST env var (not BIND_HOST). Shells like zsh commonly set HOST to the machine name — if you did not set it yourself, this may be an inherited shell variable silently configuring the bind face. Set BIND_HOST explicitly to silence this warning.",
825
820
  });
826
821
  }
822
+ try {
823
+ await new Promise((resolve, reject) => {
824
+ server.once("error", reject); // listen 失败(ENOTFOUND/EADDRINUSE…)不吊死这个 Promise
825
+ bindHost ? server.listen(config.port, bindHost, resolve) : server.listen(config.port, resolve);
826
+ });
827
+ }
828
+ catch (e) {
829
+ // [2067]③:崩溃路径自带来源诊断——不可解析的 HOST 继承名在这里最常见。
830
+ logger.error("listen_failed", {
831
+ port: config.port,
832
+ bindHost: bindHost ?? "0.0.0.0/::(all interfaces)",
833
+ ...(config.bindHostSource ? { bindHostSource: config.bindHostSource } : {}),
834
+ ...(config.bindHostSource === "HOST" ? { note: "bind failed for a host inherited from the HOST env var — set BIND_HOST explicitly (e.g. BIND_HOST=127.0.0.1)." } : {}),
835
+ error: e instanceof Error ? e.message : String(e),
836
+ });
837
+ throw e;
838
+ }
839
+ logger.info("listening", {
840
+ port: config.port,
841
+ bindHost: bindHost ?? "0.0.0.0/::(all interfaces)",
842
+ ...(bindHost === "127.0.0.1" && !config.bindHost
843
+ ? { note: "auto-narrowed to loopback: write face is unauthenticated (ALLOW_UNAUTHED_WRITES with no service token). Set BIND_HOST explicitly to override." }
844
+ : {}),
845
+ });
827
846
  // fleet worker 接线(announce/heartbeat + usage 批报;lease 消费后一拍):boot 完成后
828
847
  // 注册(=首次心跳)。门 = center lane 配置 ∧ FLEET_ADVERTISE_ADDRESS 显式声明(不自猜可达地址);任一缺 → undefined
829
848
  // = 非 fleet 部署零行为。draining 翻转的即刻 announce 在 drainThenShutdown;退出 DELETE 在 hardShutdown。
@@ -41,7 +41,13 @@ export declare function attachModelUsage<T extends TaskResult>(result: T, ctx: {
41
41
  /** E8 (shell-host contract): SUM the per-turn `model_usage` delta events in a run's durable event log into the
42
42
  * cumulative per-model total for `TaskStats.modelUsage`. The tracer drains deltas at each turn boundary
43
43
  * (append-only) → this is resume-safe: it sums across ALL process legs of a suspended/resumed run. Returns
44
- * undefined when the run logged no model usage (→ the shell soft-degrades to aggregate cost only). */
44
+ * undefined when the run logged no model usage (→ the shell soft-degrades to aggregate cost only).
45
+ *
46
+ * #59([2052]①):事件体是 **DB 里的 JSON**(旧版本 / 别的 producer 都可能写),此前的 `?? 0` 只接住
47
+ * null/undefined——NaN 与字符串会原样进 `+`,一个坏键就把该模型的整 run 总量变成 NaN 或字符串拼接,
48
+ * 再顺着 `TaskStats.modelUsage` 回声流进 usage-analytics 的整窗聚合。守卫改「非有限数 = 缺席、按 0 计」,
49
+ * 与 usage-analytics 读侧同口径。注意本折叠对「costMicroUsd 缺席」与「costMicroUsd=0」的输出相同(都是 0):
50
+ * 行级 0-vs-未知的消歧不在这里,也不在 server —— 裁定见 budget.ts 的「#59 裁定」块。 */
45
51
  export declare function aggregateModelUsage(events: RunEvent[]): Record<string, ModelUsageDelta> | undefined;
46
52
  export type TraceBlock = {
47
53
  type: "thinking";
@@ -39,8 +39,15 @@ export async function attachModelUsage(result, ctx) {
39
39
  /** E8 (shell-host contract): SUM the per-turn `model_usage` delta events in a run's durable event log into the
40
40
  * cumulative per-model total for `TaskStats.modelUsage`. The tracer drains deltas at each turn boundary
41
41
  * (append-only) → this is resume-safe: it sums across ALL process legs of a suspended/resumed run. Returns
42
- * undefined when the run logged no model usage (→ the shell soft-degrades to aggregate cost only). */
42
+ * undefined when the run logged no model usage (→ the shell soft-degrades to aggregate cost only).
43
+ *
44
+ * #59([2052]①):事件体是 **DB 里的 JSON**(旧版本 / 别的 producer 都可能写),此前的 `?? 0` 只接住
45
+ * null/undefined——NaN 与字符串会原样进 `+`,一个坏键就把该模型的整 run 总量变成 NaN 或字符串拼接,
46
+ * 再顺着 `TaskStats.modelUsage` 回声流进 usage-analytics 的整窗聚合。守卫改「非有限数 = 缺席、按 0 计」,
47
+ * 与 usage-analytics 读侧同口径。注意本折叠对「costMicroUsd 缺席」与「costMicroUsd=0」的输出相同(都是 0):
48
+ * 行级 0-vs-未知的消歧不在这里,也不在 server —— 裁定见 budget.ts 的「#59 裁定」块。 */
43
49
  export function aggregateModelUsage(events) {
50
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
44
51
  const total = {};
45
52
  let any = false;
46
53
  for (const ev of events) {
@@ -52,11 +59,11 @@ export function aggregateModelUsage(events) {
52
59
  for (const [model, d] of Object.entries(usage)) {
53
60
  any = true;
54
61
  const cur = total[model] ?? { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costMicroUsd: 0 };
55
- cur.inputTokens += d.inputTokens ?? 0;
56
- cur.outputTokens += d.outputTokens ?? 0;
57
- cur.cacheReadTokens += d.cacheReadTokens ?? 0;
58
- cur.cacheWriteTokens += d.cacheWriteTokens ?? 0;
59
- cur.costMicroUsd += d.costMicroUsd ?? 0;
62
+ cur.inputTokens += num(d.inputTokens);
63
+ cur.outputTokens += num(d.outputTokens);
64
+ cur.cacheReadTokens += num(d.cacheReadTokens);
65
+ cur.cacheWriteTokens += num(d.cacheWriteTokens);
66
+ cur.costMicroUsd += num(d.costMicroUsd);
60
67
  total[model] = cur;
61
68
  }
62
69
  }
@@ -12,7 +12,9 @@
12
12
  * - tokensOut = Σ outputTokens;
13
13
  * - costUsd = Σ costMicroUsd / 1e6(authoritative);
14
14
  * - 旧行/无 modelUsage echo 的行(1.x 早期或非终态)fallback `stats.tokens` 整数计入 tokensOut 并置
15
- * `estimated:true` 于该聚合(诚实标注,契约要求"token 数标注估算与否");
15
+ * `estimated:true` 于该聚合(诚实标注,契约要求"token 数标注估算与否");#59 起 `estimated` 还覆盖
16
+ * 第二种口径不完整:**有 echo 行但缺数值键**(该键按 0 计入 ⇒ 总量偏低)——两臂同义:别把总数当精确值;
17
+ * - 行内数值键一律走 {@link numOf} 守卫:数据源是 DB 里的 JSON,裸算术遇缺席/非数会 NaN 毒掉整窗(#59);
16
18
  * - 扫窗有行数上限(store 侧 LIMIT)——触顶时结果携带 `truncated:true`(诚实截断,绝不静默)。
17
19
  */
18
20
  /** store 侧 usageScan 的行投影(所有后端同形状;createdAtMs=epoch ms)。 */
@@ -23,21 +25,33 @@ export interface UsageRow {
23
25
  /** result.stats 摘要;result NULL/不可解析 ⇒ undefined(不计 token/cost,仍计 tasks)。 */
24
26
  stats?: {
25
27
  tokens?: number;
26
- modelUsage?: Record<string, {
27
- inputTokens: number;
28
- outputTokens: number;
29
- cacheReadTokens: number;
30
- cacheWriteTokens: number;
31
- costMicroUsd: number;
32
- }>;
28
+ modelUsage?: Record<string, UsageModelEntry>;
33
29
  };
34
30
  }
31
+ /**
32
+ * 一行 per-model echo。**五键逐个可选**——本类型描述的不是本进程铸的对象,而是 **DB 里 result JSON 的一段**:
33
+ * 旧版本写的行、别的 producer 写的行、以及 wire 契约本身(SDK `ModelUsageDelta` 五键全可选 + `[k]:unknown`
34
+ * 开放)都可能少键。#59([2052]①):此处原先声明成「全必填」,`projectUsageStats` 又对任意 JSON 做裸 cast,
35
+ * 于是 fold 侧的裸算术遇缺席就产出 NaN,**一行毒一整窗**(NaN 沿 Σ 传染,JSON.stringify(NaN)==="null",
36
+ * 消费端读到的是「没有数」而不是「少了一行」)。声明改诚实 + fold 侧 {@link numOf} 数值守卫。
37
+ */
38
+ export interface UsageModelEntry {
39
+ inputTokens?: number;
40
+ outputTokens?: number;
41
+ cacheReadTokens?: number;
42
+ cacheWriteTokens?: number;
43
+ /** 整数 micro-USD。**缺席 = 未知**,不是 0(SDK `ModelUsageDelta` 同款单轨契约)。注意在场的 `0` 仍有
44
+ * 歧义(引擎无价目表时恒发 0),那层消歧在部署面 `capabilities.pricingConfigured`——裁定见 budget.ts。 */
45
+ costMicroUsd?: number;
46
+ }
35
47
  export interface UsageTotals {
36
48
  tasks: number;
37
49
  tokensIn: number;
38
50
  tokensOut: number;
39
51
  costUsd: number;
40
- /** 有行走了 stats.tokens fallback(无 per-model echo)——token 口径部分为估算。 */
52
+ /** 本聚合的口径**不完整**——两种来源:①有行走了 stats.tokens fallback(无 per-model echo,token 数为估算);
53
+ * ②有 per-model echo 行**缺数值键**(该键按 0 计入,总量偏低;成本键缺席时尤其:少算 ≠ 免费)。
54
+ * 两者都只表达「别把这个总数当精确值」,消费端一视同仁。 */
41
55
  estimated: boolean;
42
56
  }
43
57
  /** GET /v1/usage/summary — 窗内总量。 */
@@ -12,19 +12,37 @@
12
12
  * - tokensOut = Σ outputTokens;
13
13
  * - costUsd = Σ costMicroUsd / 1e6(authoritative);
14
14
  * - 旧行/无 modelUsage echo 的行(1.x 早期或非终态)fallback `stats.tokens` 整数计入 tokensOut 并置
15
- * `estimated:true` 于该聚合(诚实标注,契约要求"token 数标注估算与否");
15
+ * `estimated:true` 于该聚合(诚实标注,契约要求"token 数标注估算与否");#59 起 `estimated` 还覆盖
16
+ * 第二种口径不完整:**有 echo 行但缺数值键**(该键按 0 计入 ⇒ 总量偏低)——两臂同义:别把总数当精确值;
17
+ * - 行内数值键一律走 {@link numOf} 守卫:数据源是 DB 里的 JSON,裸算术遇缺席/非数会 NaN 毒掉整窗(#59);
16
18
  * - 扫窗有行数上限(store 侧 LIMIT)——触顶时结果携带 `truncated:true`(诚实截断,绝不静默)。
17
19
  */
20
+ /** JSON 来源的数值键:非 number / 非有限 ⇒ undefined(缺席),**绝不返回 NaN**,也绝不让字符串滑进 `+`。 */
21
+ function numOf(d, key) {
22
+ const v = d[key];
23
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
24
+ }
18
25
  const emptyTotals = () => ({ tasks: 0, tokensIn: 0, tokensOut: 0, costUsd: 0, estimated: false });
26
+ /** 折入一行 per-model echo(summary/series 的 foldRow 与 breakdown 的 model 维共用——两处写两遍必漂移)。
27
+ * 缺席/非数键按 0 计入并置 `estimated`(诚实标注偏低,绝不静默,也绝不 NaN 毒窗)。 */
28
+ function foldModelEntry(t, d) {
29
+ const input = numOf(d, "inputTokens");
30
+ const output = numOf(d, "outputTokens");
31
+ const cacheRead = numOf(d, "cacheReadTokens");
32
+ const cacheWrite = numOf(d, "cacheWriteTokens");
33
+ const cost = numOf(d, "costMicroUsd");
34
+ t.tokensIn += (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0);
35
+ t.tokensOut += output ?? 0;
36
+ t.costUsd += (cost ?? 0) / 1e6;
37
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined || cost === undefined)
38
+ t.estimated = true;
39
+ }
19
40
  function foldRow(t, row) {
20
41
  t.tasks += 1;
21
42
  const mu = row.stats?.modelUsage;
22
43
  if (mu && Object.keys(mu).length > 0) {
23
- for (const d of Object.values(mu)) {
24
- t.tokensIn += d.inputTokens + d.cacheReadTokens + d.cacheWriteTokens;
25
- t.tokensOut += d.outputTokens;
26
- t.costUsd += d.costMicroUsd / 1e6;
27
- }
44
+ for (const d of Object.values(mu))
45
+ foldModelEntry(t, d);
28
46
  }
29
47
  else if (row.stats?.tokens) {
30
48
  t.tokensOut += row.stats.tokens; // 旧行 fallback:总数无 in/out 拆分,归 out 侧并标 estimated
@@ -75,9 +93,7 @@ export function usageBreakdown(rows, dimension) {
75
93
  for (const [model, d] of Object.entries(mu)) {
76
94
  const t = bucket(model);
77
95
  t.tasks += 1; // 每 (run, model) 计一次 —— 一个 run 用两个模型在两桶各计 1(桶内 tasks=touched-run 数)
78
- t.tokensIn += d.inputTokens + d.cacheReadTokens + d.cacheWriteTokens;
79
- t.tokensOut += d.outputTokens;
80
- t.costUsd += d.costMicroUsd / 1e6;
96
+ foldModelEntry(t, d); // #59:与 summary/series 同一守卫(缺席键不毒桶,只标 estimated;桶间不传染)
81
97
  }
82
98
  }
83
99
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.5.0",
3
+ "version": "3.5.2",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -67,7 +67,7 @@
67
67
  "sharp": "^0.35.3"
68
68
  },
69
69
  "devDependencies": {
70
- "@sema-agent/sdk": "^1.1.0",
70
+ "@sema-agent/sdk": "^2.1.1",
71
71
  "@types/libsodium-wrappers": "^0.7.14",
72
72
  "@types/node": "22.10.2",
73
73
  "@types/pg": "^8.20.0",