@sema-agent/server 7.68.0 → 7.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +3 -0
  2. package/README.zh-CN.md +3 -0
  3. package/USAGE.md +122 -9
  4. package/dist/adoption/plan.js +11 -0
  5. package/dist/approval-ask-machine.d.ts +1 -1
  6. package/dist/approval-card.d.ts +27 -0
  7. package/dist/approval-card.js +11 -0
  8. package/dist/approval-reconciler.d.ts +0 -54
  9. package/dist/approval-reconciler.js +2 -2
  10. package/dist/approval.js +5 -4
  11. package/dist/boot/config-center.d.ts +6 -7
  12. package/dist/boot/config-center.js +31 -8
  13. package/dist/boot/leader.js +1 -1
  14. package/dist/boot/parked-revive-gate.js +10 -9
  15. package/dist/boot/runner-deps.d.ts +35 -4
  16. package/dist/boot/runner-deps.js +2 -6
  17. package/dist/boot/workflow-orchestration.d.ts +2 -0
  18. package/dist/boot/workflow-orchestration.js +10 -2
  19. package/dist/budget.js +3 -2
  20. package/dist/config-catalog.d.ts +31 -2
  21. package/dist/config-catalog.js +61 -24
  22. package/dist/config-center/apply-effective.d.ts +8 -1
  23. package/dist/config-center/apply-effective.js +23 -9
  24. package/dist/config-center/facade.d.ts +1 -1
  25. package/dist/config-center/facade.js +1 -1
  26. package/dist/config-center/read-face.d.ts +30 -1
  27. package/dist/config-center/read-face.js +33 -15
  28. package/dist/config-center/restart-signal.js +2 -1
  29. package/dist/config-center/types.d.ts +20 -5
  30. package/dist/config-provider.js +1 -0
  31. package/dist/config-types.d.ts +17 -2
  32. package/dist/config.d.ts +0 -21
  33. package/dist/config.js +19 -6
  34. package/dist/http/route-ctx.d.ts +39 -1
  35. package/dist/http/routes/approvals-assistant.js +4 -12
  36. package/dist/http/routes/capabilities.js +1 -0
  37. package/dist/http/routes/diagnostics.js +4 -0
  38. package/dist/http/routes/runs.js +2 -3
  39. package/dist/http/routes/tasks.js +22 -18
  40. package/dist/http/routes/workflows.js +15 -7
  41. package/dist/http/server.d.ts +10 -2
  42. package/dist/http/server.js +74 -26
  43. package/dist/leader/wire.d.ts +9 -2
  44. package/dist/leader/wire.js +6 -6
  45. package/dist/main.js +8 -7
  46. package/dist/model-compat.d.ts +81 -0
  47. package/dist/model-compat.js +52 -0
  48. package/dist/observability/fail-open.d.ts +4 -0
  49. package/dist/observability/fail-open.js +4 -0
  50. package/dist/observability/metrics.d.ts +0 -6
  51. package/dist/observability/metrics.js +3 -1
  52. package/dist/observability/permission-rule-events.d.ts +20 -0
  53. package/dist/observability/permission-rule-events.js +28 -0
  54. package/dist/observability/run-terminal-log.d.ts +9 -19
  55. package/dist/observability/run-terminal-log.js +183 -32
  56. package/dist/observability/tool-trace.d.ts +12 -0
  57. package/dist/observability/tool-trace.js +5 -1
  58. package/dist/operator-ask.d.ts +46 -0
  59. package/dist/operator-ask.js +4 -0
  60. package/dist/orchestration/workflow-agent-session-index.d.ts +96 -0
  61. package/dist/orchestration/workflow-agent-session-index.js +146 -0
  62. package/dist/orchestration/workflow-journal-entry.d.ts +78 -0
  63. package/dist/orchestration/workflow-journal-entry.js +20 -0
  64. package/dist/orchestration/workflow-notify-journal.d.ts +43 -1
  65. package/dist/orchestration/workflow-notify-journal.js +43 -3
  66. package/dist/parked-decide.d.ts +115 -5
  67. package/dist/parked-decide.js +94 -26
  68. package/dist/plugins/pg-pool.js +8 -0
  69. package/dist/plugins/store-backend.d.ts +7 -1
  70. package/dist/plugins/store-backend.js +3 -1
  71. package/dist/plugins/tidb-pool.js +8 -0
  72. package/dist/plugins/workflow-journal-store-sql.d.ts +1 -1
  73. package/dist/plugins/workflow-journal-store-sql.js +5 -5
  74. package/dist/plugins/workflow-run-store-sql.d.ts +30 -0
  75. package/dist/plugins/workflow-run-store-sql.js +40 -0
  76. package/dist/posture-source.d.ts +23 -3
  77. package/dist/posture-source.js +1 -1
  78. package/dist/read-face-posture.d.ts +8 -1
  79. package/dist/read-face-posture.js +1 -0
  80. package/dist/router/route-orchestration.js +2 -1
  81. package/dist/run-cancel-context.d.ts +50 -1
  82. package/dist/run-cancel-context.js +22 -0
  83. package/dist/run-local.d.ts +14 -6
  84. package/dist/run-local.js +2 -2
  85. package/dist/runs.d.ts +0 -51
  86. package/dist/runs.js +40 -23
  87. package/dist/runtime-governance.js +5 -4
  88. package/dist/tool-approval.d.ts +31 -5
  89. package/dist/tool-approval.js +5 -4
  90. package/dist/trace/core-keyset-guard.d.ts +9 -5
  91. package/dist/trace/engine-notice-wire.d.ts +1 -1
  92. package/dist/trace/engine-notice-wire.js +1 -0
  93. package/dist/trace/ledger-events.d.ts +17 -1
  94. package/dist/trace/ledger-events.js +6 -1
  95. package/dist/trace/ledger-sink.d.ts +10 -8
  96. package/dist/trace/ledger-sink.js +2 -1
  97. package/dist/trace/project.d.ts +3 -2
  98. package/dist/trace/redact.d.ts +28 -0
  99. package/dist/trace/redact.js +50 -27
  100. package/package.json +4 -4
package/README.md CHANGED
@@ -191,6 +191,9 @@ stop different things at different moments (per-task ceiling / per-principal ADM
191
191
  not interrupt a run already executing / deployment usage window that does stop one at a turn
192
192
  boundary) — the side-by-side table is in `USAGE.md`, worth reading before picking one.
193
193
 
194
+ Web search (deployment `WEB_SEARCH_*` env, per-request `settings.webSearch`, the two-lane precedence
195
+ and the tool-level error forms) is documented in `USAGE.md` §9.5.
196
+
194
197
  ## HTTP API overview
195
198
 
196
199
  One row per endpoint family (not exhaustive):
package/README.zh-CN.md CHANGED
@@ -170,6 +170,9 @@ curl -s localhost:8090/v1/tasks -H "Authorization: Bearer <SERVICE_AUTH_TOKEN>"
170
170
  (单任务硬闸 / per-principal **进场门**——不打断已在跑的 run / 部署级治理窗——会在 turn 边界停住它);
171
171
  选之前请先读 `USAGE.md` 里的「三道 $ 闸的分工」对照表。
172
172
 
173
+ Web search(部署 env `WEB_SEARCH_*` 七键、per-request `settings.webSearch`、两车道优先级、工具层错误形)
174
+ 见 `USAGE.md` §9.5。
175
+
173
176
  ## HTTP API 概览
174
177
 
175
178
  一行一个端点族(非全量):
package/USAGE.md CHANGED
@@ -75,14 +75,34 @@ ANTHROPIC_MAX_RETRIES=10 # 云 Anthropic 腿
75
75
  - **重试上限**(2026-07-31):两个键**不设就不传给引擎** —— 引擎默认当家。以前这里是 server 侧硬编码 `2`(openai 腿甚至零配置出口),而**显式传参压过引擎默认**,于是引擎抬默认对 server 部署毫无效果;第三方限流 provider 下"重试两次就放弃"正是由此而来。现在:不设=继承引擎默认(抬默认那天自动跟上),设了=按设的走。钉在 `test/brain.test.ts` 的「网关腿重试次数」两条上。
76
76
  - 断路器状态:配了 `SESSION_BACKEND=mysql` 时自动用**跨副本共享态**(SQL `circuit_breaker` 表,写穿+刷新最终一致),否则进程内 Map。启动日志 `breakerState` 字段回显 `shared(mysql)`/`in-process`/`off`。
