@sema-agent/server 3.10.0 → 3.12.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.
package/USAGE.md CHANGED
@@ -59,10 +59,32 @@ MODEL_FIRST_TOKEN_TIMEOUT_MS=30000 # SSE 已开但迟迟不吐第一个 delta(
59
59
  MODEL_IDLE_TIMEOUT_MS=20000 # (1.40.1)出过 token 后中途卡死:每个 delta 重置,静默超时→中止(补 first-token 只管首字)
60
60
  # ④ 断路器:主网关连败 N 次即"开路"→ 快速失败,让 failover 立刻切备(不再逐个等超时)
61
61
  MODEL_CIRCUIT_BREAKER=true MODEL_CB_FAILURE_THRESHOLD=5 MODEL_CB_COOLDOWN_MS=30000
62
+ # ⑤ 每次调用的重试上限(0–20)。**不设 = 用引擎默认**(server 不再钉死它)
63
+ GATEWAY_MAX_RETRIES=10 # openai 兼容腿(第三方限流 provider 走的就是这条)
64
+ ANTHROPIC_MAX_RETRIES=10 # 云 Anthropic 腿
62
65
  ```
63
66
  - **全部默认关**(超时=0、断路器=false)→ 不设就和以前**逐字节一致**。
64
67
  - 断路器**只在配了 `MODEL_GATEWAY_FALLBACK_URLS`(≥2 路)时才有意义**——它的价值是"开路即快速失败 → failover 立刻切备";单网关下它是 no-op(启动日志 `circuitBreakerNoop` 会提示)。只 `network/server/rate_limit` 计入连败,`auth`/`invalid_request` 不计(坏 key 熔断整网关无意义)。备用网关(最后一路)不套断路器。
68
+ - **重试上限**(2026-07-31):两个键**不设就不传给引擎** —— 引擎默认当家。以前这里是 server 侧硬编码 `2`(openai 腿甚至零配置出口),而**显式传参压过引擎默认**,于是引擎抬默认对 server 部署毫无效果;第三方限流 provider 下"重试两次就放弃"正是由此而来。现在:不设=继承引擎默认(抬默认那天自动跟上),设了=按设的走。钉在 `test/brain.test.ts` 的「网关腿重试次数」两条上。
65
69
  - 断路器状态:配了 `SESSION_BACKEND=tidb` 时自动用**跨副本共享态**(TiDB `circuit_breaker` 表,写穿+刷新最终一致),否则进程内 Map。启动日志 `breakerState` 字段回显 `shared(tidb)`/`in-process`/`off`。详见 `design/27`。
70
+
71
+ **可选 — WebSearch 后端(core 只留注入口,不自带任何 provider)**
72
+ ```bash
73
+ WEB_SEARCH_PROVIDER=brave|tavily|searxng # 缺席 = 不装配(WebSearch 工具根本不挂,不是挂了报错)
74
+ WEB_SEARCH_API_KEY=… # brave/tavily 必填;只进 backend 闭包,永不进模型 prompt 或工具参数
75
+ WEB_SEARCH_ENDPOINT=https://searx.example # searxng 必填;brave/tavily 下是可选的 base-URL 覆盖(代理/测试)
76
+ WEB_SEARCH_MAX_RESULTS=10 # 1..20,返给模型的条数上限
77
+ WEB_SEARCH_TIMEOUT_MS=10000 # 单次搜索墙钟
78
+ ```
79
+ - 结果是**不可信输入**:core 会 `delimitUntrusted` 围栏并**重新施加** `allowed_domains`/`blocked_domains`
80
+ 地板,所以 backend 遵不遵守 `opts` 是优化不是正确性要求 —— 换 provider(包括换成自建 SearXNG)
81
+ 不会削弱域名地板。
82
+ - 🔴 **`WEB_SEARCH_ENDPOINT` 的语义是「引擎可达的地址」,不是「用户机器上可达的地址」**
83
+ (2026-07-31 与 cli 对齐的跨机契约句):壳(TUI/桌面/web)与引擎**可以不在同一台机器**。壳在用户
84
+ 笔记本上起的 localhost SearXNG,云端 worker 够不着 —— 所以「本地 SearXNG 兜底」这一档**只适用
85
+ 壳自 spawn 本地引擎的同机形**;壳连接远程引擎时该档整级跳过,由运维在**引擎侧**配 `WEB_SEARCH_*`。
86
+ 用户手填一个集中式 SearXNG 地址是合法形,照常放行。
87
+
66
88
  - **大工具结果落盘**(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`。
67
89
 
68
90
  **可选 — 成本计量 + 预算闸(core 1.37)**
