@sema-agent/server 7.102.0 → 7.103.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/MIGRATION.md +91 -0
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/USAGE.md +107 -6
  5. package/dist/background-session-reap.d.ts +4 -1
  6. package/dist/bake-runner/main.d.ts +21 -3
  7. package/dist/bake-runner/main.js +18 -11
  8. package/dist/boot/budget-tracing.d.ts +1 -0
  9. package/dist/boot/budget-tracing.js +3 -2
  10. package/dist/boot/config-center.d.ts +12 -2
  11. package/dist/boot/config-center.js +33 -21
  12. package/dist/boot/stage-01-config.js +2 -0
  13. package/dist/boot/stage-04-stores.d.ts +1 -0
  14. package/dist/boot/stage-04-stores.js +2 -2
  15. package/dist/boot/stage-05-execution-env.d.ts +1 -0
  16. package/dist/boot/stage-06-runners.d.ts +1 -0
  17. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  18. package/dist/boot/stage-08-reapers.d.ts +1 -0
  19. package/dist/boot/stage-08-reapers.js +2 -2
  20. package/dist/boot/stage-09-leader.d.ts +1 -0
  21. package/dist/boot/stage-10-http-server.d.ts +1 -0
  22. package/dist/boot/stage-10-http-server.js +6 -1
  23. package/dist/brain.d.ts +60 -9
  24. package/dist/brain.js +44 -8
  25. package/dist/capabilities/center-plugins.d.ts +9 -3
  26. package/dist/capabilities/center-plugins.js +5 -10
  27. package/dist/config-catalog.js +9 -5
  28. package/dist/config-center/apply-effective.d.ts +40 -2
  29. package/dist/config-center/apply-effective.js +32 -12
  30. package/dist/config-center/facade.d.ts +1 -1
  31. package/dist/config-center/facade.js +1 -1
  32. package/dist/config-center/mcp-revocation.d.ts +2 -2
  33. package/dist/config-center/mcp-revocation.js +1 -1
  34. package/dist/config-center/restart-signal.d.ts +39 -12
  35. package/dist/config-center/restart-signal.js +13 -24
  36. package/dist/config-types.d.ts +35 -17
  37. package/dist/config-types.js +3 -3
  38. package/dist/config.d.ts +7 -0
  39. package/dist/config.js +43 -17
  40. package/dist/env-name-allowlist-knobs.d.ts +21 -7
  41. package/dist/env-name-allowlist-knobs.js +7 -2
  42. package/dist/fleet-client.d.ts +9 -3
  43. package/dist/fleet-client.js +2 -0
  44. package/dist/host-lsp-manager.d.ts +4 -3
  45. package/dist/host-lsp-manager.js +2 -6
  46. package/dist/http/dispatch.js +4 -0
  47. package/dist/http/server.d.ts +4 -0
  48. package/dist/leader/wire.d.ts +8 -1
  49. package/dist/leader/wire.js +4 -3
  50. package/dist/model-entry-refusal.d.ts +32 -9
  51. package/dist/model-entry-refusal.js +22 -10
  52. package/dist/model-route-endpoint.d.ts +10 -0
  53. package/dist/model-route-endpoint.js +6 -2
  54. package/dist/observability/secret-env-scrub.d.ts +13 -1
  55. package/dist/observability/secret-env-scrub.js +15 -2
  56. package/dist/outbound-dispatcher.d.ts +125 -0
  57. package/dist/outbound-dispatcher.js +211 -0
  58. package/dist/plugins/host-platform.d.ts +6 -1
  59. package/dist/plugins/remote-env-adb.d.ts +10 -3
  60. package/dist/plugins/remote-env-adb.js +24 -20
  61. package/dist/plugins/remote-env-host.js +3 -11
  62. package/dist/plugins/remote-env-k8s.d.ts +8 -1
  63. package/dist/plugins/remote-env-k8s.js +31 -24
  64. package/dist/plugins/remote-env-ssh.d.ts +8 -1
  65. package/dist/plugins/remote-env-ssh.js +24 -18
  66. package/dist/plugins/remote-shell.d.ts +109 -9
  67. package/dist/plugins/remote-shell.js +83 -0
  68. package/dist/project-memory.js +3 -6
  69. package/dist/run-local.js +2 -0
  70. package/dist/server-secret-env.d.ts +89 -24
  71. package/dist/server-secret-env.js +21 -1
  72. package/dist/trace/core-keyset-guard.d.ts +6 -1
  73. package/dist/trace/project.js +13 -0
  74. package/dist/trace/redact.d.ts +11 -0
  75. package/dist/trace/redact.js +1 -0
  76. package/package.json +4 -3
  77. package/skills/update-config.md +23 -5
@@ -2,7 +2,7 @@ import { CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES } fr
2
2
  import { CENTER_CREDENTIAL_ENV } from "@sema-agent/settings-schema";
3
3
  import { CENTER_CREDENTIAL_RENEW_LEAD_DEFAULT_MS, CENTER_CREDENTIAL_RENEW_LEAD_MAX_MS, CENTER_CREDENTIAL_RENEW_LEAD_MIN_MS } from "./center-credential-renewer.js";
4
4
  import { THINKING_FORMATS } from "./model-compat.js";
5
- import { AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS } from "./config-types.js";
5
+ import { AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS, FIRST_BYTE_TIMEOUT_BOUNDS, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS } from "./config-types.js";
6
6
  import { webSearchProviderFromEnv, WEB_SEARCH_PROVIDERS } from "./plugins/web-search.js";
7
7
  import { AUTONOMY_MODES } from "./runtime-governance.js";
8
8
  import { REMOTE_EXEC_PROVIDER_WORDS } from "./execution-lane-caps.js";
@@ -10,6 +10,7 @@ import { CENTER_READ_FACE_LIVE_KEYS } from "./config-center/read-face.js";
10
10
  import { MODEL_PROVIDER_WORDS } from "./model-provider.js";
11
11
  import { buildDisplaySafeUrl } from "./trace/redact.js";
12
12
  import { describeAllowlistPostures } from "./env-name-allowlist-knobs.js";
13
+ import { buildOutboundProxyPosture } from "./outbound-dispatcher.js";
13
14
  export const CATALOG_VALUE_TYPES = ["boolean", "number", "string", "enum", "csv", "json", "path", "url", "secret", "opaque"];
14
15
  export const CATALOG_TIMINGS = ["hot", "restart", "deploy", "inert"];
15
16
  export const CATALOG_DANGER_AXES = ["security", "billing", "data"];