77
77
 
78
- **可选 — WebSearch 后端(core 只留注入口,不自带任何 provider)**
78
+ **可选 — WebSearch 后端(core 只留注入口,不自带任何 provider;部署 env 七键)**
79
79
  ```bash
80
- WEB_SEARCH_PROVIDER=brave|tavily|searxng # 缺席 = 不装配(WebSearch 工具根本不挂,不是挂了报错)
81
- WEB_SEARCH_API_KEY=… # brave/tavily 必填;只进 backend 闭包,永不进模型 prompt 或工具参数
82
- WEB_SEARCH_ENDPOINT=https://searx.example # searxng 必填;brave/tavily 下是可选的 base-URL 覆盖(代理/测试)
83
- WEB_SEARCH_MAX_RESULTS=10 # 1..20,返给模型的条数上限
84
- WEB_SEARCH_TIMEOUT_MS=10000 # 单次搜索墙钟
80
+ WEB_SEARCH_PROVIDER=brave|tavily|searxng # 唯一的"装配开关":合法词才挂 WebSearch 工具;缺席/非法词 = 不装配(不是挂了报错)
81
+ WEB_SEARCH_API_KEY=… # brave/tavily 必需(searxng 不读这一键);只进 backend 闭包,永不进模型 prompt 或工具参数
82
+ WEB_SEARCH_ENDPOINT=https://searx.example # searxng 必需(实例地址);brave/tavily 下是可选的 base-URL 覆盖(代理/测试)
83
+ WEB_SEARCH_MAX_RESULTS=10 # 1..20(夹逼);缺席/非正数/非数字 → 用 backend 默认 10;小数向下取整
84
+ WEB_SEARCH_TIMEOUT_MS=10000 # 单次搜索墙钟(ms),下限 1000;缺席/非正数/非数字 → 用 backend 默认 10000
85
+ WEB_SEARCH_SEARXNG_PARAMS="engines=bing,duckduckgo;language=zh-CN" # 仅 searxng 腿消费;`;` 分隔 k=v 对;一个都解析不出 → 键整个不铸(不产出空对象)
86
+ WEB_SEARCH_PROBE_ON_BOOT=true # boot 期一次性真出网探活,默认 OFF;只认 "true"/"1"(trim+小写后比较),含糊值当没开
85
87
  ```
88
+
89
+ 七键逐一(`src/plugins/web-search.ts` `webSearchConfigFromEnv` §294-309;类型/默认/坏值登记见
90
+ `src/config-catalog.ts:654-662`):
91
+
92
+ | env 键 | 类型 | 默认 | 缺席行为 | 坏值行为 |
93
+ |---|---|---|---|---|
94
+ | `WEB_SEARCH_PROVIDER` | 闭集(`brave`\|`tavily`\|`searxng`) | 无 | WebSearch 工具整体不装配(功能缺席,无 warn) | 三词之外的任何值 = 同缺席处理,不装配、不 warn(`:296-297`) |
95
+ | `WEB_SEARCH_API_KEY` | secret string | 无 | brave/tavily:后端仍会装配(装配只看 `PROVIDER`),**首次真实工具调用**时抛 `WEB_SEARCH_API_KEY is required for the <provider> provider`(`:152`/`:164`);searxng:本键无消费点 | 空字符串同缺席(`env.WEB_SEARCH_API_KEY ?` 只认真值,`:303`) |
96
+ | `WEB_SEARCH_ENDPOINT` | url string | brave/tavily → 各自官方 API;searxng → 无默认 | brave/tavily:落官方 endpoint;searxng:**首次真实工具调用**时抛 `WEB_SEARCH_ENDPOINT (the SearXNG instance URL) is required for the searxng provider`(`:193`) | 不做 URL 形校验——写不成 URL 由 `new URL()` 抛出,同样落到"首次调用才现形"那条路径,不拒启 |
97
+ | `WEB_SEARCH_MAX_RESULTS` | number | `10` | 用默认 10 | 非数字/≤0 → 回落默认 10;是数字则向下取整;最终值再夹在 `[1,20]`(设 999 也被 clamp 到 20,`:227`) |
98
+ | `WEB_SEARCH_TIMEOUT_MS` | number(ms) | `10000` | 用默认 10000 | 非数字/≤0 → 回落默认;最终值下限夹到 1000ms(`Math.max(1000,…)`,`:228`) |
99
+ | `WEB_SEARCH_SEARXNG_PARAMS` | string(`k=v;k=v`) | 无(不铸键 = 不传 `extraParams`,交给 core adapter 自己的缺省) | 键整个不铸 | 一个 `k=v` 对都解析不出(没有 `=`,或 `k`/`v` 任一为空)→ 同缺席,整串忽略,不拒启不 warn(`:260-277`);env 侧值恒为字符串,不会触发下面 per-request 那个"数组被误当对象吸收"的边角(见 §9.5) |
100
+ | `WEB_SEARCH_PROBE_ON_BOOT` | boolean(仅认 `"true"`/`"1"`) | `false`(OFF) | 不探活 | trim+小写后不等于 `"true"`/`"1"` 的任何拼法(含 `"yes"`/`"on"`)一律当 `false`;探活失败(网络不通/后端拒绝)只 `warn`(`web_search_probe_failed`),**不拒启**——功能型能力缺席走降级,不是保护型旋钮(`main.ts:1028-1034`) |
101
+
102
+ **结论:七键无一在 boot 期拒启。** 与本仓其它安全轴旋钮(`MODEL_DEGRADE_ON`、思考档三键等,拼错即拒启)
103
+ 姿势不同——WebSearch 挂不挂是**功能面**取舍,不是安全边界,七键统一走"回落/降级",不是"fail-closed 拒启"。
104
+ 真正的配置错误(缺 key / endpoint 错)只在**首次真实工具调用**时才现形,以 tool_result 错误文本形式回给
105
+ 模型,不是 HTTP 层错误、不影响 boot、也不使任务整体 `failed`(细节见 §9.5)。
86
106
  - 结果是**不可信输入**:core 会 `delimitUntrusted` 围栏并**重新施加** `allowed_domains`/`blocked_domains`
87
107
  地板,所以 backend 遵不遵守 `opts` 是优化不是正确性要求 —— 换 provider(包括换成自建 SearXNG)
88
108
  不会削弱域名地板。
@@ -91,6 +111,7 @@ WEB_SEARCH_TIMEOUT_MS=10000 # 单次搜索墙钟
91
111
  笔记本上起的 localhost SearXNG,云端 worker 够不着 —— 所以「本地 SearXNG 兜底」这一档**只适用
92
112
  壳自 spawn 本地引擎的同机形**;壳连接远程引擎时该档整级跳过,由运维在**引擎侧**配 `WEB_SEARCH_*`。
93
113
  用户手填一个集中式 SearXNG 地址是合法形,照常放行。
114
+ - **per-request 覆盖 + 两车道优先级 + 错误形全表**:见 §9.5。
94
115
 
95
116
  - **大工具结果落盘**(core 1.47/1.49):单条工具结果 > ~20000 字符时 core 把全文移出上下文、只留预览+ref,模型用 `read_tool_result` 按需分页回取。配了 TiDB 时自动用**durable `tool_result` 表**(跨副本 wake 仍能取回全文;否则 core 进程内默认 = 跨副本 wake 取不到→降级到预览,不崩)。`TOOL_RESULT_TTL_SEC`(默认 86400)按 TTL 回收(要 ≥ run 可恢复期)。启动日志 `toolResultStore` 回显 `shared(tidb)`/`in-process`。
96
117
 
@@ -124,6 +145,19 @@ MODEL_DEGRADE_ON=rate_limit,breaker_open # 可选,反应式触发器子集;词
124
145
  `med,high` 此前静默窄成只剩 `high` 一档,而这根旋钮的全部用途就是宣告高档可用,静默落空 = 高档
125
146
  永远上不去。空串 / 纯分隔(`, `)仍与不设同义(不铸键,行为不变);anthropic 腿刻意不铸 compat
126
147
  (那条腿不吃 `reasoning_effort`),`GET /v1/config/catalog` 对它诚实回 `null`。
