@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
@@ -0,0 +1,62 @@
1
+ /**
2
+ * #322 —— **热键登记表**(design/322 §4 机器门)。
3
+ *
4
+ * 一个键「是不是热的」在本仓有三个互相独立的说法:①代码里真的接了写入面;②测试里真的有黑盒断言;
5
+ * ③文档里对运维的承诺。三者任一漂了,受害者都是运维(改了配置,不知道该不该重启)。本表是**闭集单源**:
6
+ * 键 → 组 → 换代手法 → **参数化用例 ID**(不是文件名——文件名粒度挡不住「文件在、用例没了」)。
7
+ *
8
+ * 机器门在 test/config-hot/batch1-hot-keys-registry.test.ts:登记为热却没有三断言用例 = 红;
9
+ * 表的键集与 stage 真正消费的键集(`BATCH1_LIMIT_KEYS`)不等 = 红。
10
+ *
11
+ * ⚠️ 本表**只登记已落地的批**。批2-4 的键在各自批次里追加(设计稿 §6),不提前写进来 —— 一个「登记为热
12
+ * 但没接线」的条目正是本表要消灭的那种谎。
13
+ */
14
+ import type { ServiceConfigFlat, ServiceConfigGroups } from "../config-types.js";
15
+ /** 换代手法(design/322 §2):A′=写入面接线(消费侧本就现读);B=值换代座位(对象已建、值被冻住)。 */
16
+ export type HotKeyForm = "A-prime" | "B-setter";
17
+ /** 三断言的用例 ID —— 与 test/config-hot 的参数化生成规则(`HK-<key>-<kind>`)逐字一致。 */
18
+ export interface HotKeyCases {
19
+ /** 生效:改配置源 ⇒ ≤2 tick 行为面翻转。 */
20
+ readonly effect: string;
21
+ /** 坏值拒:响亮拒 + 旧值继续服务。 */
22
+ readonly refuse: string;
23
+ /** 回滚:改回 / center 撤键 ⇒ 行为回原状(无粘滞)。 */
24
+ readonly rollback: string;
25
+ }
26
+ export interface HotKeyEntry {
27
+ readonly key: keyof ServiceConfigFlat;
28
+ readonly group: keyof ServiceConfigGroups;
29
+ readonly form: HotKeyForm;
30
+ /** 落地批次(设计稿 §6 的批号)。 */
31
+ readonly batch: 1;
32
+ readonly cases: HotKeyCases;
33
+ }
34
+ /**
35
+ * 批1(cost 族)真正接线的 14 键 —— **stage 消费的单一真源**:`stage-limits.ts` 按本表遍历,
36
+ * 所以「表里有、stage 不写」或「stage 写、表里没有」都不可能悄悄发生。
37
+ *
38
+ * 前 3 个是 B 簇1(限额座 setter);其余 11 个是 limitsHttp 组的 A′ 接线键。
39
+ */
40
+ export declare const BATCH1_LIMIT_KEYS: readonly ["rateLimitPerMin", "maxPrincipalCostUsd", "costQuotaWindowSec", "maxTaskCostUsd", "maxTaskTokens", "infraCostRates", "attachmentOrphanGraceMs", "workspaceFileMaxBytes", "attachmentMaxBytes", "attachmentMimeAllowlist", "attachmentUnboundTtlMs", "drainGraceMs", "runStaleSec", "toolResultTtlSec"];
41
+ export type Batch1LimitKey = (typeof BATCH1_LIMIT_KEYS)[number];
42
+ /**
43
+ * 🔴 **stage 消费但不热**的键(codex R2-F2,验真后降档)。
44
+ *
45
+ * `costQuotaWindowSec` 在设计稿里归 B 簇(「加个 setter 就好」),那个判断的前提是「限额是比较参数,不是
46
+ * 行状态」—— 对**限额**成立,对**窗长**不成立:SQL 腿的窗长是**桶键的一部分**(`floor(now/windowMs)`,
47
+ * 进 DB 主键)。热切窗长于是有两个真后果:①A→B→A 会在原 A 窗内**复活**切换前的花费(旧桶还在,桶号又
48
+ * 撞上);②B 期间 A 的高编号旧桶不被 `window_bucket < current` 的 reap 清理。内存腿只能靠「换窗即清」
49
+ * 才安全,于是两腿语义必然分裂 —— 同一条发布在不同后端给出相反的成本判决(后端相关的 fail-open)。
50
+ *
51
+ * 处置:窗长**降为 restart-to-apply**(值仍逐次校验,变更逐次铸因、进世代账的 deferredKeys + 一条响亮
52
+ * warn),而不是给一个「在某些后端上会复活旧账」的热能力。真做热要给桶键加**世代隔离**(把窗长世代并进
53
+ * 持久化主键 + 跨副本一致的世代来源),那是独立的一件,归后续批。
54
+ */
55
+ export declare const DEFERRED_LIMIT_KEYS: ReadonlyArray<{
56
+ readonly key: Batch1LimitKey;
57
+ readonly restartReason: string;
58
+ readonly why: string;
59
+ }>;
60
+ /** 热键表 = 批1 消费键集**减去**显式 deferred 的那些(表只登记真的热的键)。 */
61
+ export declare const HOT_KEYS_REGISTRY: readonly HotKeyEntry[];
62
+ //# sourceMappingURL=hot-keys-registry.d.ts.map
@@ -0,0 +1,54 @@
1
+ const cases = (key) => ({ effect: `HK-${key}-effect`, refuse: `HK-${key}-refuse`, rollback: `HK-${key}-rollback` });
2
+ /**
3
+ * 批1(cost 族)真正接线的 14 键 —— **stage 消费的单一真源**:`stage-limits.ts` 按本表遍历,
4
+ * 所以「表里有、stage 不写」或「stage 写、表里没有」都不可能悄悄发生。
5
+ *
6
+ * 前 3 个是 B 簇1(限额座 setter);其余 11 个是 limitsHttp 组的 A′ 接线键。
7
+ */
8
+ export const BATCH1_LIMIT_KEYS = [
9
+ "rateLimitPerMin",
10
+ "maxPrincipalCostUsd",
11
+ "costQuotaWindowSec",
12
+ "maxTaskCostUsd",
13
+ "maxTaskTokens",
14
+ "infraCostRates",
15
+ "attachmentOrphanGraceMs",
16
+ "workspaceFileMaxBytes",
17
+ "attachmentMaxBytes",
18
+ "attachmentMimeAllowlist",
19
+ "attachmentUnboundTtlMs",
20
+ "drainGraceMs",
21
+ "runStaleSec",
22
+ "toolResultTtlSec",
23
+ ];
24
+ const B_SETTER_KEYS = ["rateLimitPerMin", "maxPrincipalCostUsd"];
25
+ /**
26
+ * 🔴 **stage 消费但不热**的键(codex R2-F2,验真后降档)。
27
+ *
28
+ * `costQuotaWindowSec` 在设计稿里归 B 簇(「加个 setter 就好」),那个判断的前提是「限额是比较参数,不是
29
+ * 行状态」—— 对**限额**成立,对**窗长**不成立:SQL 腿的窗长是**桶键的一部分**(`floor(now/windowMs)`,
30
+ * 进 DB 主键)。热切窗长于是有两个真后果:①A→B→A 会在原 A 窗内**复活**切换前的花费(旧桶还在,桶号又
31
+ * 撞上);②B 期间 A 的高编号旧桶不被 `window_bucket < current` 的 reap 清理。内存腿只能靠「换窗即清」
32
+ * 才安全,于是两腿语义必然分裂 —— 同一条发布在不同后端给出相反的成本判决(后端相关的 fail-open)。
33
+ *
34
+ * 处置:窗长**降为 restart-to-apply**(值仍逐次校验,变更逐次铸因、进世代账的 deferredKeys + 一条响亮
35
+ * warn),而不是给一个「在某些后端上会复活旧账」的热能力。真做热要给桶键加**世代隔离**(把窗长世代并进
36
+ * 持久化主键 + 跨副本一致的世代来源),那是独立的一件,归后续批。
37
+ */
38
+ export const DEFERRED_LIMIT_KEYS = [
39
+ {
40
+ key: "costQuotaWindowSec",
41
+ restartReason: "cost-quota-window",
42
+ why: "the quota window length is part of the SQL bucket key (row state), not a comparison parameter — hot-swapping it can revive spend from an older window on the SQL backends while the in-memory backend clears it (a backend-dependent cost fail-open)",
43
+ },
44
+ ];
45
+ const DEFERRED_KEYS = new Set(DEFERRED_LIMIT_KEYS.map((d) => d.key));
46
+ /** 热键表 = 批1 消费键集**减去**显式 deferred 的那些(表只登记真的热的键)。 */
47
+ export const HOT_KEYS_REGISTRY = BATCH1_LIMIT_KEYS.filter((key) => !DEFERRED_KEYS.has(key)).map((key) => ({
48
+ key,
49
+ group: "limitsHttp",
50
+ form: (B_SETTER_KEYS.includes(key) ? "B-setter" : "A-prime"),
51
+ batch: 1,
52
+ cases: cases(key),
53
+ }));
54
+ //# sourceMappingURL=hot-keys-registry.js.map
@@ -45,9 +45,19 @@ export async function fetchEffective(baseUrl, token, etag, fetchImpl = fetch, wo
45
45
  delete wire.prompts;
46
46
  else
47
47
  wire.prompts = centerPrompts;
48
+ // registry-core 0.19.0 响亮化三 kind 同批收编(与 local 腿 config-provider.ts 同映射,LANDING-7.38 件③):
49
+ // dropped/unread=写了没生效(defaulted 同级),carried=承运待键表方判——全入诊断面,禁静默丢。
48
50
  const domainErrors = warnings
49
- .filter((w) => w.kind === "domain-defaulted")
50
- .map((w) => ({ domain: w.domain, error: (w.error instanceof Error ? w.error.message : String(w.error)).slice(0, 600) }));
51
+ .map((w) => w.kind === "domain-defaulted"
52
+ ? { domain: w.domain, error: (w.error instanceof Error ? w.error.message : String(w.error)).slice(0, 600) }
53
+ : w.kind === "unknown-keys-dropped"
54
+ ? { domain: w.domain, error: `unknown keys dropped by domain schema: ${w.keys.join(", ")}`.slice(0, 600) }
55
+ : w.kind === "unknown-keys-carried"
56
+ ? { domain: w.domain, error: `unknown keys carried to consumer (spelling?): ${w.keys.join(", ")}`.slice(0, 600) }
57
+ : w.kind === "unread-config-file"
58
+ ? { domain: w.domain ?? "config.d", error: `unread config file ${w.file} (${w.why})`.slice(0, 600) }
59
+ : undefined)
60
+ .filter((r) => r !== undefined);
51
61
  // 经真校验后的 wire 值到 service 拼写副本的换装:两型同一契约面(EffectiveWire 是 service 型的同源超集,
52
62
  // service 型只声明自己消费的域且全 optional)——此断言的前提正是上面那次校验,不再是裸信任。
53
63
  const effective = wire;
@@ -6,7 +6,7 @@
6
6
  * of `facade.ts` (design/158 A13, internal-lossless — the facade re-exports every symbol below unchanged).
7
7
  */
8
8
  import { resolveActiveTiers } from "@sema-agent/registry-core";
9
- import { RUNTIME_GATE_KEYS, runtimeGatePresent, resolveDefaultModelName } from "./apply-effective.js";
9
+ import { RUNTIME_GATE_KEYS, HOT_RUNTIME_GATE_KEYS, runtimeGatePresent, resolveDefaultModelName } from "./apply-effective.js";
10
10
  const RESTART_SLICES = ["skills", "mcp", "a2a", "scenarios", "runtime-gates", "models-tiers", "degrade-route", "read-face"];
11
11
  /** Canonical, key-sorted JSON (array order preserved) so two semantically-equal effective slices fingerprint
12
12
  * identically regardless of object key order from the center serializer. */
@@ -82,8 +82,11 @@ function restartSliceValue(eff, slice, ctx) {
82
82
  if (!rt)
83
83
  return null;
84
84
  const present = {};
85
+ // #322 批1:限额五闸已转热(stage-limits 双腿写入)⇒ **不再进指纹**。留在里面的话,一次纯限额发布
86
+ // 会让整片 fleet 为一个已经生效的变更滚动重启一遍——而重启后的姿态与重启前逐字相同(read-face 的
87
+ // env-held 剔除同族论证)。剩下的 approvalRequire 仍是 boot-baked,归批2。
85
88
  for (const k of RUNTIME_GATE_KEYS)
86
- if (runtimeGatePresent(rt, k))
89
+ if (!HOT_RUNTIME_GATE_KEYS.has(k) && runtimeGatePresent(rt, k))
87
90
  present[k] = rt[k];
88
91
  return Object.keys(present).length ? present : null;
89
92
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * #322 批1 —— limitsHttp 组的 **stage 半场**(design/322 手法 A′ + B 簇1)。
3
+ *
4
+ * 形与 `stageApprovalRequire` 同族:一切可抛/可判的事在 stage 做,commit 段只剩纯赋值。三条本域纪律:
5
+ *
6
+ * 1. **非粘**(applyRuntimeHot 的 NON-STICKY 同源):每次 apply 都从 **env 基线**重算 `present ? center : baseline`
7
+ * ——center 撤键 ⇒ 回 env 底。「缺席=保上一个 center 值」会把一次已经撤回的限额永久冻在进程里。
8
+ * 2. **整批原子**(design/322 §0③ + §4 混批零变异):本域任一键坏值/任一跨键不变量不过 ⇒ **整批拒**,
9
+ * 合法键也不落地,旧值继续服务,拒因响亮点名键(#123 法)。半应用一个限额面 = 运营看到的数字与真门
10
+ * 不是同一代,比整批拒糟得多。
11
+ * 3. **判据单源**:跨键不变量走 `config-invariants.ts` 的同一张表(boot 与 stage 同表);数值下限走
12
+ * config.ts 导出的同一组 floor 常量(env 腿夹取、center 腿拒——**判据阈值同源,处置刻意不同**:
13
+ * env 是既有部署的存量值,夹取+点名不打断服务;center 发布是一次可重发的显式动作,拒绝才有意义)。
14
+ */
15
+ import type { ServiceConfig } from "../config-types.js";
16
+ import type { Logger } from "../observability/logger.js";
17
+ import { type Batch1LimitKey } from "./hot-keys-registry.js";
18
+ import type { EffectiveLimits } from "./types.js";
19
+ /**
20
+ * stage 的入参视图。
21
+ *
22
+ * 🔴 `legacyGates` 是**已过旧位 presence 判据**的闸值(判据属主 = `runtimeGatePresent`,调用方过一遍再传)。
23
+ * 两位刻意判据不同,不是疏忽:旧位(`runtime`)的容忍语义(例如 `costQuotaWindowSec: 0` 判「缺席」保 env)
24
+ * 是既有 center 的在营契约,收紧它会把一个存量发布变成整批拒;新位(`limits`)从第一天起就是严判
25
+ * (坏值 = 整批响亮拒)。判据属主各只有一个,谁都没有被复刻第二份。
26
+ */
27
+ export interface LimitsSource {
28
+ readonly version: number;
29
+ readonly limits?: EffectiveLimits;
30
+ readonly legacyGates?: Readonly<Record<string, unknown>>;
31
+ }
32
+ /** 逐键 defer 的铸因(codex R2-F2):值合法但这一键**不热**,变更只能重启生效。 */
33
+ export interface DeferredLimit {
34
+ readonly key: Batch1LimitKey;
35
+ readonly reason: string;
36
+ }
37
+ export interface StagedLimits {
38
+ /** commit 段照单赋值。 */
39
+ readonly assignments: ReadonlyArray<readonly [Batch1LimitKey, unknown]>;
40
+ /** 回 env 底、而底本身是「键缺席」的那些键(presence-as-semantics 的键必须**删**,不能写 undefined)。 */
41
+ readonly deletions: readonly Batch1LimitKey[];
42
+ /** 本世代真正换了值的键(通知载荷,post-commit 发)。 */
43
+ readonly changed: readonly string[];
44
+ /** 值合法但**不热**的键:不落地,铸因进世代账 + 一条响亮 warn(逐键 defer,不是整批拒)。 */
45
+ readonly deferred: readonly DeferredLimit[];
46
+ /** 整批被拒时的成因(消毒:只带键名与判据文案,**绝不带值**)。 */
47
+ readonly rejected?: {
48
+ readonly keys: string[];
49
+ readonly reason: string;
50
+ readonly restartReason?: string;
51
+ };
52
+ }
53
+ /**
54
+ * STAGE:算出本世代 limitsHttp 组要 COMMIT 的全部赋值(纯计算,零变异)。
55
+ *
56
+ * 返回 `rejected` 时 `assignments`/`deletions` 恒为空 —— 整批拒的物理保证在返回值形上,不靠调用方自律。
57
+ */
58
+ export declare function stageLimits(config: ServiceConfig, eff: LimitsSource, logger?: Logger): StagedLimits;
59
+ //# sourceMappingURL=stage-limits.d.ts.map
@@ -0,0 +1,188 @@
1
+ import { LIMITS_HTTP_FLOORS } from "../config.js";
2
+ import { checkCrossInvariants } from "../config-invariants.js";
3
+ import { BATCH1_LIMIT_KEYS, DEFERRED_LIMIT_KEYS } from "./hot-keys-registry.js";
4
+ const INFRA_RATE_KEYS = ["toolCallMicroUsd", "sandboxSecMicroUsd", "egressGbMicroUsd"];
5
+ const okNum = (v) => typeof v === "number" && Number.isFinite(v);
6
+ const atLeast = (key, v, min) => okNum(v) && v >= min ? { ok: true, value: v } : { ok: false, reason: `${key} must be a finite number >= ${min}` };
7
+ const positive = (key, v) => okNum(v) && v > 0 ? { ok: true, value: v } : { ok: false, reason: `${key} must be a finite number > 0` };
8
+ /** 逐键判据。**坏值一律拒整批**(不夹取、不丢弃)——见文件头注纪律 3。 */
9
+ const VALIDATORS = {
10
+ rateLimitPerMin: (v) => atLeast("rateLimitPerMin", v, 0), // 0 = 关断哨兵
11
+ maxPrincipalCostUsd: (v) => atLeast("maxPrincipalCostUsd", v, 0), // 0 = 无配额
12
+ costQuotaWindowSec: (v) => positive("costQuotaWindowSec", v), // core 拿它做除数
13
+ maxTaskCostUsd: (v) => atLeast("maxTaskCostUsd", v, 0),
14
+ maxTaskTokens: (v) => atLeast("maxTaskTokens", v, 0),
15
+ // 🔴 codex R1-F2(真 finding,红先钉在 batch1-codex-r1.test.ts):**部分发布必须按基线补齐**。
16
+ // 修前这里从**空对象**重建,于是只发 `toolCallMicroUsd` 的一次发布会把 sandbox/egress 费率变成
17
+ // undefined —— 而消费端(routes/runs.ts 的 supervisorCost)把它们当 0 算,结果是**静默漏计费**:
18
+ // 账面看起来正常,只是少了两条轴。同一条 finding 的另一半:未知成员(拼错)修前被**忽略**,
19
+ // 极端情形下提交一个空费率对象;现在拼错=整批响亮拒(一个拼错的费率名等于「这条轴没在计费」)。
20
+ infraCostRates: (v, baseline) => {
21
+ if (v === null || typeof v !== "object" || Array.isArray(v))
22
+ return { ok: false, reason: "infraCostRates must be an object of non-negative micro-USD rates" };
23
+ const src = v;
24
+ const unknown = Object.keys(src).filter((k) => !INFRA_RATE_KEYS.includes(k));
25
+ if (unknown.length > 0)
26
+ return { ok: false, reason: `infraCostRates has unknown rate(s) ${unknown.join(", ")} — a misspelled rate name means that axis is silently NOT billed; known rates: ${INFRA_RATE_KEYS.join(", ")}` };
27
+ const base = (baseline ?? {});
28
+ const out = {};
29
+ for (const k of INFRA_RATE_KEYS) {
30
+ const raw = src[k];
31
+ if (raw === undefined) {
32
+ out[k] = base[k] ?? 0; // 缺席成员 = 本域不管这一条轴 ⇒ 回 env 基线(非粘),**不是**清零
33
+ continue;
34
+ }
35
+ if (!okNum(raw) || raw < 0)
36
+ return { ok: false, reason: `infraCostRates.${k} must be a finite number >= 0 (a negative rate would credit cost back)` };
37
+ out[k] = raw;
38
+ }
39
+ return { ok: true, value: out };
40
+ },
41
+ attachmentOrphanGraceMs: (v) => atLeast("attachmentOrphanGraceMs", v, 0), // 0 = 关掉孤儿清扫腿
42
+ workspaceFileMaxBytes: (v) => positive("workspaceFileMaxBytes", v),
43
+ attachmentMaxBytes: (v) => atLeast("attachmentMaxBytes", v, LIMITS_HTTP_FLOORS.attachmentMaxBytes),
44
+ attachmentMimeAllowlist: (v) => {
45
+ if (!Array.isArray(v))
46
+ return { ok: false, reason: "attachmentMimeAllowlist must be an array of mime strings" };
47
+ const bad = v.some((e) => typeof e !== "string" || e.trim().length === 0);
48
+ if (bad)
49
+ return { ok: false, reason: "attachmentMimeAllowlist must contain only non-empty strings (a coerced/dropped entry would silently WIDEN or empty the policy)" };
50
+ return { ok: true, value: v.map((e) => String(e).trim().toLowerCase()) };
51
+ },
52
+ attachmentUnboundTtlMs: (v) => atLeast("attachmentUnboundTtlMs", v, LIMITS_HTTP_FLOORS.attachmentUnboundTtlMs),
53
+ drainGraceMs: (v) => atLeast("drainGraceMs", v, LIMITS_HTTP_FLOORS.drainGraceMs),
54
+ runStaleSec: (v) => positive("runStaleSec", v), // 与心跳的关系由跨键不变量表判(单源)
55
+ toolResultTtlSec: (v) => positive("toolResultTtlSec", v), // 0 ⇒ cutoff=now ⇒ 一拍收光所有卸载结果
56
+ };
57
+ /** env 派生基线(**首次** stage 时快照;boot 序 = loadConfig → applyEffective,此刻 config 尚是纯 env)。 */
58
+ const limitsEnvBaseline = new WeakMap();
59
+ function baselineOf(config) {
60
+ const known = limitsEnvBaseline.get(config);
61
+ if (known)
62
+ return known;
63
+ const snap = {};
64
+ for (const k of BATCH1_LIMIT_KEYS)
65
+ snap[k] = structuredClone(config[k]);
66
+ limitsEnvBaseline.set(config, snap);
67
+ return snap;
68
+ }
69
+ const same = (a, b) => (typeof a === "object" || typeof b === "object" ? JSON.stringify(a ?? null) === JSON.stringify(b ?? null) : a === b);
70
+ /** 候选视图 = 现役 + 本批 staged 赋值(跨键不变量判的就是这个「如果落地会怎样」)。 */
71
+ function candidateView(config, assignments) {
72
+ const view = { ...config };
73
+ // 写口窄化到「本批键 → unknown」的可选记录:值的真类型已在 VALIDATORS 里逐键判过,这里只是把判过的值
74
+ // 放回同名槽位(窄 `as`,不是 `as unknown as` —— ServiceConfig 本就可赋给这个形)。
75
+ const writable = view;
76
+ for (const [k, v] of assignments)
77
+ writable[k] = v;
78
+ return view;
79
+ }
80
+ /**
81
+ * STAGE:算出本世代 limitsHttp 组要 COMMIT 的全部赋值(纯计算,零变异)。
82
+ *
83
+ * 返回 `rejected` 时 `assignments`/`deletions` 恒为空 —— 整批拒的物理保证在返回值形上,不靠调用方自律。
84
+ */
85
+ export function stageLimits(config, eff, logger) {
86
+ const baseline = baselineOf(config);
87
+ const legacy = eff.legacyGates ?? {};
88
+ const assignments = [];
89
+ const deletions = [];
90
+ const changed = [];
91
+ const deferred = [];
92
+ const badKeys = [];
93
+ let reason;
94
+ // 🔴 codex R1-F1(真 finding,红先):`limits` 是 **open-world wire 数据**(无 schema 保证),所以
95
+ // 容器本身必须先判形。修前只对它做可选索引 ⇒ `"bad"` / 数组 / null 让**每个键都看起来缺席** ⇒ 全体
96
+ // 回 env 底,而且该世代被当作合法提交:中心刚把 rate/cost 门从 0 打开的部署上,一次畸形发布就把保护
97
+ // 一声不响地关回去了。方向与本域其余判据一致 —— 读不懂就更严不更松(整批拒,旧值继续服务)。
98
+ const rawLimits = eff.limits;
99
+ if (rawLimits !== undefined && (rawLimits === null || typeof rawLimits !== "object" || Array.isArray(rawLimits))) {
100
+ const rejected = { keys: [...BATCH1_LIMIT_KEYS], reason: `the published limits domain must be a JSON object — got ${rawLimits === null ? "null" : Array.isArray(rawLimits) ? "array" : typeof rawLimits}` };
101
+ logger?.warn("sema_registry_limits_invalid", {
102
+ ...rejected,
103
+ version: eff.version,
104
+ kept: "the whole limits batch is refused — a malformed container must never read as 'the center manages none of these keys' (that would silently revert every published ceiling to the env baseline)",
105
+ });
106
+ return { assignments: [], deletions: [], changed: [], deferred: [], rejected };
107
+ }
108
+ const limits = rawLimits;
109
+ // LANDING-7.38 接线件④:limits 域是 open-world(registry-core 0.19.0 承运未知键并点名),但**键表
110
+ // 属主在这里**——发布了、本批却不消费的键(拼错/超前批次)至少要 warn,否则「registry-core 点了名、
111
+ // 部署里没人听」= 配置写了不生效的静默形。DEFERRED_LIMIT_KEYS(如 costQuotaWindowSec)不算未知。
112
+ if (limits !== undefined) {
113
+ const known = new Set([...BATCH1_LIMIT_KEYS, ...DEFERRED_LIMIT_KEYS.map((d) => d.key)]);
114
+ const unconsumed = Object.keys(limits).filter((k) => !known.has(k));
115
+ if (unconsumed.length > 0) {
116
+ logger?.warn("sema_registry_limits_unconsumed_keys", {
117
+ keys: unconsumed,
118
+ version: eff.version,
119
+ note: "published in the limits domain but not consumed by this server build (misspelled, or a later-batch key) — the value is inert until a batch that owns it ships",
120
+ });
121
+ }
122
+ }
123
+ for (const key of BATCH1_LIMIT_KEYS) {
124
+ // 🔴 用**存在性**判新位优先级,不用 `??`(codex R1-F1 的另一半):`{rateLimitPerMin: null}` 在 `??`
125
+ // 下会当成「缺席」而回底/落旧位,一个显式的坏值就这样变成了静默撤回。存在即判 ⇒ null 走坏值拒。
126
+ const published = limits !== undefined && Object.prototype.hasOwnProperty.call(limits, key) ? limits[key] : legacy[key];
127
+ let next;
128
+ if (published === undefined) {
129
+ next = baseline[key]; // 非粘:center 不管这一键 ⇒ 回 env 底
130
+ }
131
+ else {
132
+ const verdict = VALIDATORS[key](published, baseline[key]);
133
+ if (!verdict.ok) {
134
+ badKeys.push(key);
135
+ reason ??= verdict.reason;
136
+ continue;
137
+ }
138
+ next = verdict.value;
139
+ }
140
+ const current = config[key];
141
+ if (same(current, next))
142
+ continue;
143
+ // 🔴 codex R2-F2:**显式 deferred 的键不落地** —— 值已验过(坏值照样整批拒),但它的变更只能重启生效,
144
+ // 所以这里逐键 defer(不是整批拒:同批的热键该生效还得生效)+ 铸因进世代账 + 一条响亮 warn。
145
+ const deferral = DEFERRED_LIMIT_KEYS.find((d) => d.key === key);
146
+ if (deferral) {
147
+ deferred.push({ key, reason: deferral.restartReason });
148
+ continue;
149
+ }
150
+ if (next === undefined)
151
+ deletions.push(key);
152
+ else
153
+ assignments.push([key, next]);
154
+ changed.push(key);
155
+ }
156
+ if (badKeys.length > 0) {
157
+ const rejected = { keys: badKeys, reason: reason };
158
+ logger?.warn("sema_registry_limits_invalid", {
159
+ ...rejected,
160
+ version: eff.version,
161
+ kept: "the whole limits batch is refused — the prior values keep serving (a half-applied limit face means the published numbers and the live gate are different generations)",
162
+ });
163
+ return { assignments: [], deletions: [], changed: [], deferred: [], rejected };
164
+ }
165
+ // 跨键不变量:本批任一键触发**全表**校验(design/322 §2 A′,codex F2)。
166
+ const verdict = checkCrossInvariants({ live: config, next: candidateView(config, assignments) });
167
+ if (!verdict.ok) {
168
+ const involved = changed.length > 0 ? changed : BATCH1_LIMIT_KEYS.slice();
169
+ const rejected = { keys: [...involved], reason: verdict.reason, restartReason: verdict.restartReason };
170
+ logger?.warn("sema_registry_limits_invalid", {
171
+ ...rejected,
172
+ invariant: verdict.id,
173
+ version: eff.version,
174
+ kept: "the whole limits batch is refused by a cross-key invariant — the prior values keep serving; this change needs a restart if it is intended",
175
+ });
176
+ return { assignments: [], deletions: [], changed: [], deferred: [], rejected };
177
+ }
178
+ if (deferred.length > 0) {
179
+ logger?.warn("sema_registry_limits_deferred", {
180
+ keys: deferred.map((d) => d.key),
181
+ version: eff.version,
182
+ reasons: [...new Set(DEFERRED_LIMIT_KEYS.filter((d) => deferred.some((x) => x.key === d.key)).map((d) => d.why))],
183
+ note: "these published keys are NOT hot — the prior values keep serving until this process restarts (the restart is what applies them)",
184
+ });
185
+ }
186
+ return { assignments, deletions, changed, deferred };
187
+ }
188
+ //# sourceMappingURL=stage-limits.js.map
@@ -172,6 +172,46 @@ export interface CenterReadFace {
172
172
  /** 按行名删内建条目,在档位选择**之后**生效。 */
173
173
  denyBuiltinExclude?: string[];
174
174
  }
175
+ /**
176
+ * #322 批1 的 wire 面(`EffectiveConfig.limits`)。**全 optional**:缺席 = 本域不管这一键 ⇒ 该键回 env
177
+ * 基线(非粘,applyRuntimeHot 的 NON-STICKY 同族纪律 —— 「缺席=保上一个 center 值」会把撤回的治理永久冻住)。
178
+ *
179
+ * 🔴 类型是**到货 JSON 的声明**,不是运行时保证(local 腿的 eff 来自原始 JSON):每个键在 stage 侧都
180
+ * 逐个验型验域,坏值 ⇒ 整批拒 + 旧值继续服务(#123 响亮拒)。
181
+ */
182
+ export interface EffectiveLimits {
183
+ /** 每 principal 每分钟请求上限;**0 = 关**(关断哨兵,不是缺席)。 */
184
+ rateLimitPerMin?: number;
185
+ /** 每 principal 滚动窗累计花费上限(USD);0 = 无配额。 */
186
+ maxPrincipalCostUsd?: number;
187
+ /** 上面那条配额的窗长(秒);必须 > 0。 */
188
+ costQuotaWindowSec?: number;
189
+ /** 单任务成本/token 天花板;0 = 无天花板。 */
190
+ maxTaskCostUsd?: number;
191
+ maxTaskTokens?: number;
192
+ /** 基建计价(每项 ≥ 0);缺席 = 不改。 */
193
+ infraCostRates?: {
194
+ toolCallMicroUsd?: number;
195
+ sandboxSecMicroUsd?: number;
196
+ egressGbMicroUsd?: number;
197
+ };
198
+ /** 附件孤儿对象 GC 宽限(ms);0 = 关掉该腿。 */
199
+ attachmentOrphanGraceMs?: number;
200
+ /** workspace 单文件读上限(字节,> 0)。 */
201
+ workspaceFileMaxBytes?: number;
202
+ /** 附件单文件上限(字节,≥ 下限)。 */
203
+ attachmentMaxBytes?: number;
204
+ /** 附件 mime 白名单(非空串数组);空数组 = 显式「一个都不许」,与缺席(不设限)不同。 */
205
+ attachmentMimeAllowlist?: string[];
206
+ /** 未绑定附件 TTL(ms)。 */
207
+ attachmentUnboundTtlMs?: number;
208
+ /** SIGTERM drain 窗(ms)。 */
209
+ drainGraceMs?: number;
210
+ /** run 心跳陈旧窗(秒);必须严格大于活体心跳(跨键不变量,见 config-invariants.ts)。 */
211
+ runStaleSec?: number;
212
+ /** 卸载工具结果的 TTL(秒,> 0)。 */
213
+ toolResultTtlSec?: number;
214
+ }
175
215
  export interface EffectiveConfig {
176
216
  version: number;
177
217
  updatedAt: string;
@@ -239,6 +279,26 @@ export interface EffectiveConfig {
239
279
  autonomy?: Autonomy;
240
280
  commandPolicy?: CommandRule[];
241
281
  };
282
+ /**
283
+ * #322 批1 —— **limitsHttp 组的热更新发布域**(design/322 §6 批1)。
284
+ *
285
+ * 🔴 为什么是**新顶层域**而不是往 `runtime` 里加键(亲验,不是推测):registry-core 的 `RuntimeConfig`
286
+ * 是 zod `"strip"` 对象,`runtime` 里的未知键在**两条腿上都会被静默剥掉**(local 腿实测
287
+ * `{rateLimitPerMin:7, toolResultTtlSec:999}` → `{rateLimitPerMin:7}`,零 domainError;remote 腿的
288
+ * `readEffectiveWire` 同一 schema)。而**未知顶层域**是 open-world verbatim 透传的(同一实测:顶层
289
+ * `limits` 原样到手)——所以这是今天唯一能真的把这些键送到消费端的形,也与 governance 域当初从
290
+ * runtime 里拆出去的先例同族。
291
+ *
292
+ * 车道现状(如实):remote 腿即刻可用(center 发什么就到什么);**local 腿(config.d/)暂不可用** ——
293
+ * `FileConfigStore` 的域表是闭集,`config.d/limits.json` 根本不会被读进 effective。补法在 registry-core
294
+ * (加 `limits` 域 + PORTABLE_DOMAINS),与 `a2a`/`readFace` 当初的缺口同族;server 侧的消费半场(本键 +
295
+ * stage + commit)先落地,不在下游自建第二个读盘腿(源头修复纪律)。
296
+ *
297
+ * 与 `runtime` 的**优先级**:五个既有闸键(rateLimitPerMin/maxTaskCostUsd/maxTaskTokens/
298
+ * maxPrincipalCostUsd/costQuotaWindowSec)两处都可能出现 ⇒ `limits` 赢(新位是真值,旧位是过渡),
299
+ * 与 governance/runtime 的读取优先级契约同形。
300
+ */
301
+ limits?: EffectiveLimits;
242
302
  /** 142-S4(registry-core 0.10.0 第 15 域):center 项目登记簿。wire 面 verbatim JSON —
243
303
  * registry-core `ProjectsConfig` 的形状是 `{projects: Record<projectId, ProjectRegistration>}`(skills/mcp
244
304
  * 同款域包裹)。[2373]B-8:裸 Record 联合臂随 apply-effective 的宽读一起删(0.13.0 只有包裹形,
@@ -0,0 +1,65 @@
1
+ /**
2
+ * #322 —— **跨键不变量的单源表**(design/322 §2 A′,codex 设计审 F2)。
3
+ *
4
+ * 病灶:boot 期的跨域不变量(拒启 throw)散在 `config.ts` 的装配尾与 `main.ts` 的开头。把配置改热之后,
5
+ * 每一条都变成一个**热路径上的漏检**:一次发布可以在运行期造出 boot 期会当场拒启的姿态(无鉴权写面被
6
+ * 打开、bake runner 凭据被删、直连门半开、reaper 追杀活 run……),而进程照跑。逐条在 stage 里手抄一遍
7
+ * 是必错的形(抄漏一条没人会发现),所以本文件是**唯一**的表:
8
+ *
9
+ * · stage 面(config-center):批1 的任一键触发**全表**校验,过不了 ⇒ 整批拒 + 旧值继续服务 + 铸
10
+ * `restartReason`(运维据此知道「这个变更只能重启生效」,而不是被静默吞掉)。
11
+ * · boot 面:每条 throw 点旁挂 `CROSS-INVARIANT:<id>` 锚,由 test/config-hot/batch1-cross-invariants.test.ts
12
+ * 双向对表 —— 新增一条拒启却不登记 = 门红。boot 的判据文本与拒启行为**逐字不变**(本批不改拒启),
13
+ * 表在这一侧承担的是「不许有表外不变量」的登记职责。
14
+ *
15
+ * 🔴 判据形状:每条不变量收 `{live, next}` 两个视图。有些条目判的是**状态**(next 自身是否自洽),有些
16
+ * 判的是**转换**(宽→窄、门姿态翻转)。boot 期没有「上一代」,调用方传 `live === next`,于是转换型条目
17
+ * 在 boot 天然惰性 —— 这正确:boot 不存在「热切」这回事。
18
+ *
19
+ * 依赖纪律:本文件是**叶**(只 type-import config-types + 值引 deployment-governance 的守卫集编译门),
20
+ * 不引 config.ts,故 config.ts / runs.ts 都可以安全引它。
21
+ */
22
+ import type { ServiceConfigFlat } from "./config-types.js";
23
+ /**
24
+ * 活体心跳周期(ms)——`runs.ts` 的 `HEARTBEAT_MS` 就是它(那边 re-export,单源在此)。
25
+ *
26
+ * 为什么常量住在不变量文件里:它唯一的**判据用途**是「陈旧窗必须大于心跳」这条不变量,而该不变量现在
27
+ * 有 boot 与 stage 两个消费面。放在这里,两面读的是同一个数,`runs.ts` 的心跳环也读同一个数。
28
+ */
29
+ export declare const LIVENESS_HEARTBEAT_MS = 30000;
30
+ /** 闭集:新增一条不变量必须在这里加词(拼错 = 编译红,而不是运行期悄悄不判)。 */
31
+ export type CrossInvariantId = "run-stale-heartbeat" | "bind-posture" | "bake-runner-credential" | "direct-door-anchors" | "operator-principals-required" | "bake-operator-principals" | "sensitive-write-patterns" | "leader-host-lane" | "leader-durable-store";
32
+ /** 表读的键集(取自平铺配置型 ⇒ 键名/类型永不与真配置漂)。 */
33
+ export type CrossInvariantView = Readonly<Pick<ServiceConfigFlat, "runStaleSec" | "authToken" | "authTokens" | "allowUnauthedWrites" | "imageBakes" | "directApprovalDoor" | "durableApproval" | "approvalHmacKeys" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "requirePrincipal" | "operatorPrincipals" | "sensitiveWritePatterns" | "leaderEnabled" | "remoteExec" | "dbBackend">>;
34
+ export interface CrossInvariantInput {
35
+ /** 现役配置(boot 期 = 候选自身)。 */
36
+ readonly live: CrossInvariantView;
37
+ /** 候选(stage 期 = 现役 + 本批 staged 赋值)。 */
38
+ readonly next: CrossInvariantView;
39
+ }
40
+ export interface CrossInvariant {
41
+ readonly id: CrossInvariantId;
42
+ /** 本条读哪些键(诊断面按键回答「我这次改动会被哪条不变量看着」)。 */
43
+ readonly keys: readonly (keyof ServiceConfigFlat)[];
44
+ /** 热拒时铸给运维的重启因由(与 restart-signal 的 slice 词表同族的自由词,不是 RestartSlice 闭集)。 */
45
+ readonly restartReason: string;
46
+ /** boot 期同一条不变量的拒启锚(机械门用;缺席 = 本条只存在于 stage 面)。 */
47
+ readonly bootAnchor?: {
48
+ readonly file: string;
49
+ readonly marker: string;
50
+ };
51
+ /** 返回拒因;`undefined` = 通过。 */
52
+ check(input: CrossInvariantInput): string | undefined;
53
+ }
54
+ export declare const CONFIG_CROSS_INVARIANTS: readonly CrossInvariant[];
55
+ export type CrossInvariantVerdict = {
56
+ ok: true;
57
+ } | {
58
+ ok: false;
59
+ id: CrossInvariantId;
60
+ reason: string;
61
+ restartReason: string;
62
+ };
63
+ /** 全表校验(**第一条不过即拒**:候选是整批的,报第一个成因足够行动,继续跑其余条目只会淹掉它)。 */
64
+ export declare function checkCrossInvariants(input: CrossInvariantInput): CrossInvariantVerdict;
65
+ //# sourceMappingURL=config-invariants.d.ts.map