@@ -9,6 +9,26 @@
9
9
  */
10
10
  import { type ToolPolicy } from "@sema-agent/core";
11
11
  import type { ApprovalStore } from "./plugins/store-backend.js";
12
+ /**
13
+ * 运维**是否表达了门意图** —— boot 的单用户 allow-all 基线只在"零门意图"时才允许铺开,
14
+ * 所以这个谓词漏一格 = 那一格的意图被 allow-all 静默吞掉。
15
+ *
16
+ * 🔴 由来(2026-07-31 缝合审):这判据本来是 `main.ts` 里的一行内联表达式,只枚举了
17
+ * requireApproval / deny / durableApproval **三格**,漏了 `approvalNeverAuto`。漏的根因不是手滑,
18
+ * 是 `config-types.ts` 当时把 neverAuto 描述成 "Subset of approvalRequire" —— 若真是子集,
19
+ * 枚举 requireApproval 就覆盖了它。但本文件 inv#2 的执行面逐字相反(neverAuto ⊄ requireApproval),
20
+ * 于是「单用户 + 只配 APPROVAL_NEVER_AUTO=Bash」这一**语义完全合理**的形被判成零门意图 ⇒
21
+ * 落进 `createAllowDenyPolicy({})` 全面放行 ⇒ 那张"永远问人"的表从未进入任何 policy,Bash 直接跑。
22
+ *
23
+ * 判定挪到**与 inv#2 同一个文件**就是为了这个:两者再想漂开,得有人同时改这两段。
24
+ * 新增任何"门意图"配置项时,这里必须同步加一格(下面的表驱动测试会点名漏的那格)。
25
+ */
26
+ export declare function hasOperatorGateIntent(config: {
27
+ approvalRequire: readonly string[];
28
+ approvalDeny: readonly string[];
29
+ approvalNeverAuto: readonly string[];
30
+ durableApproval: boolean;
31
+ }): boolean;
12
32
  /**
13
33
  * Durable approval policy (core 1.67 / design/45): returns three-state **`ask`** for gated tools instead of
14
34
  * polling. With `TaskSpec.durableApproval` set + a `checkpointStore` on the Runner, core turns that `ask` into
package/dist/approval.js CHANGED
@@ -8,6 +8,26 @@
8
8
  * dropped at the task deadline instead of holding the worker — design/06 F4 self-protection.
9
9
  */
10
10
  import { createApprovalPolicy, canonicalToolName, uuidv7 } from "@sema-agent/core";
