@sema-agent/server 3.23.0 → 4.0.0

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.
@@ -285,7 +285,7 @@ function dryRunMockFactory() {
285
285
  runWithVerification: async () => vr,
286
286
  runWithVerificationSup: async () => vr,
287
287
  resumeWithVerification: async () => vr,
288
- runRepairLoop: async () => ({ ...vr, terminal: "candidate_only", bundle: { failureTrace: "", diagnostics: [], rejectedHypotheses: [], attemptCount: 1, oracleTier: "trusted_hidden" } }),
288
+ runRepairLoop: async () => ({ ...vr, terminal: "candidate_only", oracleCostMicroUsd: 0, bundle: { failureTrace: "", diagnostics: [], rejectedHypotheses: [], attemptCount: 1, oracleTier: "trusted_hidden" } }),
289
289
  getCheckpoint: async () => null,
290
290
  runLeaderTask: async () => ({ ok: true, reports: [], merge: undefined, workerBudgetsUsd: [0.5, 0.5] }),
291
291
  runOracle: async () => oracle,
@@ -780,7 +780,11 @@ export async function createConfigCenterRuntime(ctx) {
780
780
  // expanded admission gate while core throws "Unknown model ref" until restart. Hot-apply is safe
781
781
  // only when BOTH generations are tier-less.
782
782
  const planeDeferred = (runnerTierFrozen || planeHasActiveTiers(r.effective)) && modelPlaneChanged(appliedPlaneEff, r.effective);
783
- applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferred ? { deferModelPlane: true } : {}) });
783
+ const committed = applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferred ? { deferModelPlane: true } : {}) });
784
+ // [2283]③:CAS 拒绝(更旧世代)⇒ 本拍整体跳过——prompts 采用/pricing/keyResolver/restart
785
+ // 对账/etag 推进/LKG 落盘都不得从旁路半应用同一个被拒世代(拒绝 warn 已在 applyEffective 留痕)。
786
+ if (!committed)
787
+ return;
784
788
  if (planeDeferred) {
785
789
  logger.warn("models_tiers_plane_deferred", { version: r.effective.version, note: "tier-frozen Runner: the changed model plane (models/roles/tiers/default) is NOT hot-applied — admission stays on the Runner's generation; restart applies the new plane (models-tiers restart signal rides /health)" });
786
790
  }
@@ -954,7 +958,10 @@ export async function createConfigCenterRuntime(ctx) {
954
958
  // this late arrival — if it froze a tier-expanded copy, the arriving center plane must not hot-apply
955
959
  // (admission/Runner split). Tier-less env boot (the common deferred-boot shape) keeps true hot-apply.
956
960
  const planeDeferredLate = (runnerTierFrozen || planeHasActiveTiers(r.effective)) && modelPlaneChanged(appliedPlaneEff, r.effective);
957
- applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferredLate ? { deferModelPlane: true } : {}) });
961
+ const committedLate = applyEffective(config, r.effective, logger, { teamsOnly: true, sealedKeys, ...(planeDeferredLate ? { deferModelPlane: true } : {}) });
962
+ // [2283]③(refresh 腿同款):CAS 拒绝 ⇒ 迟到 boot 拍整体跳过,旁路消费与 etag/LKG 都不动。
963
+ if (!committedLate)
964
+ return;
958
965
  if (planeDeferredLate)
959
966
  logger.warn("models_tiers_plane_deferred", { version: r.effective.version, note: "tier-frozen Runner (env tiers): the late-boot center model plane is NOT hot-applied — restart applies it" });
960
967
  else {
package/dist/budget.d.ts CHANGED
@@ -46,6 +46,12 @@ export interface ModelUsageDelta {
46
46
  * = 运营者声明免费 ⇒ 键在场且为 0。两者可区分,这正是 #59 折零案的源头修复。
47
47
  * 与 SDK `ModelUsageDelta`(五键全可选)/ `usage-analytics.UsageModelEntry` 同轨。 */
48
48
  costMicroUsd?: number;
49
+ /** core 3.0.0([2296] 装车单 S10,§DESIGN-V2 V2-⑤)可选口径标记:标出这一行的 token 分量是在哪套记账
50
+ * 基准下写的(当前唯一值 = `"uncached-components-v1"`:`inputTokens` = cache-MISS 分量,`cacheRead`/
51
+ * `cacheWrite` 分列)。**写点在 `trace/project.ts` 的 durable 行构造处**(`appendModelUsageDelta`),
52
+ * 不在这里 —— {@link ModelUsageTracker.record} 的累加逻辑本身零改动,永远不设置这个键(它只在 drain
53
+ * 之后、落盘之前被打上)。缺席 = 落盘更早、口径未知的存量行(usage-analytics 读端据此诚实标注)。 */
54
+ usageBasis?: string;
49
55
  }
50
56
  /**
51
57
  * E8 (shell-host contract): per-task × per-model usage accumulator, fed (synchronously) by the `brain.call`
package/dist/budget.js CHANGED
@@ -206,16 +206,29 @@ export function createTracer(metrics, costQuota, modelUsage, fleetUsage, fleetLe
206
206
  // they neither leak nor enter the echo; their spend is in TaskStats.nested). Drained at turn boundaries into the
207
207
  // durable log (resume-safe); the registered entry is dropped at run end (clear). NOT cardinality-guarded by
208
208
  // model: bounded by a run's model set, and by the active-run set.
209
+ // core 3.0.0([2296] S1):this lane's `inputTokens` stays `e.promptTokens` — DELIBERATELY zero-changed.
210
+ // The E8 ledger's semantics are COMPONENT-wise (MISS + cacheRead/cacheWrite listed separately;
211
+ // usage-analytics' read side SUMS them back into a total), unlike the weightedTokens/fleetUsage lane just
212
+ // below (which wants the pre-summed normalized total and migrates to `e.totalInputTokens`). Pre-3.0.0 the
213
+ // gateway family's `promptTokens` already included cache, so this same read-side sum double-counted for
214
+ // that family — an existing accounting gap in historical rows that this version does not retroactively fix
215
+ // (declared, not silently carried forward): see CHANGELOG's "存量口径断层" note.
209
216
  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 透传缺席
210
217
  // weight-at-burn:加权 tokens = (prompt+completion)×模型配额倍率,在记账点固化(此后改倍率
211
218
  // 不回算历史,registry ModelEntry.quotaWeight 同句契约)。weight 源=config.modelQuotaWeights(registry
212
219
  // hot-apply;env lane 空表 ⇒ 1 兜底);非法回调值防御性归 1(tracer 契约 never-throws,不让坏表毒记账)。
220
+ // core 3.0.0([2296] S1):`e.promptTokens` → `e.totalInputTokens`. This axis's contract (line ~365 below)
221
+ // has always been "normalized total input INCLUDING cache" — pre-3.0.0 that's what `promptTokens` meant for
222
+ // the gateway family; 3.0.0 flips `promptTokens` to mean cache-MISS-only, so staying on it here would
223
+ // massively under-count the quota/lease axis for any cache-heavy load (a lease could be walked through by
224
+ // cache-heavy traffic — the exact same failure class as the side-query R4 finding below).
213
225
  const qwRaw = quotaWeightFor?.(e.model);
214
226
  const qw = typeof qwRaw === "number" && Number.isFinite(qwRaw) && qwRaw > 0 ? qwRaw : 1;
215
- const weightedTokens = Math.round((e.promptTokens + e.completionTokens) * qw);
227
+ const weightedTokens = Math.round((e.totalInputTokens + e.completionTokens) * qw);
216
228
  // P2 用量批报:principal×model 窗口累计(同 ⑤b 的 ALS 归因点 — 子任务花费也归到发起 principal),
217
229
  // fleet-client 每窗口 drain→POST /api/fleet/usage。未配 fleet(accumulator 缺席)= 零行为。
218
- fleetUsage?.record(currentPrincipal(), e.model, e.taskId, { inputTokens: e.promptTokens, outputTokens: e.completionTokens, ...(e.costMicroUsd !== undefined ? { costMicroUsd: e.costMicroUsd } : {}), weightedTokens, quotaWeightAtUse: qw }); // RB-368 透传缺席
230
+ // core 3.0.0:同上迁移理由,inputTokens=归一化总输入含 cache(totalInputTokens),与 weightedTokens 同轴。
231
+ fleetUsage?.record(currentPrincipal(), e.model, e.taskId, { inputTokens: e.totalInputTokens, outputTokens: e.completionTokens, ...(e.costMicroUsd !== undefined ? { costMicroUsd: e.costMicroUsd } : {}), weightedTokens, quotaWeightAtUse: qw }); // RB-368 透传缺席
219
232
  // lease 消费(D4 AP):同一 ALS 归因点做 lease 本地扣减 —— 只对当前持 lease 的 principal
220
233
  // 生效(FleetLeaseManager 内部判),sync + never throws。双轴=weightedTokens(配额轴)+costMicroUsd($ 轴)。
221
234
  fleetLease?.recordSpend(currentPrincipal(), { weightedTokens, ...(e.costMicroUsd !== undefined ? { costMicroUsd: e.costMicroUsd } : {}) }); // RB-368 透传缺席
@@ -332,6 +345,9 @@ fleetUsage, fleetLease, quotaWeightFor) {
332
345
  // (input-excludes-cached)的 cache 命中几乎全部在 input 之外,不加 cacheRead/cacheWrite 就是配额轴
333
346
  // 大幅少收(lease 可被 cache-heavy side query 绕穿)。family 未知(catalog 查不到)走**保守臂**
334
347
  // (含 cache——OpenAI 族此臂多收,方向与 cost 残差一致:宁多勿少,记档同一条)。
348
+ // core 3.0.0 核实(S1 提货站):此段的输入源是 `SideQueryResult.usage`(llm 层生料 `Usage`,
349
+ // engine/llm/types.d.ts)——不是 3.0.0 翻新过的 `brain.call`/`turn_end` trace 事件面,3.0.0 未动这个类型,
350
+ // 本折算继续承重(零改动)。
335
351
  const promptTokens = r.family === "input-includes-cached" ? input : input + cacheRead + cacheWrite;
336
352
  // RB-368/#59 同判据(createTracer 上方注释同款纪律):`usage.cost.total` 整键缺席 = 未知(unpriced 部署的
337
353
  // side query 真形),不是「花了 $0」——折 0 就是把「不知道」编造成「免费」。已知(哪怕显式 0)才带键。
@@ -8,11 +8,14 @@ import type { EffectiveConfig } from "./types.js";
8
8
  * `this.deps.models/roles/pricing` LIVE per-task and `/v1/models` reads `config.models` — both share the
9
9
  * reference captured at boot, so mutating it (vs reassigning) updates both with no split, no Runner rebuild. */
10
10
  export declare function mutateInPlace<V>(target: Record<string, V>, source: Record<string, V>): void;
11
+ /** 返回值=是否 COMMIT(false 仅在 [2283]③ 世代序 CAS 拒绝时)。caller 收到 false 必须把**同一世代的
12
+ * 旁路消费**(prompts 采用/pricing/keyResolver/etag 推进/LKG 落盘)一并跳过——否则 applyEffective 拒了
13
+ * 主面、旁路却半应用同一个被拒世代,混合世代从侧门回来。首次 apply(live 未登记)恒 true。 */
11
14
  export declare function applyEffective(config: ServiceConfig, eff: EffectiveConfig, logger?: Logger, opts?: {
12
15
  teamsOnly?: boolean;
13
16
  sealedKeys?: SealedKeyOpener;
14
17
  deferModelPlane?: boolean;
15
- }): void;
18
+ }): boolean;
16
19
  /**
17
20
  * Apply the center `runtime` governance/limit gates OVER the env-derived config (mutates `config`).
18
21
  *
@@ -42,23 +45,6 @@ export type RuntimeGateKey = (typeof RUNTIME_GATE_KEYS)[number];
42
45
  * divides by it; center's `.positive()` already enforces, belt-and-suspenders here). */
