@sema-agent/server 7.37.0 → 7.38.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/USAGE.md +2 -1
  2. package/dist/adoption/plan.js +16 -0
  3. package/dist/boot/budget-tracing.d.ts +1 -1
  4. package/dist/boot/budget-tracing.js +12 -5
  5. package/dist/boot/config-center.d.ts +27 -2
  6. package/dist/boot/config-center.js +168 -15
  7. package/dist/boot/limit-sync.d.ts +32 -0
  8. package/dist/boot/limit-sync.js +35 -0
  9. package/dist/boot/reapers.d.ts +68 -0
  10. package/dist/boot/reapers.js +63 -4
  11. package/dist/boot/resolve-spec.js +2 -0
  12. package/dist/boot/runner-deps.d.ts +1 -1
  13. package/dist/boot/runner-deps.js +4 -0
  14. package/dist/boot/stores.js +13 -0
  15. package/dist/config-center/apply-effective.d.ts +41 -3
  16. package/dist/config-center/apply-effective.js +87 -10
  17. package/dist/config-center/apply-ledger.d.ts +82 -0
  18. package/dist/config-center/apply-ledger.js +104 -0
  19. package/dist/config-center/facade.d.ts +1 -1
  20. package/dist/config-center/hot-keys-registry.d.ts +62 -0
  21. package/dist/config-center/hot-keys-registry.js +54 -0
  22. package/dist/config-center/http-client.js +12 -2
  23. package/dist/config-center/restart-signal.js +5 -2
  24. package/dist/config-center/stage-limits.d.ts +59 -0
  25. package/dist/config-center/stage-limits.js +188 -0
  26. package/dist/config-center/types.d.ts +60 -0
  27. package/dist/config-invariants.d.ts +65 -0
  28. package/dist/config-invariants.js +139 -0
  29. package/dist/config-provider.js +11 -0
  30. package/dist/config-types.d.ts +10 -1
  31. package/dist/config.d.ts +11 -0
  32. package/dist/config.js +37 -5
  33. package/dist/http/routes/admin-config-refresh.d.ts +20 -0
  34. package/dist/http/routes/admin-config-refresh.js +43 -0
  35. package/dist/http/routes/diagnostics.js +4 -0
  36. package/dist/http/routes/runs.js +10 -6
  37. package/dist/http/server.d.ts +8 -0
  38. package/dist/http/server.js +26 -0
  39. package/dist/main.js +49 -6
  40. package/dist/memory-sync.js +50 -1
  41. package/dist/observability/cost-quota.d.ts +23 -3
  42. package/dist/observability/cost-quota.js +27 -4
  43. package/dist/observability/fail-open.d.ts +8 -0
  44. package/dist/observability/fail-open.js +8 -0
  45. package/dist/observability/rate-limit.d.ts +11 -1
  46. package/dist/observability/rate-limit.js +8 -2
  47. package/dist/plan-cache-probe.d.ts +5 -7
  48. package/dist/plan-cache-probe.js +27 -5
  49. package/dist/plugins/caching-session-store.d.ts +20 -4
  50. package/dist/plugins/caching-session-store.js +43 -2
  51. package/dist/plugins/fork-routing-session-store.d.ts +26 -4
  52. package/dist/plugins/fork-routing-session-store.js +134 -9
  53. package/dist/plugins/memory-engine-pg.d.ts +1 -0
  54. package/dist/plugins/memory-engine-pg.js +34 -4
  55. package/dist/plugins/memory-engine-tidb.d.ts +1 -0
  56. package/dist/plugins/memory-engine-tidb.js +17 -2
  57. package/dist/plugins/memory-origin-law.d.ts +69 -0
  58. package/dist/plugins/memory-origin-law.js +118 -1
  59. package/dist/plugins/pg-cost-quota.d.ts +7 -2
  60. package/dist/plugins/pg-cost-quota.js +25 -2
  61. package/dist/plugins/pg-rate-limiter.d.ts +7 -2
  62. package/dist/plugins/pg-rate-limiter.js +20 -2
  63. package/dist/plugins/pg-session-storage.d.ts +30 -7
  64. package/dist/plugins/pg-session-storage.js +145 -23
  65. package/dist/plugins/retention-store-sql.js +10 -1
  66. package/dist/plugins/session-placement.d.ts +113 -0
  67. package/dist/plugins/session-placement.js +153 -0
  68. package/dist/plugins/session-store.js +7 -1
  69. package/dist/plugins/tidb-cost-quota.d.ts +7 -2
  70. package/dist/plugins/tidb-cost-quota.js +26 -2
  71. package/dist/plugins/tidb-pool.js +15 -1
  72. package/dist/plugins/tidb-rate-limiter.d.ts +9 -2
  73. package/dist/plugins/tidb-rate-limiter.js +22 -2
  74. package/dist/plugins/tidb-session-storage.js +10 -2
  75. package/dist/plugins/tidb-session-store.d.ts +55 -7
  76. package/dist/plugins/tidb-session-store.js +174 -24
  77. package/dist/plugins/write-behind-counter.d.ts +17 -2
  78. package/dist/plugins/write-behind-counter.js +19 -1
  79. package/dist/runs.d.ts +3 -1
  80. package/dist/runs.js +5 -2
  81. package/dist/trace/engine-notice-wire.d.ts +1 -1
  82. package/dist/trace/engine-notice-wire.js +24 -6
  83. package/package.json +3 -3
@@ -74,6 +74,57 @@ export function createThrottledReaperCatch(name, logger, threshold = REAPER_FAIL
74
74
  },
75
75
  };
76
76
  }