@@ -172,6 +173,8 @@ export const CONFIG_CATALOG = [
172
173
  r("HOST_FS_DRAIN_WAIT_MS", "orchestration", "number", "host 执行环境拆除时等在飞文件写落地的上界(ms;到点 destroy 返回,rm 仍排在那批写之后;下限 1)", { staticDefault: "10000" }),
173
174
  r("HOST_SHELL_PATH", "orchestration", "path", "host 车道 shell 覆写口(POSIX 与 win32 同源解析)"),
174
175
  r("HOST_WORKSPACE_BASE", "orchestration", "path", "host 车道工作区 base"),
176
+ r("HTTPS_PROXY", "limitsHttp", "url", "https: 目标的出网代理 —— 平台标准变量(小写 `https_proxy` 同认且优先 —— 与 undici 读 env 逐字同序);server 进程**所有走 undici 的出网跳**都认:引擎 → 网关、中心拉取 / 公告 / 凭证续签、WebSearch、对象存储、OTLP …(e2b / k8s SDK、ws、git 子进程不在此列,见 USAGE「出网代理」);**回环 / 未指定地址目标恒直连,不看 NO_PROXY**;userinfo 译成 `Proxy-Authorization`,回显 display-safe;Node 运行时下 undici 用不了的值(无 scheme、带路径 / 查询)拒启,机读码 `config.outbound_proxy_invalid`(Bun 运行时不校验);回显只出 scheme//host[:port],形不干净整只占位;boot 冻结;只设小写拼法时本行 envHeld 读 false(目录按大写名判在场),值照实回显", { danger: SEC, derivedDefaultNote: "缺席 / 空 ⇒ https 目标走 HTTP_PROXY(undici 的回落,不是本仓的发明);两者都缺 ⇒ 直连", resolve: (_c, env) => buildOutboundProxyPosture(env).httpsProxy }),
177
+ r("HTTP_PROXY", "limitsHttp", "url", "http: 目标的出网代理(https 目标在 HTTPS_PROXY 缺席时也走它)—— 平台标准变量(小写 `http_proxy` 同认且优先 —— 与 undici 读 env 逐字同序);server 进程**所有走 undici 的出网跳**都认:引擎 → 网关、中心拉取 / 公告 / 凭证续签、WebSearch、对象存储、OTLP …(e2b / k8s SDK、ws、git 子进程不在此列,见 USAGE「出网代理」);**回环 / 未指定地址目标恒直连,不看 NO_PROXY**;userinfo 译成 `Proxy-Authorization`,回显 display-safe;Node 运行时下 undici 用不了的值(无 scheme、带路径 / 查询)拒启,机读码 `config.outbound_proxy_invalid`(Bun 运行时不校验);回显只出 scheme//host[:port],形不干净整只占位;boot 冻结;只设小写拼法时本行 envHeld 读 false(目录按大写名判在场),值照实回显", { danger: SEC, derivedDefaultNote: "缺席 ⇒ http 目标直连", resolve: (_c, env) => buildOutboundProxyPosture(env).httpProxy }),
175
178
  r("IMAGE_BAKES_ENABLED", "orchestration", "boolean", "烤镜像 job API(/v1/images/bakes*;需 SQL 池 + 非空 operator 名单,boot 断言)", { staticDefault: "false", danger: SEC }),
176
179
  r("INFRA_COST_EGRESS_GB", "limitsHttp", "number", "出网计费费率(per-run 出网计量未接,恒乘 0 —— 设非零会 boot 点名)", { staticDefault: "0", danger: BILL, envTiming: "inert", center: cLimits("infraCostRates.egressGbMicroUsd", { timing: "inert" }) }),
177
180
  r("INFRA_COST_SANDBOX_SEC", "limitsHttp", "number", "沙箱秒基建费率(0=不计价)", { staticDefault: "0", danger: BILL, center: cLimits("infraCostRates.sandboxSecMicroUsd") }),
@@ -214,7 +217,7 @@ export const CONFIG_CATALOG = [
214
217
  r("LOCKED_CONFIG_KEYS", "auth", "csv", "管理员锁定层声明(词表=core 闭集注册表,未知键拒启)", { danger: SEC, configKey: "lockedConfigKeys" }),
215
218
  r("LOG_LEVEL", "observability", "string", "日志档位", { staticDefault: "info", configKey: "logLevel" }),
216
219
  r("LSP_ENABLED", "orchestration", "boolean", "LSP 沙箱腿 opt-in(需烤好的 LSP 模板;host 腿另有 LSP_HOST_ENABLED)", { staticDefault: "false", configKey: "lspEnabled" }),
217
- r("LSP_ENV_ALLOW", "orchestration", "secret", `host 腿语言服务器子进程 env 的点名放行名单(点到的键从服务 env 原样放回、部署方显式承担;没点到的照剥;与 host shell inheritEnv:[names] 同一只谓词;形错 / 点名名单型旋钮(自己或两只沙箱名单)拒启且不回显值;本进程环境里没有的名按序号 warn、不放行、不回显;多租户姿态(忽略时起服 warn 一次):${describeAllowlistPostures()};原文按凭据类对待:只回在场位、不进任何子进程,生效名单见起服披露行 allowed)`, { danger: SEC, derivedDefaultNote: "缺席 / 空串 ⇒ 空名单 = 全剥(7.100.0 行为)" }),
220
+ r("LSP_ENV_ALLOW", "orchestration", "secret", `host 腿语言服务器子进程 env 的点名放行名单(点到的键从服务 env 原样放回、部署方显式承担;没点到的照剥;与 host shell inheritEnv:[names] 同一只谓词;形错 / 点名任一只名单型旋钮(含自己)拒启且不回显值;本进程环境里没有的名按序号 warn、不放行、不回显;多租户姿态(忽略时起服 warn 一次):${describeAllowlistPostures()};原文按凭据类对待:只回在场位、不进任何子进程,生效名单见起服披露行 allowed)`, { danger: SEC, derivedDefaultNote: "缺席 / 空串 ⇒ 空名单 = 全剥(7.100.0 行为)" }),
218
221
  r("LSP_HOST_ENABLED", "orchestration", "boolean", "LSP host 腿开关(本机 language server,优雅降级)", { staticDefault: "true", configKey: "lspHostEnabled" }),
219
222
  r("MANUAL_MODE_SHELL_GATE", "approval", "enum", "手动模式 shell 门收紧腿(off=no-op 非放松 —— 本键只有收紧半场;拼错拒启)", { danger: SEC, enumValues: ["always", "classify", "off"], echoWords: ["always", "classify"], resolve: (c) => c.manualModeShellGate ?? null, derivedDefaultNote: "off/未设 ⇒ 本键不贡献任何东西(归一成键缺席)⇒ 回显 null;真姿态只有 always/classify 两个" }),
220
223
  r("MAX_PRINCIPAL_COST_USD", "limitsHttp", "number", "per-principal 窗内成本天花板(0=关)", { staticDefault: "0", danger: BILL, configKey: "maxPrincipalCostUsd", center: cLimits("maxPrincipalCostUsd") }),
@@ -277,7 +280,6 @@ export const CONFIG_CATALOG = [
277
280
  r("MODEL_CHEAP_VISION", "modelPlane", "boolean", "cheap 槽 vision 声明(三态:缺席继承主模型;text-only cheap 必设 false 让预检真拦)", { derivedDefaultNote: "缺席 ⇒ 继承主模型" }),
278
281
  r("MODEL_CIRCUIT_BREAKER", "modelPlane", "boolean", "模型腿熔断开关", { staticDefault: String(RESILIENCE_DEFAULTS.circuitBreaker.value) }),
279
282
  r("MODEL_CODE_ROLES", "modelPlane", "csv", "给点名 role 附编码 persona(role 表内名字才生效)"),
280
- r("MODEL_CONNECT_TIMEOUT_MS", "modelPlane", "number", "连接超时(ms;0=关)", { staticDefault: String(RESILIENCE_DEFAULTS.connectTimeoutMs.value) }),
281
283
  r("MODEL_CONTEXT_WINDOW", "modelPlane", "number", "主模型上下文窗声明", { staticDefault: "262144", configKey: "model", resolve: (c) => c.model.contextWindow ?? null, center: cModels("models[].contextWindow") }),
282
284
  r("MODEL_COST_CACHE_READ", "modelPlane", "number", "主模型 cache-read 单价(per-1M USD;显式 0=声明免费,不是「没配」)", { derivedDefaultNote: "四键全缺席 ⇒ 未定价(不铸 cost 键,成本读面缺席);任一设 ⇒ 四键在场,未设的按 0 显式", danger: BILL, resolve: (c) => c.model.cost?.cacheRead ?? null, center: cModels("models[].cost.cacheRead") }),
283
285
  r("MODEL_COST_CACHE_WRITE", "modelPlane", "number", "主模型 cache-write 单价", { derivedDefaultNote: "四键全缺席 ⇒ 未定价(不铸 cost 键,成本读面缺席);任一设 ⇒ 四键在场,未设的按 0 显式", danger: BILL, resolve: (c) => c.model.cost?.cacheWrite ?? null, center: cModels("models[].cost.cacheWrite") }),
@@ -290,12 +292,13 @@ export const CONFIG_CATALOG = [
290
292
  r("MODEL_DEGRADE_TO", "modelPlane", "string", "降级目标模型(设了=开降级;外部网关名不必在目录)", { danger: BILL }),
291
293
  r("MODEL_DEGRADE_TO_VISION", "modelPlane", "boolean", "降级目标的 vision 声明(false ⇒ 带图任务丢降级不丢图)", { staticDefault: "false" }),
292
294
  r("MODEL_EXTRA_BODY", "modelPlane", "opaque", "生成参数直通 JSON(top_k/logit_bias 等;必须静态 —— 前缀缓存键序)", { danger: SEC }),
293
- r("MODEL_FIRST_TOKEN_TIMEOUT_MS", "modelPlane", "number", "首 token 超时(ms;0=关 —— 开流不吐字的唯一看门狗;自托管长 prefill 后端按此档留足)", { staticDefault: String(RESILIENCE_DEFAULTS.firstTokenTimeoutMs.value) }),
295
+ r("MODEL_FIRST_BYTE_TIMEOUT_MS", "modelPlane", "number", `请求发出 → 响应头的等待(ms;缺席 = core 缺省 + 上传宽限;到点 core 自有一次加宽重试,配了 MODEL_GATEWAY_FALLBACK_URLS 也一样(之后才切备;server 不铸 firstByteRetries);0 = 该段不设引擎钟、传输层的响应头 / 响应体钟也关(TCP + TLS 建连仍归传输层自己的钟);自托管慢网关按排队 + prefill 留足;failover 拓扑想快切须显式设小;须为 [${FIRST_BYTE_TIMEOUT_BOUNDS.minMs}, ${FIRST_BYTE_TIMEOUT_BOUNDS.maxMs}] 内十进制整数,其余拒启)`, { derivedDefaultNote: "未设/空串 ⇒ 不铸键,跟随 core 缺省(本仓不复制上游数字;core 改缺省本旋钮照映不改)", resolve: (c) => c.resilience.firstByteTimeoutMs ?? null }),
296
+ r("MODEL_FIRST_TOKEN_TIMEOUT_MS", "modelPlane", "number", "首 token 超时(ms;0 = 该段不设引擎钟、传输层的响应体钟也关 —— 开流不吐字的唯一看门狗;自托管长 prefill 后端按此档留足)", { staticDefault: String(RESILIENCE_DEFAULTS.firstTokenTimeoutMs.value) }),
294
297
  r("MODEL_FREQUENCY_PENALTY", "modelPlane", "number", "frequency_penalty 直通(压过 EXTRA_BODY 同键)"),
295
298
  r("MODEL_GATEWAY_BASEURL", "modelPlane", "url", "openai 兼容网关 base URL(S-623:**无出厂缺省** —— 配置能派发到的每条 openai 兼容路由都必须解析到显式端点(条目自己的 baseUrl,或本键),否则拒启 / 热路径整批拒,机读码 `config.model_route_endpoint_missing`,拒因点名条目并给出本机写法 `MODEL_GATEWAY_BASEURL=<本机网关>/v1`;anthropic 单路由部署不设本键合法;S-475:带 userinfo 拒启,机读码 `config.gateway_url_userinfo` —— WHATWG 禁止用带凭据的 URL 构造 fetch Request,凭据放 MODEL_API_KEY;S-483:解析不动/不透明形拒启,机读码 `config.model_route_url_invalid`;S-514:带 `#fragment` 拒启,机读码 `config.model_route_url_fragment`)", { derivedDefaultNote: "缺席 ⇒ 本部署没有网关(不铸键);有 openai 路由落不到端点 ⇒ 拒启", configKey: "gatewayBaseUrl", center: cModels("models[].baseUrl") }),
296
299
  r("MODEL_GATEWAY_FALLBACK_URLS", "modelPlane", "url", "网关故障转移地址表(CSV;S-623:主网关 MODEL_GATEWAY_BASEURL 未设而本键设了 ⇒ 拒启,机读码 `config.gateway_fallback_without_primary` —— 备用是主网关的镜像;S-475:**逐项**判 userinfo,任一项带 ⇒ 拒启,机读码 `config.gateway_url_userinfo`;S-483:逐项判可解析性,机读码 `config.model_route_url_invalid`;S-514:**逐项**判 `#fragment`,机读码 `config.model_route_url_fragment`)", { configKey: "gatewayFallbackUrls" }),
297
300
  r("MODEL_ID", "modelPlane", "string", "主模型 id(3.0.0 起无出厂缺省,缺 ⇒ 拒启指路)", { configKey: "model", resolve: (c) => c.model.id, center: cModels("models[].id") }),
298
- r("MODEL_IDLE_TIMEOUT_MS", "modelPlane", "number", "流中静默看门狗(ms;0=关)", { staticDefault: String(RESILIENCE_DEFAULTS.idleTimeoutMs.value) }),
301
+ r("MODEL_IDLE_TIMEOUT_MS", "modelPlane", "number", "流中静默看门狗(ms;0 = 该段不设引擎钟、传输层的响应体钟也关)", { staticDefault: String(RESILIENCE_DEFAULTS.idleTimeoutMs.value) }),
299
302
  r("MODEL_MAX_TOKENS", "modelPlane", "number", "主模型输出上限", { derivedDefaultNote: "缺席 ⇒ min(32000, contextWindow)", resolve: (c) => c.model.maxTokens ?? null, center: cModels("models[].maxTokens") }),
300
303
  r("MODEL_PRESENCE_PENALTY", "modelPlane", "number", "presence_penalty 直通"),
301
304
  r("MODEL_PROVIDER", "modelPlane", "enum", "主模型 provider(路由选 brain;S-461 起是闭集,词表外的值启动期拒启并点名机读码 `config.model_provider_invalid`)", { enumValues: MODEL_PROVIDER_WORDS, derivedDefaultNote: "缺席:ANTHROPIC base+cred 同在 ⇒ anthropic,否则 gateway", resolve: (c) => c.model.provider, center: cModels("models[].provider") }),
@@ -328,6 +331,7 @@ export const CONFIG_CATALOG = [
328
331
  r("MYSQL_POOL_SIZE", "store", "number", "MySQL 连接池上限(坏值回 driver 默认+warn)", { derivedDefaultNote: "缺席 ⇒ driver 默认池" }),
329
332
  r("MYSQL_PORT", "store", "number", "MySQL 协议端口", { staticDefault: "3306" }),
330
333
  r("MYSQL_USER", "store", "string", "MySQL 协议用户", { danger: DATA }),
334
+ r("NO_PROXY", "limitsHttp", "csv", "不走代理的目标(小写 `no_proxy` 同认且优先);语法 = undici 的:逗号或空白分隔,`host` 精确且含其子域、`.host` / `*.host` 后缀、`host:port` 限端口、`*` 全直连;**回环 / 未指定地址目标无论写没写都直连**(本仓的无条件规则,不是 NO_PROXY 的一条);boot 冻结", { danger: SEC, derivedDefaultNote: "缺席 ⇒ 除回环外一律按 HTTP(S)_PROXY 走", resolve: (_c, env) => buildOutboundProxyPosture(env).noProxy }),
331
335
  r("OPERATOR_PRINCIPALS", "auth", "csv", "operator 名单(operator lane 全族门;审批队列上空名单=旧行为 token 即 operator,explicitOperatorOk 族空名单=谁都不是)", { danger: SEC, configKey: "operatorPrincipals" }),
332
336
  r("OTEL_EXPORTER_OTLP_ENDPOINT", "observability", "url", "OTLP 出口(设了=开 otel)"),
333
337
  r("OTEL_EXPORTER_OTLP_HEADERS", "observability", "secret", "OTLP 出口头(k=v CSV;常载鉴权头 ⇒ 按凭据处置)", { danger: SEC }),
@@ -10,6 +10,7 @@ import type { ServiceConfig } from "../config-types.js";
10
10
  import { type SealedKeyOpener } from "../sealed-key.js";
11
11
  import type { Logger } from "../observability/logger.js";
12
12
  import type { EffectiveConfig } from "./types.js";
13
+ import { type ModelRefusalVerdict } from "../model-entry-refusal.js";
13
14
  import { type ConfigApplyReport } from "./apply-ledger.js";
14
15
  /** Apply center config OVER the env-derived config (mutates `config`). Empty domains keep env defaults. */
15
16
  /** Replace a record's contents IN PLACE (clear keys, copy source) so holders of the SAME reference see the
@@ -41,6 +42,10 @@ export interface ApplyReport extends ConfigApplyReport {
41
42
  /** 🔴 codex R2-F1:本候选是否**完整**应用 —— 有任何一组被拒 ⇒ false。调用方据此决定要不要把它发布成
42
43
  * LKG / 推进 etag(一个「其余面提交了但某组被拒」的候选覆盖掉好 LKG,重启后就再也回不去了)。 */
43
44
  readonly fullyApplied: boolean;
45
+ /** S-768:本候选目录的模型拒因判决 —— 逐条(`entries`,stage 判出的那份候选的,键已过名筛)+ 组拒码(`plane`,模型面组被拒时在场,
46
+ * 判决那刻当数据带出)。落地与组拒同一只来源:两者都描述**这一份**候选(组拒时引擎自己的拒因表不换代,这里仍报被拒的那份)。
47
+ * 候选不管模型面(零启用条目、零墓碑)⇒ `entries` 空、无 `plane`。`/health.modelRefusals` 与心跳的唯一数据源(config-center 投影)。 */
48
+ readonly modelRefusals: ModelRefusalVerdict;
44
49
  }
45
50
  /**
46
51
  * S-668 波 2 —— 一次 apply 在**哪条腿**上跑,以及这条腿知道的**部署事实**。必填(没有缺省腿):安全轴上「忘了说是哪条
@@ -90,8 +95,41 @@ export type ModelPlaneOf = Pick<EffectiveConfig, ModelPlaneDomain>;
90
95
  * 手写 `{ ...x, models, collab }`(漏一个域正是 V1 的病形)。纯数据:不改两个入参。
91
96
  */
92
97
  export declare function withModelPlaneOf<E extends EffectiveConfig>(target: E, source: ModelPlaneOf): E;
93
- /** 「没有中心模型面」—— 组拒且无 LKG 时进程真正服务的那一代(env 表:没有目录、没有中心 collab 模板)。纯数据,每次新铸。 */
94
- export declare function buildEnvOnlyModelPlane(): ModelPlaneOf;
98
+ /**
99
+ * **boot 快照**(restart 片的对照基线,`boot/config-center.ts` 的 `effective`)= 本次 boot 读到的那份(`read`:拉到的候选,或读回的盘上 LKG)
100
+ * 换上**真落地**的模型面 —— `appliedPlane` 是模型面组真落地的那一代(`read` 自己 / 按组回落的 LKG),`undefined` = 组被拒且没有可用的上一代
101
+ * (进程服务的是 env 表 ⇒「没有中心模型面」)。其余域恒是 `read` 的。
102
+ *
103
+ * boot 的两条落地路径(候选拉取、整份 LKG 读回)都经这一只,禁在调用点各写一份:S-789 的病形正是这句只在候选路径上成立(2d09c8e6),
104
+ * LKG 读回路径把被拒的 LKG 目录记成基线 ⇒ 修好的目录换掉降级目标时零 `degrade-route`,boot 焊死的降级壳永远指着 env 端点。
105
+ * S-794 起 `degrade-route` 片不再读快照的目录(改读降级壳 boot 焊的那只目标 vs 活配置,brain.ts `createDegradeRouteWatch`)——今天没有
106
+ * 重启片读快照的模型面;本函数保证的是快照本身说真话(进程真在服务的那一代),不让下一个读它的片重演 S-789。
107
+ * 纯数据:不改入参;模型面就是 `read` 自己那一代 ⇒ 原样交回(同一引用)。
108
+ */
109
+ export declare function bootSnapshotOf<E extends EffectiveConfig>(read: E, appliedPlane: ModelPlaneOf | undefined): E;
110
+ /**
111
+ * E-1(7.103.1 修复轮,opus 腿):boot 读到的这一代**整套落地**了吗 —— 只有整套落地的那一代才可以成为 LKG(`latestEffective` /
112
+ * 盘上 LKG 候选)、才让它的 ETag 做条件请求(§9.5-2 + codex R2-F1)。三件同判,缺一不算:读腿没报坏域(`domainErrors`)、
113
+ * prompts 面过候选门同一只判形、apply 报告没有被整拒的组(`fullyApplied`;报告缺席 = 没 apply,dry-run 只判前两件)。
114
+ * boot 的两条落地路径(候选拉取、整份 LKG 读回)都调这一只,禁在调用点各写一份(此前候选路径三件、LKG 路径只抄了一件)。
115
+ * LKG 读回路径上前两件在上游已有门(`loadLkg` 的载荷复判拒坏 prompts;读回不产 `domainErrors`),这里同判是同一条规则的同一只谓词,
116
+ * 不是第二道门。refresh / 迟到 boot 两腿的候选门在 apply **之前**整拒坏域与坏 prompts(拒的是整份候选,不只是 LKG 推进),不走本谓词。
117
+ */
118
+ export declare function fullyLandedForLkg(read: {
119
+ readonly effective: EffectiveConfig;
120
+ readonly domainErrors?: readonly unknown[];
121
+ }, report: ApplyReport | undefined): boolean;
122
+ /**
123
+ * S-768:组拒码 → 铸它的那条模型面不变量 —— **以码为键**(组拒码闭集的属主是 settings-schema `MODEL_PLANE_REFUSAL_CODES`;那边加一个码
124
+ * ⇒ 本表缺键 ⇒ 本仓 tsc 红)。模型面 stage 的组拒按**码**铸(码是判决那刻的数据),不变量 id 由本表派生 —— 一个 id 两个码
125
+ * (`model-route-endpoint`)时码不可能从 id 反推,反过来查才是全函数。
126
+ */
127
+ export declare const PLANE_REFUSAL_INVARIANT_OF: {
128
+ readonly "config.default_model_missing": "default-model-resolvable";
129
+ readonly "config.model_refused_name_minted": "refused-names-not-minted";
130
+ readonly "config.model_route_endpoint_missing": "model-route-endpoint";
131
+ readonly "config.gateway_fallback_without_primary": "model-route-endpoint";
132
+ };
95
133
  /**
96
134
  * UNIFORM presence semantics across the center gates (center `f95438a` adopted this from our sentinel flag —
97
135
  * SERVICE-INTEGRATION「Runtime 治理/限额闸」): every gate field is center `.optional()`, so
@@ -8,9 +8,11 @@ import { stageLimits } from "./stage-limits.js";
8
8
  import { APPROVAL_GROUP_KEYS, BATCH1_LIMIT_KEYS } from "./hot-keys-registry.js";
9
9
  import { buildDisplaySafeUrl } from "../trace/redact.js";
10
10
  import { crossInvariant } from "../config-invariants.js";
11
- import { judgeCatalogEntries, mintedRefusedNames, refusalEventOf, refusalWarnFields, malformedDroppedCarrierRefusal } from "../model-entry-refusal.js";
11
+ import { DEFAULT_MODEL_MISSING_CODE, REFUSED_NAME_MINTED_CODE, judgeCatalogEntries, mintedRefusedNames, refusalEventOf, refusalWarnFields, malformedDroppedCarrierRefusal } from "../model-entry-refusal.js";
12
+ import { judgeModelRouteEndpoint } from "../model-route-endpoint.js";
12
13
  import { gateIntentUnserviceableReason } from "../approval.js";
13
14
  import { refusedGroups } from "./apply-ledger.js";
15
+ import { validatePromptsDomain } from "../prompts-domain-validate.js";
14
16
  const charsPerTokenOf = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
15
17
  function envDerivedModel(config) {
16
18
  const m = config.envModel;
@@ -136,7 +138,7 @@ export function applyEffective(config, eff, logger, opts) {
136
138
  modelPlane: modelPlaneGroupReport(staged.modelPlaneGroup),
137
139
  },
138
140
  };
139
- opts.report({ ...report, fullyApplied: refusedGroups(report).length === 0 });
141
+ opts.report({ ...report, fullyApplied: refusedGroups(report).length === 0, modelRefusals: modelRefusalVerdictOf(staged.modelPlaneGroup) });
140
142
  }
141
143
  if (staged.limits.changed.length > 0)
142
144
  logger?.info("sema_registry_limits", { version: staged.version, keys: staged.limits.changed });
@@ -203,9 +205,26 @@ function takeModelPlaneDomain(out, source, d) {
203
205
  else
204
206
  out[d] = v;
205
207
  }
206
- export function buildEnvOnlyModelPlane() {
208
+ function buildEnvOnlyModelPlane() {
207
209
  return { models: { models: [], roles: {} } };
208
210
  }
211
+ export function bootSnapshotOf(read, appliedPlane) {
212
+ return appliedPlane === read ? read : withModelPlaneOf(read, appliedPlane ?? buildEnvOnlyModelPlane());
213
+ }
214
+ export function fullyLandedForLkg(read, report) {
215
+ const prompts = read.effective.prompts;
216
+ return (read.domainErrors ?? []).length === 0 && (prompts === undefined || validatePromptsDomain(prompts).ok) && report?.fullyApplied !== false;
217
+ }
218
+ function modelRefusalVerdictOf(group) {
219
+ const entries = group.refused ?? {};
220
+ return group.rejected !== undefined ? { entries, plane: group.rejected.code } : { entries };
221
+ }
222
+ export const PLANE_REFUSAL_INVARIANT_OF = {
223
+ "config.default_model_missing": "default-model-resolvable",
224
+ "config.model_refused_name_minted": "refused-names-not-minted",
225
+ "config.model_route_endpoint_missing": "model-route-endpoint",
226
+ "config.gateway_fallback_without_primary": "model-route-endpoint",
227
+ };
209
228
  function modelPlaneGroupReport(group) {
210
229
  return {
211
230
  applied: MODEL_PLANE_DOMAINS.flatMap((d) => (group.stages[d] !== undefined ? MODEL_PLANE_SURFACES[d] : [])),
@@ -221,7 +240,8 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
221
240
  {
222
241
  for (const [name, r] of Object.entries(verdict.refused))
223
242
  logger?.warn(refusalEventOf(r.reason), refusalWarnFields(name, r));
224
- const refuse = (invariant, reason, detail = {}) => {
243
+ const refuse = (code, reason, detail = {}) => {
244
+ const invariant = PLANE_REFUSAL_INVARIANT_OF[code];
225
245
  logger?.warn("sema_registry_model_plane_refused", {
226
246
  version: typeof eff.version === "number" ? eff.version : 0,
227
247
  invariant,
@@ -229,10 +249,10 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
229
249
  ...detail,
230
250
  kept: "the model plane of this candidate is refused as a group — the current model plane (catalog / roles / tiers / refusals / allowlist, and the collab workflows that reference it) keeps serving and the other groups of this candidate still apply; the candidate is not fully applied (LKG / ETag stay on the last fully applied generation) and is re-judged next pull",
231
251
  });
232
- return { stages: {}, rejected: { keys: MODEL_PLANE_GROUP_KEYS, reason, invariant }, refused: verdict.refused };
252
+ return { stages: {}, rejected: { keys: MODEL_PLANE_GROUP_KEYS, reason, invariant, code }, refused: verdict.refused };
233
253
  };
234
254
  if (verdict.malformed.length > 0)
235
- return refuse("refused-names-not-minted", malformedDroppedCarrierRefusal(verdict.malformed), { entries: [...verdict.malformed] });
255
+ return refuse(REFUSED_NAME_MINTED_CODE, malformedDroppedCarrierRefusal(verdict.malformed), { entries: [...verdict.malformed] });
236
256
  const usable = verdict.usable;
237
257
  const models = {};
238
258
  const modelApiKeyEnv = {};
@@ -311,13 +331,13 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
311
331
  const nextView = { ...config, models, tiers: activeTiers, modelRefusals: verdict.refused };
312
332
  const noDefault = crossInvariant("default-model-resolvable").check({ live: config, next: nextView });
313
333
  if (noDefault !== undefined)
314
- return refuse("default-model-resolvable", noDefault);
315
- const endpointRefusal = crossInvariant("model-route-endpoint").check({ live: config, next: nextView });
316
- if (endpointRefusal !== undefined)
317
- return refuse("model-route-endpoint", endpointRefusal);
334
+ return refuse(DEFAULT_MODEL_MISSING_CODE, noDefault);
335
+ const endpoint = judgeModelRouteEndpoint(nextView);
336
+ if (endpoint !== undefined)
337
+ return refuse(endpoint.code, endpoint.reason);
318
338
  const minted = crossInvariant("refused-names-not-minted").check({ live: config, next: nextView });
319
339
  if (minted !== undefined)
320
- return refuse("refused-names-not-minted", minted, { minted: mintedRefusedNames(nextView) });
340
+ return refuse(REFUSED_NAME_MINTED_CODE, minted, { minted: mintedRefusedNames(nextView) });
321
341
  for (const [role, spec] of Object.entries(roles)) {
322
342
  const target = typeof spec === "string" ? spec : spec !== null && typeof spec === "object" && "model" in spec && typeof spec.model === "string" ? spec.model : undefined;
323
343
  const refusal = target !== undefined && Object.hasOwn(verdict.refused, target) ? verdict.refused[target] : undefined;
@@ -597,7 +617,7 @@ export function logEffectiveDiff(config, eff, logger) {
597
617
  wouldOverrideModels: enabled.length > 0,
598
618
  wouldDefaultModel: plane.defaultName ?? null,
599
619
  ...(Object.keys(refused).length > 0 ? { wouldRefuse: Object.fromEntries(Object.entries(refused).map(([n, r]) => [n, r.code])) } : {}),
600
- ...(plane.rejected !== undefined ? { wouldRefuseModelPlane: { invariant: plane.rejected.invariant, reason: plane.rejected.reason } } : {}),
620
+ ...(plane.rejected !== undefined ? { wouldRefuseModelPlane: { invariant: plane.rejected.invariant, code: plane.rejected.code, reason: plane.rejected.reason } } : {}),
601
621
  currentDefaultModel: config.model.id,
602
622
  models: { center: centerModels, envDerived: envModels },
603
623
  roles: { center: centerRoles, current: config.roles },
@@ -48,7 +48,7 @@
48
48
  * importers to update, which was done in the same commit.
49
49
  */
50
50
  export { fetchEffective, fetchPrincipalCaps, fetchPrincipalOrgMemory, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
51
- export { mutateInPlace, applyEffective, type ApplyReport, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, MODEL_PLANE_DOMAINS, type ModelPlaneDomain, type ModelPlaneOf, withModelPlaneOf, buildEnvOnlyModelPlane, } from "./apply-effective.js";
51
+ export { mutateInPlace, applyEffective, type ApplyReport, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, MODEL_PLANE_DOMAINS, type ModelPlaneDomain, type ModelPlaneOf, withModelPlaneOf, bootSnapshotOf, fullyLandedForLkg, } from "./apply-effective.js";
52
52
  export { restartReasons, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type EngineRestartSignal, } from "./restart-signal.js";
53
53
  export { applyCenterSkills, resolveMcpServers, mcpForScenario, resolveA2aPeers, a2aForScenario } from "./skills-mcp.js";
54
54
  export { applyCenterReadFace, CENTER_READ_FACE_LIVE_KEYS, type CenterReadFaceApply, type CenterReadFaceReport, type ReadFaceBaseline } from "./read-face.js";
@@ -1,5 +1,5 @@
1
1
  export { fetchEffective, fetchPrincipalCaps, fetchPrincipalOrgMemory, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./http-client.js";
2
- export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, MODEL_PLANE_DOMAINS, withModelPlaneOf, buildEnvOnlyModelPlane, } from "./apply-effective.js";
2
+ export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, MODEL_PLANE_DOMAINS, withModelPlaneOf, bootSnapshotOf, fullyLandedForLkg, } from "./apply-effective.js";
3
3
  export { restartReasons, modelPlaneChanged, } from "./restart-signal.js";
4
4
  export { applyCenterSkills, resolveMcpServers, mcpForScenario, resolveA2aPeers, a2aForScenario } from "./skills-mcp.js";
5
5
  export { applyCenterReadFace, CENTER_READ_FACE_LIVE_KEYS } from "./read-face.js";
@@ -46,8 +46,8 @@ export declare function createMcpRevocationLedger(onChange?: (ev: {
46
46
  }) => void): McpRevocationLedger;
47
47
  /** 一拍 EffectiveConfig 的 ENABLED MCP 服务器条目表(`resolveMcpServers` 同判据:`enabled !== false`;
48
48
  * 值=整条目的 key 排序 JSON 指纹,Map 插入序=发布序);mcp 域缺席 ⇒ `null`(台账不动)。
49
- * 指纹序列化器 = restart-signal 的 `stableStringify`([ref] 2026-08-31 起同源:此前本文件持一份**逐字相同**的
50
- * 私有复刻 `fingerprintOf`,两份在全部输入上字节恒等,合并零行为差,golden 钉 test/canonical-json-golden.test.ts)。
49
+ * 指纹序列化器 = `@sema-agent/settings-schema/hash` 的 `stableStringify`(S-778 起;[ref] 2026-08-31 起与 restart-signal 同源:此前本文件
50
+ * 持一份**逐字相同**的私有复刻 `fingerprintOf`;JSON 形输入上字节恒等,golden 钉 test/canonical-json-golden.test.ts)。
51
51
  * 两处指纹仍只在各自域内自比、不跨域比对——同源只是去掉一份多余的复刻,不引入跨域约束;也**不**改用
52
52
  * `src/canonical-json.ts`(undefined 值键语义不同,见 restart-signal.ts 该函数顶注)。 */
53
53
  export declare function enabledMcpEntries(mcp: {
@@ -1,4 +1,4 @@
1
- import { stableStringify } from "./restart-signal.js";
1
+ import { stableStringify } from "@sema-agent/settings-schema/hash";
2
2
  export function createMcpRevocationLedger(onChange) {
3
3
  let baseline;
4
4
  let seen;
@@ -5,9 +5,14 @@ export type RestartSlice = "skills" | "mcp" | "a2a" | "scenarios" | "degrade-rou
5
5
  * slices cannot see them, because they are configured from env (`ServiceConfig`), not from the center's
6
6
  * EffectiveConfig. Absent/empty = that lane is off ⇒ its slice is inert (null on both sides). */
7
7
  export interface RestartSliceCtx {
8
- /** `config.degrade.to` when REACTIVE degrade is on (env `MODEL_DEGRADE_TO` + `MODEL_DEGRADE_REACTIVE`);
9
- * undefined = the lane is off ⇒ no degrading brain was composed ⇒ nothing is boot-frozen. */
10
- reactiveDegradeTo?: string;
8
+ /** S-794:反应式降级壳的两侧读数 —— `baked` = createBrain 组壳那一刻焊进去的目标路由指纹,`current` = 同一只
9
+ * 指纹函数(brain.ts `degradeRouteFingerprint`)对本拍活配置再读一次(读法见 brain.ts `createDegradeRouteWatch`)。
10
+ * 缺席 = 调用方没有递席(单元调用)⇒ `degrade-route` 片恒不签;车道没开 / 没焊壳在席内表达为两侧同为 null。
11
+ * (此前的 `reactiveDegradeTo` 位只剩「阀门开着」一义,已并进本席 —— 一位不留两义。) */
12
+ degradeRoute?: {
13
+ readonly baked: unknown;
14
+ readonly current: unknown;
15
+ };
11
16
  /** #A4(codex 交叉复审 finding 2,验真后采纳):本机 env **显式占住**的 readFace 键
12
17
  * (`applyCenterReadFace` 的返回值,由 boot 存下)。这些键的 center 值在本机**永不 boot-bake**,
13
18
  * 所以它们的变化不该产生 restart 理由 —— 否则 center 改一个本机用不上的键,整片 fleet 会为一次
@@ -40,16 +45,38 @@ export interface RestartSliceCtx {
40
45
  export type EngineRestartSignal = RestartSignal & {
41
46
  reasons: RestartSlice[];
42
47
  };
43
- /** Canonical, key-sorted JSON (array order preserved) so two semantically-equal effective slices fingerprint
44
- * identically regardless of object key order from the center serializer.
48
+ /** One catalog entry, reduced to the fields whose change is genuinely restart-to-apply. Consumers of a
49
+ * boot-frozen catalog entry read almost every field on the FULLY-DERIVED Model (`toModel`) — id → body.model,
50
+ * baseUrl → the endpoint, provider → which brain, maxTokens/compat/extraBody/input → the request body,
51
+ * apiKeyEnv/sealedApiKey → the credential — so we fingerprint the WHOLE entry minus the three provably HOT
52
+ * fields:
53
+ * - `cost`'s VALUES — refreshed by `mutateInPlace(pricing, buildPricing(config.models))`, and core prefers
54
+ * `deps.pricing[model.id]` over the (possibly frozen) `Model.cost`, so a pure re-pricing
55
+ * needs neither a swap nor a restart. 🔴 B-089:its PRESENCE is subtracted no longer —
56
+ * a roster row WITHOUT `cost` now mints no `cost` key (unpriced) and therefore no pricing
57
+ * row, and core's fallback is literally `deps.pricing?.[id] ?? modelCostToPricing(model.cost)`
58
+ * (core 7.13.0 run-settle-and-teardown.js and three siblings) — so a FROZEN generation
59
+ * would quietly keep billing at the WITHDRAWN rates. Presence rides the fingerprint in both
60
+ * directions (the rule does not branch on direction); the numbers stay subtracted.
61
+ * - `quotaWeight` — refreshed by `mutateInPlace(config.modelQuotaWeights, …)`, read live per burn.
62
+ * - `enabled` — `enabledOnly` already filtered on it, so among the entries that reach here it is
63
+ * `true`-or-absent: two encodings of ONE state. Keeping it would let a center serializer
64
+ * that starts/stops emitting the explicit `true` manufacture a restart out of nothing.
65
+ * Every real enable/disable FLIP changes the filtered SET, so it still signals.
66
+ * A DENY-list (not an allow-list) on purpose: a catalog field added later rides the fingerprint automatically,
67
+ * so the failure mode of drift is a spurious restart, never a silently missed one.
45
68
  *
46
- * ⚠️ [ref](2026-08-31)如实登记:这**不是** `src/canonical-json.ts#canonicalJson`(core 逐字港,全仓单源)——
47
- * 两者在 JSON 形输入上逐字节相同(golden 交叉读数已证),但本函数把 `undefined` 值键**保留为 `"k":null`**,
48
- * 共享件按 `JSON.stringify` 语义**省略**。restart 指纹的输入是进程内构造的切片对象(zod 解析产物 + rest 展开/
49
- * 挑字段),`{k: undefined}` 形原则上可达,而它与 `{}` 是否同指纹 = 本轴上的**行为**(多签/少签一次 fleet 滚动
50
- * 重启),不是序列化器的私事 ⇒ 语义不恒等不合并;字节钉 test/canonical-json-golden.test.ts,K→O 统一另立件
51
- * 走三问。导出只为 mcp-revocation 同源改调(两处此前各持一份逐字相同的复刻)与 golden 直钉,不是公共 API。 */
52
- export declare function stableStringify(v: unknown): string;
69
+ * Shared by BOTH frozen-entry consumers — the `modelPlaneChanged` swap gate (over catalog entries) and brain.ts
70
+ * `degradeRouteFingerprint` (over the Model the reactive-degrade shell welded, S-794) — so "which fields are hot"
71
+ * is answered once. (复审 2026-07-29 留档:the since-retired `models-tiers` slice had
72
+ * kept all three, so on a tiers-active deployment a pure re-pricing / weight tweak / serializer writing
73
+ * `enabled:true` rolling-restarted the fleet.) For the swap gate a subtracted field means "no swap needed": the
74
+ * Runner reads pricing and quota weights live off the maps `mutateInPlace` refreshes. */
75
+ export declare function catalogEntryFingerprint(entry: {
76
+ readonly cost?: unknown;
77
+ readonly quotaWeight?: unknown;
78
+ readonly enabled?: unknown;
79
+ }): unknown;
53
80
  /** Restart-to-apply slices that DIFFER between this process's BOOT config and a freshly-pulled one. Empty =
54
81
  * nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
55
82
  * can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
@@ -1,25 +1,15 @@
1
1
  import { resolveActiveTiers } from "@sema-agent/settings-schema";
2
+ import { stableStringify } from "@sema-agent/settings-schema/hash";
2
3
  import { DEFAULT_PLUGIN_ALLOW_HOSTS, materializablePlugins } from "../capabilities/center-plugins.js";
3
4
  import { resolveDefaultModelName } from "./apply-effective.js";
4
5
  import { CENTER_READ_FACE_LIVE_KEYS } from "./read-face.js";
5
6
  const RESTART_SLICES = ["skills", "mcp", "a2a", "scenarios", "degrade-route", "read-face", "memory-consolidation"];
6
- export function stableStringify(v) {
7
- if (v === null || typeof v !== "object")
8
- return JSON.stringify(v) ?? "null";
9
- if (Array.isArray(v))
10
- return `[${v.map(stableStringify).join(",")}]`;
11
- const obj = v;
12
- return `{${Object.keys(obj)
13
- .sort()
14
- .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
15
- .join(",")}}`;
16
- }
17
7
  function enabledOnly(rows) {
18
8
  if (!rows)
19
9
  return null;
20
10
  return rows.filter((r) => r.enabled !== false);
21
11
  }
22
- function catalogEntryFingerprint(entry) {
12
+ export function catalogEntryFingerprint(entry) {
23
13
  const { cost, quotaWeight: _quotaWeight, enabled: _enabled, ...route } = entry;
24
14
  return { ...route, costDeclared: cost !== undefined };
25
15
  }
@@ -28,11 +18,17 @@ function planeEntriesFingerprint(rows) {
28
18
  return enabled ? enabled.map((m) => catalogEntryFingerprint(m)) : null;
29
19
  }
30
20
  function restartSlicePair(boot, current, slice, ctx) {
31
- if (slice === "memory-consolidation") {
32
- const seat = ctx?.consolidationSeat;
33
- return seat === undefined ? [null, null] : [seat.baked, seat.current];
21
+ switch (slice) {
22
+ case "memory-consolidation":
23
+ return seatPair(ctx?.consolidationSeat);
24
+ case "degrade-route":
25
+ return seatPair(ctx?.degradeRoute);
26
+ default:
27
+ return [restartSliceValue(boot, slice, ctx), restartSliceValue(current, slice, ctx)];
34
28
  }
35
- return [restartSliceValue(boot, slice, ctx), restartSliceValue(current, slice, ctx)];
29
+ }
30
+ function seatPair(seat) {
31
+ return seat === undefined ? [null, null] : [seat.baked, seat.current];
36
32
  }
37
33
  function restartSliceValue(eff, slice, ctx) {
38
34
  if (!eff)
@@ -58,13 +54,6 @@ function restartSliceValue(eff, slice, ctx) {
58
54
  const view = Object.entries(d).filter(([k, v]) => !held.has(k) && !CENTER_READ_FACE_LIVE_KEYS.has(k) && v !== undefined);
59
55
  return view.length > 0 ? Object.fromEntries(view) : null;
60
56
  }
61
- case "degrade-route": {
62
- const to = ctx?.reactiveDegradeTo;
63
- if (to === undefined)
64
- return null;
65
- const target = (enabledOnly(eff.models?.models) ?? []).find((m) => m.name === to);
66
- return target ? catalogEntryFingerprint(target) : null;
67
- }
68
57
  }
69
58
  }
70
59
  export function restartReasons(boot, current, ctx) {
@@ -73,7 +62,7 @@ export function restartReasons(boot, current, ctx) {
73
62
  if (stableStringify(b) === stableStringify(c))
74
63
  return false;
75
64
  if (s === "mcp" && ctx?.mcpRevocationLive === true && Array.isArray(b) && Array.isArray(c) && c.length < b.length) {
76
- const bootFps = b.map(stableStringify);
65
+ const bootFps = b.map((row) => stableStringify(row));
77
66
  let bi = 0;
78
67
  const pureRemoval = c.every((row) => {
79
68
  const fp = stableStringify(row);
@@ -18,6 +18,7 @@ import type { InfraCostRates } from "./observability/cost-taxonomy.js";
18
18
  import type { Autonomy, CommandRule } from "./runtime-governance.js";
19
19
  import type { CenterCredentialSource } from "./center-credential.js";
20
20
  import type { GitApiKind } from "./git-api-kind.js";
21
+ import { DRAIN_GRACE_FLOOR_MS } from "@sema-agent/settings-schema/fleet";
21
22
  /** [ref]:记忆向量面的 embedder 坐标(env `MEMORY_EMBEDDER_*` 解析结果;单一属主 = 本类型,
22
23
  * `plugins/memory-embedder.ts` 的工厂直接消费它,不复制形状)。语义与三问见
23
24
  * {@link ServiceConfigFlat.memoryEmbedder}。纯数据 —— 端点原文照存(拼 `/embeddings` 是 transport
@@ -88,12 +89,12 @@ export declare const AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS: {
88
89
  };
89
90
  /**
90
91
  * `DRAIN_GRACE_MS` 的**地板**(10s)—— 夹取(env,[ref] B 档)与中心 stage 校验(`stage-limits.ts` 的 `atLeast`)两条写入腿
91
- * 都以它为下界,于是「排空窗」在任何写入通道上都不会短于它。S-752 起从 `config.ts` 的 `LIMITS_HTTP_FLOORS` 字面提到本
92
- * 纯叶:{@link DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS} 的上界与配置目录的合法域句共读这一个数([ref]:边界不手抄;
93
- * 目录模块不能值引 `config.ts` —— `config → server-secret-env → config-catalog` 已成链)。`LIMITS_HTTP_FLOORS` 仍是
94
- * 那张表,只是这一格改为引用本常量,零语义变化。
92
+ * 都以它为下界,于是「排空窗」在任何写入通道上都不会短于它。**属主在 settings-schema**(`@sema-agent/settings-schema/fleet` 的
93
+ * `DRAIN_GRACE_FLOOR_MS`,drain 协议合同值;S-778 起本叶只转出口,不再本地铸同一个数)。S-752 起它从 `config.ts` 的
94
+ * `LIMITS_HTTP_FLOORS` 字面提到本叶:{@link DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS} 的上界与配置目录的合法域句共读这一个数([ref]:
95
+ * 边界不手抄;目录模块不能值引 `config.ts` —— `config → server-secret-env → config-catalog` 已成链)。
95
96
  */
96
- export declare const DRAIN_GRACE_FLOOR_MS = 10000;
97
+ export { DRAIN_GRACE_FLOOR_MS };
97
98
  /**
98
99
  * S-752(test [ref])—— `DRAIN_SECOND_SIGNAL_MIN_GAP_MS` 的**合法域**(闭区间、整数 ms)与缺省。解析器(`config.ts`)
99
100
  * 与目录行(`config-catalog.ts`)共读这一处。
@@ -112,6 +113,21 @@ export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS: {
112
113
  readonly maxMs: 10000;
113
114
  };
114
115
  export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
116
+ /**
117
+ * S-753(core 7.33.0 #1121)—— `MODEL_FIRST_BYTE_TIMEOUT_MS`(→ core `BrainTimeoutConfig.firstByteTimeoutMs`,`fetch()` → 响应头
118
+ * 的等待)的**合法域**,闭区间、整数 ms。解析器(`config.ts`)与目录行(`config-catalog.ts`)共读这一处。
119
+ *
120
+ * · **下界 0**:`0` 在这一座有真含义 —— 该段**不设引擎钟,传输层的响应头 / 响应体钟也关**(TCP + TLS 建连仍归传输层自己的钟;core 7.33.0 起每请求带引擎 dispatcher,把传输层的
121
+ * headers / body 钟置 0;在此之前 `0` 只关引擎的钟,传输层 300 s 仍在)。亚秒正值不拒:它是合法的「很快就切」表态
122
+ * (failover 拓扑真会这么配;窗上还有 core 按 body 大小加的上传宽限)。
123
+ * · **上界 = {@link MAX_TIMER_DELAY_MS}**:core 对超过它的值构造期拒(`config.stall_timeout_invalid`);本仓在 boot 就拒,
124
+ * 拒句点名 env 键,而不是等到 brain 装配时报一个运维没写过的 config 键名。
125
+ * · **没有缺省常量**:缺席 = 不铸键,core 自缺省当家([ref];本仓不复制上游数字 —— S-753 的病根正是本仓自带的 30 000)。
126
+ */
127
+ export declare const FIRST_BYTE_TIMEOUT_BOUNDS: {
128
+ readonly minMs: 0;
129
+ readonly maxMs: 2147483647;
130
+ };
115
131
  /**
116
132
  * S-220 —— **`resilience` 块缺省的唯一属主**(S-202 的单源从一根扩到整族)。
117
133
  *
@@ -131,7 +147,9 @@ export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
131
147
  * `satisfies Readonly<Record<keyof ServiceConfigFlat["resilience"], …>>` 双向咬:块上加字段而表里没行
132
148
  * ⇒ tsc 红(加字段的人被迫当场回答「它的出厂缺省是多少、归哪个 env」);表里多一行块上没有的键同样红。
133
149
  *
134
- * ⚠️ 语义面**一个数字都没动**:`0 = 关`(三个看门狗)与「熔断默认关」照旧,env 恒赢。
150
+ * ⚠️ 语义面**一个数字都没动**:「熔断默认关」照旧,env 恒赢。看门狗的 `0` 自 core 7.33.0 起 = 该段**不设引擎钟,传输层的响应头 / 响应体钟
151
+ * 也关**(TCP + TLS 建连仍归传输层自己的钟;此前 = 只关引擎的钟)。S-753 起本表**不再有首字节那一行**:`MODEL_CONNECT_TIMEOUT_MS`(自带 30 000)退役,替代键
152
+ * `MODEL_FIRST_BYTE_TIMEOUT_MS` 缺席 = 不铸键(见 {@link ServerDefaultedResilienceKey})。
135
153
  * 首 token 那一行的 600_000 由来(S-202 clay 裁 A):自托管后端在长 prefill(大上下文 / 冷缓存 /
136
154
  * 排队)下首字合理地要等几分钟,120s 会把一次**正常**的慢启动判成 `[network]` 失败去重试,而重试
137
155
  * 再交同一份 prompt 给同一个排队中的后端只会更慢更贵;依赖旧姿态的部署显式写回
@@ -142,10 +160,6 @@ export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
142
160
  * 判「字面量」这一类写法为红(下一份镜像出生即红)。
143
161
  */
144
162
  export declare const RESILIENCE_DEFAULTS: {
145
- readonly connectTimeoutMs: {
146
- readonly env: "MODEL_CONNECT_TIMEOUT_MS";
147
- readonly value: 30000;
148
- };
149
163
  readonly firstTokenTimeoutMs: {
150
164
  readonly env: "MODEL_FIRST_TOKEN_TIMEOUT_MS";
151
165
  readonly value: 600000;
@@ -467,16 +481,20 @@ export interface ServiceConfigFlat {
467
481
  * immediately instead of waiting out timeouts). BreakerState is core's in-process Map for now —
468
482
  * cross-replica shared state (TiDB adapter) is a documented follow-up. */
469
483
  resilience: {
470
- /** Abort fetch if response headers don't arrive within this many ms → retryable [network].
471
- * Default 30s ([ref] fleet audit). 0 = off. */
472
- connectTimeoutMs: number;
484
+ /** S-753(core 7.33.0 #1121)—— `fetch()` → 响应头的等待上限(ms;env `MODEL_FIRST_BYTE_TIMEOUT_MS`,合法域
485
+ * {@link FIRST_BYTE_TIMEOUT_BOUNDS})。**缺席 = 不铸键** ⇒ core 自缺省当家(另加 core 按 body 大小给的上传宽限,
486
+ * 到点后 core 自有一次加宽重试,不吃 `maxRetries`)—— 本仓不复制上游数字([ref])。`0` = 该段不设引擎钟,传输层的响应头 / 响应体钟
487
+ * 也关(TCP + TLS 建连仍归传输层自己的钟)。取代退役的 `connectTimeoutMs`(名义建连、实际就是计到响应头;本仓自带的 30 000 缺省正是 S-753 的病根)。 */
488
+ firstByteTimeoutMs?: number;
473
489
  /** Cancel an open SSE stream if no content/thinking delta arrives within this many ms.
474
- * Default 120s ([ref] fleet audit — core's idle timer only arms after the first token,
475
- * so this is the only guard for a stream that opens and never emits). 0 = off. */
490
+ * Default 600s (S-202; core's idle timer only arms after the first token,
491
+ * so this is the only guard for a stream that opens and never emits). 0 = no engine clock on this
492
+ * segment and the transport's body clock off too (core ≥7.33.0). */
476
493
  firstTokenTimeoutMs: number;
477
494
  /** Mid-stream stall watchdog (1.40.1): after output starts, re-armed on every delta; silence for
478
- * this many ms → cancel + retryable [network]. Catches a gateway that hangs AFTER the first token
479
- * (firstTokenTimeoutMs only covers the first). 0 = off. */
495
+ * this many ms → cancel + retryable. Catches a gateway that hangs AFTER the first token
496
+ * (firstTokenTimeoutMs only covers the first). 0 = no engine clock on this segment and the transport's
497
+ * body clock off too (core ≥7.33.0). */
480
498
  idleTimeoutMs: number;
481
499
  /** Wrap the primary gateway route(s) in a circuit breaker (in-process state, per model+provider). */
482
500
  circuitBreaker: boolean;
@@ -1,13 +1,14 @@
1
+ import { DRAIN_GRACE_FLOOR_MS } from "@sema-agent/settings-schema/fleet";
1
2
  export const TASK_WRITE_FACE_ESCALATIONS = ["allow", "refuse"];
2
3
  export const RETENTION_MODES = ["audit-only", "enforce"];
3
4
  export const MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC = 300;
4
5
  export const MAX_TIMER_DELAY_MS = 2_147_483_647;
5
6
  export const AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS = { minMs: 1_000, maxMs: MAX_TIMER_DELAY_MS };
6
- export const DRAIN_GRACE_FLOOR_MS = 10_000;
7
+ export { DRAIN_GRACE_FLOOR_MS };
7
8
  export const DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS = { minMs: 0, maxMs: DRAIN_GRACE_FLOOR_MS };
8
9
  export const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
10
+ export const FIRST_BYTE_TIMEOUT_BOUNDS = { minMs: 0, maxMs: MAX_TIMER_DELAY_MS };
9
11
  export const RESILIENCE_DEFAULTS = {
10
- connectTimeoutMs: { env: "MODEL_CONNECT_TIMEOUT_MS", value: 30_000 },
11
12
  firstTokenTimeoutMs: { env: "MODEL_FIRST_TOKEN_TIMEOUT_MS", value: 600_000 },
12
13
  idleTimeoutMs: { env: "MODEL_IDLE_TIMEOUT_MS", value: 300_000 },
13
14
  circuitBreaker: { env: "MODEL_CIRCUIT_BREAKER", value: false },
@@ -17,7 +18,6 @@ export const RESILIENCE_DEFAULTS = {
17
18
  export function buildDefaultResilience() {
18
19
  const d = RESILIENCE_DEFAULTS;
19
20
  return {
20
- connectTimeoutMs: d.connectTimeoutMs.value,
21
21
  firstTokenTimeoutMs: d.firstTokenTimeoutMs.value,
22
22
  idleTimeoutMs: d.idleTimeoutMs.value,
23
23
  circuitBreaker: d.circuitBreaker.value,
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Model, type PathRuleBases, type RuleBehavior } from "@sema-agent/core";
2
2
  import { type RemoteExecProvider } from "./execution-lane-caps.js";
3
3
  import { type Autonomy } from "./runtime-governance.js";
4
+ import { type RunnerEnvNameAllowlistKnob, type ServerEnvNameAllowlistKnob } from "./env-name-allowlist-knobs.js";
4
5
  import type { ServiceConfig, ServiceConfigFlat, ServiceConfigGroups } from "./config-types.js";
5
6
  /** [ref] A1: the config TYPE face lives in the leaf module config-types.ts; re-exported here so
6
7
  * every existing `from "./config.js"` importer compiles unchanged (pure-type consumers should prefer
@@ -31,6 +32,8 @@ export declare function parseUsageWindows(raw: string | undefined): readonly {
31
32
  maxCostUsd?: number;
32
33
  anchor: "first-use" | "rolling";
33
34
  }[] | undefined;
35
+ /** 墓碑拒启码(机读;拒句方括号里)。 */
36
+ export declare const RETIRED_ENV_REFUSAL_CODE = "config.env_key_retired";
34
37
  /** Numeric env with validation: a non-numeric value FAILS at startup instead of silently becoming `NaN`
35
38
  * (which e.g. slips past the `runStaleSec` liveness guard and silently disables the reaper). */
36
39
  export declare function numEnv(name: string, fallback: string): number;
@@ -231,6 +234,10 @@ export declare const hostExecSpoolEnabled: () => boolean;
231
234
  * 门 = `test/deploy-lanes-contract.test.ts`,它读的是本表而不是任何副本。
232
235
  */
233
236
  export declare const REQUIRED_ENVS_BY_LANE: Record<RemoteExecProvider, readonly string[]>;
237
+ /** 名单型 env 旋钮的**唯一**解析器(规则全文见上方 `ENV_NAME_ALLOWLIST_KNOBS` 头注)。`undefined` = 没配(未设 / 空串)。
238
+ * 服务侧旋钮要交本进程的姿态(③);runner 旋钮没有姿态,签名上就不收 —— 读者与行形由叶表的类型一起定,传错是编译红。 */
239
+ export declare function parseEnvNameAllowlist(knob: ServerEnvNameAllowlistKnob, singleUserTurnkey: boolean): readonly string[] | undefined;
240
+ export declare function parseEnvNameAllowlist(knob: RunnerEnvNameAllowlistKnob): readonly string[] | undefined;
234
241
  /** [ref]① 1M dual-window (core 1.289 `Model.autoCompactTokens`): an EXPLICIT positive value wins (env
235
242
  * `MODEL_AUTO_COMPACT_TOKENS` / center roster `autoCompactTokens`); else the **model-id table** applies —
236
243
  * sonnet-5-family id ∧ contextWindow>=1e6 ⇒ 967000 (the faithful shape of CC's single-entry per-model