11
+ /**
12
+ * 运维**是否表达了门意图** —— boot 的单用户 allow-all 基线只在"零门意图"时才允许铺开,
13
+ * 所以这个谓词漏一格 = 那一格的意图被 allow-all 静默吞掉。
14
+ *
15
+ * 🔴 由来(2026-07-31 缝合审):这判据本来是 `main.ts` 里的一行内联表达式,只枚举了
16
+ * requireApproval / deny / durableApproval **三格**,漏了 `approvalNeverAuto`。漏的根因不是手滑,
17
+ * 是 `config-types.ts` 当时把 neverAuto 描述成 "Subset of approvalRequire" —— 若真是子集,
18
+ * 枚举 requireApproval 就覆盖了它。但本文件 inv#2 的执行面逐字相反(neverAuto ⊄ requireApproval),
19
+ * 于是「单用户 + 只配 APPROVAL_NEVER_AUTO=Bash」这一**语义完全合理**的形被判成零门意图 ⇒
20
+ * 落进 `createAllowDenyPolicy({})` 全面放行 ⇒ 那张"永远问人"的表从未进入任何 policy,Bash 直接跑。
21
+ *
22
+ * 判定挪到**与 inv#2 同一个文件**就是为了这个:两者再想漂开,得有人同时改这两段。
23
+ * 新增任何"门意图"配置项时,这里必须同步加一格(下面的表驱动测试会点名漏的那格)。
24
+ */
25
+ export function hasOperatorGateIntent(config) {
26
+ return (config.approvalRequire.length > 0 ||
27
+ config.approvalDeny.length > 0 ||
28
+ config.approvalNeverAuto.length > 0 || // ← 漏了这格,见上面的由来
29
+ config.durableApproval);
30
+ }
11
31
  /**
12
32
  * Durable approval policy (core 1.67 / design/45): returns three-state **`ask`** for gated tools instead of
13
33
  * polling. With `TaskSpec.durableApproval` set + a `checkpointStore` on the Runner, core turns that `ask` into
package/dist/brain.js CHANGED
@@ -48,10 +48,15 @@ export function createBrain(config, deps = {}) {
48
48
  firstTokenTimeoutMs: r.firstTokenTimeoutMs,
49
49
  idleTimeoutMs: r.idleTimeoutMs,
50
50
  };
51
+ // 🔴 重试上限:**配了才传**。此前这里(和下面 degrade fallback 那处)硬编码 `maxRetries: 2`,而显式传参
52
+ // 压过引擎默认 ⇒ core 把默认抬到 10 对 server 部署零效果。缺席不传之后引擎默认当家、抬默认自动继承。
53
+ // (五报清查 R1① 的 server 半场:限流 provider 走的正是这条 openai 兼容腿,而它此前**零配置出口**——
54
+ // 同文件的 anthropic 腿反倒早有 `ANTHROPIC_MAX_RETRIES`。钉在 test/brain.test.ts 的两条上。)
55
+ const retryCap = config.gatewayMaxRetries !== undefined ? { maxRetries: config.gatewayMaxRetries } : {};
51
56
  // Local openai-compatible gateway stack: primary + optional same-protocol fallbacks (failover).
52
57
  const routes = [config.gatewayBaseUrl, ...config.gatewayFallbackUrls];
53
58
  const openaiBrains = routes.map((baseUrl, i) => {
54
- const brain = createOpenAIBrain({ baseUrl, apiKey: config.gatewayApiKey, maxRetries: 2, fetchImpl, ...timeouts });
59
+ const brain = createOpenAIBrain({ baseUrl, apiKey: config.gatewayApiKey, ...retryCap, fetchImpl, ...timeouts });
55
60
  // Breaker on every route EXCEPT the last (the bare last-resort backup), and only with ≥2 routes —
56
61
  // a breaker without a failover alternative would just hard-fail the only gateway during cooldown.
57
62
  const isLast = i === routes.length - 1;
@@ -82,7 +87,8 @@ export function createBrain(config, deps = {}) {
82
87
  baseUrl: config.anthropic.baseUrl,
83
88
  version: config.anthropic.version,
84
89
  cacheBreakpoints: config.anthropic.cacheBreakpoints,
85
- maxRetries: config.anthropic.maxRetries,
90
+ // 同 retryCap:配了才传(`ANTHROPIC_MAX_RETRIES` 缺席时 config 里就没这个键)
91
+ ...(config.anthropic.maxRetries !== undefined ? { maxRetries: config.anthropic.maxRetries } : {}),
86
92
  fetchImpl,
87
93
  ...timeouts,
88
94
  })
@@ -138,7 +144,7 @@ export function createBrain(config, deps = {}) {
138
144
  get apiKey() {
139
145
  return fbApiKey();
140
146
  },
141
- maxRetries: 2,
147
+ ...retryCap, // 与主路同源:配了才传,没配让引擎默认当家(见上面 retryCap 的注)
142
148
  fetchImpl,
143
149
  ...timeouts,
144
150
  });
@@ -64,6 +64,13 @@ export interface ServiceConfigFlat {
64
64
  /** Secondary openai-compatible gateways for cross-gateway FAILOVER (same protocol + same model id).
65
65
  * Tried in order after the primary when a gateway fails before streaming any content. */
66
66
  gatewayFallbackUrls: string[];
67
+ /** `GATEWAY_MAX_RETRIES` —— openai 兼容腿的**每次调用**重试上限。
68
+ *
69
+ * 🔴 **缺席 = 不传给引擎**(不是"传个默认值"):此前这里是硬编码 `maxRetries: 2`,而显式传参**压过**
70
+ * 引擎默认 ⇒ core 抬默认对 server 部署一点效果都没有。缺席不传之后,引擎默认当家、core 抬默认自动继承。
71
+ * (五报清查 R1① 的 server 半场,黑板 [2161]/[2162];anthropic 腿早有 `ANTHROPIC_MAX_RETRIES` 出口,
72
+ * 这条只是把同样的出口补给限流 provider 真正走的那条腿。) */
73
+ gatewayMaxRetries?: number;
67
74
  /** Optional Anthropic Messages API ROUTE (cloud). Selected for models whose `provider` is
68
75
  * "anthropic" (routing is by model.provider); the local gateway serves every other provider. */