43
46
  export declare function runtimeGatePresent(rt: NonNullable<EffectiveConfig["runtime"]>, key: RuntimeGateKey): boolean;
44
47
  export declare function applyRuntimeGates(config: ServiceConfig, rt: EffectiveConfig["runtime"], logger?: Logger): void;
45
- /**
46
- * Apply the runtime governance "second baton" (center §10): `autonomy` + `commandPolicy`. UNLIKE the 6 gates in
47
- * {@link applyRuntimeGates}, these are per-request HOT (read live in main.ts `resolveSpec` via
48
- * `applyRuntimeGovernance`), so they apply on BOTH boot and refresh (NOT via RUNTIME_GATE_KEYS / restart-to-apply).
49
- *
50
- * 🔴 NON-STICKY (differs from the restart-to-apply gates on purpose — adversarial-review HIGH): a hot field that
51
- * center STOPS publishing must REVERT to the env baseline, not keep the last center value. The restart-to-apply
52
- * gates can be sticky because a refresh never touches them (the running middleware holds boot values until a
53
- * restart re-reads env+center); a HOT field has no such reset, so "absent ⇒ keep" would silently freeze a stale
54
- * center override (e.g. center published `auto`, then un-published it — the gate would stay OFF forever). So we
55
- * recompute every call as `present ? center : envBaseline`. The env baseline = `AUTONOMY` (re-derived, env is
56
- * immutable at runtime) for autonomy; `undefined` (no env scalar source) for commandPolicy.
57
- *
58
- * 🔴 commandPolicy is VALIDATED here (adversarial-review HIGH): an invalid command (glob/path/operator — which
59
- * core's EXACT-name matcher would silently never match → a hole) is rejected FAIL-LOUD and the prior good policy
60
- * is KEPT (a broken publish never half-applies a silently-weakened gate).
61
- */
62
48
  export declare function applyRuntimeHot(config: ServiceConfig, rt: EffectiveConfig["runtime"], logger?: Logger): void;
