@sema-agent/server 3.5.3 → 3.6.1

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.d.ts CHANGED
@@ -29,7 +29,11 @@ export interface ModelUsageDelta {
29
29
  outputTokens: number;
30
30
  cacheReadTokens: number;
31
31
  cacheWriteTokens: number;
32
- costMicroUsd: number;
32
+ /** 🔴 RB-368(core 2.3.0 起)**可选 = 缺席表示「未知」,不是 0**。unpriced 部署(目录里该模型没配
33
+ * `cost`、也没注 `pricing`)下 core 在 `brain.call` 帧上**整键缺席**,我们如实透传;显式全零 `cost`
34
+ * = 运营者声明免费 ⇒ 键在场且为 0。两者可区分,这正是 #59 折零案的源头修复。
35
+ * 与 SDK `ModelUsageDelta`(五键全可选)/ `usage-analytics.UsageModelEntry` 同轨。 */
36
+ costMicroUsd?: number;
33
37
  }
34
38
  /**
35
39
  * E8 (shell-host contract): per-task × per-model usage accumulator, fed (synchronously) by the `brain.call`
package/dist/budget.js CHANGED
@@ -76,10 +76,18 @@ export class ModelUsageTracker {
76
76
  cur.outputTokens += d.outputTokens;
77
77
  cur.cacheReadTokens += d.cacheReadTokens;
78
78
  cur.cacheWriteTokens += d.cacheWriteTokens;
79
- cur.costMicroUsd += d.costMicroUsd;
79
+ // 🔴 RB-368 未知传染:已知 + 未知 = **未知**(和里含一个不知道的项,这个和就不知道)⇒ 删键,不折 0。
80
+ // 顺序无关(先未知后已知同样保持未知),因为 `cur.costMicroUsd` 已是 undefined 时 `+` 也不成立。
81
+ if (d.costMicroUsd === undefined || cur.costMicroUsd === undefined)
82
+ delete cur.costMicroUsd;
83
+ else
84
+ cur.costMicroUsd += d.costMicroUsd;
80
85
  }
81
86
  else {
82
- perModel.set(model, { ...d });
87
+ // 显式重建而非 `{...d}`:调用方可能传 `{costMicroUsd: undefined}`(显式 undefined 键),
88
+ // 那样 `Object.hasOwn` 为真、JSON.stringify 又把它抹掉 ⇒ 「缺席」在内存与落库两处形状不一致。
89
+ const { costMicroUsd, ...tokens } = d;
90
+ perModel.set(model, { ...tokens, ...(costMicroUsd !== undefined ? { costMicroUsd } : {}) });
83
91
  }
84
92
  }
85
93
  /** Return the usage accumulated for this task SINCE the last drain (per neutral model id), and RESET it.
@@ -131,37 +139,45 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
131
139
  if (principal)
132
140
  costQuota?.add(principal, e.costMicroUsd);
133
141
  }
134
- // ┌─ #59 裁定([2052]① cli 报案 → 黑板 [2073] 立案;本块是该工单的落点,勿删) ─────────────────┐
135
- // 病灶报案:下面三处 `e.costMicroUsd ?? 0`(本行 + fleetUsage + fleetLease)把「成本未知」记成
142
+ // ┌─ #59 裁定 / RB-368 结案([2052]① cli 报案 → [2073] 立案 → [2076] 工单挂 core → [2083] 到货) ─┐
143
+ // 病灶报案:下面三处曾是 `e.costMicroUsd ?? 0`(账本 + fleetUsage + fleetLease),把「成本未知」记成
136
144
  // 「免费」,与 `pricingConfigured=false` 的部署组合 ⇒ 全部账目假 0。
137
145
  //
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),不是语义折零点。
146
+ // **第一轮裁定(core ≤2.2.0,已被上游修复取代,留档见证判断链)**:亲读上游后纠偏——core 当时在
147
+ // brain.call 帧上**恒发数字、从不缺席**(`costMicroUsd: turnCostMicroUsd` 是无条件键;无价目表时
148
+ // `modelCostToPricing(undefined)` 产出全零单价 ⇒ 如实算出 0)。歧义**产生在 core**,server 收到的
149
+ // 信息里根本不含这个区分 当时改透传治不了根(缺席永不到达),`?? 0` 定性为**类型面守卫**。
150
+ // 结论是把根因工单挂回 core,而不是在本层做样子。
145
151
  //
146
- // 三问:
147
- // · 谁需要 —— 消费端要区分真零/未知(cli 在 CC 对照里正是靠「诚实缺席不编造」赢的那一点)。
152
+ // 🔴 **第二轮 = 现行(core ≥2.3.0,RB-368 到货)**:core 已在源头消歧——unpriced 部署下
153
+ // brain.call(`runtask.js:831`)/ task.end(`:2851`)/ `stats.costMicroUsd`+`costBreakdown`
154
+ // (`assemble-result.js:39,144`)**整键缺席**;显式全零 `cost` = 声明免费 ⇒ 照发 0。于是三处
155
+ // `?? 0` 全部改为**条件展开透传**:键在 ⇒ 原样传(含真 0);键不在 ⇒ 三路都不带该键。
156
+ // 事实钉在 test/budget.test.ts(真 Runner 跑一趟取证),floor 已钉 ^2.3.0——装回旧 core 这三条
157
+ // 透传臂会变成永不到达的死枝而全量照绿,那正是 floor 门存在的理由。
158
+ //
159
+ // 三问(第二轮):
160
+ // · 谁需要 —— 消费端要区分真零/未知(cli 在 CC 对照里正是靠「诚实缺席不编造」赢的那一点;
161
+ // cli #68 的两信号分渲 = 部署级 pricingConfigured + 行级缺席,行级这一半由本改动供给)。
148
162
  // · 谁受伤 —— (a) 聚合面遇缺席:usage-analytics 与 aggregateModelUsage 此前把 DB JSON 当全必填做裸
149
163
  // 算术,一行缺键就 NaN/字符串毒掉整窗(#59 同批已修,红先复现在
150
164
  // 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。
165
+ // 区分,只能靠部署位读回——**本改动不回溯**,分界线 = 升到 core 2.3.0 那一刻。
166
+ // · 补偿 —— 第一级 = `capabilities.pricingConfigured`(main.ts,已在):false ⇒ 消费端把任何 0
167
+ // 渲染成「未知」而非「免费」;第二级 = 行级缺席(本改动),沿账本→durable `model_usage`→
168
+ // `aggregateModelUsage`→`TaskStats.modelUsage` 回声→usage-analytics `estimated` 标记,一路
169
+ // 保持缺席(聚合面的**未知传染**语义见 trace/project.ts)
170
+ // · **一处没接住,如实记账**:fleet 批报腿(下面 fleetUsage)——registry-core
171
+ // `UsageEntry.costMicroUsd` 是 `z.number()` **必填**,wire 上表达不了缺席。故批报入参虽然
172
+ // 如实不带键,accumulator 侧仍只能把未知折成「不计入已知和」(见 fleet-client.ts 的旁注),
173
+ // center 看到的仍是低估而非「未知」。要真治需 registry-core 契约改 optional + center 消费面同改。
158
174
  // └──────────────────────────────────────────────────────────────────────────────────────────────┘
159
175
  // E8 (shell-host): per-task × per-model usage for the `TaskStats.modelUsage` echo. `record` is a NO-OP unless
160
176
  // e.taskId is a REGISTERED top-level run (the leak fence — sub-task/throwaway taskIds are never registered, so
161
177
  // they neither leak nor enter the echo; their spend is in TaskStats.nested). Drained at turn boundaries into the
162
178
  // durable log (resume-safe); the registered entry is dropped at run end (clear). NOT cardinality-guarded by
163
179
  // model: bounded by a run's model set, and by the active-run set.
164
- modelUsage?.record(e.taskId, e.model, { inputTokens: e.promptTokens, outputTokens: e.completionTokens, cacheReadTokens: e.cacheRead, cacheWriteTokens: e.cacheWrite, costMicroUsd: e.costMicroUsd ?? 0 });
180
+ modelUsage?.record(e.taskId, e.model, { inputTokens: e.promptTokens, outputTokens: e.completionTokens, cacheReadTokens: e.cacheRead, cacheWriteTokens: e.cacheWrite, ...(e.costMicroUsd !== undefined ? { costMicroUsd: e.costMicroUsd } : {}) }); // RB-368 透传缺席
165
181
  // weight-at-burn:加权 tokens = (prompt+completion)×模型配额倍率,在记账点固化(此后改倍率
166
182
  // 不回算历史,registry ModelEntry.quotaWeight 同句契约)。weight 源=config.modelQuotaWeights(registry
167
183
  // hot-apply;env lane 空表 ⇒ 1 兜底);非法回调值防御性归 1(tracer 契约 never-throws,不让坏表毒记账)。
@@ -170,10 +186,10 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
170
186
  const weightedTokens = Math.round((e.promptTokens + e.completionTokens) * qw);
171
187
  // P2 用量批报:principal×model 窗口累计(同 ⑤b 的 ALS 归因点 — 子任务花费也归到发起 principal),
172
188
  // fleet-client 每窗口 drain→POST /api/fleet/usage。未配 fleet(accumulator 缺席)= 零行为。
173
- fleetUsage?.record(currentPrincipal(), e.model, e.taskId, { inputTokens: e.promptTokens, outputTokens: e.completionTokens, costMicroUsd: e.costMicroUsd ?? 0, weightedTokens, quotaWeightAtUse: qw });
189
+ fleetUsage?.record(currentPrincipal(), e.model, e.taskId, { inputTokens: e.promptTokens, outputTokens: e.completionTokens, ...(e.costMicroUsd !== undefined ? { costMicroUsd: e.costMicroUsd } : {}), weightedTokens, quotaWeightAtUse: qw }); // RB-368 透传缺席
174
190
  // lease 消费(D4 AP):同一 ALS 归因点做 lease 本地扣减 —— 只对当前持 lease 的 principal
175
191
  // 生效(FleetLeaseManager 内部判),sync + never throws。双轴=weightedTokens(配额轴)+costMicroUsd($ 轴)。
176
- fleetLease?.recordSpend(currentPrincipal(), { weightedTokens, costMicroUsd: e.costMicroUsd ?? 0 });
192
+ fleetLease?.recordSpend(currentPrincipal(), { weightedTokens, ...(e.costMicroUsd !== undefined ? { costMicroUsd: e.costMicroUsd } : {}) }); // RB-368 透传缺席
177
193
  if (e.firstTokenMs !== undefined)
178
194
  metrics.observe("brain_first_token_ms", e.firstTokenMs);
179
195
  metrics.observe("brain_call_latency_ms", e.latencyMs);
@@ -109,6 +109,16 @@ export interface FleetTaskRow {
109
109
  path: string;
110
110
  edits: number;
111
111
  }>;
112
+ /** [2070]①/[2080] 裁定(server 半场,3.5.4):**产出这一行的车道**。
113
+ * - 缺席 = BCE 车道(`fleetBackgroundChildPublisher`)的 a\* 与 wa\* 真行,或顶层 run 行 —— 单源,常态。
114
+ * - `"run-leg"` = 由每腿的 `fleetRunPublisher` 从 `task_progress` tick 铸的**复合 id 行**
115
+ * (`${runId} ${childTaskId}`)。此形只在**没有 BCE 真行**的前台 delegation 上保留(同步腿委派的
116
+ * 子代不过 background-child registry ⇒ 不让位就没有行);同 (scope, progressTaskId) 一旦有 BCE 真行,
117
+ * 本车道**让位不铸**(双生行退役)。
118
+ * 消费端读法:带 `sourceLane:"run-leg"` 的行是过渡形,与 a\* 行**不会**同时描述同一个 agent;不需要再
119
+ * 自己发明「复合 id 首段==parentId 且撞 (parentId,name)」那类集合级判据(cli 侧的防御税)。
120
+ * ADDITIVE / tolerate-absent。 */
121
+ sourceLane?: "run-leg";
112
122
  }