69
76
  anthropic?: {
@@ -79,7 +86,9 @@ export interface ServiceConfigFlat {
79
86
  version?: string;
80
87
  /** Prompt-cache breakpoints on system + last tool (stable prefix). Default true. */
81
88
  cacheBreakpoints: boolean;
82
- maxRetries: number;
89
+ /** `ANTHROPIC_MAX_RETRIES` —— **缺席不铸键**,同 `gatewayMaxRetries` 的理由(见其顶注):
90
+ * env 没配时以前会铸成 `2` 再显式传给引擎,把引擎默认钉死在 server 这一层。 */
91
+ maxRetries?: number;
83
92
  };
84
93
  /** Resilience brain stack (1.38, design/27): tiered timeouts + an optional circuit breaker layered
85
94
  * UNDER the existing failover (gatewayFallbackUrls). Every field defaults to off/disabled, so the
@@ -484,9 +493,17 @@ export interface ServiceConfigFlat {
484
493
  * asks, the human only sees the rest". `APPROVAL_AUTO_BUDGET`. */
485
494
  approvalAutoBudget: number;
486
495
  /** design/80 D-E (inv#2 — safety asks are never budgetable): gated tools that ALWAYS require a human even when
487
- * the auto-budget is on — the "irreversible / never auto-approve" set. Subset of approvalRequire; empty = the
488
- * budget may auto-approve any required tool. (In-service every gated ask is gate.kind="human", so this set is
489
- * the ONLY way to keep an irreversible tool human-gated under an auto-budget.) `APPROVAL_NEVER_AUTO`. */
496
+ * the auto-budget is on — the "irreversible / never auto-approve" set. empty = the budget may auto-approve any
497
+ * required tool. (In-service every gated ask is gate.kind="human", so this set is the ONLY way to keep an
498
+ * irreversible tool human-gated under an auto-budget.) `APPROVAL_NEVER_AUTO`.
499
+ *
500
+ * 🔴 **NOT required to be a subset of `approvalRequire`**(2026-07-31 修口)。这里原来写的是
501
+ * "Subset of approvalRequire",而 `approval.ts` 的 inv#2 执行面逐字相反:
502
+ * 「even if it was not also listed in requireApproval — the footgun: neverAuto ⊄ requireApproval」。
503
+ * 两处文档互相矛盾**不是文字问题**:`main.ts` 的 `operatorGateIntent` 是照着"子集"那句写的
504
+ * ——若真是子集,枚举 `approvalRequire.length > 0` 就覆盖了它,漏掉看着无害。实际不是子集,于是
505
+ * 「只配 APPROVAL_NEVER_AUTO」这一形被判成"零门意图" ⇒ 落进单用户 allow-all 基线 ⇒ 那张表
506
+ * 从未进入任何 policy。判定现在单源在 `hasOperatorGateIntent()`(与 inv#2 同文件)。 */
490
507
  approvalNeverAuto: string[];
491
508
  /** design/80 D-G (HMAC = integrity, not identity; ✅ WIRED — the "SCAFFOLD, not yet wired" note here was STALE,
492
509
  * corrected 2026-07-25: consumed by `verifyDirectDoorProof` → `verifyApprovalHmac`, reached from the live decide
@@ -865,7 +882,7 @@ export interface ServiceConfigFlat {
865
882
  /** 组:store(持久化)—— DB 引擎三态、session 后端、SQL coords、快照 BLOB / SendUserFile 对象存储。 */
866
883
  export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "sessionCacheTtlSec" | "rewindSnapshotMaxMb" | "dbBackend" | "dbBackendExplicit" | "localDataRoot" | "tidb" | "pg" | "dbQueryTimeoutMs" | "snapshotBlobStore" | "snapshotBlobSqlMaxBytes" | "snapshotBlobAllowSql" | "sendUserFile">;
867
884
  /** 组:modelPlane(模型面)—— 网关坐标、Anthropic 路线、韧性旋钮、主/廉价 model entry、role 表、降级梯。 */
868
- export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "cascadeLadder" | "degrade">;
885
+ export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "cascadeLadder" | "degrade">;
869
886
  /** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
870
887
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
871
888
  export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalPollMs" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "toolApprovalEnabled" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
package/dist/config.js CHANGED
@@ -670,6 +670,9 @@ function parseModelDomain() {
670
670
  gatewayBaseUrl,
671
671
  gatewayApiKey: process.env.MODEL_API_KEY,
672
672
  gatewayFallbackUrls: csv("MODEL_GATEWAY_FALLBACK_URLS"),
673
+ // 缺席**不铸键**(不是铸个 2):见 config-types 的 `gatewayMaxRetries` 顶注 —— 缺席时 brain.ts 不把
674
+ // 这个字段传给引擎,引擎默认当家。铸个默认值等于把「server 钉死引擎默认」这个病换个地方犯。
675
+ ...(process.env.GATEWAY_MAX_RETRIES ? { gatewayMaxRetries: numEnvBounded("GATEWAY_MAX_RETRIES", "2", 0, 20) } : {}),
673
676
  // Anthropic-compatible-ecosystem env compat (2026-07-03): the route now assembles when EITHER credential
674
677
  // is present — `ANTHROPIC_API_KEY` (x-api-key) or `ANTHROPIC_AUTH_TOKEN` (Authorization: Bearer; many such
675
678
  // providers accept only this). AUTH_TOKEN wins when both are set. Base URL reads the widely-adopted
@@ -683,7 +686,7 @@ function parseModelDomain() {
683
686
  baseUrl: firstSetEnv("ANTHROPIC_BASEURL", "ANTHROPIC_BASE_URL"),
684
687
  version: process.env.ANTHROPIC_VERSION,
685
688
  cacheBreakpoints: (process.env.ANTHROPIC_CACHE_BREAKPOINTS ?? "true") === "true",
686
- maxRetries: Number(env("ANTHROPIC_MAX_RETRIES", "2")),
689
+ ...(process.env.ANTHROPIC_MAX_RETRIES ? { maxRetries: Number(env("ANTHROPIC_MAX_RETRIES", "2")) } : {}),
687
690
  }
688
691
  : undefined,
689
692
  resilience: {
@@ -1211,7 +1214,7 @@ const STORE_GROUP_KEYS = [
1211
1214
  "tidb", "pg", "dbQueryTimeoutMs", "snapshotBlobStore", "snapshotBlobSqlMaxBytes", "snapshotBlobAllowSql", "sendUserFile",
1212
1215
  ];
1213
1216
  const MODEL_PLANE_GROUP_KEYS = [
1214
- "gatewayBaseUrl", "gatewayApiKey", "gatewayFallbackUrls", "anthropic", "resilience", "model", "models",
1217
+ "gatewayBaseUrl", "gatewayApiKey", "gatewayFallbackUrls", "gatewayMaxRetries", "anthropic", "resilience", "model", "models",
1215
1218
  "modelApiKeyEnv", "modelApiKeys", "modelQuotaWeights", "tiers", "projects", "roles", "cascadeLadder", "degrade",
1216
1219
  ];
1217
1220
  const APPROVAL_GROUP_KEYS = [
@@ -10,5 +10,16 @@
10
10
  */
11
11
  import type { IncomingMessage, ServerResponse } from "node:http";
12
12
  import type { RouteCtx } from "../route-ctx.js";
13
+ /**
14
+ * `POST /v1/sessions/:id/wake` 的路由形 —— **单源导出**,因为它有两个消费者:本模块的分发,
15
+ * 和 `server.ts` 的 `isBillableSubmitPath`(wake 会烧模型,必须吃 drain / model-roster-pending /
16
+ * 无 service-token 三道提交门)。
17
+ *
18
+ * 🔴 为什么提成常量:2026-07-31 的缝合审查出这两处**曾经不同步** —— 匹配形写死在本文件里,
19
+ * 名单在 server.ts 里,wake 从来没进过名单,于是同一条 `driveResumeIntoRunLog` 模型腿
20
+ * 从 `/v1/assistant/tasks/:id/resume` 进来受三道门管、从 wake 进来一道都不受。
21
+ * 共用一个常量之后,再想漏就得两处一起改。
22
+ */
23
+ export declare const SESSION_WAKE_RE: RegExp;
13
24
  export declare function handleNotifyWake(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
14
25
  //# sourceMappingURL=notify-wake.d.ts.map
@@ -1,6 +1,17 @@
1
1
  import { taskNotificationInboxEntry } from "../../orchestration/workflow-completion-inbox.js";
2
2
  import { sendJson, sendError } from "../send.js";
3
3
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
4
+ /**
5
+ * `POST /v1/sessions/:id/wake` 的路由形 —— **单源导出**,因为它有两个消费者:本模块的分发,
6
+ * 和 `server.ts` 的 `isBillableSubmitPath`(wake 会烧模型,必须吃 drain / model-roster-pending /
7
+ * 无 service-token 三道提交门)。
8
+ *
9
+ * 🔴 为什么提成常量:2026-07-31 的缝合审查出这两处**曾经不同步** —— 匹配形写死在本文件里,
10
+ * 名单在 server.ts 里,wake 从来没进过名单,于是同一条 `driveResumeIntoRunLog` 模型腿
11
+ * 从 `/v1/assistant/tasks/:id/resume` 进来受三道门管、从 wake 进来一道都不受。
12
+ * 共用一个常量之后,再想漏就得两处一起改。
13
+ */
14
+ export const SESSION_WAKE_RE = /^\/v1\/sessions\/[^/]+\/wake$/;
4
15
  export async function handleNotifyWake(req, res, url, ctx) {
5
16
  const miss = { fell: false };
6
17
  await handleNotifyWakeBody(req, res, url, ctx, miss);
@@ -112,7 +123,7 @@ async function handleNotifyWakeBody(req, res, url, ctx, miss) {
112
123
  sendJson(res, 202, { sessionId: notifySession, delivery: "parked", note: "no live stream — parked in the session inbox; drained as a task_notification on the next stream open" });
113
124
  return;
114
125
  }
115
- if (req.method === "POST" && /^\/v1\/sessions\/[^/]+\/wake$/.test(url)) {
126
+ if (req.method === "POST" && SESSION_WAKE_RE.test(url)) {
116
127
  // design/144 §3(core 1.283 wake arm):对 task_done 纯 park 的「消息唤醒」。非门决策——不 allow/deny
117
128
  // 任何 pending 动作,只解除 park 并把消息(或已 park 的 pendingSteer)作为续跑首轮输入。有未决门 =
118
129
  // core `wake.gate_pending` 拒(永不绕审批);空 park + 无 message = `wake.nothing_to_deliver` 拒。
@@ -34,7 +34,7 @@ import { handleAttachments } from "./routes/attachments.js";
34
34
  import { handleFleet } from "./routes/fleet.js";
35
35
  import { handleTraceUsage } from "./routes/trace-usage.js";
36
36
  import { handleWorkflows, handleWorkflowAgentSteer } from "./routes/workflows.js";
37
- import { handleNotifyWake } from "./routes/notify-wake.js";
37
+ import { handleNotifyWake, SESSION_WAKE_RE } from "./routes/notify-wake.js";
38
38
  import { handleApprovalsAssistant, streamApprovals, isQuestionAnswer, ASSISTANT_RESUME_RE, ASSISTANT_PLAN_REVIEW_RE } from "./routes/approvals-assistant.js";
39
39
  export { streamApprovals, isQuestionAnswer };
40
40
  import { handleRuns, handleRunVerbs, RUN_SUBAGENT_RESUME_RE } from "./routes/runs.js";
@@ -415,7 +415,12 @@ export function createHttpServer(rawDeps) {
415
415
  }
416
416
  const { authToken: t, metricsToken: mt } = deps.config;
417
417
  const anyT = Boolean(t) || Object.keys(deps.config.authTokens ?? {}).length > 0;
418
- const ok = (!anyT && !mt) || (anyT && systemFor(req, deps.config) !== undefined) || (!!mt && authorized(req, mt));
418
+ // 🔴 第一臂的 `!requirePrincipal` 2026-07-31 缝合审补的:同族单源 `isFleetWide` 早就带着它
419
+ // (连同那段「多租户下 token 的缺席不得开放全网」的审计注释),这一处停在了旧形。要紧在于
420
+ // `/metrics/plan-cache` 的 dump **按 principal 分键** ⇒ 它是租户名册而不是聚合数;而「多租 + 无
421
+ // service token + 无 metrics token」并不是 boot 会拒的形(main.ts 只 warn)。dev-open 只在单用户形成立。
422
+ const devOpen = !anyT && !mt && !deps.config.requirePrincipal;
423
+ const ok = devOpen || (anyT && systemFor(req, deps.config) !== undefined) || (!!mt && authorized(req, mt));
419
424
  if (!ok) {
420
425
  sendError(res, 401, "auth.unauthorized", "unauthorized");
421
426
  return;
@@ -2447,6 +2452,11 @@ function isBillableSubmitPath(url) {
2447
2452
  ASSISTANT_RESUME_RE.test(url) || // resource_limit/preempt resume runs the model → BILLABLE (preempt is not)
2448
2453
  ASSISTANT_PLAN_REVIEW_RE.test(url) || // plan_review approve/edit resumes + runs the model → BILLABLE
2449
2454
  RUN_SUBAGENT_RESUME_RE.test(url) || // 对抗复查 B-2(HIGH):subagent revive (handle.resume) STARTS model work → BILLABLE
2455
+ // 缝合审 2026-07-31:wake 走的是 assistant resume 那条同一个 `driveResumeIntoRunLog` 驱动腿
2456
+ // (`routes/notify-wake.ts` 头注:「wake 会**烧模型**(续跑)」),resume 在名单里而它一直不在 ⇒
2457
+ // 同一条模型腿从 wake 进来时 drain / model-roster-pending / 无 service-token 三道门全不生效。
2458
+ // 正则从 notify-wake 单源导入,免得又漂开。钉在 test/drain.test.ts。
2459
+ SESSION_WAKE_RE.test(url) ||
2450
2460
  url.startsWith("/v1/approvals"));
2451
2461
  }
2452
2462
  // ⚠️ SSE 帧纪律(2026-07-12):center BFF 中继凭「data 无 type 字段」识别心跳帧并吞掉
@@ -2503,6 +2513,10 @@ const ROUTE_LABEL_LITERALS = new Set([
2503
2513
  "/v1/assistant/inbox", "/v1/assistant/tasks", "/v1/usage", "/v1/policy", "/v1/capabilities",
2504
2514
  "/v1/images", "/v1/images/bakes", "/v1/images/bakes/claim", "/v1/images/select", "/v1/images/register",
2505
2515
  "/v1/workflows", "/v1/attachments", "/v1/leader", "/v1/outcomes", "/v1/side-query",
2516
+ // 2026-07-31 缝合审:它一直不在表里,而它是**每个壳一条的长连接** —— 那种 duration 落进 `other`
2517
+ // 桶会把该桶的 P99 拉爆,运营看不出是谁。批1 建表时漏掉它不是遗忘:fleet 域用 `pathname === …` 判路,
2518
+ // 名册门的枚举器当时只认 `url === …`,整条路由对门隐形(枚举器已一并补上)。
2519
+ "/v1/fleet/stream",
2506
2520
  "/v1/memory/export", "/v1/sendfile-links",
2507
2521
  ]);
2508
2522
  /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`). */
package/dist/main.js CHANGED
@@ -14,7 +14,7 @@ import { ensureChildSessionDurableWithPromotion } from "./plugins/session-store.
14
14
  import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js";
15
15
  import { webSearchConfigFromEnv, createWebSearchBackend, setWebSearchBadPayloadObserver } from "./plugins/web-search.js";
16
16
  import { createAuthorizer } from "./security.js";
17
- import { createDurableAskPolicy } from "./approval.js";
17
+ import { createDurableAskPolicy, hasOperatorGateIntent } from "./approval.js";
18
18
  import { loadSkills } from "./capabilities/skills.js";
19
19
  import { GiteaClient } from "./capabilities/repo-tools.js";
20
20
  import { buildScenarios, builtinScenarioDetails } from "./capabilities/scenarios.js";
@@ -30,6 +30,7 @@ import { performMemorySync } from "./memory-sync.js";
30
30
  import { startOtlpExporter } from "./observability/otel-exporter.js";
31
31
  import { HEARTBEAT_MS, backgroundAgentOutput, taskHandleOutput, taskHandleStop } from "./runs.js";
32
32
  import { SQL_BLOB_DEFAULT_MAX_BYTES } from "./plugins/blob-backend.js";
33
+ import { counterStoreLabel } from "./plugins/store-backend.js";
33
34
  import { composeHooks } from "./hooks/hook-runner.js";
34
35
  import { createHookLlm } from "./hooks/hook-llm.js";
35
36
  import { startFleetClientFromEnv } from "./fleet-client.js";
@@ -367,7 +368,8 @@ async function main() {
367
368
  // backing store is wired (e.g. DB_BACKEND=local → no approvalStore/checkpointStore ⇒ approvalEnabled/durableEnabled
368
369
  // false), that is a MISCONFIG — do NOT mask it with allow-all; leave toolPolicy undefined so core's UNGATED warning
369
370
  // still surfaces it. So the baseline needs single-user turnkey ∧ zero expressed gate intent.
370
- const operatorGateIntent = config.approvalRequire.length > 0 || config.approvalDeny.length > 0 || config.durableApproval;
371
+ // 单源在 approval.ts(与 inv#2 同文件)—— 这里原是内联的三格枚举,漏了 approvalNeverAuto,详见该函数顶注。
372
+ const operatorGateIntent = hasOperatorGateIntent(config);
371
373
  const singleUserAutoAcceptBaseline = config.requirePrincipal !== true && !operatorGateIntent;
372
374
  // design/45 durable-checkpoint (core 1.67): opt-in (DURABLE_APPROVAL + a pool). When on, F4 uses durable
373
375
  // suspend/resume instead of the poll gate — a gated `ask` suspends the task (status:"suspended" + checkpoint)
@@ -869,9 +871,9 @@ async function main() {
869
871
  memory: memoryEngine ? `engine(${memoryEngine.root})` : "off",
870
872
  instanceId,
871
873
  rateLimitPerMin: config.rateLimitPerMin,
872
- rateLimit: config.rateLimitPerMin > 0 ? (backend ? `shared()` : "in-process") : "off",
874
+ rateLimit: config.rateLimitPerMin > 0 ? counterStoreLabel(backend?.rateLimiter, backend?.kind) : "off",
873
875
  maxPrincipalCostUsd: config.maxPrincipalCostUsd || null,
874
- costQuota: config.maxPrincipalCostUsd > 0 ? (backend ? `shared()` : "in-process") : "off",
876
+ costQuota: config.maxPrincipalCostUsd > 0 ? counterStoreLabel(backend?.costQuota, backend?.kind) : "off",
875
877
  // durable offload store for large tool results (cross-replica wake); else core's in-process default
876
878
  toolResultStore: toolResultStore ? (backend?.kind === "local" ? "file(local)" : "shared(sql)") : "in-process",
877
879
  otel: config.otel ? config.otel.endpoint : null,
@@ -186,6 +186,19 @@ export declare function assertCloudSnapshotBlobPosture(kind: "mysql" | "pg" | "l
186
186
  * field the 20G-workspace case needed; more knobs when a case shows up. Exported for tests
187
187
  * (fractional-MB rounding + partial-merge semantics need a regression lock). */
188
188
  export declare function snapshotBoundsFromConfig(config: ServiceConfig): FileSnapshotBounds;
189
+ /**
190
+ * 启动日志里「跨副本 or 进程内」那一格的标签。
191
+ *
192
+ * 🔴 判据必须是**那个 store 本身在不在**,不是「有没有 backend」(2026-07-31 缝合审)。
193
+ * `LocalBackend` 明确省略 `costQuota`/`rateLimiter`(见它类尾那行 "intentionally absent"),
194
+ * 而 `DB_BACKEND=local` 是**裸 boot 的默认值** —— 原来 main.ts 那两行用 `backend ? "shared()" : …` 判,
195
+ * 于是单机默认形配了 `RATE_LIMIT_PER_MIN` 会打出 `shared()`,实际跑的是每副本一份的进程内限流器。
196
+ * 运维扩到 N 副本时读这行判定"限流是跨副本的",真实额度是 N 倍。
197
+ * (同一个说谎病族在紧邻的 `breakerState` 上修过一次——"was hard-coded 'shared(tidb)' — lied under
198
+ * DB_BACKEND=pg"——`session:` 也跟着修了,这两行没跟上。顺带:原来的 `shared()` 括号是空的,
199
+ * 而兄弟行都写 `shared(${kind})`。)
200
+ */
201
+ export declare function counterStoreLabel(store: unknown, kind: string | undefined): string;
189
202
  /** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily; the
190
203
  * reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured. */
191
204
  export declare function createStoreBackend(config: ServiceConfig): StoreBackend | undefined;
@@ -276,6 +276,21 @@ class LocalBackend {
276
276
  mysqlPool() { return undefined; }
277
277
  pgPool() { return undefined; }
278
278
  }
279
+ /**
280
+ * 启动日志里「跨副本 or 进程内」那一格的标签。
281
+ *
282
+ * 🔴 判据必须是**那个 store 本身在不在**,不是「有没有 backend」(2026-07-31 缝合审)。
283
+ * `LocalBackend` 明确省略 `costQuota`/`rateLimiter`(见它类尾那行 "intentionally absent"),
284
+ * 而 `DB_BACKEND=local` 是**裸 boot 的默认值** —— 原来 main.ts 那两行用 `backend ? "shared()" : …` 判,
285
+ * 于是单机默认形配了 `RATE_LIMIT_PER_MIN` 会打出 `shared()`,实际跑的是每副本一份的进程内限流器。
286
+ * 运维扩到 N 副本时读这行判定"限流是跨副本的",真实额度是 N 倍。
287
+ * (同一个说谎病族在紧邻的 `breakerState` 上修过一次——"was hard-coded 'shared(tidb)' — lied under
288
+ * DB_BACKEND=pg"——`session:` 也跟着修了,这两行没跟上。顺带:原来的 `shared()` 括号是空的,
289
+ * 而兄弟行都写 `shared(${kind})`。)
290
+ */
291
+ export function counterStoreLabel(store, kind) {
292
+ return store ? `shared(${kind ?? "unknown"})` : "in-process";
293
+ }
279
294
  /** Build the durable-store backend for `config.dbBackend`. Does NOT connect (the pool connects lazily; the
280
295
  * reachability probe is the caller's first `ensureSchema`). Returns undefined when no DB is configured. */
281
296
  export function createStoreBackend(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.10.0",
3
+ "version": "3.12.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",