63
49
  /**
64
50
  * [865]① 显式默认解析——applyEffective 与 dry-run(logEffectiveDiff.wouldDefaultModel)共用的单源。优先级
@@ -78,7 +78,74 @@ export function mutateInPlace(target, source) {
78
78
  delete target[k];
79
79
  Object.assign(target, source);
80
80
  }
81
+ /** 每个 config 对象上一次 COMMIT 的世代号([2283]③ CAS 的 live 端)。WeakMap——config 回收即回收;
82
+ * version 0(未发布/本地一次性 lane)不参与登记。 */
83
+ const appliedGeneration = new WeakMap();
84
+ /** 返回值=是否 COMMIT(false 仅在 [2283]③ 世代序 CAS 拒绝时)。caller 收到 false 必须把**同一世代的
85
+ * 旁路消费**(prompts 采用/pricing/keyResolver/etag 推进/LKG 落盘)一并跳过——否则 applyEffective 拒了
86
+ * 主面、旁路却半应用同一个被拒世代,混合世代从侧门回来。首次 apply(live 未登记)恒 true。 */
81
87
  export function applyEffective(config, eff, logger, opts = {}) {
88
+ const staged = stageEffective(config, eff, logger, opts);
89
+ const live = appliedGeneration.get(config);
90
+ if (staged.version > 0 && live !== undefined && staged.version < live) {
91
+ // [2283]③:两次拉取的 staging 并发/乱序完成时,慢的旧世代不得整体覆盖快的新世代——每次都是
92
+ // 「整世代」,但方向反了。拒绝必须留痕(C6);caller 的 etag 纪律本就只在 apply 成功后推进,
93
+ // 下一拍拉到的自然是更新的世代。等版本重放(etag 未推进的幂等重放)照常放行。
94
+ logger?.warn("sema_registry_stale_generation_refused", { staged: staged.version, live, note: "an older staged generation must not overwrite a newer committed one — refused whole; next poll replays" });
95
+ return false;
96
+ }
97
+ commitStaged(config, staged);
98
+ // ── post-commit notifications(全部世代描述性通知在最后一笔赋值之后,[2283]②)──
99
+ if (staged.modelPlane)
100
+ logger?.info("sema_registry_models", staged.modelPlane.infoLine);
101
+ if (staged.gatesInfo)
102
+ logger?.info("sema_registry_runtime", staged.gatesInfo);
103
+ if (Object.keys(staged.hot.infoLine).length > 0)
104
+ logger?.info("sema_registry_runtime_hot", staged.hot.infoLine);
105
+ for (const n of staged.teamsPlane?.notices ?? []) {
106
+ if (n.level === "warn")
107
+ logger?.warn(n.msg, n.fields);
108
+ else
109
+ logger?.info(n.msg, n.fields);
110
+ }
111
+ if (Object.keys(staged.projects).length > 0)
112
+ logger?.info("sema_registry_projects", { projects: Object.keys(staged.projects) });
113
+ return true;
114
+ }
115
+ /** COMMIT 段:纯赋值,零 await/零回调/零发射(源码钉守着——[2283]①②)。mutateInPlace/整对象重赋值/
116
+ * registry 表整体换装(registerTeams/registerCollabWorkflows 皆为过滤+swap 的赋值形,无回调面)。 */
117
+ function commitStaged(config, staged) {
118
+ const mp = staged.modelPlane;
119
+ if (mp) {
120
+ config.modelApiKeyEnv = mp.modelApiKeyEnv; // reassign ok — keyResolver is rebuilt from it on refresh (main.ts)
121
+ config.modelApiKeys = mp.modelApiKeys; // plaintext values — memory-only; poison markers ride the same map
122
+ mutateInPlace(config.modelQuotaWeights, mp.quotaWeights);
123
+ mutateInPlace(config.models, mp.models); // IN PLACE: keep the ref the Runner + /v1/models share (hot-apply)
124
+ config.model = config.models.default;
125
+ if (Object.keys(mp.roles).length > 0)
126
+ mutateInPlace(config.roles, mp.roles); // IN PLACE: Runner reads this.deps.roles live
127
+ mutateInPlace(config.tiers, mp.activeTiers);
128
+ }
129
+ if (staged.gateAssignments) {
130
+ for (const [k, v] of staged.gateAssignments)
131
+ config[k] = v;
132
+ }
133
+ if (staged.hot.setAutonomy)
134
+ config.autonomy = staged.hot.autonomyNext;
135
+ if (staged.hot.setCommandPolicy)
136
+ config.commandPolicy = staged.hot.commandPolicyNext;
137
+ const tp = staged.teamsPlane;
138
+ if (tp) {
139
+ registerTeams(tp.teams);
140
+ registerCollabWorkflows(tp.workflows);
141
+ }
142
+ mutateInPlace(config.projects, staged.projects); // IN PLACE: keep the ref per-request consumers captured at boot
143
+ if (staged.version > 0)
144
+ appliedGeneration.set(config, staged.version);
145
+ }
146
+ /** STAGE 段:一切计算/校验/解封/投影(可抛;抛=候选整体拒绝,活配置零触碰)。内容判定性 warn/error
147
+ * (描述 eff 真伪,与是否 commit 无关)在此发;世代描述性 info 只装载荷,post-commit 发。 */
148
+ function stageEffective(config, eff, logger, opts) {
82
149
  // version 0 / empty effective = the config-center has nothing for us yet — typically CONFIG_PUBLISH_MODE
83
150
  // is ON but nothing has been published. We do NOT wipe: models/roles fall back to env (the enabled>0
84
151
  // guard below), teams to BUILTIN_TEAMS (registerTeams resets to built-ins). Warn once at boot so the
@@ -96,6 +163,7 @@ export function applyEffective(config, eff, logger, opts = {}) {
96
163
  // split, no Runner rebuild, no core change. Caller (main.ts refresh) additionally refreshes `pricing` (same
97
164
  // ref) + rebuilds `keyResolver`. (Was startup-only when reassignment split the Runner's ref from config.)
98
165
  const enabled = (eff.models?.models ?? []).filter((m) => m.enabled !== false);
166
+ let modelPlane;
99
167
  // codex R10: `deferModelPlane` skips the WHOLE model plane (models/roles/tiers + per-model keys) — main.ts
100
168
  // sets it when the Runner is tier-frozen (private expanded copy) and the plane changed: hot-applying would
101
169
  // split admission (/v1/models, catalog gates) from the Runner's frozen generation, letting a same-key retarget
@@ -189,11 +257,6 @@ export function applyEffective(config, eff, logger, opts = {}) {
189
257
  logger?.warn("sema_registry_model_no_brain", { model: m.name, provider: m.provider, hint: "set ANTHROPIC_API_KEY in this service's env, else it mis-routes to the gateway brain" });
190
258
  }
191
259
  }
192
- config.modelApiKeyEnv = modelApiKeyEnv; // reassign ok — keyResolver is rebuilt from it on refresh (main.ts)
193
- // same rebuild contract; plaintext values — memory-only, never logged. Poison markers ride the same
194
- // map (they ARE per-model key state: "configured but broken"); the cast bridges config.ts's
195
- // plaintext-only field type until it is widened to Record<string, string | SealedKeyPoison>.
196
- config.modelApiKeys = modelApiKeys; // 类型已放真(string | SealedKeyPoison),桥接 cast 退役
197
260
  // weight-at-burn 源:quotaWeight 按 name+id 双键索引(tracer 的 brain.call e.model=模型 id,
198
261
  // 目录键=name——双键免猜);非法值(≤0/NaN)按缺省 1 丢弃。IN PLACE 与 models 同批 hot-apply。
199
262
  const quotaWeights = {};
@@ -205,7 +268,6 @@ export function applyEffective(config, eff, logger, opts = {}) {
205
268
  quotaWeights[m.id] = qw;
206
269
  }
207
270
  }
208
- mutateInPlace(config.modelQuotaWeights, quotaWeights);
209
271
  const roles = {};