77
+ /**
78
+ * 🔴 #292 C18(core 5.48.0 design/323,[4743] 提货单)——**分区腿武装**的判据本体。
79
+ *
80
+ * core 5.48 起 `reapDurableAgents` 的 `deps.sessions` 多一只可选 `listPlaced`:在场即启用**分区收割
81
+ * 腿** —— 一条 placed 转录的属主行已经不在(put 从未落地 / 上一轮条件删赢了但 release 失败)时,行驱动
82
+ * 的那条臂**结构上看不见**它(它从行出发枚举),只有从会话侧枚举才找得到。
83
+ *
84
+ * 两只面必须落在**host durable 店**上,而 release 这一格是承重的:
85
+ * · `listPlaced` = host 店的分区枚举(SQL 孪生实装;local/File 后端无此面 ⇒ 键**不设**,core 那条腿
86
+ * 如实不可用 —— 绝不给一个恒返回 `[]` 的假面,那会把「枚不了」读成「没有孤儿」);
87
+ * · `release` 对 **placed id** 必须走 host 店:路由店(`ForkRoutingSessionStore`)对一个重启后不认识
88
+ * 的 id 会回落 transient(进程内 no-op),那会让 core 数出一次**从未发生**的 release
89
+ * (`orphanPlacedReleased` 当场变成谎)而字节还在库里。故先按**持久化元组**判(一次 PK 探针),
90
+ * placed 走 host 店的真删除;其余 id 原样保留 [1522] 裁定的双店语义。
91
+ * · 🔴 探针本身失败 ⇒ **响亮抛**(codex 对抗复审 R1-[medium],验真后修:本函数的初版把探针错误
92
+ * 改写成 `undefined` 再落路由店 —— 而重启后路由店不认识 placed id ⇒ transient no-op,core 却因为
93
+ * release 正常 resolve 而 `orphanPlacedReleased++`。间歇性 DB 故障于是被**伪装成 GC 成功**:监控
94
+ * 报着「收掉了 N 条孤儿」,durable 字节一条没动。这正是本函数顶注要防的那个形,初版自己踩了。
95
+ * 抛的后果是安全的:core 两条臂的 release 都在 try 内、且计数在 release **之后**递增 ⇒ 抛 = 不计数
96
+ * + 下一 tick 重新枚举重试(失败经济学与 core 的成文一致:泄漏给下一轮,而不是谎报已清)。
97
+ *
98
+ * 抽成具名工厂而不是就地闭包的理由与 `createEngineNoticeSeat` 逐字同一条:判据在**值**上
99
+ * (哪只店被调),源码文本门看不出对错,只有行为测得出来。
100
+ */
101
+ export function createPlacementAwareReapSessions(deps) {
102
+ const { host, routed } = deps;
103
+ const listPlaced = typeof host?.listPlaced === "function" ? host.listPlaced.bind(host) : undefined;
104
+ const placementOf = typeof host?.placementOf === "function" ? host.placementOf.bind(host) : undefined;
105
+ return {
106
+ unpin: (sessionId) => routed.unpin?.(sessionId),
107
+ release: async (sessionId) => {
108
+ if (placementOf !== undefined && host !== undefined) {
109
+ let placed;
110
+ try {
111
+ placed = await placementOf(sessionId);
112
+ }
113
+ catch (err) {
114
+ // 探针失败 = 「这条 id 是不是 placed」这件事**不知道**。不知道就不许当成「不是」——那会把它
115
+ // 送去路由店拿一次 no-op 冒充成功释放(见上方顶注)。响亮抛,计数不涨,下一 tick 重来。
116
+ throw new Error(`placement probe failed for session ${sessionId} — refusing to release it blindly (a routed no-op would be counted as a real GC): ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? { cause: err } : undefined);
117
+ }
118
+ if (placed !== undefined) {
119
+ await host.release(sessionId); // placed ⇒ host 店的 release = 真删除(义务 4)
120
+ return;
121
+ }
122
+ }
123
+ await routed.release(sessionId);
124
+ },
125
+ ...(listPlaced !== undefined ? { listPlaced } : {}),
126
+ };
127
+ }
77
128
  /** 起后台维护 tick,返回定时器 handle(收尾段 clearInterval 用)。 */
78
129
  export function startReapers(ctx) {
79
130
  const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores, getRunDenySweep, } = ctx;
@@ -190,11 +241,12 @@ export function startReapers(ctx) {
190
241
  // release 被调次数」(transient=真删,durable=lease 释放),非「durable 转录处置数」——如实注。
191
242
  // 复审 F4 如实注:条件删的赢者若非写者副本,fork-routing 对未知 id 回落 transient=本地 no-op,写者
192
243
  // 进程内 pinned 转录滞留到自身 TTL/进程退出——sessionsReleased 计数偏低是诚实读数,非缺陷。
244
+ const reapSessions = createPlacementAwareReapSessions({ host: backend?.session(), routed: subRunner.sessions });
193
245
  const reapBgAgents = backgroundAgentStore
194
246
  ? async () => {
195
247
  const store = backgroundAgentStore;
196
248
  const scopes = await (store.listScopes?.() ?? Promise.resolve(["default"]));
197
- let rows = 0, sessions = 0, skipped = 0, failedScopes = 0;
249
+ let rows = 0, sessions = 0, skipped = 0, failedScopes = 0, orphanPlaced = 0;
198
250
  for (const scope of scopes) {
199
251
  // [1522] LOW3:per-scope 错误隔离——一个 scope 抛(坏 record_json/权限)不饿死后续 scope;
200
252
  // 失败响亮(warn+计数),下 tick 重试。
@@ -221,13 +273,14 @@ export function startReapers(ctx) {
221
273
  }
222
274
  // deps.mailbox(core 1.374,[1533]②):行删联动 F-13——agent 行 reap 时同 drop 其信箱
223
275
  // (盒生命周期随行终结;不挂=信箱行无 retention 无界涨)。
224
- const r = await defaultTaskRegistry.reapDurableAgents(scope, { store, sessions: subRunner.sessions, ...(mailboxStore ? { mailbox: mailboxStore } : {}) }, {
276
+ const r = await defaultTaskRegistry.reapDurableAgents(scope, { store, sessions: reapSessions, ...(mailboxStore ? { mailbox: mailboxStore } : {}) }, {
225
277
  maxAgeMs: config.backgroundAgentRetentionMs,
226
278
  staleRunningMaxAgeMs: config.backgroundAgentStaleRunningMs,
227
279
  });
228
280
  rows += r.rowsReaped;
229
281
  sessions += r.sessionsReleased;
230
282
  skipped += r.skippedNoSessions;
283
+ orphanPlaced += r.orphanPlacedReleased;
231
284
  }
232
285
  catch (err) {
233
286
  failedScopes++;
@@ -246,9 +299,15 @@ export function startReapers(ctx) {
246
299
  }
247
300
  if (failedScopes > 0)
248
301
  metrics.inc("bg_agent_reap_scope_failures_total", {}, failedScopes);
249
- if (rows > 0 || sessions > 0) {
302
+ // #292:`orphanPlacedReleased` 是**独立的读数**(分区腿的产出),不折进 `sessionsReleased`
303
+ // ——两者回答的是不同的问题(「行驱动地释放了几条」vs「行已不在、从分区侧收掉了几条」),
304
+ // 相加会让「行与会话同亡的联合 reap 是否健康」这条判读永久失去分辨率。行数为 0 但分区腿有
305
+ // 收成时也要出线(那正是 reap 失败/漏跑留下的历史欠账被追平的时刻)。
306
+ if (orphanPlaced > 0)
307
+ metrics.inc("bg_agents_orphan_placed_released_total", {}, orphanPlaced);
308
+ if (rows > 0 || sessions > 0 || orphanPlaced > 0) {
250
309
  metrics.inc("bg_agents_reaped_total", {}, rows);
251
- logger.info("reaper_swept", { metric: "bg_agents_reaped_total", count: rows, sessionsReleased: sessions, skippedNoSessions: skipped });
310
+ logger.info("reaper_swept", { metric: "bg_agents_reaped_total", count: rows, sessionsReleased: sessions, skippedNoSessions: skipped, orphanPlacedReleased: orphanPlaced });
252
311
  }
253
312
  }
254
313
  : undefined;
@@ -60,6 +60,8 @@ export function createResolveSpec(ctx) {
60
60
  // ① 守卫集的**编译**从此每个请求都发生(旧家只在 default/auto/acceptEdits 三个模式臂里发生)。
61
61
  // 非法旋钮值必须当场炸在启动上而不是每任务一条 500 —— 判据与文案随 design/181 件一搬进构造口
62
62
  // (`assertGuardPatternsUsable`),赎回腿/run-local 装配时调同一只,三腿同得。
63
+ // CROSS-INVARIANT:sensitive-write-patterns —— 同一条判据的热路径半场在 config-invariants.ts 的表里
64
+ // (#322:stage 期用同一只编译门判候选,禁手抄第二份判据)。
63
65
  assertGuardPatternsUsable(config);
64
66
  // ② governance 拍的 DENY 基线让 `spec.toolPolicy` 在**任何**客户端表态下都在场,于是 core 的
65
67
  // 「write-capable hand tools are present but UNGATED」那条 onError 不再触发(core 判据是
@@ -121,7 +121,7 @@ export interface RunnerDepsCtx {
121
121
  }
122
122
  /** design/158 A10 留档发现②:main runner `RunnerDeps` 与 main.ts subRunner 字面量之间此前手工重复
123
123
  * 的 ~15 个键,类型标注见 {@link createSharedRunnerDeps} 头注。 */
124
- export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude" | "memoryDelegationEvidence" | "memoryProvenance" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "hands" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores" | "compliancePostureResolver" | "lockedConfig" | "retentionPolicy" | "onNotice">;
124
+ export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude" | "memoryDelegationEvidence" | "memoryProvenance" | "delegationEntryCaps" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "hands" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores" | "compliancePostureResolver" | "lockedConfig" | "retentionPolicy" | "onNotice">;
125
125
  /**
126
126
  * design/158 A10 留档发现②(review 2026-07-29,[1543]§三族A 同源修补的延续):main runner 的
127
127
  * `RunnerDeps` 字面量(下方 `createRunnerDeps`)与 `main.ts` 里 subRunner 的 `new Runner({...})`
@@ -141,6 +141,10 @@ export function createSharedRunnerDeps(ctx) {
141
141
  // 照收)。leader lane 的族级缺口同上一席,边界钉在 `memory-delegation-evidence-lane.test.ts` ⑤ 组
142
142
  // 的 DEPLOYMENT_SEATS 名单里(本席已入册)。缺席=undefined=core 自缺省(`carry`)。
143
143
  memoryProvenance: ctx.config.memoryProvenance ?? undefined,
144
+ // debt #322-B(core 5.48 C18):委派入口 caps ——上两席同族的部署座席,三族(主/sub/run-local)
145
+ // 同源经本基座;缺席=undefined=core 自缺省(CC parity 20/200)。坏值双门:env 解析 numEnvBounded
146
+ // 启动期拒,pair 合法性(concurrent ≤ cumulative)core prepare `config.delegation_entry_caps` 拒。
147
+ delegationEntryCaps: ctx.config.delegationEntryCaps ?? undefined,
144
148
  executionEnvFactory: ctx.executionEnvFactory ? ctx.executionEnvFactory : undefined,
145
149
  lspManager: ctx.lspManager ? ctx.lspManager : undefined,
146
150
  // core 1.364 durable bg agents 读半场(写半场=scenarioDeps.backgroundAgentStore 同实例,组装区注释)。
@@ -183,6 +183,19 @@ export async function openStores(ctx) {
183
183
  "SAME identity later reuses vectors only for rows that have not been touched in the meantime",
184
184
  });
185
185
  }
186
+ // #319 copy-out 生产化站(core 5.47/5.48 契约冻结面)—— **本装配点即换装点,不另起一支**:
187
+ // 这条腿上的 `PgMemoryEngineBackend` 就是 [4706] 点名的 PG twin(它是 core 1.267 整段移交过来的
188
+ // copy-out;生产化=把它补到冻结形,不是在旁边再写一只)。本站补的两条行为条款(header 携
189
+ // `exposure` / `search` 两带序)落在**读面**,所以换装的实际形状是:
190
+ // · DDL 零变化 —— origin 在库里是 frontmatter jsonb 里的普通字段(138 S3 形),没有新列、
191
+ // 没有迁移、没有回填;ensure*Schema 的调用序逐字未动。
192
+ // · 旋钮零新增 —— 两带序不是部署开关:后端只在调用方**显式**传 `opts.exposureBands` 时分带,
193
+ // 缺席 = pre-336 单带字节形。请求方是 core 引擎的读侧,按 `RunnerDeps.memoryProvenance`
194
+ // (本仓 MEMORY_PROVENANCE=off|carry,见 boot/runner-deps.ts)自己决定 —— server 不代它翻。
195
+ // · 因此本行**不需要**任何前置开关或灰度位:同一只 backend,老调用方拿到逐字节相同的答复,
196
+ // 新调用方(升到 5.47+ 的 core)拿到带序与 exposure 事实。
197
+ // 验收面 = `test/memory-engine-db-integration.test.ts`:core 自己的 28 格契约(两方言真库)+
198
+ // 钉1-钉7(重启存续/并发抢 slug/无副作用读/跨 scope 拒/原生档两带序/谓词对表/同分截断)。
186
199
  const pgMem = new PgMemoryEngineBackend(q, {
187
200
  history: countedHistorySink(new PgMemoryHistoryStore(q)),
188
201
  ...(embedder !== undefined ? { embedder } : {}),
@@ -1,3 +1,4 @@
1
+ import type { Model } from "@sema-agent/core";
1
2
  import type { ServiceConfig } from "../config-types.js";
2
3
  import { type SealedKeyOpener } from "../sealed-key.js";
3
4
  import type { Logger } from "../observability/logger.js";
@@ -22,13 +23,44 @@ export declare function mutateInPlace<V>(target: Record<string, V>, source: Reco
22
23
  * 🔴 只活在内存判定面:不进 wire(`/v1/models` 只发布尔)、不进日志(warn 打的是原始坏值的类型标记)。
23
24
  */
24
25
  export declare const UNPARSABLE_AT_MODEL_ALLOWLIST_ENTRY = "\0<unparsable-at-model-allowlist-entry>";
25
- /** 返回值=是否 COMMIT(false 仅在 [2283]③ 世代序 CAS 拒绝时)。caller 收到 false 必须把**同一世代的
26
- * 旁路消费**(prompts 采用/pricing/keyResolver/etag 推进/LKG 落盘)一并跳过——否则 applyEffective 拒了
27
- * 主面、旁路却半应用同一个被拒世代,混合世代从侧门回来。首次 apply(live 未登记) true。 */
26
+ /** 返回值=是否 COMMIT(false 两臂:[2283]③ 世代序 CAS 拒绝 / 5.49 swapPlane 换装拒绝——两臂都在
27
+ * commit 之前,活配置零触碰)。caller 收到 false 必须把**同一世代的旁路消费**(prompts 采用/pricing/
28
+ * keyResolver/etag 推进/LKG 落盘)一并跳过——否则 applyEffective 拒了主面、旁路却半应用同一个被拒
29
+ * 世代,混合世代从侧门回来。首次 apply(live 未登记)恒 true。 */
30
+ /** #322:一次 apply 的**世代账载荷**(post-commit 发,[2283]② —— commit 段仍是零回调零发射)。 */
31
+ export interface ApplyReport {
32
+ readonly version: number;
33
+ /** #325:本候选的收货序数(= opts.generation 原样带回;世代账按它记拒收,身份标仍是 version)。 */
34
+ readonly ordinal?: number;
35
+ readonly limits: {
36
+ /** 本世代真正换了值的键。 */
37
+ readonly applied: readonly string[];
38
+ /** 值合法但**不热**的键(逐键 defer:变更只能重启生效)。 */
39
+ readonly deferred?: ReadonlyArray<{
40
+ readonly key: string;
41
+ readonly reason: string;
42
+ }>;
43
+ /** 整批被拒时的成因(消毒:键名 + 判据文案,绝不带值)。 */
44
+ readonly rejected?: {
45
+ readonly keys: string[];
46
+ readonly reason: string;
47
+ readonly restartReason?: string;
48
+ };
49
+ };
50
+ /** 🔴 codex R2-F1:本候选是否**完整**应用 —— 有任何一组被拒 ⇒ false。调用方据此决定要不要把它发布成
51
+ * LKG / 推进 etag(一个「其余面提交了但某组被拒」的候选覆盖掉好 LKG,重启后就再也回不去了)。 */
52
+ readonly fullyApplied: boolean;
53
+ }
28
54
  export declare function applyEffective(config: ServiceConfig, eff: EffectiveConfig, logger?: Logger, opts?: {
29
55
  teamsOnly?: boolean;
30
56
  sealedKeys?: SealedKeyOpener;
31
57
  deferModelPlane?: boolean;
58
+ generation?: number;
59
+ report?: (r: ApplyReport) => void;
60
+ swapPlane?: (plane: {
61
+ models: Record<string, Model>;
62
+ tiers: Record<string, string>;
63
+ }) => void;
32
64
  }): boolean;
33
65
  /**
34
66
  * Apply the center `runtime` governance/limit gates OVER the env-derived config (mutates `config`).
@@ -54,6 +86,12 @@ export declare function applyEffective(config: ServiceConfig, eff: EffectiveConf
54
86
  * the sibling restart-signal module can share the identical key list + presence rule. */
55
87
  export declare const RUNTIME_GATE_KEYS: readonly ["rateLimitPerMin", "approvalRequire", "maxTaskCostUsd", "maxTaskTokens", "maxPrincipalCostUsd", "costQuotaWindowSec"];
56
88
  export type RuntimeGateKey = (typeof RUNTIME_GATE_KEYS)[number];
89
+ /**
90
+ * #322 批1:六闸里**已转热**的五个(限额族)——它们的属主是 `stage-limits.ts`(boot+refresh 双腿),
91
+ * 所以 boot-only 的闸车道不再写它们,`runtime-gates` 重启片也不再为它们铸重启理由(键是热的却还要求重启
92
+ * = 白白滚动重启整片 fleet)。剩下的 `approvalRequire` 仍是 restart-to-apply,归批2。
93
+ */
94
+ export declare const HOT_RUNTIME_GATE_KEYS: ReadonlySet<string>;
57
95
  /** Uniform presence rule (center f95438a): a gate is "managed" when the field is present — any number (incl. 0),
58
96
  * or an array for approvalRequire. Sole guard: costQuotaWindowSec must be > 0 (a 0 window is nonsensical — core
59
97
  * divides by it; center's `.positive()` already enforces, belt-and-suspenders here). */
@@ -12,6 +12,8 @@ import { applyAutoCompactWindow, findUnmatchableToolNames, formatUnmatchableTool
12
12
  import { validateCommandRules } from "../runtime-governance.js";
13
13
  import { projectCollabToWorkflows, registerCollabWorkflows } from "../capabilities/collab-workflows.js";
14
14
  import { registerTeams } from "../capabilities/team.js";
15
+ import { stageLimits } from "./stage-limits.js";
16
+ import { BATCH1_LIMIT_KEYS } from "./hot-keys-registry.js";
15
17
  const asModelThinking = (v) => (v && isThinkingLevel(v) && v !== "off" ? v : undefined);
16
18
  /** design/123: accept only a positive finite roster charsPerToken (fail-safe to env default / core 4). */
17
19
  const charsPerTokenOf = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
@@ -154,21 +156,56 @@ function stageAtModelAllowlist(raw, models, logger) {
154
156
  }
155
157
  return names;
156
158
  }
157
- /** 返回值=是否 COMMIT(false 仅在 [2283]③ 世代序 CAS 拒绝时)。caller 收到 false 必须把**同一世代的
158
- * 旁路消费**(prompts 采用/pricing/keyResolver/etag 推进/LKG 落盘)一并跳过——否则 applyEffective 拒了
159
- * 主面、旁路却半应用同一个被拒世代,混合世代从侧门回来。首次 apply(live 未登记)恒 true。 */
160
159
  export function applyEffective(config, eff, logger, opts = {}) {
161
160
  const staged = stageEffective(config, eff, logger, opts);
161
+ // #325(test [4809]):CAS 的序轴=**收货序数**(opts.generation,config-center 收货处铸的进程内
162
+ // 单调 ordinal),不再是 source version——local 腿的 version 是内容哈希(registry-core file-store
163
+ // getVersion(),上游只承诺「同 files⇒同 number」,无序承诺),按它比序会把哈希更小的合法新代
164
+ // 永久静默拒掉。generation 缺席(单元测试/一次性 lane)退回 version:既有单调 version 语义不变。
165
+ const gen = opts.generation ?? staged.version;
162
166
  const live = appliedGeneration.get(config);
163
- if (staged.version > 0 && live !== undefined && staged.version < live) {
167
+ if (staged.version > 0 && live !== undefined && gen < live) {
164
168
  // [2283]③:两次拉取的 staging 并发/乱序完成时,慢的旧世代不得整体覆盖快的新世代——每次都是
165
169
  // 「整世代」,但方向反了。拒绝必须留痕(C6);caller 的 etag 纪律本就只在 apply 成功后推进,
166
- // 下一拍拉到的自然是更新的世代。等版本重放(etag 未推进的幂等重放)照常放行。
167
- 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" });
170
+ // 下一拍拉到的自然是更新的世代。等版本重放(etag 未推进的幂等重放,同 ordinal)照常放行。
171
+ logger?.warn("sema_registry_stale_generation_refused", { staged: staged.version, stagedOrdinal: gen, live, note: "an older staged generation must not overwrite a newer committed one — refused whole; next poll replays" });
168
172
  return false;
169
173
  }
174
+ // core 5.49 `Runner.swapModels` 换装腿([4775]①,defer 臂退役):caller 传入 swapPlane 时,staged
175
+ // 模型面在 COMMIT 之前先喂给全部 boot Runner 原子换代——swap 自己 expandTiers 验证候选,抛=原代
176
+ // byte-identical 不动 ⇒ 这里按候选整拒处理(与 CAS 拒同一 false 臂:活配置零触碰、etag 不进、
177
+ // 下一拍原样重判,中心修好即自愈)。先 swap 后 commit 的次序是硬约束:反过来 commit 先落,swap 再
178
+ // 抛,admission(config.models)已在新代而执行(Runner 私有目录)还在旧代——正是 R10 封掉的分裂窗。
179
+ if (staged.modelPlane !== undefined && opts.swapPlane !== undefined) {
180
+ try {
181
+ opts.swapPlane({ models: staged.modelPlane.models, tiers: staged.modelPlane.activeTiers });
182
+ }
183
+ catch (err) {
184
+ logger?.warn("sema_registry_model_plane_swap_refused", {
185
+ version: staged.version,
186
+ error: err instanceof Error ? err.message : String(err),
187
+ note: "the Runner refused the candidate model plane (swapModels validates atomically — the current generation stays in force untouched); candidate refused whole, re-judged next poll (/health shows target≠applied while it persists)",
188
+ });
189
+ return false;
190
+ }
191
+ }
170
192
  commitStaged(config, staged);
193
+ if (staged.version > 0)
194
+ appliedGeneration.set(config, gen); // #325:CAS 的 live 端=收货序数
171
195
  // ── post-commit notifications(全部世代描述性通知在最后一笔赋值之后,[2283]②)──
196
+ // #322 世代账:apply 报告也是「世代描述性通知」⇒ 同样在 commit 之后发(commit 段零回调纪律不破)。
197
+ opts.report?.({
198
+ version: staged.version,
199
+ ...(opts.generation !== undefined ? { ordinal: opts.generation } : {}),
200
+ limits: {
201
+ applied: staged.limits.changed,
202
+ ...(staged.limits.deferred.length > 0 ? { deferred: staged.limits.deferred } : {}),
203
+ ...(staged.limits.rejected ? { rejected: staged.limits.rejected } : {}),
204
+ },
205
+ fullyApplied: staged.limits.rejected === undefined,
206
+ });
207
+ if (staged.limits.changed.length > 0)
208
+ logger?.info("sema_registry_limits", { version: staged.version, keys: staged.limits.changed });
172
209
  if (staged.modelPlane)
173
210
  logger?.info("sema_registry_models", staged.modelPlane.infoLine);
174
211
  if (staged.gatesInfo)
@@ -210,6 +247,13 @@ function commitStaged(config, staged) {
210
247
  for (const [k, v] of staged.gateAssignments)
211
248
  config[k] = v;
212
249
  }
250
+ // #322 批1:limitsHttp 组的整批赋值(stage 已保证「要么全在、要么全空」)。缺席基线的键**删**而不是
251
+ // 写 undefined —— 本仓的 presence-as-semantics 判定(`"k" in config`)只打平铺面,写 undefined 会让
252
+ // 「center 撤键回默认」看起来像「显式设成 undefined」。
253
+ for (const [k, v] of staged.limits.assignments)
254
+ config[k] = v;
255
+ for (const k of staged.limits.deletions)
256
+ delete config[k];
213
257
  if (staged.hot.setAutonomy)
214
258
  config.autonomy = staged.hot.autonomyNext;
215
259
  if (staged.hot.setCommandPolicy)
@@ -220,8 +264,7 @@ function commitStaged(config, staged) {
220
264
  registerCollabWorkflows(tp.workflows);
221
265
  }
222
266
  mutateInPlace(config.projects, staged.projects); // IN PLACE: keep the ref per-request consumers captured at boot
223
- if (staged.version > 0)
224
- appliedGeneration.set(config, staged.version);
267
+ // 世代登记在 applyEffective 内做(#325:登记的是收货序数 gen,不是 source version)
225
268
  }
226
269
  /** STAGE 段:一切计算/校验/解封/投影(可抛;抛=候选整体拒绝,活配置零触碰)。内容判定性 warn/error
227
270
  * (描述 eff 真伪,与是否 commit 无关)在此发;世代描述性 info 只装载荷,post-commit 发。 */
@@ -397,6 +440,12 @@ function stageEffective(config, eff, logger, opts) {
397
440
  // 双写期两处逐键相等语义不变;center 撤双写后 governance 即唯一来源。限额残余(rateLimit/cost 五件)仍在 runtime。
398
441
  const gatesView = eff.governance ? { ...eff.runtime, ...eff.governance } : eff.runtime;
399
442
  const gates = opts.teamsOnly ? undefined : stageRuntimeGates(gatesView, logger);
443
+ // #322 批1:limitsHttp 组是**热**的 ⇒ 与 hot 二件同姿势,boot 与 refresh 双腿都算(不在 teamsOnly 门内)。
444
+ // 五个既有闸键因此从 `stageRuntimeGates` 的 boot-only 车道**移出**(RUNTIME_GATE_KEYS 里剩 approvalRequire),
445
+ // 单一属主:一个键只能有一个写者,否则 boot 一次 refresh 一次的两条腿迟早给出不同答案。
446
+ // 🔴 `limits` 按**键存在性**透传,不按真值(codex R1-F1 同族):`null`/`0`/`""` 这些畸形值必须原样送到
447
+ // stage 的判形门上去,truthy 过滤会把它们折成「域缺席」= 静默回 env 底,正是那条 finding 要堵的形。
448
+ const limits = stageLimits(config, { version: typeof eff.version === "number" ? eff.version : 0, ...("limits" in eff ? { limits: eff.limits } : {}), legacyGates: legacyGateValues(eff.runtime) }, logger);
400
449
  // Runtime governance "second baton" (center §10): autonomy + commandPolicy are per-request HOT (read live in
401
450
  // resolveSpec), NOT baked into boot middleware → apply on BOTH boot and refresh (outside the teamsOnly guard) so
402
451
  // they hot-reload. No restart-to-apply signal (they take effect on the next task without a restart).
@@ -463,6 +512,7 @@ function stageEffective(config, eff, logger, opts) {
463
512
  version: typeof eff.version === "number" && Number.isFinite(eff.version) ? eff.version : 0,
464
513
  ...(modelPlane !== undefined ? { modelPlane } : {}),
465
514
  ...(gates !== undefined ? { gateAssignments: gates.assignments, ...(gates.info !== undefined ? { gatesInfo: gates.info } : {}) } : {}),
515
+ limits,
466
516
  hot,
467
517
  ...(teamsPlane !== undefined ? { teamsPlane } : {}),
468
518
  projects,
@@ -491,6 +541,12 @@ function stageEffective(config, eff, logger, opts) {
491
541
  * across `EffectiveConfig["runtime"]` and `ServiceConfig`. Exported (not in the original single-file form) so
492
542
  * the sibling restart-signal module can share the identical key list + presence rule. */
493
543
  export const RUNTIME_GATE_KEYS = ["rateLimitPerMin", "approvalRequire", "maxTaskCostUsd", "maxTaskTokens", "maxPrincipalCostUsd", "costQuotaWindowSec"];
544
+ /**
545
+ * #322 批1:六闸里**已转热**的五个(限额族)——它们的属主是 `stage-limits.ts`(boot+refresh 双腿),
546
+ * 所以 boot-only 的闸车道不再写它们,`runtime-gates` 重启片也不再为它们铸重启理由(键是热的却还要求重启
547
+ * = 白白滚动重启整片 fleet)。剩下的 `approvalRequire` 仍是 restart-to-apply,归批2。
548
+ */
549
+ export const HOT_RUNTIME_GATE_KEYS = new Set(BATCH1_LIMIT_KEYS);
494
550
  /** Uniform presence rule (center f95438a): a gate is "managed" when the field is present — any number (incl. 0),
495
551
  * or an array for approvalRequire. Sole guard: costQuotaWindowSec must be > 0 (a 0 window is nonsensical — core
496
552
  * divides by it; center's `.positive()` already enforces, belt-and-suspenders here). */
@@ -562,6 +618,8 @@ function stageRuntimeGates(rt, logger) {
562
618
  const assignments = [];
563
619
  const applied = {};
564
620
  for (const key of RUNTIME_GATE_KEYS) {
621
+ if (HOT_RUNTIME_GATE_KEYS.has(key))
622
+ continue; // #322 批1:属主已移交 stage-limits(热腿),一个键只许一个写者
565
623
  if (!runtimeGatePresent(rt, key))
566
624
  continue; // undefined / sentinel → keep env
567
625
  if (key === "approvalRequire") {
@@ -577,14 +635,33 @@ function stageRuntimeGates(rt, logger) {
577
635
  }
578
636
  return { assignments, ...(Object.keys(applied).length > 0 ? { info: applied } : {}) };
579
637
  }
638
+ /** #322:旧位(`runtime`)里**已过 presence 判据**的限额闸值。判据属主恒是 `runtimeGatePresent`
639
+ * —— stage-limits 不复刻它,只消费它的产物(两位判据不同的理由见 stage-limits 的 `LimitsSource` 注)。 */
640
+ function legacyGateValues(rt) {
641
+ const out = {};
642
+ if (!rt)
643
+ return out;
644
+ for (const k of RUNTIME_GATE_KEYS)
645
+ if (HOT_RUNTIME_GATE_KEYS.has(k) && runtimeGatePresent(rt, k))
646
+ out[k] = rt[k];
647
+ return out;
648
+ }
580
649
  export function applyRuntimeGates(config, rt, logger) {
581
650
  // 独立调用面的兼容壳(测试/外部):stage → 就地赋值 → 通知。applyEffective 不走这里——它把
582
651
  // assignments 并进自己的 commit 段以保住整世代原子性([2283]②)。
652
+ // #322:限额五闸的属主已移交热腿,所以本壳**同样**要过 stage-limits —— 否则一个仍叫 applyRuntimeGates
653
+ // 的函数会静默漏掉六个键里的五个(比改名更坏的那种沉默)。
583
654
  const s = stageRuntimeGates(rt, logger);
584
655
  for (const [k, v] of s.assignments)
585
656
  config[k] = v;
586
- if (s.info)
587
- logger?.info("sema_registry_runtime", s.info);
657
+ const limits = stageLimits(config, { version: 0, legacyGates: legacyGateValues(rt) }, logger);
658
+ for (const [k, v] of limits.assignments)
659
+ config[k] = v;
660
+ for (const k of limits.deletions)
661
+ delete config[k];
662
+ const info = { ...s.info, ...Object.fromEntries(limits.assignments) };
663
+ if (Object.keys(info).length > 0)
664
+ logger?.info("sema_registry_runtime", info);
588
665
  }
589
666
  /**
590
667
  * Apply the runtime governance "second baton" (center §10): `autonomy` + `commandPolicy`. UNLIKE the 6 gates in
@@ -0,0 +1,82 @@
1
+ /**
2
+ * #322 —— **世代账**(design/322 §2 世代账,codex 设计审 F4)。
3
+ *
4
+ * 身份标 = 上游 `EffectiveConfig.version`,**刻意不造跨进程 epoch**(codex F4 语义不变):
5
+ * · 同版本幂等重放(etag 未推进的下一拍)不该伪增世代;
6
+ * · 双副本对账「是不是同一代」必须能跨进程判 —— 同 source version = 同一代。
7
+ *
8
+ * 🔴 #325(test [4809],[4810] 阻断裁定):**序轴与身份标分离**。source version 在
9
+ * `CONFIG_PROVIDER=local` 下是 registry-core file-store 的内容哈希(u53,上游契约只承诺
10
+ * 「同 files ⇒ 同 number」,从未承诺序)——把它当序数消费,哈希更小的合法新代会被永久
11
+ * 静默忽略且本账双盲(target/applied 都不动)。修后:**推进判断吃收货处铸的进程内单调
12
+ * ordinal**(config-center `generationOf`;等版本重放沿用同 ordinal=幂等语义保留),
13
+ * `version` 只做对账身份标——configTargetVersion 语义从「max(见过)」变为「**最后**见过的
14
+ * 源代」(remote 单调腿二者等价;local 腿这才是真相)。
15
+ *
16
+ * 读面两个:`/health` 的三键(target / applied / 失败持续时长;#325 起 additive 补
17
+ * targetOrdinal/appliedOrdinal 两键=推进/停滞的机器判据)给编排器判「这个副本要不要摘」;
18
+ * 诊断端点的 `configApply` 段给运维看**按组**的细节(哪一组停在哪一代、上一次为什么被拒)。
19
+ *
20
+ * 🔴 原子承诺的**域**是单进程(§2):本账记的是「本副本的收敛状态」,不是 fleet 的。fleet 级判据 = 各副本
21
+ * /health 的 applied == target,拉齐工具归运维面。
22
+ */
23
+ /** 一组的收敛状态(批1 只有 limitsHttp 组;后续批次按组追加,读面形不变)。 */
24
+ export interface ConfigApplyGroupState {
25
+ /** 本组最后一次**真的落地**的源版本(身份标)。 */
26
+ readonly appliedVersion: number;
27
+ /** 被判定为「只能重启生效」的键 + 铸因(热路径拒了,但拒因是姿态而不是坏值)。 */
28
+ readonly deferredKeys?: ReadonlyArray<{
29
+ readonly key: string;
30
+ readonly reason: string;
31
+ }>;
32
+ /** 上一次整批被拒(消毒:键名 + 判据文案 + 版本 + 时刻;**绝不含值**)。 */
33
+ readonly lastRejected?: {
34
+ readonly keys: readonly string[];
35
+ readonly reason: string;
36
+ readonly version: number;
37
+ readonly at: number;
38
+ };
39
+ }
40
+ export interface ConfigApplyLedgerView {
41
+ /** 本副本**最后**见到的源版本(身份标;拉到即算,哪怕候选被整拒)。 */
42
+ readonly configTargetVersion: number;
43
+ /** 最后一次成功 commit 的源版本(身份标)。 */
44
+ readonly configAppliedVersion: number;
45
+ /** #325:target 的收货序数(进程内单调)——「见过多少代」的推进判据,哈希方向免疫。 */
46
+ readonly configTargetOrdinal: number;
47
+ /** #325:applied 的收货序数。targetOrdinal 推进而这里停滞 = 「改了没生效」的机器判据。 */
48
+ readonly configAppliedOrdinal: number;
49
+ /** target≠applied 的**持续**时长(ms);收敛时键缺席(健康面不制造噪音)。 */
50
+ readonly configApplyStaleMs?: number;
51
+ readonly groups: Readonly<Record<string, ConfigApplyGroupState>>;
52
+ }
53
+ /** applyEffective 的 post-commit 报告(见 apply-effective.ts `ApplyReport`)。 */
54
+ export interface ConfigApplyReport {
55
+ readonly version: number;
56
+ /** #325:本候选的收货序数(applyEffective opts.generation 原样带回;缺席=version 兼容臂)。 */
57
+ readonly ordinal?: number;
58
+ readonly limits: {
59
+ readonly applied: readonly string[];
60
+ readonly deferred?: ReadonlyArray<{
61
+ readonly key: string;
62
+ readonly reason: string;
63
+ }>;
64
+ readonly rejected?: {
65
+ readonly keys: readonly string[];
66
+ readonly reason: string;
67
+ readonly restartReason?: string;
68
+ };
69
+ };
70
+ readonly fullyApplied?: boolean;
71
+ }
72
+ export interface ConfigApplyLedger {
73
+ /** 拉到一个候选(无论后续是否应用)。ordinal=收货序数,version=身份标。 */
74
+ seenTarget(ordinal: number, version: number): void;
75
+ /** 一个候选整体 commit 成功。 */
76
+ applied(ordinal: number, version: number): void;
77
+ /** 消费 applyEffective 的报告(按组记账)。 */
78
+ recordApply(report: ConfigApplyReport): void;
79
+ view(): ConfigApplyLedgerView;
80
+ }
81
+ export declare function createConfigApplyLedger(now?: () => number): ConfigApplyLedger;
82
+ //# sourceMappingURL=apply-ledger.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * #322 —— **世代账**(design/322 §2 世代账,codex 设计审 F4)。
3
+ *
4
+ * 身份标 = 上游 `EffectiveConfig.version`,**刻意不造跨进程 epoch**(codex F4 语义不变):
5
+ * · 同版本幂等重放(etag 未推进的下一拍)不该伪增世代;
6
+ * · 双副本对账「是不是同一代」必须能跨进程判 —— 同 source version = 同一代。
7
+ *
8
+ * 🔴 #325(test [4809],[4810] 阻断裁定):**序轴与身份标分离**。source version 在
9
+ * `CONFIG_PROVIDER=local` 下是 registry-core file-store 的内容哈希(u53,上游契约只承诺
10
+ * 「同 files ⇒ 同 number」,从未承诺序)——把它当序数消费,哈希更小的合法新代会被永久
11
+ * 静默忽略且本账双盲(target/applied 都不动)。修后:**推进判断吃收货处铸的进程内单调
12
+ * ordinal**(config-center `generationOf`;等版本重放沿用同 ordinal=幂等语义保留),
13
+ * `version` 只做对账身份标——configTargetVersion 语义从「max(见过)」变为「**最后**见过的
14
+ * 源代」(remote 单调腿二者等价;local 腿这才是真相)。
15
+ *
16
+ * 读面两个:`/health` 的三键(target / applied / 失败持续时长;#325 起 additive 补
17
+ * targetOrdinal/appliedOrdinal 两键=推进/停滞的机器判据)给编排器判「这个副本要不要摘」;
18
+ * 诊断端点的 `configApply` 段给运维看**按组**的细节(哪一组停在哪一代、上一次为什么被拒)。
19
+ *
20
+ * 🔴 原子承诺的**域**是单进程(§2):本账记的是「本副本的收敛状态」,不是 fleet 的。fleet 级判据 = 各副本
21
+ * /health 的 applied == target,拉齐工具归运维面。
22
+ */
23
+ export function createConfigApplyLedger(now = () => Date.now()) {
24
+ let targetOrd = 0;
25
+ let appliedOrd = 0;
26
+ let targetVersion = 0;
27
+ let appliedVersion = 0;
28
+ /**
29
+ * 🔴 codex R1-F3(真 finding,红先):**有组被拒的世代不算「本副本应用了这一代」**。
30
+ *
31
+ * applyEffective 对一个 limits 被整组拒的候选仍返回 true(其余面照常提交),生产腿随后无条件调
32
+ * `applied(...)` —— 修前于是 `/health` 报 applied==target、stale 时长消失,而限额面其实停在旧代。
33
+ * 世代账存在的全部理由就是让这种漂移可见,所以拒过的世代在这里被记下,`applied()` 拒绝为它收敛。
34
+ * #325 起按 **ordinal** 记(local 腿同 version 可能隔代重现,序数才是「这一次候选」的唯一键)。
35
+ */
36
+ const rejectedOrds = new Set();
37
+ /** 首次分歧的时刻 —— **持续**时长的锚(每拍刷新会把「卡了一小时」写成「卡了 0 秒」)。 */
38
+ let divergedSince;
39
+ const groups = {};
40
+ const reconcile = () => {
41
+ if (targetOrd === appliedOrd)
42
+ divergedSince = undefined;
43
+ else
44
+ divergedSince ??= now();
45
+ };
46
+ return {
47
+ seenTarget(ordinal, version) {
48
+ if (ordinal > targetOrd) {
49
+ targetOrd = ordinal;
50
+ targetVersion = version;
51
+ }
52
+ reconcile();
53
+ },
54
+ applied(ordinal, version) {
55
+ if (ordinal > targetOrd) {
56
+ targetOrd = ordinal; // 迟到 boot / LKG 腿:应用的那一代自然也是见过的
57
+ targetVersion = version;
58
+ }
59
+ // 本世代有组被拒 ⇒ 只推进 target(诚实的「见过、没能完整应用」),applied 停在上一个完整世代。
60
+ if (!rejectedOrds.has(ordinal) && ordinal > appliedOrd) {
61
+ appliedOrd = ordinal;
62
+ appliedVersion = version;
63
+ }
64
+ reconcile();
65
+ },
66
+ recordApply(report) {
67
+ const prev = groups.limitsHttp;
68
+ const ord = report.ordinal ?? report.version; // 兼容臂:未传 ordinal 的单元调用退回 version 键
69
+ if (report.limits.rejected)
70
+ rejectedOrds.add(ord);
71
+ else
72
+ rejectedOrds.delete(ord); // 同一世代重放且这次过了(拒因已修)⇒ 允许收敛
73
+ if (report.limits.rejected) {
74
+ groups.limitsHttp = {
75
+ appliedVersion: prev?.appliedVersion ?? 0, // 被拒的世代不许推进组的 applied
76
+ ...(prev?.deferredKeys ? { deferredKeys: prev.deferredKeys } : {}),
77
+ lastRejected: { keys: [...report.limits.rejected.keys], reason: report.limits.rejected.reason, version: report.version, at: now() },
78
+ ...(report.limits.rejected.restartReason
79
+ ? { deferredKeys: report.limits.rejected.keys.map((key) => ({ key, reason: report.limits.rejected.restartReason })) }
80
+ : {}),
81
+ };
82
+ return;
83
+ }
84
+ groups.limitsHttp = {
85
+ appliedVersion: report.version,
86
+ // 逐键 defer(值合法但键不热):每代重算 —— 不再被发布的 defer 自然消失,不粘。
87
+ ...(report.limits.deferred && report.limits.deferred.length > 0 ? { deferredKeys: report.limits.deferred.map((d) => ({ key: d.key, reason: d.reason })) } : {}),
88
+ ...(prev?.lastRejected ? { lastRejected: prev.lastRejected } : {}), // 上一次拒因保留:修好之后仍要能查到发生过什么
89
+ };
90
+ },
91
+ view() {
92
+ const stale = divergedSince !== undefined ? now() - divergedSince : undefined;
93
+ return {
94
+ configTargetVersion: targetVersion,
95
+ configAppliedVersion: appliedVersion,
96
+ configTargetOrdinal: targetOrd,
97
+ configAppliedOrdinal: appliedOrd,
98
+ ...(stale !== undefined ? { configApplyStaleMs: stale } : {}),
99
+ groups: { ...groups },
100
+ };
101
+ },
102
+ };
103
+ }
104
+ //# sourceMappingURL=apply-ledger.js.map
@@ -40,7 +40,7 @@
40
40
  * importers to update, which was done in the same commit.
41
41
  */
42
42
  export { fetchEffective, fetchPrincipalCaps, fetchPrincipalOrgMemory, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
43
- export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
43
+ export { mutateInPlace, applyEffective, type ApplyReport, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./apply-effective.js";
44
44
  export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type RestartSignal, } from "./restart-signal.js";
45
45
  export { applyCenterSkills, resolveMcpServers, mcpForScenario, resolveA2aPeers, a2aForScenario } from "./skills-mcp.js";
46
46
  export { applyCenterReadFace } from "./read-face.js";