113
123
  /** One workflow row (wire subset of contract `FleetWorkflow`). */
114
124
  export interface FleetWorkflowRow {
@@ -272,6 +282,15 @@ export declare class FleetEventBus {
272
282
  publishHookNotice(notice: HookNotice): void;
273
283
  /** Drop a task row (terminal + swept) + fan out the removal. Idempotent (a no-op if already gone). */
274
284
  removeTask(id: string): void;
285
+ private readonly bceClaims;
286
+ private static readonly BCE_CLAIM_MAX;
287
+ private static bceKey;
288
+ /** BCE 车道:本车道已为该子代 uuid 建了真行 —— run-leg 车道自此让位。幂等。 */
289
+ claimBackgroundChildLane(scope: string | undefined, childUuid: string, rowId: string): void;
290
+ /** BCE 车道:该子代已终态离场 —— 让位解除(后续同 uuid 的迟到 tick 回到旧行为)。幂等。 */
291
+ releaseBackgroundChildLane(scope: string | undefined, childUuid: string): void;
292
+ /** run-leg 车道:该子代是否已有 BCE 真行(有 ⇒ 让位,不铸复合 id 行)。 */
293
+ backgroundChildLaneRow(scope: string | undefined, childUuid: string): string | undefined;
275
294
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
276
295
  publishWorkflow(delta: Partial<FleetWorkflowRow> & {
277
296
  id: string;
@@ -56,6 +56,37 @@ export class FleetEventBus {
56
56
  if (this.tasks.delete(id))
57
57
  this.emit({ type: "task_remove", id, ts: this.now() });
58
58
  }
59
+ // ┌─ [2070]①/[2080] 让位登记簿(BCE 车道 → run-leg 车道的单向让位信号)────────────────────────────┐
60
+ // 为什么登记簿而不是"查快照有没有 a* 行":两条车道用**不同 id 域**给同一个 agent 建行(BCE=`a*`/`wa*`
61
+ // 稳定 handle;run-leg=`${runId} ${childUuid}` 复合键),快照里没有可直接比对的键。join 键 = core 在
62
+ // BCE 帧上给的 `progressTaskId`(= run-leg tick 的 `taskId`,即子代 uuid),[2080] 明确它已在 tick/terminal
63
+ // 帧上。BCE 车道学到 uuid 即 claim,terminal 即 release;run-leg 车道 claim 在场就让位不铸。
64
+ // 单向 + 只增可见性:claim 丢了(容量逐出/旧 core 不发 progressTaskId)= 退回双生行的旧行为,不会错杀。
65
+ bceClaims = new Map(); // `${scope}\u0000${childUuid}` → a* 行 id
66
+ static BCE_CLAIM_MAX = 4096;
67
+ static bceKey(scope, childUuid) { return `${scope ?? "default"}\u0000${childUuid}`; }
68
+ /** BCE 车道:本车道已为该子代 uuid 建了真行 —— run-leg 车道自此让位。幂等。 */
69
+ claimBackgroundChildLane(scope, childUuid, rowId) {
70
+ const k = FleetEventBus.bceKey(scope, childUuid);
71
+ this.bceClaims.delete(k);
72
+ this.bceClaims.set(k, rowId);
73
+ // 容量兜底:逐最老。丢一条 claim 只是让那个 agent 退回"双生行"旧行为(可见性回退,不是错行),
74
+ // 所以这里可以简单逐出——与 Meta 表"活溯源绝不逐出"的取舍不同,因为丢 claim 无归因损失。
75
+ if (this.bceClaims.size > FleetEventBus.BCE_CLAIM_MAX) {
76
+ const oldest = this.bceClaims.keys().next();
77
+ if (!oldest.done)
78
+ this.bceClaims.delete(oldest.value);
79
+ }
80
+ }
81
+ /** BCE 车道:该子代已终态离场 —— 让位解除(后续同 uuid 的迟到 tick 回到旧行为)。幂等。 */
82
+ releaseBackgroundChildLane(scope, childUuid) {
83
+ this.bceClaims.delete(FleetEventBus.bceKey(scope, childUuid));
84
+ }
85
+ /** run-leg 车道:该子代是否已有 BCE 真行(有 ⇒ 让位,不铸复合 id 行)。 */
86
+ backgroundChildLaneRow(scope, childUuid) {
87
+ return this.bceClaims.get(FleetEventBus.bceKey(scope, childUuid));
88
+ }
89
+ // └────────────────────────────────────────────────────────────────────────────────────────────────┘
59
90
  /** Upsert a workflow row (MERGE by `id`) + fan out. */
60
91
  publishWorkflow(delta) {
61
92
  const merged = { ...(this.workflows.get(delta.id) ?? { id: delta.id, name: delta.id, status: "running" }), ...delta };
@@ -125,6 +156,36 @@ export function fleetRunPublisher(bus, run) {
125
156
  const t = usage?.toolUses;
126
157
  return typeof t === "number" ? t : undefined;
127
158
  };
159
+ /**
160
+ * 🔴 [2070]①/[2080] 让位判据(run-leg 侧半场,server 3.5.4)。
161
+ *
162
+ * 病灶(cli [2070]① 实测):本车道给**每一条**子代 tick 铸复合 id 行 `${runId} ${childTaskId}`,而
163
+ * BCE 车道(`fleetBackgroundChildPublisher`)对同一个 agent 已有 `a*`/`wa*` 稳定 handle 真行 ⇒ 同屏两行
164
+ * (424 次采样 180 次 max=2,连续可见窗约 15s)。BCE 侧此前只有**反应式**清扫(首 tick 一次性后缀扫),
165
+ * 清完本车道下一拍又铸回来,所以 6 双生帧 : 7 真帧。
166
+ *
167
+ * [2080] 裁定=**双生臂让位、不铸新 id 域**(join 键 progressTaskId 已在 BCE 帧上)。本函数即让位闸:
168
+ * 同 (scope, childTaskId) 已有 BCE claim ⇒ 返回 true,调用点**不发行帧**;并把本车道**先前**可能已铸的
169
+ * 复合行清场(claim 可能晚于我们第一拍到达),此后该 agent 单源。
170
+ *
171
+ * 为什么不是"直接删掉复合行铸造":前台 delegation(同步腿委派的子代)**不过** background-child
172
+ * registry ⇒ 永远没有 BCE 真行,让位就等于让这些 agent 从面板消失。那一形保留复合行,并在帧上带
173
+ * `sourceLane:"run-leg"` 明示这是过渡形——消费端不必再自己发明集合级去重判据。
174
+ */
175
+ const yieldToBackgroundChildLane = (childTaskId, cid) => {
176
+ if (bus.backgroundChildLaneRow(run.scope, childTaskId) === undefined)
177
+ return false;
178
+ if (children.has(cid)) {
179
+ // 我们早于 claim 铸过一行 —— 清场(BCE 侧的后缀扫是同一意图的反应式兜底,两者幂等叠加无害)
180
+ bus.removeTask(cid);
181
+ children.delete(cid);
182
+ childStartedAt.delete(cid);
183
+ for (const [k, v] of childByToolCall)
184
+ if (v === cid)
185
+ childByToolCall.delete(k);
186
+ }
187
+ return true;
188
+ };
128
189
  const sumTokens = (usage) => {
129
190
  const u = usage;
130
191
  if (!u)
@@ -158,6 +219,8 @@ export function fleetRunPublisher(bus, run) {
158
219
  else if (ev.type === "task_progress" && ev.taskId) {
159
220
  // core 1.147 subagent usage tick (per-turn, cumulative) → a CHILD fleet row nested under the run.
160
221
  const cid = childId(ev.taskId);
222
+ if (yieldToBackgroundChildLane(ev.taskId, cid))
223
+ return; // [2070]① BCE 真行在场 ⇒ 让位不铸
161
224
  children.add(cid);
162
225
  // BC-2 (core 1.151): use the subagent's sanitized display `name` (taskName/agent-type) for the child row.
163
226
  // [2070]③:name 缺席时**不铸**(此前回落 taskId=子代 uuid,人眼乱码)——行 IDENTITY 一直是 `cid`
@@ -176,7 +239,7 @@ export function fleetRunPublisher(bus, run) {
176
239
  // [1415]③ 点亮半场:core 1.354 起 settle 时补终态 tick(status 值域 additive 扩 completed|failed;
177
240
  // 此前恒 "running"=行为逐位不变)——workflow 子的 uuid 行终于有完成态可见(running→removed 的
178
241
  // [1414]#3 形就此闭)。
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) });
242
+ bus.publishTask({ id: cid, sourceLane: "run-leg", ...(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) });
180
243
  }
181
244
  else if (ev.type === "tool_start" && ev.toolName) {
182
245
  // BC-1 (clay 2026-06-27): the top-level run row's `description` = LIVE ACTIVITY (the tool now running), NOT
@@ -210,6 +273,8 @@ export function fleetRunPublisher(bus, run) {
210
273
  if (ev.type !== "task_progress" || !ev.taskId)
211
274
  return;
212
275
  const cid = childId(ev.taskId);
276
+ if (yieldToBackgroundChildLane(ev.taskId, cid))
277
+ return; // [2070]① BCE 真行在场 ⇒ 让位不铸
213
278
  children.add(cid);
214
279
  const parentId = ev.parentTaskId && ev.parentTaskId !== run.rootTaskId ? childId(ev.parentTaskId) : run.runId;
215
280
  // [WF2-A] redact the forwarded subagent name for parity with the top-level run name (see onEvent above).
@@ -220,7 +285,7 @@ export function fleetRunPublisher(bus, run) {
220
285
  childStartedAt.set(cid, Date.now());
221
286
  if (ev.parentToolCallId)
222
287
  childByToolCall.set(ev.parentToolCallId, cid);
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 口径
288
+ bus.publishTask({ id: cid, sourceLane: "run-leg", ...(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 口径
224
289
  },
225
290
  onChildTerminal(taskId, status, altId, toolUseId) {
226
291
  // A background subagent/bash CHILD settles — task_progress never emits a terminal tick,
@@ -375,6 +440,24 @@ export function fleetBackgroundChildPublisher(bus, log) {
375
440
  }
376
441
  }
377
442
  };
443
+ /**
444
+ * [2070]①/[2080] 让位登记(BCE 侧半场)。把「本车道已为这个子代 uuid 建了真行」登记到 bus 上,
445
+ * run-leg 车道的 task_progress 臂据此让位(不铸 `${runId} ${uuid}` 复合行)。
446
+ *
447
+ * uuid 从哪来(两源,先到先得):①`e.sessionId` —— fork 形 spawn 就带(= forkedId);
448
+ * ②`e.progressTaskId` —— core 在 tick/terminal 帧上给的 join 键([2080] 逐字确认已在场),普通 bg
449
+ * producer 要等首 tick 才现。两者都是**同一个** run-leg tick 的 `taskId`,这就是 join 的依据。
450
+ *
451
+ * `progressParentTaskId` 判别照 suppressedTwin 同款:只有**本 child 自己**的 tick 才算数——嵌套孙代的
452
+ * 帧带的是父的 uuid,拿它 claim 会把父的 run-leg 行误让位掉(父在前台 delegation 形下没有 BCE 真行)。
453
+ * 旧 core 不发这两键 ⇒ 无 claim ⇒ 退回双生行旧行为(可见性回退,不错杀)。
454
+ */
455
+ const claimIfKnown = (e, m) => {
456
+ const own = !e.progressParentTaskId || e.progressParentTaskId === (m.parentTaskId ?? m.parentSessionLink);
457
+ const uuid = e.sessionId ?? (own ? e.progressTaskId : undefined);
458
+ if (uuid)
459
+ bus.claimBackgroundChildLane(m.tenantScope, uuid, e.taskId);
460
+ };
378
461
  // [1338]③ 换算臂:core spawn emit 现把 hostSessionId 装进 parentTaskId(会话 id,非行 id)→ 子行
379
462
  // parentId 指向不存在的行=孤儿伪影(值域修在 core)。server 兜:parentTaskId 不是现存行 id、却恰是
380
463
  // 某 TOP-LEVEL run 行的 sessionId 时,换算成该 run 行 id。guard 式——core 修后 parentTaskId 直接命中
@@ -544,6 +627,9 @@ export function fleetBackgroundChildPublisher(bus, log) {
544
627
  : {}),
545
628
  };
546
629
  remember(e.taskId, m);
630
+ // [2070]① 让位登记(spawn 臂):fork 形 spawn 就带 sessionId=子代 uuid;普通 bg producer 的 spawn
631
+ // 不带,要等首 tick 的 progressTaskId(下方 handleTick 补 claim)。
632
+ claimIfKnown(e, m);
547
633
  // [1364]③:spawn 帧 1.349+ 也带 agentType(类型名)/name——行 name 位保 description(display 标签,
548
634
  // 历史语义),TYPE 列自 spawn 即亮(此前要等首 tick 自愈)。
549
635
  // [1827]①(cli):spawn 行也透传 transcriptId(委派 prompt 的映射,详情页运行中即可取件)。
@@ -554,6 +640,9 @@ export function fleetBackgroundChildPublisher(bus, log) {
554
640
  dlog("bg_child_event", { kind: "spawn", taskId: e.taskId, sessionScoped: e.sessionScoped, scope: m.tenantScope, hostSessionId: m.hostSessionId ?? null, parentTaskId: m.parentTaskId ?? null });
555
641
  };
556
642
  const handleTick = (e, m) => {
643
+ // [2070]① 让位登记(tick 臂):普通 bg 形的 uuid 首次可见就在这里。**在**下面任何发布之前 claim,
644
+ // 这样即便 run-leg 的 forward 与本 tick 同拍到达,run-leg 也已经能看到让位信号。
645
+ claimIfKnown(e, m);
557
646
  // dedup the per-leg uuid-keyed twin ONCE: the leg's onForwardEvent keys the same child's rows by
558
647
  // `${runId} ${uuid}` and on the /v1/runs path runId === the host canonical taskId (spec.taskId is the
559
648
  // durable run id). The tick that carries the child's OWN frame has progressParentTaskId === the host
@@ -632,6 +721,9 @@ export function fleetBackgroundChildPublisher(bus, log) {
632
721
  : {}),
633
722
  });
634
723
  bus.removeTask(e.taskId);
724
+ // [2070]① 让位解除:本子代已终态离场,同 uuid 的迟到帧回到旧行为(claim 只在活期有效)。
725
+ if (m.aliasUuid)
726
+ bus.releaseBackgroundChildLane(m.tenantScope, m.aliasUuid);
635
727
  // codex 1.240-R1(M2):1.356 的回调序=bceTick 先于 host forward——tick 处的双生抑制扫在 uuid 双生
636
728
  // 行发布**之前**,one-shot 空扫后置旗,双生行永存。terminal 补扫一次(aliasUuid=tick 学到的 child
637
729
  // uuid),把 run-leg 的复合键行清场。
@@ -24,10 +24,19 @@ export declare const FLEET_USAGE_KEY_CAP = 1024;
24
24
  export declare class FleetUsageAccumulator {
25
25
  private cells;
26
26
  private runsSeen;
27
+ /**
28
+ * 🔴 RB-368 边界(core 2.3.0):`d.costMicroUsd` **可缺席 = 未知**(unpriced 部署,budget.ts 如实不带键)。
29
+ * 这里只能折成「不计入已知和」(`?? 0`)——**不是**因为未知等于 0,而是因为 wire 上表达不了:
30
+ * registry-core 的 `UsageEntry.costMicroUsd` 是 `z.number()` **必填**,batch 报文没有缺席位。
31
+ * 后果如实记账:center 侧看到的是**低估**($ 轴少算),不是「未知」;配额轴(weightedTokens)不受影响,
32
+ * 仍逐笔精确 —— 也就是说 lease/配额治理在 unpriced 部署下靠 token 轴仍然咬得住,$ 轴形同未启用。
33
+ * 要真治:registry-core 把该键改 optional(或加 `costUnknownCalls`)+ center 聚合面同改,再回本处透传。
34
+ * 在那之前,请**不要**把这个 `?? 0` 读成「已裁定未知=0」——裁定见 budget.ts 的 #59/RB-368 块。
35
+ */
27
36
  record(principal: string | undefined, model: string, taskId: string, d: {
28
37
  inputTokens: number;
29
38
  outputTokens: number;
30
- costMicroUsd: number;
39
+ costMicroUsd?: number;
31
40
  weightedTokens?: number;
32
41
  quotaWeightAtUse?: number;
33
42
  }): void;
@@ -24,6 +24,15 @@ export const FLEET_USAGE_KEY_CAP = 1024;
24
24
  export class FleetUsageAccumulator {
25
25
  cells = new Map(); // key = principal <NUL> model
26
26
  runsSeen = new Set(); // taskId per window → runs count (first brain.call of a run in this window)
27
+ /**
28
+ * 🔴 RB-368 边界(core 2.3.0):`d.costMicroUsd` **可缺席 = 未知**(unpriced 部署,budget.ts 如实不带键)。
29
+ * 这里只能折成「不计入已知和」(`?? 0`)——**不是**因为未知等于 0,而是因为 wire 上表达不了:
30
+ * registry-core 的 `UsageEntry.costMicroUsd` 是 `z.number()` **必填**,batch 报文没有缺席位。
31
+ * 后果如实记账:center 侧看到的是**低估**($ 轴少算),不是「未知」;配额轴(weightedTokens)不受影响,
32
+ * 仍逐笔精确 —— 也就是说 lease/配额治理在 unpriced 部署下靠 token 轴仍然咬得住,$ 轴形同未启用。
33
+ * 要真治:registry-core 把该键改 optional(或加 `costUnknownCalls`)+ center 聚合面同改,再回本处透传。
34
+ * 在那之前,请**不要**把这个 `?? 0` 读成「已裁定未知=0」——裁定见 budget.ts 的 #59/RB-368 块。
35
+ */
27
36
  record(principal, model, taskId, d) {
28
37
  const p = principal ?? "__anonymous__"; // single-user turnkey: no principal — still meter, under a stable bucket
29
38
  let key = `${p}\u0000${model}`;
@@ -36,7 +45,7 @@ export class FleetUsageAccumulator {
36
45
  if (cur) {
37
46
  cur.inputTokens += d.inputTokens;
38
47
  cur.outputTokens += d.outputTokens;
39
- cur.costMicroUsd += d.costMicroUsd;
48
+ cur.costMicroUsd += d.costMicroUsd ?? 0; // 缺席=未知 ⇒ 不计入已知和(wire 必填,见上方旁注)
40
49
  cur.weightedTokens += d.weightedTokens ?? 0;
41
50
  if (d.quotaWeightAtUse !== undefined)
42
51
  cur.quotaWeightAtUse = d.quotaWeightAtUse; // 审计尾迹=最新倍率(窗口内基本恒定)
@@ -44,7 +53,7 @@ export class FleetUsageAccumulator {
44
53
  cur.runs += 1;
45
54
  }
46
55
  else {
47
- this.cells.set(key, { inputTokens: d.inputTokens, outputTokens: d.outputTokens, costMicroUsd: d.costMicroUsd, weightedTokens: d.weightedTokens ?? 0, quotaWeightAtUse: d.quotaWeightAtUse ?? 1, runs: firstOfRun ? 1 : 0 });
56
+ this.cells.set(key, { inputTokens: d.inputTokens, outputTokens: d.outputTokens, costMicroUsd: d.costMicroUsd ?? 0, weightedTokens: d.weightedTokens ?? 0, quotaWeightAtUse: d.quotaWeightAtUse ?? 1, runs: firstOfRun ? 1 : 0 });
48
57
  }
49
58
  }
50
59
  /** Drain the window into UsageReport entries and RESET. Empty window → []. */
@@ -95,7 +95,13 @@ export declare class FleetLeaseManager {
95
95
  /** Local deduction — wired at the SAME tracer `brain.call` point as the usage accumulator (ALS principal),
96
96
  * sync + never throws (TracerHook contract). No-op unless the principal currently holds a lease.
97
97
  * registry-core 0.6.1 双轴:weightedTokens(加权 tokens 配额轴,weight-at-burn 已在调用方固化)
98
- * + costMicroUsd($ 轴,轴名沿用 0.5 既定)。任一轴耗尽=leaseExhausted 拒(deny-wins,registry 类型层定死)。 */
98
+ * + costMicroUsd($ 轴,轴名沿用 0.5 既定)。任一轴耗尽=leaseExhausted 拒(deny-wins,registry 类型层定死)。
99
+ *
100
+ * 🔴 RB-368(core 2.3.0)已核 undefined 安全:两轴都是 `?? 0`,unpriced 部署下 budget.ts 不带
101
+ * costMicroUsd 键调进来 ⇒ $ 轴不扣、不产 NaN。**这是有意的 fail-open**:租约是**执行面**,把"未知"
102
+ * 当"扣满"会让没配价目表的部署一上来就被 leaseExhausted 拒掉所有请求(deny-wins),那是把可观测性
103
+ * 缺口升级成可用性事故。补偿=配额轴(weightedTokens)不依赖价目表、逐笔精确,治理在 unpriced 部署下
104
+ * 仍然咬得住;$ 轴对这类部署等于未启用,这一点在部署位 `capabilities.pricingConfigured` 上可读回。 */
99
105
  recordSpend(principal: string | undefined, d: {
100
106
  weightedTokens?: number;
101
107
  costMicroUsd?: number;
@@ -74,7 +74,13 @@ export class FleetLeaseManager {
74
74
  /** Local deduction — wired at the SAME tracer `brain.call` point as the usage accumulator (ALS principal),
75
75
  * sync + never throws (TracerHook contract). No-op unless the principal currently holds a lease.
76
76
  * registry-core 0.6.1 双轴:weightedTokens(加权 tokens 配额轴,weight-at-burn 已在调用方固化)
77
- * + costMicroUsd($ 轴,轴名沿用 0.5 既定)。任一轴耗尽=leaseExhausted 拒(deny-wins,registry 类型层定死)。 */
77
+ * + costMicroUsd($ 轴,轴名沿用 0.5 既定)。任一轴耗尽=leaseExhausted 拒(deny-wins,registry 类型层定死)。
78
+ *
79
+ * 🔴 RB-368(core 2.3.0)已核 undefined 安全:两轴都是 `?? 0`,unpriced 部署下 budget.ts 不带
80
+ * costMicroUsd 键调进来 ⇒ $ 轴不扣、不产 NaN。**这是有意的 fail-open**:租约是**执行面**,把"未知"
81
+ * 当"扣满"会让没配价目表的部署一上来就被 leaseExhausted 拒掉所有请求(deny-wins),那是把可观测性
82
+ * 缺口升级成可用性事故。补偿=配额轴(weightedTokens)不依赖价目表、逐笔精确,治理在 unpriced 部署下
83
+ * 仍然咬得住;$ 轴对这类部署等于未启用,这一点在部署位 `capabilities.pricingConfigured` 上可读回。 */
78
84
  recordSpend(principal, d) {
79
85
  if (!principal)
80
86
  return;
@@ -1,4 +1,24 @@
1
1
  import type { SessionTreeEntry } from "@sema-agent/core";
2
+ /**
3
+ * ── [2092] 渲染臂的判据:**证据面与模型所见同进同出** ─────────────────────────────────────────────
4
+ * core 的 `SessionTreeEntry` 是 12 型联合,曾经这里只渲 message/compaction 两支 ⇒ 10 型静默丢弃。
5
+ * 其中 `custom_message` 正是非标准入口用户内容(steering)的落点:core `convertToLlm` 把它按
6
+ * `role:"user"` 送模型,而证据面渲没了 ⇒ 评估者如实判「证据不足」⇒ 拦停死循环直到 CAP(cli 带
7
+ * 最小复现的真缺陷)。所以分类规则不是「渲会话内容」,而是:**模型看得见的必须在,看不见的必须不在**。
8
+ *
9
+ * 两张表 = 对 core 型全集的**显式全分类**;test/branch-transcript.test.ts 里的闭合钉从 core d.ts
10
+ * 逐字取联合成员核对「型全集 = 渲染臂 ∪ 跳过表」—— core 加第 13 型 ⇒ 红,逼一次显式分类,
11
+ * 默认可见(下面循环的 default 臂做通用文本抽取)而不是默认失踪。
12
+ */
13
+ export declare const RENDERED_ENTRY_TYPES: readonly ["message", "compaction", "custom_message"];
14
+ /**
15
+ * 跳过的理由逐型给(「纯控制型」不是一句话,是逐个核过模型看不见):
16
+ * thinking_level_change / model_change / label / session_info / leaf / prompt_epoch /
17
+ * announced_listing / workspace_state —— 控制面元数据,core 折 messages 时不产出内容;
18
+ * custom —— data-only(session.js 折 messages 只认 message/custom_message 两型),模型看不见它的
19
+ * data;把它渲出来反而是把模型没见过的东西冒充成证据,与 [2092] 是同一判据的反方向。
20
+ */
21
+ export declare const SKIPPED_ENTRY_TYPES: readonly ["thinking_level_change", "model_change", "label", "session_info", "leaf", "prompt_epoch", "announced_listing", "workspace_state", "custom"];
2
22
  /** 渲染结果。**判别式**:证据不足时调用方拿不到 `text`,想用也用不了(见顶注)。 */
3
23
  export type BranchTranscript = {
4
24
  sufficient: true;
@@ -22,14 +22,40 @@
22
22
  * ⇒ 截断时必须在文本里**明说截了**,让模型知道自己看到的是一个后缀,而不是全部。
23
23
  */
24
24
  import { redactSecrets } from "../trace/redact.js";
25
+ /**
26
+ * ── [2092] 渲染臂的判据:**证据面与模型所见同进同出** ─────────────────────────────────────────────
27
+ * core 的 `SessionTreeEntry` 是 12 型联合,曾经这里只渲 message/compaction 两支 ⇒ 10 型静默丢弃。
28
+ * 其中 `custom_message` 正是非标准入口用户内容(steering)的落点:core `convertToLlm` 把它按
29
+ * `role:"user"` 送模型,而证据面渲没了 ⇒ 评估者如实判「证据不足」⇒ 拦停死循环直到 CAP(cli 带
30
+ * 最小复现的真缺陷)。所以分类规则不是「渲会话内容」,而是:**模型看得见的必须在,看不见的必须不在**。
31
+ *
32
+ * 两张表 = 对 core 型全集的**显式全分类**;test/branch-transcript.test.ts 里的闭合钉从 core d.ts
33
+ * 逐字取联合成员核对「型全集 = 渲染臂 ∪ 跳过表」—— core 加第 13 型 ⇒ 红,逼一次显式分类,
34
+ * 默认可见(下面循环的 default 臂做通用文本抽取)而不是默认失踪。
35
+ */
36
+ export const RENDERED_ENTRY_TYPES = ["message", "compaction", "custom_message"];
37
+ /**
38
+ * 跳过的理由逐型给(「纯控制型」不是一句话,是逐个核过模型看不见):
39
+ * thinking_level_change / model_change / label / session_info / leaf / prompt_epoch /
40
+ * announced_listing / workspace_state —— 控制面元数据,core 折 messages 时不产出内容;
41
+ * custom —— data-only(session.js 折 messages 只认 message/custom_message 两型),模型看不见它的
42
+ * data;把它渲出来反而是把模型没见过的东西冒充成证据,与 [2092] 是同一判据的反方向。
43
+ */
44
+ export const SKIPPED_ENTRY_TYPES = [
45
+ "thinking_level_change",
46
+ "model_change",
47
+ "label",
48
+ "session_info",
49
+ "leaf",
50
+ "prompt_epoch",
51
+ "announced_listing",
52
+ "workspace_state",
53
+ "custom",
54
+ ];
25
55
  /** 单次评估送进模型的 transcript 字符上限。超出保留**尾部**(最近的对话对"条件是否已达成"更相关)。 */
26
56
  export const BRANCH_TRANSCRIPT_MAX_CHARS = 60_000;
27
- /** 从一条 message entry 里抽人类可读文本(只取 text 片段;工具结果等非文本片段不进 transcript)。 */
28
- function messageText(entry) {
29
- const m = entry.message;
30
- if (!m || typeof m.role !== "string")
31
- return undefined;
32
- const c = m.content;
57
+ /** content(string | 片段数组)→ 可读文本。只取 text 片段;image/工具结果等非文本片段不进 transcript。 */
58
+ function contentText(c) {
33
59
  const parts = [];
34
60
  if (typeof c === "string")
35
61
  parts.push(c);
@@ -40,7 +66,36 @@ function messageText(entry) {
40
66
  parts.push(t);
41
67
  }
42
68
  }
43
- const text = parts.join("\n").trim();
69
+ return parts.join("\n").trim();
70
+ }
71
+ /**
72
+ * `role:"bashExecution"` 消息(`!` 命令)→ 模型所见文本。镜像 core `bashExecutionToText`
73
+ * (dist/engine/harness/messages.js —— core 根不导出,本地镜像,锚在闭合钉+本注上):
74
+ * convertToLlm 把它按 `role:"user"` 送模型,除非 `excludeFromContext`(那时模型看不见 ⇒ 证据面也不该有)。
75
+ */
76
+ function bashExecutionText(m) {
77
+ if (m["excludeFromContext"])
78
+ return undefined;
79
+ let text = `Ran \`${String(m["command"] ?? "")}\`\n`;
80
+ const out = m["output"];
81
+ text += typeof out === "string" && out ? `\`\`\`\n${out}\n\`\`\`` : "(no output)";
82
+ if (m["cancelled"])
83
+ text += "\n\n(command cancelled)";
84
+ else if (m["exitCode"] !== null && m["exitCode"] !== undefined && m["exitCode"] !== 0)
85
+ text += `\n\nCommand exited with code ${String(m["exitCode"])}`;
86
+ return text;
87
+ }
88
+ /** 从一条 message entry 里抽人类可读文本。 */
89
+ function messageText(entry) {
90
+ const m = entry.message;
91
+ if (!m || typeof m.role !== "string")
92
+ return undefined;
93
+ if (m.role === "bashExecution") {
94
+ const text = bashExecutionText(m)?.trim();
95
+ // convertToLlm 对 bashExecution 的送模型 role 是 "user" —— 证据面同款,别让评估者猜生僻 role。
96
+ return text ? { role: "user", text } : undefined;
97
+ }
98
+ const text = contentText(m.content);
44
99
  return text ? { role: m.role, text } : undefined;
45
100
  }
46
101
  /**
@@ -64,7 +119,23 @@ export function renderBranchTranscript(entries, opts) {
64
119
  const sum = e.summary;
65
120
  if (typeof sum === "string" && sum.trim())
66
121
  lines.push(`[earlier conversation, summarized] ${sum.trim()}`);
122
+ continue;
67
123
  }
124
+ if (e.type === "custom_message") {
125
+ // [2092] 真缺陷的落点:steering 等非标准入口的用户内容。core convertToLlm 把它按 role:"user"
126
+ // 送模型(display 只管 UI,不改模型可见性)—— 证据面同进同出,渲成 user 行。
127
+ const text = contentText(e.content);
128
+ if (text)
129
+ lines.push(`user: ${text}`);
130
+ continue;
131
+ }
132
+ if (SKIPPED_ENTRY_TYPES.includes(e.type))
133
+ continue;
134
+ // default 臂:比我们的分类表更新的型(core 加了第 13 型、部署窗里还没重新分类)——
135
+ // **默认可见而不是默认失踪**:通用文本抽取,能抽出来就带型标渲出;闭合钉会在 CI 红,逼显式分类。
136
+ const generic = contentText(e.content ?? e.text);
137
+ if (generic)
138
+ lines.push(`[${e.type}] ${generic}`);
68
139
  }
69
140
  if (lines.length === 0)
70
141
  return { sufficient: false, reason: "nothing-renderable" };
@@ -65,6 +65,26 @@ function isWithin(root, child) {
65
65
  // (or absolute, on Windows drive change) path for an outside/sibling target.
66
66
  return rel.length > 0 && !rel.startsWith("..") && !path.isAbsolute(rel);
67
67
  }
68
+ /**
69
+ * SERIALIZE git worktree ADMIN ops per repo (MODULE-level, keyed by normalized repoRoot). `git worktree
70
+ * add`/`remove`/`prune` all mutate the same repo's `.git/worktrees` registry; running any two CONCURRENTLY
71
+ * against one repo races. Two callers share this registry:
72
+ * - the isolation wrapper's mint (`add`) and teardown (`remove`) legs;
73
+ * - the service reaper's `prune` leg (#62 svc3 race: prune used to bypass the wrapper's closure-local
74
+ * lock entirely — the adverse arm is prune observing a mid-mint "registered, dir not yet populated"
75
+ * window and DEREGISTERING the half-minted worktree, handing the task a dir git no longer tracks and
76
+ * making its eventual `git worktree remove` fail loud on a healthy run).
77
+ * A promise-chain mutex per repoRoot: each op runs after the prior one SETTLES (success OR failure — never
78
+ * deadlock). Git ops are ~ms and serial-safe; agents still run fully parallel. The map is bounded by the
79
+ * number of distinct configured repoRoots (operator config, typically 1) — no eviction needed.
80
+ */
81
+ const gitAdminLocks = new Map();
82
+ function serializeGitAdmin(repoRoot, fn) {
83
+ const key = norm(repoRoot);
84
+ const run = (gitAdminLocks.get(key) ?? Promise.resolve()).then(fn, fn);
85
+ gitAdminLocks.set(key, run.then(() => undefined, () => undefined));
86
+ return run;
87
+ }
68
88
  /**
69
89
  * Wrap a deployment's `executionEnvFactory` so an agent with `ctx.isolation === "worktree"` runs in its own
70
90
  * detached git worktree. See the file header for the full contract + trust gate + caveats.
@@ -80,19 +100,7 @@ export function withWorktreeIsolation(baseFactory, opts) {
80
100
  if (!allowedRoots.some((root) => isWithin(root, repoRoot))) {
81
101
  throw new Error(`withWorktreeIsolation: repoRoot ${repoRoot} is not within any allowedRoots [${allowedRoots.join(", ")}] — refusing to git-worktree an untrusted path`);
82
102
  }
83
- // SERIALIZE git worktree ADMIN ops on this shared repo. `git worktree add`/`remove` mutate the repo's
84
- // `.git/worktrees` registry under a repo-level lock; running them CONCURRENTLY against one repo races (a
85
- // parallel fan-out finishing together → the Runner calls several `destroy()` at once → some
86
- // `git worktree remove` lose the lock and leave the worktree dir+registration behind — an orphan the
87
- // dir-only `pruneWorktrees` can't reap). A per-wrapper (= per-repoRoot) promise-chain mutex chains every
88
- // add/remove so they apply one at a time (git ops are fast + serial-safe; the agents themselves still run
89
- // fully in parallel — only the ~ms git bookkeeping serializes).
90
- let gitLock = Promise.resolve();
91
- const serialize = (fn) => {
92
- const run = gitLock.then(fn, fn); // run after the prior op SETTLES (success OR failure — never deadlock)
93
- gitLock = run.then(() => undefined, () => undefined);
94
- return run;
95
- };
103
+ const serialize = (fn) => serializeGitAdmin(repoRoot, fn);
96
104
  return async (ctx) => {
97
105
  // TRUST GATE: the ONLY trigger is core's RunInternals-sourced ctx.isolation. We read nothing else from ctx
98
106
  // (and never anything from a TaskSpec) to decide isolation — see file header.
@@ -162,7 +170,9 @@ export function withWorktreeIsolation(baseFactory, opts) {
162
170
  */
163
171
  export async function reapOrphanWorktrees(baseEnvForGit, repoRoot, logger) {
164
172
  try {
165
- await pruneWorktrees(baseEnvForGit, norm(repoRoot));
173
+ // #62:prune 动的是与 add/remove 同一张 `.git/worktrees` 注册表 ⇒ 必须过同一把 per-repoRoot
174
+ // (曾绕锁直跑;恶劣臂见 serializeGitAdmin 注)。
175
+ await serializeGitAdmin(repoRoot, () => pruneWorktrees(baseEnvForGit, norm(repoRoot)));
166
176
  }
167
177
  catch (e) {
168
178
  logger?.warn?.("worktree_prune_failed", { repoRoot: norm(repoRoot), error: e instanceof Error ? e.message : String(e) });
@@ -46,8 +46,19 @@ export declare function attachModelUsage<T extends TaskResult>(result: T, ctx: {
46
46
  * #59([2052]①):事件体是 **DB 里的 JSON**(旧版本 / 别的 producer 都可能写),此前的 `?? 0` 只接住
47
47
  * null/undefined——NaN 与字符串会原样进 `+`,一个坏键就把该模型的整 run 总量变成 NaN 或字符串拼接,
48
48
  * 再顺着 `TaskStats.modelUsage` 回声流进 usage-analytics 的整窗聚合。守卫改「非有限数 = 缺席、按 0 计」,
49
- * 与 usage-analytics 读侧同口径。注意本折叠对「costMicroUsd 缺席」与「costMicroUsd=0」的输出相同(都是 0):
50
- * 行级 0-vs-未知的消歧不在这里,也不在 server —— 裁定见 budget.ts 的「#59 裁定」块。 */
49
+ * 与 usage-analytics 读侧同口径。
50
+ *
51
+ * 🔴 RB-368(core 2.3.0,[2083] 提货单①)——**缺席必须活着走完整条链**。此前本折叠对「costMicroUsd
52
+ * 缺席」与「=0」输出相同(都是 0),那时无所谓:core 恒发数字,缺席没有 producer。现在 unpriced 部署
53
+ * 下 core 行级缺席、budget.ts 如实透传落库,若在这里折 0,诚实就只活到落库为止——回声上又变回
54
+ * 「花了 0」,行级消歧白做。故改**未知传染**:该模型任一贡献行缺 costMicroUsd 键 ⇒ 总量的该键**整个不在**
55
+ * (和里含一个不知道的项,这个和就不知道)。传染按**模型隔离**,不毒别的模型,更不毒整窗。
56
+ *
57
+ * 判别轴(两种"没数"不同罪):
58
+ * · **键缺席** = 契约允许的「未知」(SDK ModelUsageDelta 五键全可选)⇒ 传染,输出也缺席。
59
+ * · **键在场但值坏**(NaN / 字符串 / null)= 数据损坏,写了键的 producer 是在声称自己知道 ⇒ 维持既有
60
+ * 折叠(按 0 计,不传染,已知量不受损),否则一条陈年坏行就能把整个模型的成本抹成"未知"。
61
+ * 下游补偿件早已就位:usage-analytics 的 numOf + `estimated` 标记(缺数值键 ⇒ 总量偏低并诚实标注)。 */
51
62
  export declare function aggregateModelUsage(events: RunEvent[]): Record<string, ModelUsageDelta> | undefined;
52
63
  export type TraceBlock = {
53
64
  type: "thinking";
@@ -44,11 +44,23 @@ export async function attachModelUsage(result, ctx) {
44
44
  * #59([2052]①):事件体是 **DB 里的 JSON**(旧版本 / 别的 producer 都可能写),此前的 `?? 0` 只接住
45
45
  * null/undefined——NaN 与字符串会原样进 `+`,一个坏键就把该模型的整 run 总量变成 NaN 或字符串拼接,
46
46
  * 再顺着 `TaskStats.modelUsage` 回声流进 usage-analytics 的整窗聚合。守卫改「非有限数 = 缺席、按 0 计」,
47
- * 与 usage-analytics 读侧同口径。注意本折叠对「costMicroUsd 缺席」与「costMicroUsd=0」的输出相同(都是 0):
48
- * 行级 0-vs-未知的消歧不在这里,也不在 server —— 裁定见 budget.ts 的「#59 裁定」块。 */
47
+ * 与 usage-analytics 读侧同口径。
48
+ *
49
+ * 🔴 RB-368(core 2.3.0,[2083] 提货单①)——**缺席必须活着走完整条链**。此前本折叠对「costMicroUsd
50
+ * 缺席」与「=0」输出相同(都是 0),那时无所谓:core 恒发数字,缺席没有 producer。现在 unpriced 部署
51
+ * 下 core 行级缺席、budget.ts 如实透传落库,若在这里折 0,诚实就只活到落库为止——回声上又变回
52
+ * 「花了 0」,行级消歧白做。故改**未知传染**:该模型任一贡献行缺 costMicroUsd 键 ⇒ 总量的该键**整个不在**
53
+ * (和里含一个不知道的项,这个和就不知道)。传染按**模型隔离**,不毒别的模型,更不毒整窗。
54
+ *
55
+ * 判别轴(两种"没数"不同罪):
56
+ * · **键缺席** = 契约允许的「未知」(SDK ModelUsageDelta 五键全可选)⇒ 传染,输出也缺席。
57
+ * · **键在场但值坏**(NaN / 字符串 / null)= 数据损坏,写了键的 producer 是在声称自己知道 ⇒ 维持既有
58
+ * 折叠(按 0 计,不传染,已知量不受损),否则一条陈年坏行就能把整个模型的成本抹成"未知"。
59
+ * 下游补偿件早已就位:usage-analytics 的 numOf + `estimated` 标记(缺数值键 ⇒ 总量偏低并诚实标注)。 */
49
60
  export function aggregateModelUsage(events) {
50
61
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
51
62
  const total = {};
63
+ const costUnknown = new Set(); // RB-368:该模型至少有一行没带 costMicroUsd 键 ⇒ 总量不可知
52
64
  let any = false;
53
65
  for (const ev of events) {
54
66
  if (ev.type !== "model_usage")
@@ -63,10 +75,17 @@ export function aggregateModelUsage(events) {
63
75
  cur.outputTokens += num(d.outputTokens);
64
76
  cur.cacheReadTokens += num(d.cacheReadTokens);
65
77
  cur.cacheWriteTokens += num(d.cacheWriteTokens);
66
- cur.costMicroUsd += num(d.costMicroUsd);
78
+ // `d` 是 DB 里的 JSON:用 hasOwn 判「键在不在」(值为 undefined 的显式键与缺键同罪——落库后
79
+ // 两者本就无法区分,JSON.stringify 会把显式 undefined 抹掉)。
80
+ if (d !== null && typeof d === "object" && Object.hasOwn(d, "costMicroUsd") && d.costMicroUsd !== undefined)
81
+ cur.costMicroUsd = num(cur.costMicroUsd) + num(d.costMicroUsd);
82
+ else
83
+ costUnknown.add(model);
67
84
  total[model] = cur;
68
85
  }
69
86
  }
87
+ for (const model of costUnknown)
88
+ delete total[model]?.costMicroUsd;
70
89
  return any ? total : undefined;
71
90
  }
72
91
  /** Identity fields core stamps on each content event (1.121.0 `TaskEventIdentity`, shell-host contract E2):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.5.3",
3
+ "version": "3.6.1",
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",
@@ -53,7 +53,7 @@
53
53
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
54
54
  },
55
55
  "dependencies": {
56
- "@sema-agent/core": "^2.2.0",
56
+ "@sema-agent/core": "^2.3.0",
57
57
  "@sema-agent/registry-core": "^0.10.24",
58
58
  "e2b": "^2.28.0",
59
59
  "libsodium-wrappers": "^0.8.4",
@@ -67,7 +67,7 @@
67
67
  "sharp": "^0.35.3"
68
68
  },
69
69
  "devDependencies": {
70
- "@sema-agent/sdk": "^2.1.2",
70
+ "@sema-agent/sdk": "^2.1.3",
71
71
  "@types/libsodium-wrappers": "^0.7.14",
72
72
  "@types/node": "22.10.2",
73
73
  "@types/pg": "^8.20.0",