210
272
  for (const [role, tgt] of Object.entries(eff.models.roles ?? {})) {
211
273
  if ("model" in tgt)
@@ -225,15 +287,16 @@ export function applyEffective(config, eff, logger, opts = {}) {
225
287
  // 静默翻转,default 必须消费 wire 里已有的显式意图。解析单源 = resolveDefaultModelName(dry-run 的
226
288
  // wouldDefaultModel 共用同一只,报数与真 apply 永不撕裂——codex M2)。
227
289
  const picked = resolveDefaultModelName(eff, (n) => models[n] !== undefined, enabled[0].name, (source, name) => logger?.warn("sema_registry_default_dangling", { source, name, hint: "explicit default names a model that is not in the enabled catalog — falling to the next source" }));
228
- const defaultName = picked.name;
229
- const defaultSource = picked.source;
230
- models.default = models[defaultName];
231
- mutateInPlace(config.models, models); // IN PLACE: keep the ref the Runner + /v1/models share (hot-apply)
232
- config.model = config.models.default;
233
- if (Object.keys(roles).length > 0)
234
- mutateInPlace(config.roles, roles); // IN PLACE: Runner reads this.deps.roles live
235
- mutateInPlace(config.tiers, activeTiers);
236
- logger?.info("sema_registry_models", { count: enabled.length, default: config.model.id, defaultSource, roles: Object.keys(roles), tiers: Object.keys(config.tiers), sealedKeys: Object.values(modelApiKeys).filter((v) => typeof v === "string").length, sealedPoisoned: Object.values(modelApiKeys).filter((v) => typeof v !== "string").length });
290
+ models.default = models[picked.name];
291
+ modelPlane = {
292
+ models,
293
+ modelApiKeyEnv,
294
+ modelApiKeys,
295
+ quotaWeights,
296
+ roles,
297
+ activeTiers,
298
+ infoLine: { count: enabled.length, default: models.default.id, defaultSource: picked.source, roles: Object.keys(roles), tiers: Object.keys(activeTiers), sealedKeys: Object.values(modelApiKeys).filter((v) => typeof v === "string").length, sealedPoisoned: Object.values(modelApiKeys).filter((v) => typeof v !== "string").length },
299
+ };
237
300
  }
238
301
  // Runtime governance/limit gates (phase-2): STARTUP only (restart-to-apply) — the live RateLimiter / CostQuota
239
302
  // / approval gate are built from `config` AFTER this in main.ts, so mutating it here before they're constructed
@@ -241,18 +304,18 @@ export function applyEffective(config, eff, logger, opts = {}) {
241
304
  // governance 切新位:治理三件优先读 governance 域(真值),runtime 旧位(双写镜像)fallback——
242
305
  // 双写期两处逐键相等语义不变;center 撤双写后 governance 即唯一来源。限额残余(rateLimit/cost 五件)仍在 runtime。
243
306
  const gatesView = eff.governance ? { ...eff.runtime, ...eff.governance } : eff.runtime;
244
- if (!opts.teamsOnly)
245
- applyRuntimeGates(config, gatesView, logger);
307
+ const gates = opts.teamsOnly ? undefined : stageRuntimeGates(gatesView);
246
308
  // Runtime governance "second baton" (center §10): autonomy + commandPolicy are per-request HOT (read live in
247
309
  // resolveSpec), NOT baked into boot middleware → apply on BOTH boot and refresh (outside the teamsOnly guard) so
248
310
  // they hot-reload. No restart-to-apply signal (they take effect on the next task without a restart).
249
- applyRuntimeHot(config, gatesView, logger);
311
+ const hot = stageRuntimeHot(config, gatesView, logger);
250
312
  // Teams → registry (hot-reloadable; center overrides/extends the built-ins).
251
313
  // codex R11: teams + collab workflows carry MODEL REFERENCES (member.model / workflow model args) — when the
252
314
  // model plane is deferred (tier-frozen Runner, see the deferModelPlane guard above) these faces must defer
253
315
  // WITH it, or a candidate that atomically adds/retargets a model AND updates a team/workflow to reference it
254
316
  // publishes a mixed generation: the new template goes live while config.models/the Runner stay old (a new ref
255
317
  // fails as unknown; a same-name retarget silently executes stale). One catalog generation = one visibility.
318
+ let teamsPlane;
256
319
  if (opts.deferModelPlane !== true) {
257
320
  const teams = {};
258
321
  for (const t of (eff.teams?.teams ?? []).filter((t) => t.enabled !== false)) {
@@ -265,22 +328,22 @@ export function applyEffective(config, eff, logger, opts = {}) {
265
328
  synthesizer: t.synthesizer ? { role: t.synthesizer.role, modelRole: t.synthesizer.modelRole, systemPrompt: t.synthesizer.systemPrompt } : undefined,
266
329
  };
267
330
  }
268
- registerTeams(teams);
269
- if (Object.keys(teams).length > 0)
270
- logger?.info("sema_registry_teams", { teams: Object.keys(teams) });
271
331
  // collab → named-workflow projection(切片 1.5,design/140 统一解;切片① 的
272
332
  // TeamTemplate 投影已整体替换——纪律「别留双路径降级」)。可投影子集翻译成命名 workflow 注册条目
273
333
  // (执行体=core 内置 team-discussion 脚本,center 模板=defaultArgs 合并链第二级;键=collab id,shell
274
334
  // `/team`/LLM 经 Workflow({name}) 调用);子集外整条跳过+结构化上报,绝不静默降级。HOT:boot+refresh
275
- // 双腿整表替换(非粘——center 停发即空表,内置 workflow 不受影响)。
335
+ // 双腿整表替换(非粘——center 停发即空表,内置 workflow 不受影响)。register* 在 commit 段成对换装。
276
336
  const projected = projectCollabToWorkflows(eff.collab?.templates);
277
- registerCollabWorkflows(projected.workflows);
337
+ const notices = [];
338
+ if (Object.keys(teams).length > 0)
339
+ notices.push({ level: "info", msg: "sema_registry_teams", fields: { teams: Object.keys(teams) } });
278
340
  if (Object.keys(projected.workflows).length > 0)
279
- logger?.info("sema_registry_collab_workflows", { workflows: Object.keys(projected.workflows) });
341
+ notices.push({ level: "info", msg: "sema_registry_collab_workflows", fields: { workflows: Object.keys(projected.workflows) } });
280
342
  if (projected.skipped.length > 0)
281
- logger?.warn("sema_registry_collab_skipped", { skipped: projected.skipped });
343
+ notices.push({ level: "warn", msg: "sema_registry_collab_skipped", fields: { skipped: projected.skipped } });
282
344
  if (projected.notes.length > 0)
283
- logger?.info("sema_registry_collab_notes", { notes: projected.notes });
345
+ notices.push({ level: "info", msg: "sema_registry_collab_notes", fields: { notes: projected.notes } });
346
+ teamsPlane = { teams, workflows: projected.workflows, notices };
284
347
  }
285
348
  // 142-S4 projects 域(registry-core 0.10.0):center 项目登记簿 → config.projects(键=
286
349
  // projectId)。消费是 per-request 查表(memoryScope 派生 + defaultScopes 种子,security.ts/main.ts),
@@ -304,9 +367,14 @@ export function applyEffective(config, eff, logger, opts = {}) {
304
367
  ...(Array.isArray(r.defaultScopes) ? { defaultScopes: r.defaultScopes.filter((x) => typeof x === "string") } : {}),
305
368
  };
306
369
  }
307
- mutateInPlace(config.projects, projects); // IN PLACE: keep the ref per-request consumers captured at boot
308
- if (Object.keys(projects).length > 0)
309
- logger?.info("sema_registry_projects", { projects: Object.keys(projects) });
370
+ return {
371
+ version: typeof eff.version === "number" && Number.isFinite(eff.version) ? eff.version : 0,
372
+ ...(modelPlane !== undefined ? { modelPlane } : {}),
373
+ ...(gates !== undefined ? { gateAssignments: gates.assignments, ...(gates.info !== undefined ? { gatesInfo: gates.info } : {}) } : {}),
374
+ hot,
375
+ ...(teamsPlane !== undefined ? { teamsPlane } : {}),
376
+ projects,
377
+ };
310
378
  }
311
379
  /**
312
380
  * Apply the center `runtime` governance/limit gates OVER the env-derived config (mutates `config`).
@@ -342,18 +410,28 @@ export function runtimeGatePresent(rt, key) {
342
410
  return typeof v === "number" && v > 0;
343
411
  return typeof v === "number";
344
412
  }
345
- export function applyRuntimeGates(config, rt, logger) {
413
+ /** [2283] stage 半场:六闸的赋值清单(纯计算,零变异)。commit 段照单赋值,通知载荷 post-commit 发。 */
414
+ function stageRuntimeGates(rt) {
346
415
  if (!rt)
347
- return;
416
+ return { assignments: [] };
417
+ const assignments = [];
348
418
  const applied = {};
349
419
  for (const key of RUNTIME_GATE_KEYS) {
350
420
  if (!runtimeGatePresent(rt, key))
351
421
  continue; // undefined / sentinel → keep env
352
- config[key] = rt[key]; // present (incl. explicit 0/[]) → override
422
+ assignments.push([key, rt[key]]); // present (incl. explicit 0/[]) → override
353
423
  applied[key] = rt[key];
354
424
  }
355
- if (Object.keys(applied).length > 0)
356
- logger?.info("sema_registry_runtime", applied);
425
+ return { assignments, ...(Object.keys(applied).length > 0 ? { info: applied } : {}) };
426
+ }
427
+ export function applyRuntimeGates(config, rt, logger) {
428
+ // 独立调用面的兼容壳(测试/外部):stage → 就地赋值 → 通知。applyEffective 不走这里——它把
429
+ // assignments 并进自己的 commit 段以保住整世代原子性([2283]②)。
430
+ const s = stageRuntimeGates(rt);
431
+ for (const [k, v] of s.assignments)
432
+ config[k] = v;
433
+ if (s.info)
434
+ logger?.info("sema_registry_runtime", s.info);
357
435
  }
358
436
  /**
359
437
  * Apply the runtime governance "second baton" (center §10): `autonomy` + `commandPolicy`. UNLIKE the 6 gates in
@@ -372,34 +450,49 @@ export function applyRuntimeGates(config, rt, logger) {
372
450
  * core's EXACT-name matcher would silently never match → a hole) is rejected FAIL-LOUD and the prior good policy
373
451
  * is KEPT (a broken publish never half-applies a silently-weakened gate).
374
452
  */