148
+ - 🔴 **`MODEL_THINKING_FORMAT`(7.69.0 新,同族第三根)**:这台网关按哪种拼法收「开/关思考」——
149
+ `openai|openrouter|deepseek|together|zai|qwen|qwen-chat-template` 七词闭集(上 core
150
+ `OpenAICompletionsCompat.thinkingFormat`),未知词同样 **boot 拒启并点名闭集**。缺席 ⇒ 不铸这一键
151
+ (core 按 baseUrl / 模型 id 自行推断,行为逐字不变)。**真需求**:vLLM / Qwen 类**缺省开思考**的网关,
152
+ 只有 `qwen-chat-template`(`chat_template_kwargs.enable_thinking=false`)关得掉,而 `MODEL_EXTRA_BODY`
153
+ 穿不过引擎的 OpenAI 保留键表 —— 不设它,这类部署每一轮都白烧一段思考且读数上看不出来。它与
154
+ `MODEL_REASONING_EFFORT_LEVELS` 铸进**同一个** `compat` 对象(两根旋钮同时设两键都在)。
155
+ - 🔴 **逐模型声明(目录腿):`models.models[].compat`**(settings-schema ≥1.10.0;7.69.0 消费)——
156
+ 同一张 `OpenAICompletionsCompat` 五键(`thinkingFormat` / `reasoningEffortLevels` / `maxTokensField` /
157
+ `requiresReasoningContentOnAssistantMessages` / `supportsReasoningEffort`)可以**按模型**写在
158
+ `config.d/models.json` 或配置控制面的目录条目上,逐键盖过上面两根 env 缺省(BL-8:没写的键仍继承 env)。
159
+ **坏形/坏词/未知键 ⇒ 丢掉这条 `compat` 声明、模型本体保留**,并打一条 `model_compat_dropped`
160
+ 点名 warn(引擎回落到自己的推断 = 今天的行为);一条写坏的声明**不会**让这只模型或整套目录消失。
127
161
  - 反应式降级 brain 包在**最外层**;fallback brain 用自己的凭据(decorator 清掉主模型的 per-call key 防外泄给别的 provider)。**坑**:`MODEL_DEGRADE_TO` 最好别和被限流的是同一网关/账号,否则反应式切过去照样撞同一个 rate_limit。
128
162
  - **定价怎么设**:`model.cost` 来自 `MODEL_COST_INPUT/OUTPUT/CACHE_READ/CACHE_WRITE`(**USD per 1M tokens**,默认 0 = `costUsd` 读 0)。云模型(如 review-gw 的 deepseek-v4-flash)必须设,否则 spend 恒为 $0;本地自托管(qwen)留 0 即对(无 per-token 外部花费)。字段名是 `costUsd`,**非美元计价的网关要先折算**(如 DeepSeek 官方 CNY ÷ 汇率)。配置控制面管的模型走 `CenterModel.cost`(中心存价、不存 secret)。
129
163
  - 成本计量**自动开**:从 config 的 `model.cost`(per-1M 绝对 USD)注入 `pricing`,core 算出权威的整数 `costMicroUsd`(避免浮点累计误差)。`/metrics` 新增:`model_cost_micro_usd_total{model}`(覆盖所有 brain 调用=主任务+异步+council 子任务的总花费)、`brain_first_token_ms`(网关 hang 早警)、`brain_call_latency_ms`、`tool_calls_total{name,ok}`、`budget_exceeded_total{code}`、`cost_quota_rejected_total`、`degraded_total{reason}`(1.40 降级)。
@@ -638,8 +672,12 @@ READ_DENY_PATTERNS='[{"pattern":"**/secrets/**"},{"pattern":".ssh","caseSensitiv
638
672
  ① **本机 env 显式恒赢**(逐键判)——env 是这台机器的部署主权,下发值不会静默盖掉它,被忽略的键留一条
639
673
  `sema_registry_read_face_env_wins` warn;② **坏值整域拒**——任何一个成员过不了引擎自己的校验器就整域不落、
640
674
  保留 env 前值 + `sema_registry_read_face_invalid` warn(半应用一份 READ 姿态比不应用更危险);
641
- ③ **restart-to-apply**——四键在 boot 期被引擎装配捕获,下发改动经 `/health` 的 `restart.reasons`
642
- 带 `read-face` 通告,由编排器重启兑现。当前生效档可从 `GET /v1/capabilities` 的 `readFace` 位读
675
+ ③ **一席热、三席重启**(server 7.69.0 起,引擎侧交付了 read-face 的活体座席):`face` 是**热换席** ——
676
+ 下发或撤回一个档位,下一拍 refresh(≤60s,或手动 `POST /v1/admin/config/refresh`)当场换进全部 Runner,
677
+ **不重启、也不再签 `read-face` 重启理由**(已在飞的任务保留它准备时的档位,新任务读新档);三个 deny 键
678
+ (`denyPatterns`/`denyBuiltinTiers`/`denyBuiltinExclude`)**仍是 boot-baked**(core 的可换席闭集只收了
679
+ `readFace` 一席),下发改动仍经 `/health` 的 `restart.reasons` 带 `read-face` 通告、由编排器重启兑现。
680
+ 当前生效档可从 `GET /v1/capabilities` 的 `readFace` 位读
643
681
  (`"open"`/`"roots"`/`null`=本部署未钉,引擎默认当家;deny 表内容属策略内容,**不上**能力面);
644
682
  **来源**(env / center / posture / engine-default)在 operator 面 `GET /v1/diagnostics/wiring` 的 `readFace.source`。
645
683
  ⚠️ 本地 `config.d/` 车道今天**接不上**这一域(registry-core 的可移植域表里没有它),只有
@@ -1375,5 +1413,80 @@ GET /v1/capabilities?permissionMode=default
1375
1413
  **settings 层 kill-switch(件⑥)**:请求体 `settings.permissions.disableAutoMode` 受理 **`"disable"` / `true` / `false`** 三形(两套已发布契约的拼写:CC 250 的严极词 `"disable"`,与 `@sema-agent/sdk` `SettingsPermissions.disableAutoMode?: boolean`);受理集之外的值 400 `request.field_invalid`。
1376
1414
  `"disable"` 与 `true` **同义**:把该 run 的生效模式由 auto 折成 default(座不写 ⇒ 不武装;tighten-only,只能关不能开);`false` = 显式「不禁」= **合法且无效果**(本键没有放宽臂——它不是 auto 的开关)。`disableBypassPermissionsMode` 本批不落点。
1377
1415
 
1378
- **per-run 面**:本批不在 run 记录上加 per-run `autoMode` 座——引擎侧的武装结果 / 未武装原因(含只有引擎知道的熔断类)
1416
+ **per-run 面**:本批不在 run 记录上加 per-run `autoMode` 座——引擎侧的武装结果 / 未武装原因(含只有引擎知道的那些;core 7.12.0 起熔断族已退役)
1379
1417
  将随 core 的状态座到货,届时 server 只投影不自算;此前 `armed` 是 server 侧三项可知判据的裁决。