375
- export function applyRuntimeHot(config, rt, logger) {
376
- const applied = {};
453
+ /** [2283] stage 半场:hot 二件的赋值决定(计算+校验;`sema_registry_commandpolicy_invalid` 是内容判定,
454
+ * stage 期发——它描述 eff 的真伪,与是否 commit 无关)。对比基准=stage 时刻的 config 现值:stage 与
455
+ * commit 同一同步 tick,期间无人能改 config(单线程),对比不失效。 */
456
+ function stageRuntimeHot(config, rt, logger) {
457
+ const infoLine = {};
377
458
  // autonomy: present ⇒ center value; absent ⇒ revert to the env baseline (never a stale center override).
378
459
  const envAutonomy = parseAutonomy(process.env.AUTONOMY);
379
- const nextAutonomy = rt?.autonomy !== undefined ? rt.autonomy : envAutonomy;
380
- if (config.autonomy !== nextAutonomy) {
381
- config.autonomy = nextAutonomy;
382
- applied.autonomy = nextAutonomy ?? "(env-baseline)";
383
- }
460
+ const autonomyNext = rt?.autonomy !== undefined ? rt.autonomy : envAutonomy;
461
+ const setAutonomy = config.autonomy !== autonomyNext;
462
+ if (setAutonomy)
463
+ infoLine.autonomy = autonomyNext ?? "(env-baseline)";
384
464
  // commandPolicy: present+valid ⇒ apply; present+invalid ⇒ fail-loud + keep prior; absent ⇒ revert to baseline
385
465
  // (undefined — no env scalar source for structured command rules). Only re-set + log on an ACTUAL change
386
466
  // (deep-equal compare) — refresh runs every ~60s and the policy is usually unchanged; logging every tick = noise.
467
+ let setCommandPolicy = false;
468
+ let commandPolicyNext;
387
469
  if (rt?.commandPolicy !== undefined) {
388
470
  const errors = validateCommandRules(rt.commandPolicy);
389
471
  if (errors.length > 0) {
390
472
  logger?.error("sema_registry_commandpolicy_invalid", { errors, kept: config.commandPolicy?.length ?? 0 });
391
473
  }
392
474
  else if (JSON.stringify(config.commandPolicy) !== JSON.stringify(rt.commandPolicy)) {
393
- config.commandPolicy = rt.commandPolicy;
394
- applied.commandPolicy = rt.commandPolicy.length; // log the COUNT, not the rules (avoid leaking on every refresh)
475
+ setCommandPolicy = true;
476
+ commandPolicyNext = rt.commandPolicy;
477
+ infoLine.commandPolicy = rt.commandPolicy.length; // log the COUNT, not the rules (avoid leaking on every refresh)
395
478
  }
396
479
  }
397
480
  else if (config.commandPolicy !== undefined) {
398
- config.commandPolicy = undefined; // center stopped managing → revert to baseline (no env source)
399
- applied.commandPolicy = "(env-baseline)";
481
+ setCommandPolicy = true;
482
+ commandPolicyNext = undefined; // center stopped managing → revert to baseline (no env source)
483
+ infoLine.commandPolicy = "(env-baseline)";
400
484
  }
401
- if (Object.keys(applied).length > 0)
402
- logger?.info("sema_registry_runtime_hot", applied);
485
+ return { setAutonomy, autonomyNext, setCommandPolicy, commandPolicyNext, infoLine };
486
+ }
487
+ export function applyRuntimeHot(config, rt, logger) {
488
+ // 独立调用面的兼容壳(测试/外部)——applyEffective 不走这里(同 applyRuntimeGates 的注)。
489
+ const s = stageRuntimeHot(config, rt, logger);
490
+ if (s.setAutonomy)
491
+ config.autonomy = s.autonomyNext;
492
+ if (s.setCommandPolicy)
493
+ config.commandPolicy = s.commandPolicyNext;
494
+ if (Object.keys(s.infoLine).length > 0)
495
+ logger?.info("sema_registry_runtime_hot", s.infoLine);
403
496
  }
404
497
  /** [865]①/H3:activeTierGroup 档绑定当默认时的档梯,与 core 单一语义源逐字对齐(core roles.js:
405
498
  * `ROLE_TIER_DEFAULTS.default = "pro"` + `resolveTier` 从本档位置**只向低档**扫 DEFAULT_TIER_ORDER
@@ -1,10 +1,17 @@
1
- import type { EntitlementRuntimeCaps } from "@sema-agent/registry-core";
1
+ import { type EntitlementRuntimeCaps } from "@sema-agent/registry-core";
2
2
  import type { ScenarioRuling } from "../capabilities/scenarios.js";
3
3
  import type { EffectiveConfig, ExecutionRuling } from "./types.js";
4
- /** GET the effective config (Bearer + ETag). null = 304 (unchanged). Throws on transport/HTTP error. */
4
+ /** GET the effective config (Bearer + ETag). null = 304 (unchanged). Throws on transport/HTTP error, and on a
5
+ * payload that is not an effective config at all(非对象 / version 非有限数 / gate 域坏形——[2281] 裁B)。
6
+ * `domainErrors`:坏 catalog 域(schema default 已落)逐域单列——与本地腿 `FetchEffectiveResult` 同键同义,
7
+ * boot/refresh 的候选门直接消费。 */
5
8
  export declare function fetchEffective(baseUrl: string, token: string, etag: string | undefined, fetchImpl?: typeof fetch, worker?: string): Promise<{
6
9
  effective: EffectiveConfig;
7
10
  etag?: string;
11
+ domainErrors?: Array<{
12
+ domain: string;
13
+ error: string;
14
+ }>;
8
15
  } | null>;
9
16
  /**
10
17
  * design/99 §K (core 1.157 `RunnerDeps.runtimeCapsResolver`) — GET the PER-PRINCIPAL runtime
@@ -5,7 +5,11 @@
5
5
  * facade re-exports every symbol below unchanged).
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
- /** GET the effective config (Bearer + ETag). null = 304 (unchanged). Throws on transport/HTTP error. */
8
+ import { readEffectiveWire } from "@sema-agent/registry-core";
9
+ /** GET the effective config (Bearer + ETag). null = 304 (unchanged). Throws on transport/HTTP error, and on a
10
+ * payload that is not an effective config at all(非对象 / version 非有限数 / gate 域坏形——[2281] 裁B)。
11
+ * `domainErrors`:坏 catalog 域(schema default 已落)逐域单列——与本地腿 `FetchEffectiveResult` 同键同义,
12
+ * boot/refresh 的候选门直接消费。 */
9
13
  export async function fetchEffective(baseUrl, token, etag, fetchImpl = fetch, worker) {
10
14
  // Defensive scheme guard: Node's fetch supports file:// — a mis-set
11
15
  // SEMA_REGISTRY_URL must not turn into a local-file read. Reject anything but http(s).
@@ -23,7 +27,21 @@ export async function fetchEffective(baseUrl, token, etag, fetchImpl = fetch, wo
23
27
  return null;
24
28
  if (!res.ok)
25
29
  throw new Error(`config-center HTTP ${res.status}`);
26
- return { effective: (await res.json()), etag: res.headers.get("etag") ?? undefined };
30
+ // [2281] 裁B(§M1):wire 载荷在**这个消费端边界**过 registry-core `readEffectiveWire`(0.12.0)真校验,
31
+ // 不再裸断言——「两个契约共用一个拼写不是一个被检查的契约」。判据与本地腿同源(parseDomain +
32
+ // DOMAIN_READ_FALLBACK):catalog 域坏形 → 该域 schema default + 下面折进 domainErrors(候选门在
33
+ // refresh 拒候选、boot 逐域 warn);gate 域坏形/垃圾载荷 → throw(caller 整包 catch 回落 env/LKG)。
34
+ // 未知顶层键 verbatim 透传(open-world:新 center 新域、legacy teams 键都不丢)。grandfather 类
35
+ // 警告(值已被收编接受)不算 error,不进 domainErrors——与本地店同口径。
36
+ const warnings = [];
37
+ const wire = readEffectiveWire(await res.json(), (w) => warnings.push(w));
38
+ const domainErrors = warnings
39
+ .filter((w) => w.kind === "domain-defaulted")
40
+ .map((w) => ({ domain: w.domain, error: (w.error instanceof Error ? w.error.message : String(w.error)).slice(0, 600) }));
41
+ // 经真校验后的 wire 值到 service 拼写副本的换装:两型同一契约面(EffectiveWire 是 service 型的同源超集,
42
+ // service 型只声明自己消费的域且全 optional)——此断言的前提正是上面那次校验,不再是裸信任。
43
+ const effective = wire;
44
+ return { effective, etag: res.headers.get("etag") ?? undefined, ...(domainErrors.length > 0 ? { domainErrors } : {}) };
27
45
  }
28
46
  /**
29
47
  * design/99 §K (core 1.157 `RunnerDeps.runtimeCapsResolver`) — GET the PER-PRINCIPAL runtime
@@ -91,10 +91,16 @@ export class RemoteConfigProvider {
91
91
  this.cc = cc;
92
92
  this.deps = deps;
93
93
  }
94
- fetchEffective(etag) {
94
+ async fetchEffective(etag) {
95
95
  const fn = this.deps.fetchEffective ?? remoteFetchEffective;
96
96
  // 5th arg = worker scope; transport (fetchImpl) stays the config-center's default.
97
- return fn(this.cc.baseUrl, this.cc.token, etag, undefined, this.cc.worker);
97
+ const r = await fn(this.cc.baseUrl, this.cc.token, etag, undefined, this.cc.worker);
98
+ if (r === null || r.domainErrors === undefined)
99
+ return r;
100
+ // [2281] 裁B:远程腿从此也产 domainErrors(http-client 消费端校验)。与本地腿同一条产出边界纪律:
101
+ // error 文本先过 redactConfigError(zod 错误会回声违规值——operand 指纹化,防受控值经
102
+ // config_candidate_rejected/config_domain_invalid 进结构化日志流)。
103
+ return { ...r, domainErrors: r.domainErrors.map((de) => ({ domain: de.domain, error: redactConfigError(de.error) })) };
98
104
  }
99
105
  async fetchSkillContent(contentHash) {
100
106
  const fn = this.deps.fetchSkillContent ?? remoteFetchSkillContent;
package/dist/config.js CHANGED
@@ -514,8 +514,10 @@ function parseModelDomain() {
514
514
  }
515
515
  // Model.api must reflect the brain that actually serves it (routing is by provider): an "anthropic"
516
516
  // provider runs the Anthropic brain, whose usage reports `input` EXCLUDING cached tokens. core ≥1.22
517
- // normalizes the cache-hit-rate denominator per api family a wrong api makes promptTokens too small
518
- // and cacheHitRate exceed 100%. (Brains select by provider, not api, so this only fixes accounting.)
517
+ // normalizes the cache-hit-rate denominator per api family (`cacheFamilyOf` picks the fold formula from
518
+ // `Model.api`) a wrong api picks the wrong family, so the normalized total (core 3.0.0: `totalInputTokens`,
519
+ // core's own cacheHitRate = cachedTokens/totalInputTokens) comes out too small and cacheHitRate exceeds
520
+ // 100%. (Brains select by provider, not api, so this only fixes accounting.)
519
521
  //
520
522
  // Default inference ([792]④, core ruling 2026-07-14): an EXPLICIT MODEL_PROVIDER always wins (unchanged).
521
523
  // When it is unset but the operator explicitly configured an Anthropic-protocol base URL AND a matching
@@ -222,13 +222,16 @@ export function fleetRunPublisher(bus, run) {
222
222
  return 0;
223
223
  if (typeof u.totalTokens === "number")
224
224
  return u.totalTokens;
225
- // #9 (workflow 2nd-pass re-review): a turn_end.usage has NO totalTokens → this field-sum. core's turn_end.inputTokens
226
- // is the NORMALIZED TOTAL INPUT *INCLUDING CACHE* (usage-accounting promptTokensOf — input + cacheRead + cacheWrite
227
- // for the Anthropic family; OpenAI already folds cacheRead into input). So inputTokens + outputTokens ALREADY is the
228
- // turn total the old code ALSO added cacheRead/cacheWrite, double-counting them (~2x with prompt caching, the
229
- // normal case). Display-only (the leader fleet-row token number); billing (costMicroUsd) + context/compaction use
230
- // totalTokens and were never affected; child rows use task_progress.usage.totalTokens (the early-return) — correct.
231
- return (u.inputTokens ?? 0) + (u.outputTokens ?? 0);
225
+ // core 3.0.0([2296] 装车单 S2, [2298] R#9 二次复审并入):`turn_end.usage.inputTokens` 的语义在 3.0.0
226
+ // **翻转**——此前(core ≤2.x)它是 NORMALIZED TOTAL INPUT *INCLUDING CACHE*(usage-accounting
227
+ // promptTokensOf:Anthropic = input+cacheRead+cacheWrite,OpenAI 族已把 cache 折进 input),本函数当时
228
+ // 直接 `inputTokens + outputTokens` 就是整轮总量;3.0.0 `inputTokens` 恒为 cache-MISS 分量,新必填的
229
+ // `totalInputTokens` 才是那个「含 cache 的归一化总量」( inputTokens 语义的正统继承者)。继续只加
230
+ // `inputTokens` 会在有 cache 命中时**漏计**(leader 行的展示 token 数偏低)——故总量优先取
231
+ // `totalInputTokens`,旧核帧(尚未升级到 3.0.0、该键缺席)回落 `inputTokens`(防御性:本引擎恒 ≥3.0.0
232
+ // 时两键并存;异常帧宁少算不 NaN)。Display-only(leader fleet 行的 token 数);billing(costMicroUsd)+
233
+ // context/compaction 走 totalTokens,不受影响;child 行走 task_progress.usage.totalTokens(上面的早退臂)。
234
+ return (u.totalInputTokens ?? u.inputTokens ?? 0) + (u.outputTokens ?? 0);
232
235
  };
233
236
  return {
234
237
  onStart() {
@@ -31,6 +31,10 @@ export declare class FleetUsageAccumulator {
31
31
  * 同款=**传染**(同为单 producer 累加器:已知+未知=未知);center 聚合面的「下界 + costUnknown
32
32
  * 标记」语义在 registry-core aggregateUsage(跨 worker 审计总账层,两层判据见 [2103])。
33
33
  * 配额轴(weightedTokens)不受影响,仍逐笔精确。
34
+ *
35
+ * core 3.0.0([2296] 装车单 S4c)语义注:`d.inputTokens` 在唯一调用点(budget.ts 的
36
+ * `fleetUsage.record(...)`,S1 已迁)传的是 `e.totalInputTokens`(归一化总输入含 cache),不是
37
+ * `e.promptTokens`(3.0.0 起恒 cache-MISS)——上游已切换口径,本函数只是照收不重算,值域/签名零改动。
34
38
  */
35
39
  record(principal: string | undefined, model: string, taskId: string, d: {
36
40
  inputTokens: number;
@@ -31,6 +31,10 @@ export class FleetUsageAccumulator {
31
31
  * 同款=**传染**(同为单 producer 累加器:已知+未知=未知);center 聚合面的「下界 + costUnknown
32
32
  * 标记」语义在 registry-core aggregateUsage(跨 worker 审计总账层,两层判据见 [2103])。
33
33
  * 配额轴(weightedTokens)不受影响,仍逐笔精确。
34
+ *
35
+ * core 3.0.0([2296] 装车单 S4c)语义注:`d.inputTokens` 在唯一调用点(budget.ts 的
36
+ * `fleetUsage.record(...)`,S1 已迁)传的是 `e.totalInputTokens`(归一化总输入含 cache),不是
37
+ * `e.promptTokens`(3.0.0 起恒 cache-MISS)——上游已切换口径,本函数只是照收不重算,值域/签名零改动。
34
38
  */
35
39
  record(principal, model, taskId, d) {
36
40
  const p = principal ?? "__anonymous__"; // single-user turnkey: no principal — still meter, under a stable bucket
@@ -213,8 +213,13 @@ export function createMetrics() {
213
213
  m.counter("council_runs_total", "Code-review council runs (L1+L3)");
214
214
  m.histogram("council_tokens", "Total model tokens per council review (lenses + arbiter)");
215
215
  // Prefix-cache effectiveness (core 1.22): per-task hit rate (cost-critical) + a count of tasks the
216
- // Runner flagged as low-hit (unstable/poisoned prefix) so it can be alerted on.
217
- m.histogram("task_cache_hit_rate", "Prefix-cache hit rate per task (cachedTokens/promptTokens, 0..1)", [
216
+ // Runner flagged as low-hit (unstable/poisoned prefix) so it can be alerted on. The observed value is
217
+ // `result.stats.cacheHitRate` — core-computed (core 3.0.0: cachedTokens/totalInputTokens, the normalized
218
+ // total-including-cache denominator; runs.ts / http/server.ts just forward it), not derived here — this
219
+ // description names the field, not a formula this file owns (E3: don't restate a formula whose owner
220
+ // can change it without telling us; core 3.0.0's denominator switched from `promptTokens` to
221
+ // `totalInputTokens`, which the old "(cachedTokens/promptTokens)" text would have silently mis-described).
222
+ m.histogram("task_cache_hit_rate", "Prefix-cache hit rate per task (core-computed stats.cacheHitRate, 0..1)", [
218
223
  0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 1,
219
224
  ]);
220
225
  m.counter("prompt_cache_low_hit_total", "Tasks the Runner flagged with a low prefix-cache hit rate");
@@ -867,7 +867,8 @@ export class RemoteHostExecutionEnv {
867
867
  }
868
868
  catch (e) {
869
869
  // 同步 spawn throw(如 cwd=文件的 ENOTDIR)——与管道旧形字节级对齐:executor 内 re-throw=Promise
870
- // reject(消费方契约:core pruneWorktrees 等 await 抛才算失败;吞成 Result err 会让 reap 静默)。
870
+ // reject(消费方按 await 抛感知传输层失败;吞成 Result err 会让上层静默。⚠️ 引例更新:core 3.0.0
871
+ // 起 pruneWorktrees 改回执形 {ok},其消费点已双臂消费——worktree-isolation.ts;本处 spawn 层契约不变)。
871
872
  closeSpoolFds();
872
873
  cleanupSpool();
873
874
  throw e;
@@ -227,7 +227,12 @@ export async function reapOrphanWorktrees(baseEnvForGit, repoRoot, logger) {
227
227
  try {
228
228
  // #62:prune 动的是与 add/remove 同一张 `.git/worktrees` 注册表 ⇒ 必须过同一把 per-repoRoot 锁
229
229
  // (曾绕锁直跑;恶劣臂见 serializeGitAdmin 注)。
230
- await serializeGitAdmin(repoRoot, () => pruneWorktrees(baseEnvForGit, norm(repoRoot)));
230
+ // core 3.0.0(#94 提货):pruneWorktrees 从「失败 throw」改为回执 `{ok:true}|{ok:false,detail}`——
231
+ // 只靠下面的 catch 发警在 3.0.0 下= prune 失败**静默化**(CI svc3 钉抓出)。回执臂与 throw 臂
232
+ // (serializeGitAdmin/env 传输层仍可抛)两路都进同一条 warn,判据不变:失败必须留痕。
233
+ const r = await serializeGitAdmin(repoRoot, () => pruneWorktrees(baseEnvForGit, norm(repoRoot)));
234
+ if (r.ok === false)
235
+ logger?.warn?.("worktree_prune_failed", { repoRoot: norm(repoRoot), error: r.detail });
231
236
  }
232
237
  catch (e) {
233
238
  logger?.warn?.("worktree_prune_failed", { repoRoot: norm(repoRoot), error: e instanceof Error ? e.message : String(e) });
@@ -19,7 +19,13 @@ type AppendFn = (type: string, data: unknown) => Promise<void>;
19
19
  type GetEventsFn = (taskId: string, afterSeq: number) => Promise<RunEvent[]>;
20
20
  /** E8 (shell-host): drain the per-task model-usage accumulator into an append-only `model_usage` DELTA event.
21
21
  * Shared by BOTH durable-write legs (runInBackground + the resume leg) so the persisted shape never drifts; the
22
- * append-only deltas make a suspended/resumed run's total resume-safe (each leg appends its own, the echo sums). */
22
+ * append-only deltas make a suspended/resumed run's total resume-safe (each leg appends its own, the echo sums).
23
+ *
24
+ * core 3.0.0([2296] 装车单 S10,§DESIGN-V2 V2-⑤): every drained row is tagged with `usageBasis:
25
+ * "uncached-components-v1"` — the write point for this label (the tracker itself, {@link ModelUsageTracker},
26
+ * never sets it; it stays a pure token/cost accumulator). This is what lets {@link aggregateModelUsage} tell
27
+ * apart rows written under the current component semantics from older rows (pre-dating this label) when it
28
+ * sums across a run's whole event log. */
23
29
  export declare function appendModelUsageDelta(append: AppendFn, modelUsage: ModelUsageTracker | undefined, taskId: string): Promise<void>;
24
30
  /** [998]② drain the pending prompt-manifest records (whitelisted at RECORD time, budget.ts — ids/digests/
25
31
  * counts only, no prompt bodies by core's manifest contract) into durable `prompt_assembled` events, one per
@@ -287,9 +293,15 @@ export interface TraceTurn {
287
293
  ts: string;
288
294
  role: "system" | "user" | "assistant" | "tool";
289
295
  blocks: TraceBlock[];
296
+ /** core 3.0.0([2296] 装车单 S3,§DESIGN-V2 V2-②):这是**本服务自有的投影契约**,不是 core wire 帧的
297
+ * verbatim 转发——`in` 刻意保持它历史上的「总量」语义不变(旧帧/新帧读端零伤),不随 core 的
298
+ * `promptTokens`/`turn_end.usage.inputTokens` 翻转为 cache-MISS。新增 `uncachedIn` 承接 core 3.0.0 才有的
299
+ * cache-MISS 分量(命名取自 core `uncachedInputTokensOf`),只在能确认自己踩在新契约帧上时才发
300
+ * (见 {@link toContractTokens})。 */
290
301
  tokens?: {
291
302
  in: number;
292
303
  out: number;
304
+ uncachedIn?: number;
293
305
  cacheRead?: number;
294
306
  cacheWrite?: number;
295
307
  };
@@ -326,7 +338,14 @@ export declare function turnEndEventData(ev: {
326
338
  stopReason?: string;
327
339
  }): Record<string, unknown>;
328
340
  /** Map core's `turn_end.usage` (1.76 / core [R59]: inputTokens/outputTokens/cacheReadTokens/cacheWriteTokens/
329
- * costMicroUsd) → the contract's `TraceTurn.tokens {in,out,cacheRead?,cacheWrite?}`. Absent/empty → undefined. */
341
+ * costMicroUsd; core 3.0.0 adds required `totalInputTokens`) → the contract's `TraceTurn.tokens`.
342
+ * Absent/empty → undefined.
343
+ *
344
+ * core 3.0.0([2296] 装车单 S3,§DESIGN-V2 V2-②,codex 复审「异议」采纳):`TraceTurn.tokens` 是本服务自有
345
+ * 的投影契约,不是 wire verbatim——`in` 保持它一直以来的**总量**语义,3.0.0 起改取 `totalInputTokens`
346
+ * (归一化总输入含 cache)优先,`inputTokens` 回落(旧帧/尚未升级的 core 下 `totalInputTokens` 缺席时,
347
+ * `in` 落回旧值,读端零伤)。`uncachedIn` 只在**能确认自己踩在新契约帧上**(`totalInputTokens` 在场)时
348
+ * 才发——旧帧的 `inputTokens` 是族依赖的旧语义,标成「uncachedIn(cache-MISS)」会是谎言。 */
330
349
  export declare function toContractTokens(usage: unknown): TraceTurn["tokens"] | undefined;
331
350
  export interface ProjectOpts {
332
351
  /** Prepend the user turn (the objective is not in the event log; the endpoint supplies it). */
@@ -1,11 +1,21 @@
1
1
  import { redactSecrets, redactDeep } from "./redact.js";
2
2
  /** E8 (shell-host): drain the per-task model-usage accumulator into an append-only `model_usage` DELTA event.
3
3
  * Shared by BOTH durable-write legs (runInBackground + the resume leg) so the persisted shape never drifts; the
4
- * append-only deltas make a suspended/resumed run's total resume-safe (each leg appends its own, the echo sums). */
4
+ * append-only deltas make a suspended/resumed run's total resume-safe (each leg appends its own, the echo sums).
5
+ *
6
+ * core 3.0.0([2296] 装车单 S10,§DESIGN-V2 V2-⑤): every drained row is tagged with `usageBasis:
7
+ * "uncached-components-v1"` — the write point for this label (the tracker itself, {@link ModelUsageTracker},
8
+ * never sets it; it stays a pure token/cost accumulator). This is what lets {@link aggregateModelUsage} tell
9
+ * apart rows written under the current component semantics from older rows (pre-dating this label) when it
10
+ * sums across a run's whole event log. */
5
11
  export async function appendModelUsageDelta(append, modelUsage, taskId) {
6
12
  const delta = modelUsage?.drain(taskId);
7
- if (delta)
8
- await append("model_usage", { usage: delta });
13
+ if (delta) {
14
+ const tagged = {};
15
+ for (const [model, d] of Object.entries(delta))
16
+ tagged[model] = { ...d, usageBasis: "uncached-components-v1" };
17
+ await append("model_usage", { usage: tagged });
18
+ }
9
19
  }
10
20
  /** [998]② drain the pending prompt-manifest records (whitelisted at RECORD time, budget.ts — ids/digests/
11
21
  * counts only, no prompt bodies by core's manifest contract) into durable `prompt_assembled` events, one per
@@ -61,6 +71,11 @@ export function aggregateModelUsage(events) {
61
71
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
62
72
  const total = {};
63
73
  const costUnknown = new Set(); // RB-368:该模型至少有一行没带 costMicroUsd 键 ⇒ 总量不可知
74
+ // S10([2296] 装车单,§DESIGN-V2 V2-⑤):usageBasis 只在**这个模型贡献的每一行**都带同一个值时才透传到
75
+ // 回声行——混窗(升级前落盘的旧行 / 坏行 / 未来换了别的 basis 字符串)一律诚实省略,不猜、不传染出一个
76
+ // 假的一致标记。Map 记「目前为止这个模型见过的一致值」:第一行定值;之后任何一行不同(含缺席,记作
77
+ // undefined)⇒ 永久锁定为 undefined(一旦分歧就回不去)。
78
+ const basisSeen = new Map();
64
79
  let any = false;
65
80
  for (const ev of events) {
66
81
  if (ev.type !== "model_usage")
@@ -74,6 +89,7 @@ export function aggregateModelUsage(events) {
74
89
  // 之后,对唯一能判别的形(null)先抛,守卫不可达;坏行按整行 0 折(#59 同口径),cost 记未知。
75
90
  if (d === null || typeof d !== "object") {
76
91
  costUnknown.add(model);
92
+ basisSeen.set(model, undefined); // 坏行 = 不可知,视同分歧,不透传 basis
77
93
  continue;
78
94
  }
79
95
  const cur = total[model] ?? { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costMicroUsd: 0 };
@@ -87,11 +103,19 @@ export function aggregateModelUsage(events) {
87
103
  cur.costMicroUsd = num(cur.costMicroUsd) + num(d.costMicroUsd);
88
104
  else
89
105
  costUnknown.add(model);
106
+ const rowBasis = typeof d.usageBasis === "string" ? d.usageBasis : undefined;
107
+ if (!basisSeen.has(model))
108
+ basisSeen.set(model, rowBasis);
109
+ else if (basisSeen.get(model) !== rowBasis)
110
+ basisSeen.set(model, undefined);
90
111
  total[model] = cur;
91
112
  }
92
113
  }
93
114
  for (const model of costUnknown)
94
115
  delete total[model]?.costMicroUsd;
116
+ for (const [model, basis] of basisSeen)
117
+ if (basis !== undefined && total[model])
118
+ total[model].usageBasis = basis;
95
119
  return any ? total : undefined;
96
120
  }
97
121
  /** Identity fields core stamps on each content event (1.121.0 `TaskEventIdentity`, shell-host contract E2):
@@ -396,16 +420,24 @@ export function turnEndEventData(ev) {
396
420
  };
397
421
  }
398
422
  /** Map core's `turn_end.usage` (1.76 / core [R59]: inputTokens/outputTokens/cacheReadTokens/cacheWriteTokens/
399
- * costMicroUsd) → the contract's `TraceTurn.tokens {in,out,cacheRead?,cacheWrite?}`. Absent/empty → undefined. */
423
+ * costMicroUsd; core 3.0.0 adds required `totalInputTokens`) → the contract's `TraceTurn.tokens`.
424
+ * Absent/empty → undefined.
425
+ *
426
+ * core 3.0.0([2296] 装车单 S3,§DESIGN-V2 V2-②,codex 复审「异议」采纳):`TraceTurn.tokens` 是本服务自有
427
+ * 的投影契约,不是 wire verbatim——`in` 保持它一直以来的**总量**语义,3.0.0 起改取 `totalInputTokens`
428
+ * (归一化总输入含 cache)优先,`inputTokens` 回落(旧帧/尚未升级的 core 下 `totalInputTokens` 缺席时,
429
+ * `in` 落回旧值,读端零伤)。`uncachedIn` 只在**能确认自己踩在新契约帧上**(`totalInputTokens` 在场)时
430
+ * 才发——旧帧的 `inputTokens` 是族依赖的旧语义,标成「uncachedIn(cache-MISS)」会是谎言。 */
400
431
  export function toContractTokens(usage) {
401
432
  if (!usage || typeof usage !== "object")
402
433
  return undefined;
403
434
  const u = usage;
404
- if (u.inputTokens == null && u.outputTokens == null)
435
+ if (u.inputTokens == null && u.totalInputTokens == null && u.outputTokens == null)
405
436
  return undefined;
406
437
  return {
407
- in: u.inputTokens ?? 0,
438
+ in: u.totalInputTokens ?? u.inputTokens ?? 0,
408
439
  out: u.outputTokens ?? 0,
440
+ ...(u.totalInputTokens != null && u.inputTokens != null ? { uncachedIn: u.inputTokens } : {}),
409
441
  ...(u.cacheReadTokens != null ? { cacheRead: u.cacheReadTokens } : {}),
410
442
  ...(u.cacheWriteTokens != null ? { cacheWrite: u.cacheWriteTokens } : {}),
411
443
  };
@@ -40,6 +40,10 @@ export interface UsageModelEntry {
40
40
  outputTokens?: number;
41
41
  cacheReadTokens?: number;
42
42
  cacheWriteTokens?: number;
43
+ /** S10(core 3.0 计量语义统一,§DESIGN-V2 V2-⑤)口径标记:`"uncached-components-v1"` = 三个输入键是
44
+ * 分量制(互不重叠,本文件的 tokensIn 求和公式正确)。缺席/别的值 = 口径不可分辨(存量行可能把
45
+ * cache 计入 inputTokens,求和会双算)⇒ fold 侧标 {@link UsageTotals.estimated}。 */
46
+ usageBasis?: string;
43
47
  /** 整数 micro-USD。**缺席 = 未知**,不是 0(SDK `ModelUsageDelta` 同款单轨契约)。注意在场的 `0` 仍有
44
48
  * 歧义(引擎无价目表时恒发 0),那层消歧在部署面 `capabilities.pricingConfigured`——裁定见 budget.ts。 */
45
49
  costMicroUsd?: number;
@@ -49,9 +53,11 @@ export interface UsageTotals {
49
53
  tokensIn: number;
50
54
  tokensOut: number;
51
55
  costUsd: number;
52
- /** 本聚合的口径**不完整**——两种来源:①有行走了 stats.tokens fallback(无 per-model echo,token 数为估算);
53
- * ②有 per-model echo 行**缺数值键**(该键按 0 计入,总量偏低;成本键缺席时尤其:少算 ≠ 免费)
54
- * 两者都只表达「别把这个总数当精确值」,消费端一视同仁。 */
56
+ /** 本聚合的口径**不完整**——三种来源:①有行走了 stats.tokens fallback(无 per-model echo,token 数为估算);
57
+ * ②有 per-model echo 行**缺数值键**(该键按 0 计入,总量偏低;成本键缺席时尤其:少算 ≠ 免费);
58
+ * ③(S10/V2-⑤)有行的 {@link UsageModelEntry.usageBasis} 缺席或非本读端认识的分量制——存量行某族
59
+ * 历史上把 cache 计入 inputTokens,tokensIn 求和会**双算**,口径不可分辨。
60
+ * 三者都只表达「别把这个总数当精确值」,消费端一视同仁。 */
55
61
  estimated: boolean;
56
62
  }
57
63
  /** GET /v1/usage/summary — 窗内总量。 */
@@ -36,6 +36,10 @@ function foldModelEntry(t, d) {
36
36
  t.costUsd += (cost ?? 0) / 1e6;
37
37
  if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined || cost === undefined)
38
38
  t.estimated = true;
39
+ // S10/V2-⑤:上面这条 tokensIn 求和公式只在「分量制」行上正确。缺标记(存量行,某族历史上 inputTokens
40
+ // 含 cache ⇒ 双算)或本读端不认识的标记值(将来别的口径)⇒ 走既有不精确通道,不显示为精确值。
41
+ if (d.usageBasis !== "uncached-components-v1")
42
+ t.estimated = true;
39
43
  }
40
44
  function foldRow(t, row) {
41
45
  t.tasks += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.23.0",
3
+ "version": "4.0.0",
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",
@@ -54,8 +54,8 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "^2.13.0",
58
- "@sema-agent/registry-core": "^0.11.0",
57
+ "@sema-agent/core": "^3.0.0",
58
+ "@sema-agent/registry-core": "^0.12.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",
61
61
  "mysql2": "^3.22.4",