1418
+
1419
+ ### 9.5 Web search:两车道优先级、per-request `settings.webSearch`、错误形
1420
+
1421
+ **两车道,整段整取(不是逐键 merge)**:部署 env(`WEB_SEARCH_*`,§0)与 per-request `body.settings.webSearch`
1422
+ 是唯二两条配置来源。当 per-request 那条腿"生效"(见下面的受理门槛)时,它产出一个**全新、完全独立**的
1423
+ `WebSearchBackendConfig`,**整只替换**掉 env 配出来的 backend —— 不是把 per-request 给的字段一个个覆盖到
1424
+ env 的 config 上,env 的其余字段(尤其是 `WEB_SEARCH_TIMEOUT_MS`)**不会**被继承到 per-request 那次调用里
1425
+ (`src/capabilities/scenarios.ts:264-266`):
1426
+
1427
+ ```
1428
+ const reqWebSearch = deps.requirePrincipal !== true
1429
+ ? webSearchConfigFromSettings(req.settings?.webSearch)
1430
+ : undefined;
1431
+ const webSearch = reqWebSearch ? createWebSearchBackend(reqWebSearch) : deps.webSearch;
1432
+ ```
1433
+
1434
+ `reqWebSearch` 非 `undefined`(即 `settings.webSearch.provider` 是合法词**且**本部署是单用户车道)时,
1435
+ `deps.webSearch`(env 配的 backend)整个不参与这次请求 —— 谁赢是**二选一**,不是字段级合并。
1436
+
1437
+ **受理门槛(单用户车道)**:🔒 per-request `settings.webSearch` 只在 `REQUIRE_PRINCIPAL !== true`(单用户 /
1438
+ TOC 本地形)时被读取;`REQUIRE_PRINCIPAL=true`(多租户)上这个键**结构性够不着**——不是被拒、也不留任何
1439
+ `warn`/`capabilities` 位说"你发的 webSearch 被忽略了"(对比 `mcpServers` 有 `capabilities.mcpInjection` +
1440
+ `mcp_injection_dropped` 日志可读;`settings.webSearch` 没有对应的能力探测位,`src/http/wire-types.ts:320-323`)。
1441
+ 原因是安全边界(与多租户能力配置隔离规则同源):per-request `endpoint`/`apiKey` 是能力配置,多租户下一个租户把 `searxng`
1442
+ 指向内网地址就是 SSRF,所以多租户上只认部署 env 配的 backend。
1443
+
1444
+ **`settings.webSearch` 键表**(`src/plugins/web-search.ts` `webSearchConfigFromSettings` §318-335;类型契约见
1445
+ `@sema-agent/sdk` 9.0.0 `settings.d.ts` `SettingsWebSearch`):
1446
+
1447
+ | 键 | 类型 | 必填 | 默认 | 消费点 |
1448
+ |---|---|---|---|---|
1449
+ | `provider` | `"brave"|"tavily"|"searxng"` | 是(缺席/非三词之一 ⇒ 整个 `settings.webSearch` 被丢,回落到 env 的 backend,**不报错**) | 无 | `:321-322` |
1450
+ | `apiKey` | string(明文) | brave/tavily 建议带(缺了首次调用才报错,见下表);searxng 不读 | 无(不继承 env 的 `WEB_SEARCH_API_KEY`) | `:326` |
1451
+ | `endpoint` | string | searxng 必需(缺了首次调用才报错);brave/tavily 可选覆盖 | 无(不继承 env 的 `WEB_SEARCH_ENDPOINT`) | `:327` |
1452
+ | `searxngParams` | `Record<string,string>`(对象)或 `"k=v;k=v"`(字符串) | 否 | 无 | `:330-333`,解析逻辑与 env 腿共用 `parseSearxngParams`(`:260-277`)。⚠️ **未列入已发布的 `@sema-agent/sdk` 8.8.0 `SettingsWebSearch` 类型**(该接口只有 `provider`/`apiKey`/`endpoint`/`maxResults` 四键、无开放下标)——server 侧代码认这个键,但当前发布的 TS 类型接不到它;手写 JSON 请求体仍可以发,server 会照常解析(源码头注自述:字段和消费点先落地,配置录入面尚未跟上,是一笔尚未还清的既有债务) |
1453
+ | `maxResults` | number | 否 | 10(与 env 同一 clamp,`[1,20]`,小数向下取整) | `:323` |
1454
+ | *(无)* `timeoutMs` | — | — | — | **per-request 车道没有这个字段**——即便部署用 `WEB_SEARCH_TIMEOUT_MS` 配了非默认超时,per-request 生效那次调用永远退回 backend 自己的默认(10000ms,下限 1000ms),因为"整段整取"意味着 env 的 `timeoutMs` 根本不在 `reqWebSearch` 那个新对象里 |
1455
+ | *(无)* `fetchImpl` | — | — | — | 同上,仅测试注入用,per-request 车道不可达 |
1456
+
1457
+ **门槛以外的键**:`settings.webSearch` 本身是 `TASK_SETTINGS_KEYS` 闭集里的受理顶层键
1458
+ (`src/task-settings.ts:309`,集外顶层键 400 `request.body_shape`),但 `webSearch` **自己的子键没有闭集门**
1459
+ ——不像 `settings.permissions.*` 有 `TASK_SETTINGS_PERMISSION_KEYS` 逐键拒(`src/task-settings.ts:367-403`
1460
+ 的 `taskSettingsKeyIssue` 只扫 `settings.*` 顶层和 `settings.permissions.*` 两层)。`settings.webSearch` 下
1461
+ 塞一个上表之外的键(拼错的 `mxResults` 之类)**不会** 400,会被 `webSearchConfigFromSettings` 静默无视
1462
+ ——门槛之外没有"未知键"这一说。
1463
+
1464
+ **错误形**(逐条对应任务书里的四问;全部经**首次真实工具调用**才现形,均为 WebSearch 工具的
1465
+ `tool_result` 错误文本,回到模型的对话里,**不是** HTTP 层 `errorCode`,**不会**使 `TaskResult` 整体
1466
+ `failed`,也不拒启/不拒收请求本身;core `dist/tools/web.js:840-919` `createWebSearchTool` 统一兜底):
1467
+
1468
+ | 触发条件 | 现象 | 是否 retryable(core `classifySearchFailure`) |
1469
+ |---|---|---|
1470
+ | 坏 `provider`(`settings.webSearch.provider` 非三词之一,或缺席) | **不是错误** —— `webSearchConfigFromSettings` 返回 `undefined`,整段回落到 env 配的 backend(env 也没配 ⇒ WebSearch 工具不装配,模型看不到这个工具);无 warn、无日志 | 不适用 |
1471
+ | 缺 `apiKey`(brave/tavily,env 或 per-request 均未给) | 首次调用抛 `WEB_SEARCH_API_KEY is required for the <provider> provider`,core 接住转成 `Error (WebSearch): the search backend failed. …` 文本回模型 | `"unknown"`(消息里没有 HTTP 状态码模式,`classifySearchFailure` 落最后一条默认分支) |
1472
+ | `searxngParams` 非对象/非字符串(如数字、布尔、`null`) | 静默丢弃——`parseSearxngParams` 落到"非 object 且非 string"分支,返回 `undefined`,键整个不铸,**不报错、不 warn** | 不适用 |
1473
+ | ⚠️ `searxngParams` 是**数组**(如 `["engines=bing"]`) | `typeof [] === "object"` 让它落进对象分支:`Object.entries` 按数组下标产出 `{"0":"engines=bing"}`——**不被拒绝**,但产出的键是数字字符串、值是未拆分的原始 `"k=v"` 串,传给 core adapter 的 `extraParams` 后是一组没有意义的查询参数(不是解析出 `engines=bing`)。这条边角只在 per-request 车道可达(env 值恒为字符串,不会触发);已用与源码逐字一致的独立复现脚本核验(见收车档),未改代码 | 不适用(不是异常路径) |
1474
+ | 搜索后端返回**非 2xx HTTP 响应**(鉴权失败/限流/服务端 5xx 等) | 三个 provider 各自的 `!res.ok` 分支抛错,消息形固定为 `"<provider> search failed (<status>): <body首 200 字符>"`(brave/tavily,`:158`/`:174`)或 `"SearXNG <status> <statusText> from <url>"`(searxng,core adapter) | ⚠️ **实测几乎恒为 `"unknown"`,不是按状态码分档**:`classifySearchFailure` 的状态码分支要求 `http`/`status`/`code`/`error` 四词之一紧邻数字前(`\D{0,12}`内),但本仓三个 adapter 的消息把状态码写在 `failed (…)`/`SearXNG …` 之后,不触发该分支;独立复现脚本核验(见收车档)brave/tavily/searxng 的 429/500/403/403 全部落到最后一条默认分支 `retryable:"unknown"`。**真正被分类对的只有两条兜底正则**:上游错误体文本里若真含 `"rate limit"` 才判 429 类 `true`,含 `"timed out"`/`"timeout"` 才判 408 类 `true`——都取决于上游返回的具体措辞,不是本仓能保证的 |
1475
+ | `endpoint` **网络层**不可达(连接被拒/DNS 解析失败/fetch 自身抛错,尚未拿到任何 HTTP 响应) | `fetch` 抛出的传输层错误(`ECONNREFUSED`/`ENOTFOUND`/`fetch failed` 等 Node/undici 标准措辞)被同一 `catch` 接住 | `true`——这条路径的错误文本天然含 `econnrefused`/`enotfound`/`fetch failed`/`dns` 等词,`classifySearchFailure` 的网络故障正则能命中(与上一行"已拿到 HTTP 响应但非 2xx"是两条不同的失败路径,别混淆) |
1476
+
1477
+ ⚠️ **"坏 provider 静默回落"这条对调用方是否可观察,取决于部署 env 有没有配 backend**:上表第一行说
1478
+ "env 也没配 ⇒ 工具不装配、模型看不到这个工具"——那只是**部署 env 同样缺席**这一种情形。若部署 env
1479
+ **已经**配了合法 backend(例如 `WEB_SEARCH_PROVIDER=brave`),调用方 per-request 传一个拼错的 `provider`
1480
+ (如 `"searx"`)、或带着一个本想打到自建 SearXNG 的 `endpoint`,`settings.webSearch` 整段被丢弃、静默回落
1481
+ 到 env 的 brave backend——**WebSearch 工具照常挂载、照常可用**,查询实际发给了 env 配的 provider,不是
1482
+ 调用方以为自己指定的那个;工具存在这一事实本身**不能**证明 per-request 的 `provider`/`endpoint` 真的
1483
+ 生效了,两种情形(per-request 生效 / per-request 被静默丢弃回落 env)在壳侧不可判别(此条经独立复现验证,
1484
+ 见收车档)。
1485
+
1486
+ **`apiKey` 明文与 env 槽边界**:server 收到的 `settings.webSearch.apiKey` 是**明文字符串**,没有任何服务端
1487
+ 密钥槽位/引用间接——收到什么字符串就直接进 backend 闭包(`:326`,与 `WEB_SEARCH_API_KEY` 同一条消费路径,
1488
+ `src/plugins/web-search.ts:63-64`)。**server 本批不改受理面**:明文字段的形状维持原样。调用方(壳)如何在
1489
+ 自己机器上管理这份明文是调用方的事——例如 cli 壳侧的约定是在**调用方自己的环境**里按
1490
+ `SEMA_WEBSEARCH_KEY_<PROVIDER>` 这样的命名空间存放每个 provider 的 key,由壳在本地读出后把明文塞进请求体;
1491
+ 这纯粹是**客户端约定**,本仓不读取、不校验、也不感知任何这类客户端侧 env 命名(这条命名事实来自任务书,
1492
+ 本车未读 cli 源码核实,列入收车档「未闭环」)。
@@ -223,6 +223,17 @@ export const REBIND_LEGS = [
223
223
  residualKey: undefined,
224
224
  why: "resume 重放日志的 scope 守卫列 —— 与 run 行**同事务**(错配会让 load 守卫拒掉整条 resume)",
225
225
  },
226
+ {
227
+ leg: "workflow_agent_session#scope",
228
+ table: "workflow_agent_session",
229
+ kind: "bulk-rebind",
230
+ action: "bucket-rebind",
231
+ columns: ["scope"],
232
+ matchColumn: "scope",
233
+ encoding: "verbatim",
234
+ residualKey: undefined,
235
+ why: "S-185 workflow 出身 park 的 join 索引行的租户列(值 = 那条 run 的 `scope`,与 workflow_run/journal 同一个字节)——与它们**同族同事务**迁:不迁 ⇒ 索引行留在旧身份名下,而 run 行已迁,一次收编后人对这条 park 的批准结构上投递不出去",
236
+ },
226
237
  {
227
238
  leg: "workflow_resume_claim#scope",
228
239
  table: "workflow_resume_claim",
@@ -34,7 +34,7 @@ export declare function canBatchTransition(from: BatchState, to: BatchState): bo
34
34
  * ── `legKey`(取代数值 `leg`,一腿一凭据)─────────────────────────────────────────────────────────────
35
35
  * = `sha256(resume checkpoint token)` 的 hex,**首腿 = 空串**。一次 park→resume 的 token 就是这条腿的
36
36
  * 天然身份:同 token 重投 = 同一腿(幂等,正确);新 park ⇒ 新 token ⇒ 新腿 ⇒ 新 askId。
37
- * 取摘要而非原始 token 是因为 token 是能力凭据(本仓有 `stripCheckpointToken` 专门把它从可重放账本里
37
+ * 取摘要而非原始 token 是因为 token 是能力凭据(本仓有 `stripResumeToken` 专门把它从可重放账本里
38
38
  * 剥掉),而 `askId` 是 wire 可见值 ⇒ **只存/只喂摘要**。用它当轴顺带消掉了原裁需要的 `task_run` 加列
39
39
  * 与 `markResuming` 返回加宽(后者有第二个调用点 `http/routes/runs.ts:463`,加宽会误杀已成功 resume 的
40
40
  * running run)。残留边界:同一 run 内**不经 checkpoint token** 的重入形若未来出现会得到同一 legKey ——
@@ -328,6 +328,27 @@ declare const DenialLimitFallbackSchema: z.ZodObject<{
328
328
  autoDenyAfterMs: z.ZodNumber;
329
329
  }, z.core.$strict>;
330
330
  export type DenialLimitFallback = z.infer<typeof DenialLimitFallbackSchema>;
331
+ /** S-185(core 7.10.0 [ref])—— **分类器为什么答不了**。`cause` 是 core 的闭集词
332
+ * (`AUTO_MODE_UNAVAILABLE_CAUSES`;core 7.12.0 起 = error | timeout,`breaker_open` 随熔断族退役),
333
+ * 本仓**刻意不枚举**它:词表的单一
334
+ * 属主在 core,抄一份的形会在 core 加词那天把新词静默吞成缺席 —— 而丢的正是「这次不可用是新出现的那
335
+ * 一类」这条信息(与 `AskRequest.origin` 顶注同一条纪律)。判据只到「非空串」这一层形门。
336
+ * `.strict()` + 单成员必填:core 将来给这只对象加成员时本读面当场判假、**整键不铸**,新成员必须由人
337
+ * 处置过才上 wire;而一个**没有 cause** 的空壳等于说「分类器坏了但说不出为什么」,比不说更坏。 */
338
+ declare const ClassifierUnavailableSchema: z.ZodObject<{
339
+ cause: z.ZodString;
340
+ }, z.core.$strict>;
341
+ export type ClassifierUnavailable = z.infer<typeof ClassifierUnavailableSchema>;
342
+ /**
343
+ * `AskRequest.classifierUnavailable`(core 7.10.0 [ref])的**边界窄读** —— 活卡帧与 `card_json` 的唯一
344
+ * 铸造点(与 {@link readDenialLimitFallback} 同款分工),两面结构性同值。
345
+ *
346
+ * 🔴 **缺席不是断言**:缺席同时覆盖「分类器答上了」「这只 ask 没资格走分类器」「本部署没接分类器」
347
+ * 三形(core d.ts 逐字:read presence, never absence),消费端禁读成「分类器好着呢」。
348
+ * 🔴 **echo-only**:server 不据它做任何裁决 —— 熔断/超时的处置全在 core 的分类器站,据本键在本仓自铸
349
+ * 第二套回落判据就是同一语义面两个写者(源头修复纪律)。
350
+ */
351
+ export declare function readClassifierUnavailable(req: unknown): ClassifierUnavailable | undefined;
331
352
  /**
332
353
  * `AskRequest.denialLimitFallback`(core 7.4.0 [ref])的**边界窄读** —— 活卡帧与 `card_json` 的唯一铸造点
333
354
  * (与 {@link readRuleOffersAbsence} / {@link readProbeCause} 同款分工),两面结构性同值。
@@ -422,6 +443,9 @@ export declare const ApprovalCardSchema: z.ZodObject<{
422
443
  }>;
423
444
  autoDenyAfterMs: z.ZodNumber;
424
445
  }, z.core.$strict>>;
446
+ classifierUnavailable: z.ZodOptional<z.ZodObject<{
447
+ cause: z.ZodString;
448
+ }, z.core.$strict>>;
425
449
  probeCause: z.ZodOptional<z.ZodObject<{
426
450
  code: z.ZodString;
427
451
  roots: z.ZodObject<{
@@ -532,6 +556,9 @@ export declare const ApprovalCardEnvelopeSchema: z.ZodObject<{
532
556
  }>;
533
557
  autoDenyAfterMs: z.ZodNumber;
534
558
  }, z.core.$strict>>;
559
+ classifierUnavailable: z.ZodOptional<z.ZodObject<{
560
+ cause: z.ZodString;
561
+ }, z.core.$strict>>;
535
562
  probeCause: z.ZodOptional<z.ZodObject<{
536
563
  code: z.ZodString;
537
564
  roots: z.ZodObject<{
@@ -237,6 +237,14 @@ const DenialLimitFallbackSchema = z
237
237
  const _denialLimitFallbackConformance = [true, true];
238
238
  void _denialLimitFallbackConformance;
239
239
  const DenialLimitFallbackEnvelopeSchema = z.object({ denialLimitFallback: DenialLimitFallbackSchema.optional() });
240
+ const ClassifierUnavailableSchema = z.object({ cause: z.string().min(1).max(MAX_IDENT) }).strict();
241
+ const _classifierUnavailableConformance = [true];
242
+ void _classifierUnavailableConformance;
243
+ const ClassifierUnavailableEnvelopeSchema = z.object({ classifierUnavailable: ClassifierUnavailableSchema.optional() });
244
+ export function readClassifierUnavailable(req) {
245
+ const parsed = ClassifierUnavailableEnvelopeSchema.safeParse(req);
246
+ return parsed.success ? parsed.data.classifierUnavailable : undefined;
247
+ }
240
248
  export function readDenialLimitFallback(req) {
241
249
  const parsed = DenialLimitFallbackEnvelopeSchema.safeParse(req);
242
250
  return parsed.success ? parsed.data.denialLimitFallback : undefined;
@@ -261,6 +269,7 @@ export const ApprovalCardSchema = z
261
269
  ruleOffers: z.array(RuleOfferSchema).max(MAX_RULE_OFFERS).optional(),
262
270
  ruleOffersAbsence: RuleOffersAbsenceSchema.optional(),
263
271
  denialLimitFallback: DenialLimitFallbackSchema.optional(),
272
+ classifierUnavailable: ClassifierUnavailableSchema.optional(),
264
273
  probeCause: ProbeCauseSchema.optional(),
265
274
  ruleEvidence: RuleEvidenceSchema.optional(),
266
275
  fromSubagent: z.literal(true).optional(),
@@ -303,6 +312,7 @@ export function buildApprovalCard(source, req, requiresRealApproval) {
303
312
  const ruleEvidence = readRuleEvidence(req);
304
313
  const ruleOffersAbsence = readRuleOffersAbsence(req);
305
314
  const denialLimitFallback = readDenialLimitFallback(req);
315
+ const classifierUnavailable = readClassifierUnavailable(req);
306
316
  const toolCallId = clip(source.toolCallId, MAX_IDENT);
307
317
  const sourceTaskId = clip(source.sourceTaskId, MAX_IDENT);
308
318
  const sourceAgentName = clip(source.sourceAgentName, MAX_AGENT_NAME);
@@ -325,6 +335,7 @@ export function buildApprovalCard(source, req, requiresRealApproval) {
325
335
  ...(source.ruleOffers !== undefined && source.ruleOffers.length > 0 ? { ruleOffers: source.ruleOffers.map(copyRuleOffer) } : {}),
326
336
  ...(ruleOffersAbsence !== undefined ? { ruleOffersAbsence } : {}),
327
337
  ...(denialLimitFallback !== undefined ? { denialLimitFallback } : {}),
338
+ ...(classifierUnavailable !== undefined ? { classifierUnavailable } : {}),
328
339
  ...(source.fromSubagent === true ? { fromSubagent: true } : {}),
329
340
  ...(sourceTaskId !== undefined ? { sourceTaskId } : {}),
330
341
  ...(sourceAgentName !== undefined ? { sourceAgentName } : {}),
@@ -1,57 +1,3 @@
1
- /**
2
- * 流内审批协议([ref] §3.0)的**对账收敛器** —— [ref] 车5。
3
- *
4
- * 职责一句话:把 `PARKING`(窗到期中选、正在转投递面)这个**唯一的非终态中间态**收敛成
5
- * `PARKED | DENIED | VOID`,并在崩溃后补位那些没人打 expire 的孤儿 `STREAM_PENDING` 行。
6
- *
7
- * ── 判据表 v2(设计稿 §9 尾的五臂汇总,逐字落地;每臂注读口)────────────────────────────────────────
8
- * ① **身份三元组 ∧ hash 双等** ⇒ `bindBatch`(判别式返回;`ok:false` ⇒ 降级续判)
9
- * 身份 = `sourceTaskId` 相等 ∧ `toolCallId` 相等 ∧ **因果下界**(不是等式)`cp.createdAtMs ≥
10
- * ask.createdAtMs`;再 ∧ `cp.boundInputHash === ask.boundInputHash`。三维里只有前两维是等式,把时间
11
- * 那一维读成等式会让合法 park 几乎命不中。逐字实现在 {@link classifyGateMatch};判据的**唯一
12
- * 属主**是那个函数的头注,这里只列纲要,细则(祖先层 fold 否决、多候选取舍)不在此复述。
13
- * 🔴 两处易错,写在这里免得下一个人照旧口径改码:
14
- * · 承重的第一维是 **`sourceTaskId`**([ref] 件1,黑板 [ref]③①)——`sessionId` **不进身份等式**
15
- * (委派子代的 ask 落行记的是投递上下文的根会话,park 却发生在子代自己的 sessionId 上,只按
16
- * session 等值会张冠李戴),它在这一层只是读口 `findCheckpointCandidatesForAsk` 的入参。
17
- * ⚠️ 但别据此把它当无用键:{@link isAncestorFoldMint} 的祖先层否决判的正是
18
- * `sourceTaskId !== sessionId` —— 那是内存里的承重用法,只是不属于身份等式;
19
- * · hash **任一侧缺席一律不 bind**(硬相等不放宽,理由同下)。缺席的**归因**分两级,别写成一句:
20
- * 身份先判 —— 同身份候选一条都没有且候选集非空 ⇒ `identity_miss`(有对家但不是这一只,或读不出);
21
- * 只有在身份这一层没被判掉时,hash 缺席才落 `single_mint` 三形
22
- * (`ask_only` / `checkpoint_only` / `neither`)。`single_mint` 是可观测分类、不是放宽的命中;
23
- * 把「只有一侧铸过」这格结构事实混进 `identity_miss` 的噪声底,运维就读不出两者的区别。
24
- * 两道等式都不许放宽的原因不变(§9 C2:同 session 内 `toolCallId` 会被网关重用,身份不严会把旧 ask
25
- * PARK 到别人的 resume 坐标上,而 `PARKED` 是不可回滚的终态)。读口 = `findCheckpointCandidatesForAsk`
26
- * (§9 C4 窄谓词精确查,无分页假阴性);`unparseable` 候选**视同不匹配**(单行读不出不许打断整段
27
- * 扫描,§8 C-6)。
28
- * ② run 终局分臂(读口 `runStore.getRun`):`status ∈ {completed, failed, blocked}`(§8 A-1 词表修正 ——
29
- * `cancelled` 不是 run 状态,取消 = `failed` + `errorCode`)——
30
- * - `failed ∧ errorCode === "cancelled"`,或批行已 `ABORTED` ⇒ `VOID`(取消不是路由失败,§9 C3);
31
- * - 其余终局 ⇒ `DENIED(routing_failure_fail_closed)`。**全仓唯一的 DENIED 写点**(grep 钉)。
32
- * ②′ **bind-once 落选者**(codex round2 R2-3 增补,排在 ② 之前判):批已 `ROUTING_BOUND` 且中选者是
33
- * 兄弟 ⇒ `VOID(batch_bound_elsewhere)` —— 迟到进 PARKING 的行(`bindBatch` 的原子事务连坐不到它)
34
- * 结构上再也赢不了 bind,当下即可判;不判会让它滞留到 run 终局再被 ② 误报成路由失败。
35
- * ③ else(run 还在跑 / suspended / needs_review / 无行且不满足 ④)⇒ **保持 PARKING** + `deferReconcile`
36
- * touch(推 `updated_at_ms` 排到队尾,配合 `listByState` 的 `ORDER BY updated_at_ms ASC` 解队头堵塞,
37
- * §8 D-4 / §9 C5)。**超时永不产生终态 denial**(约束②)。
38
- * ④ adhoc 双谓词(`sessionId === taskId` ∧ `getRun` 无行,§8 C-2)∧ 窗过 + `ADHOC_GRACE` ⇒ `VOID`
39
- * (归因 `adhoc_leg_no_durable_domain`)。
40
- * ⑤ `createdAtMs` 量的 `ORPHAN_TTL` ⇒ `VOID(orphan_ttl_exceeded)` + warn ——「任何未在 TTL 内收敛的
41
- * PARKING」的最后兜底(§8 A-1 放宽形,不限「getRun 无行」),约束①(遗孤最终可判)。
42
- * 🔴 TTL 一律量 immutable 的 `createdAtMs`/`expiresAtMs`,**绝不量 `updatedAtMs`**(它被 ③ 的队列
43
- * 轮转每轮刷新,量它的 TTL 永不到期,§9 C5)。
44
- *
45
- * ── 三条铁则 ────────────────────────────────────────────────────────────────────────────────────
46
- * 1. **一经发布的终态不改义**:本模块只从 `PARKING`/`STREAM_PENDING` 出发,`PARKED/DECIDED/DENIED/VOID`
47
- * 的行永不再被碰(CAS 的 `WHERE state=?` 谓词是机器保证,不靠调用序自觉)。
48
- * 2. **幂等可重放**:两副本同扫无害——每条转移都是带 `from` 态的 CAS,单赢者;输者本轮什么都不做。
49
- * 3. **宁可不命中,绝不错配**:判别不出(hash 缺席 / 候选 `unparseable` / 列与 blob 矛盾)一律落 ②③⑤,
50
- * 绝不发一张别人的 resume 凭据。
51
- *
52
- * 命名(CLAUDE.md 工厂命名律):`decideReconcileAction`/`selectGateCandidate` 是纯判定函数;
53
- * `createApprovalReconciler` 返回带方法的活对象 ⇒ `create*`。
54
- */
55
1
  import type { AskRow, ApprovalAskStore } from "./plugins/approval-ask-store-sql.js";
56
2
  import type { BatchState } from "./approval-ask-machine.js";
57
3
  import type { CheckpointAskCandidate } from "./plugins/checkpoint-store-sql.js";
@@ -1,8 +1,8 @@
1
+ import { CANCELLED_CODE } from "./run-cancel-context.js";
1
2
  import { isTerminalRunStatus } from "./plugins/store-contracts.js";
2
3
  import { DENY_REASONS, VOID_REASONS } from "./approval-deny-reasons.js";
3
4
  import { buildRevokeFrame } from "./approval-card.js";
4
5
  import { encodeCheckpointScope } from "./security.js";
5
- const CANCELLED_ERROR_CODE = "cancelled";
6
6
  const RECONCILE_STORE_TIMEOUT_MS = 10_000;
7
7
  const RECONCILE_SEGMENT_BUDGET_MS = 30_000;
8
8
  function withDeadline(op, label, timeoutMs = RECONCILE_STORE_TIMEOUT_MS) {
@@ -64,7 +64,7 @@ export function decideReconcileAction(input) {
64
64
  return { kind: "void", reason: VOID_REASONS.BATCH_BOUND_ELSEWHERE };
65
65
  }
66
66
  if (run !== null && isTerminalRunStatus(run.status)) {
67
- if (run.status === "failed" && run.errorCode === CANCELLED_ERROR_CODE) {
67
+ if (run.status === "failed" && run.errorCode === CANCELLED_CODE) {
68
68
  return { kind: "void", reason: VOID_REASONS.RUN_CANCELLED };
69
69
  }
70
70
  return { kind: "deny", reason: DENY_REASONS.ROUTING_FAILURE };
package/dist/approval.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { isNamespacedCoveringRuleName, namespacedRuleNameCovers } from "@sema-agent/core";
2
+ import { operatorAsk } from "./operator-ask.js";
2
3
  import { isExplicitPosture } from "./posture-source.js";
3
4
  export function hasOperatorGateIntent(config) {
4
5
  return (config.approvalRequire.length > 0 ||
@@ -51,11 +52,11 @@ export function createDurableAskPolicy(opts) {
51
52
  if (deny(toolName))
52
53
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
53
54
  if (neverAuto(toolName))
54
- return { action: "ask", message: `human approval required to run "${req.toolName}"` };
55
+ return operatorAsk(`APPROVAL_NEVER_AUTO(${toolName})`, `human approval required to run "${req.toolName}"`);
55
56
  if (require(toolName)) {
56
57
  const remEarly = req.budget?.resourceRemainingMicroUsd;
57
58
  if (remEarly !== undefined && remEarly <= 0) {
58
- return { action: "ask", message: `durable resource budget exhausted — human approval required to run "${req.toolName}"` };
59
+ return operatorAsk("RESOURCE_SUSPEND budget exhausted", `durable resource budget exhausted — human approval required to run "${req.toolName}"`);
59
60
  }
60
61
  if (opts.exempt && toolName !== "AskUserQuestion") {
61
62
  return (async () => {
@@ -81,13 +82,13 @@ export function createDurableAskPolicy(opts) {
81
82
  function decideRequired(req, toolName) {
82
83
  const rem = req.budget?.resourceRemainingMicroUsd;
83
84
  if (rem !== undefined && rem <= 0) {
84
- return { action: "ask", message: `durable resource budget exhausted — human approval required to run "${req.toolName}"` };
85
+ return operatorAsk("RESOURCE_SUSPEND budget exhausted", `durable resource budget exhausted — human approval required to run "${req.toolName}"`);
85
86
  }
86
87
  if (toolName !== "AskUserQuestion" && budget > 0 && autoApproved < budget) {
87
88
  autoApproved++;
88
89
  return { action: "allow" };
89
90
  }
90
- return { action: "ask", message: `approval required to run "${req.toolName}"` };
91
+ return operatorAsk(`APPROVAL_REQUIRE(${toolName})`, `approval required to run "${req.toolName}"`);
91
92
  }
92
93
  }
93
94
  //# sourceMappingURL=approval.js.map
@@ -1,4 +1,4 @@
1
- import { CenterPromptSource, type Model } from "@sema-agent/core";
1
+ import { CenterPromptSource, type SwappableDeps } from "@sema-agent/core";
2
2
  import { buildPricing } from "../budget.js";
3
3
  import { type PromptsDomainFaces } from "../prompts-domain-validate.js";
4
4
  import { type Scenario, type ScenarioDeps, type ScenarioDetail } from "../capabilities/scenarios.js";
@@ -42,16 +42,15 @@ export interface ConfigCenterRuntime {
42
42
  /** 60s refresh cadence + boot-deferred 到货续接。
43
43
  * `limitSync`([ref] 批1)= 恒构造的两只限额座的换代口:commit 之后**零 await 内**推一把,与 pricing /
44
44
  * keyResolver 那两件派生重建同族(缺席 = 这条腿本部署没装配,例如 run-local)。
45
- * `swapRunnerModels`(core 5.49,[ref]①)= 模型面永久热的换装口:plane 变更候选在 commit 前喂给
46
- * 全部 boot Runner 原子换代;在场 ⇒ models-tiers defer/restart 臂退役,缺席 ⇒ 旧 defer 行为逐字保留。 */
45
+ * `swapRunnerDeps`(core 7.10.0 `Runner.swapDeps` 一扇门;5.49 起的 `swapModels` 口在 7.10.0 退役、
46
+ * 本参数同语义改名)= **可换席**的换装口:模型面 plane 变更候选在 commit 前喂给全部 boot Runner
47
+ * 原子换代(在场 ⇒ models-tiers defer/restart 臂退役,缺席 ⇒ 旧 defer 行为逐字保留);S-174 起
48
+ * read **face** 席也走同一只函数(见 refresh 拍里的 `applyCenterReadFace` 调用点)。 */
47
49
  startRefreshLoop(a: {
48
50
  runnerTierFrozen: boolean;
49
51
  pricing: ReturnType<typeof buildPricing>;
50
52
  limitSync?: LimitSync;
51
- swapRunnerModels?: (plane: {
52
- models: Record<string, Model>;
53
- tiers: Record<string, string>;
54
- }) => void;
53
+ swapRunnerDeps?: (next: SwappableDeps) => void;
55
54
  }): void;
56
55
  /** [ref]-2:停刷新环(hardShutdown 收尾链;幂等,未起环时 no-op)。 */
57
56
  stopRefreshLoop(): void;
@@ -328,7 +328,8 @@ export async function createConfigCenterRuntime(ctx) {
328
328
  }
329
329
  }
330
330
  }
331
- const readFaceEnvHeld = applyCenterReadFace(config, effective ?? { version: 0, updatedAt: "", models: { models: [], roles: {} } }, effective ? logger : undefined);
331
+ const readFaceBaseline = { ...(config.readFace !== undefined ? { readFace: config.readFace } : {}), readFaceSource: config.readFaceSource };
332
+ const readFaceEnvHeld = applyCenterReadFace(config, effective ?? { version: 0, updatedAt: "", models: { models: [], roles: {} } }, effective ? logger : undefined).envHeld;
332
333
  if (!modelReadyState.ready)
333
334
  logger.warn("model_roster_pending", {
334
335
  note: "CONFIG_REQUIRE_ROSTER=true and no center roster has landed yet — the env MODEL_ID is treated as a PLACEHOLDER, so billable submissions 503 (and /health reports ready:false) until the first effective pull lands ≥1 enabled model. Unset the knob if this worker's env model is authoritative.",
@@ -341,6 +342,26 @@ export async function createConfigCenterRuntime(ctx) {
341
342
  ...(readFaceEnvHeld.length > 0 ? { readFaceEnvHeld } : {}),
342
343
  ...(mcpRevocationWired && mcpRevocations.hasBaseline() ? { mcpRevocationLive: true } : {}),
343
344
  });
345
+ const applyLiveReadFace = (eff, swap, phase) => {
346
+ const before = config.readFace;
347
+ const beforeSource = config.readFaceSource;
348
+ const { faceChanged } = applyCenterReadFace(config, eff, logger, { phase: "live", baseline: readFaceBaseline });
349
+ if (!faceChanged)
350
+ return;
351
+ if (swap === undefined) {
352
+ logger.warn("read_face_swap_unwired", { face: config.readFace ?? null, phase, note: "the center's read FACE changed but no Runner swap door was wired — the value landed in config while the engine keeps the seat it booted with" });
353
+ return;
354
+ }
355
+ try {
356
+ swap({ readFace: config.readFace });
357
+ logger.info("read_face_swapped", { from: before ?? null, to: config.readFace ?? null, phase, note: "hot seat (core swapDeps) — legs that already prepared keep the face they prepared under" });
358
+ }
359
+ catch (err) {
360
+ config.readFace = before;
361
+ config.readFaceSource = beforeSource;
362
+ throw err;
363
+ }
364
+ };
344
365
  let ccTimer;
345
366
  return {
346
367
  providerKind: configProvider?.kind,
@@ -489,7 +510,7 @@ export async function createConfigCenterRuntime(ctx) {
489
510
  }
490
511
  },
491
512
  startRefreshLoop(a) {
492
- const { runnerTierFrozen, pricing, limitSync, swapRunnerModels } = a;
513
+ const { runnerTierFrozen, pricing, limitSync, swapRunnerDeps } = a;
493
514
  if (configProvider) {
494
515
  const ccRef = config.configCenter;
495
516
  let refreshInFlight = false;
@@ -555,7 +576,7 @@ export async function createConfigCenterRuntime(ctx) {
555
576
  }
556
577
  else {
557
578
  const planeChanged = modelPlaneChanged(appliedPlaneEff, r.effective);
558
- const planeDeferred = swapRunnerModels === undefined && (runnerTierFrozen || planeHasActiveTiers(r.effective)) && planeChanged;
579
+ const planeDeferred = swapRunnerDeps === undefined && (runnerTierFrozen || planeHasActiveTiers(r.effective)) && planeChanged;
559
580
  let applyReport;
560
581
  const committed = applyEffective(config, r.effective, logger, {
561
582
  teamsOnly: true,
@@ -566,7 +587,7 @@ export async function createConfigCenterRuntime(ctx) {
566
587
  applyLedger.recordApply(rep);
567
588
  },
568
589
  ...(planeDeferred ? { deferModelPlane: true } : {}),
569
- ...(swapRunnerModels !== undefined && planeChanged ? { swapPlane: swapRunnerModels } : {}),
590
+ ...(swapRunnerDeps !== undefined && planeChanged ? { swapPlane: swapRunnerDeps } : {}),
570
591
  });
571
592
  if (!committed)
572
593
  return "ok";
@@ -574,6 +595,7 @@ export async function createConfigCenterRuntime(ctx) {
574
595
  keyResolver = createKeyResolver(config.modelApiKeyEnv, process.env, config.modelApiKeys);
575
596
  limitSync?.sync();
576
597
  mcpRevocations.applyEnabledEntries(enabledMcpEntries(r.effective.mcp));
598
+ applyLiveReadFace(r.effective, swapRunnerDeps, "refresh");
577
599
  applyLedger.applied(tickGen, r.effective.version);
578
600
  if (planeDeferred) {
579
601
  logger.warn("models_tiers_plane_deferred", { version: r.effective.version, note: "tier-frozen Runner: the changed model plane (models/roles/tiers/default) is NOT hot-applied — admission stays on the Runner's generation; restart applies the new plane (models-tiers restart signal rides /health)" });
@@ -596,7 +618,7 @@ export async function createConfigCenterRuntime(ctx) {
596
618
  }
597
619
  const lkgPersisted = await persistLkgDurable(r.effective, r.etag);
598
620
  const reasons = restartReasons(effective, r.effective, restartCtx());
599
- if (swapRunnerModels !== undefined) {
621
+ if (swapRunnerDeps !== undefined) {
600
622
  const mi = reasons.indexOf("models-tiers");
601
623
  if (mi >= 0)
602
624
  reasons.splice(mi, 1);
@@ -701,7 +723,7 @@ export async function createConfigCenterRuntime(ctx) {
701
723
  return;
702
724
  }
703
725
  const planeChangedLate = modelPlaneChanged(appliedPlaneEff, r.effective);
704
- const planeDeferredLate = swapRunnerModels === undefined && (runnerTierFrozen || planeHasActiveTiers(r.effective)) && planeChangedLate;
726
+ const planeDeferredLate = swapRunnerDeps === undefined && (runnerTierFrozen || planeHasActiveTiers(r.effective)) && planeChangedLate;
705
727
  let applyReportLate;
706
728
  const committedLate = applyEffective(config, r.effective, logger, {
707
729
  teamsOnly: true,
@@ -712,13 +734,14 @@ export async function createConfigCenterRuntime(ctx) {
712
734
  applyLedger.recordApply(rep);
713
735
  },
714
736
  ...(planeDeferredLate ? { deferModelPlane: true } : {}),
715
- ...(swapRunnerModels !== undefined && planeChangedLate ? { swapPlane: swapRunnerModels } : {}),
737
+ ...(swapRunnerDeps !== undefined && planeChangedLate ? { swapPlane: swapRunnerDeps } : {}),
716
738
  });
717
739
  if (!committedLate)
718
740
  return;
719
741
  mutateInPlace(pricing, buildPricing(config.models));
720
742
  keyResolver = createKeyResolver(config.modelApiKeyEnv, process.env, config.modelApiKeys);
721
743
  limitSync?.sync();
744
+ applyLiveReadFace(r.effective, swapRunnerDeps, "boot-deferred");
722
745
  applyLedger.applied(lateGen, r.effective.version);
723
746
  if (planeDeferredLate)
724
747
  logger.warn("models_tiers_plane_deferred", { version: r.effective.version, note: "tier-frozen Runner (env tiers): the late-boot center model plane is NOT hot-applied — restart applies it" });
@@ -743,7 +766,7 @@ export async function createConfigCenterRuntime(ctx) {
743
766
  ccEtag = r.etag;
744
767
  const lkgPersistedLate = await persistLkgDurable(r.effective, r.etag);
745
768
  const reasons = restartReasons(undefined, r.effective, restartCtx());
746
- if (swapRunnerModels !== undefined) {
769
+ if (swapRunnerDeps !== undefined) {
747
770
  const miLate = reasons.indexOf("models-tiers");
748
771
  if (miLate >= 0)
749
772
  reasons.splice(miLate, 1);
@@ -50,7 +50,7 @@ export function createLeaderFace(ctx) {
50
50
  lockedConfig: governanceSeams.lockedConfig,
51
51
  retentionPolicy: governanceSeams.retentionPolicy,
52
52
  },
53
- deploymentPosture: buildDeploymentPostureSeats(config),
53
+ deploymentPosture: () => buildDeploymentPostureSeats(config),
54
54
  ...(ctx.tracer ? { tracer: ctx.tracer } : {}),
55
55
  ...(config.usageWindows && ctx.usageWindowStore
56
56
  ? { usageWindows: config.usageWindows, usageWindowStore: ctx.usageWindowStore }