@sema-agent/server 2.0.1 → 3.1.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 (48) hide show
  1. package/MIGRATION.md +74 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +395 -0
  5. package/dist/boot/config-center.d.ts +2 -1
  6. package/dist/boot/config-center.js +28 -7
  7. package/dist/capabilities/scenarios.d.ts +5 -3
  8. package/dist/capabilities/scenarios.js +5 -3
  9. package/dist/config-center/apply-effective.js +4 -3
  10. package/dist/config-center/restart-signal.d.ts +10 -2
  11. package/dist/config-center/restart-signal.js +28 -4
  12. package/dist/config-lkg.d.ts +2 -1
  13. package/dist/config-lkg.js +2 -1
  14. package/dist/config-types.d.ts +7 -5
  15. package/dist/config.d.ts +16 -11
  16. package/dist/config.js +30 -33
  17. package/dist/hooks/hook-llm.js +9 -0
  18. package/dist/http/routes/approvals-assistant.js +1 -1
  19. package/dist/http/routes/attachments.js +2 -2
  20. package/dist/http/routes/memory-policy.js +3 -3
  21. package/dist/http/routes/runs.js +1 -1
  22. package/dist/http/routes/session-sync.js +2 -2
  23. package/dist/http/routes/sessions.js +2 -2
  24. package/dist/http/routes/tasks.js +2 -2
  25. package/dist/http/routes/trace-usage.js +2 -2
  26. package/dist/http/routes/workflows.js +3 -1
  27. package/dist/http/server.d.ts +1 -1
  28. package/dist/http/server.js +25 -5
  29. package/dist/http/sse-log.js +1 -1
  30. package/dist/main.js +1 -1
  31. package/dist/model-select.d.ts +1 -1
  32. package/dist/model-select.js +1 -1
  33. package/dist/plugins/checkpoint-store-sql.d.ts +13 -5
  34. package/dist/plugins/checkpoint-store-sql.js +10 -3
  35. package/dist/plugins/local-checkpoint-store.js +8 -2
  36. package/dist/plugins/remote-env-host.d.ts +34 -15
  37. package/dist/plugins/remote-env-host.js +30 -10
  38. package/dist/plugins/remote-env-local-docker.d.ts +14 -0
  39. package/dist/plugins/remote-env-local-docker.js +10 -3
  40. package/dist/plugins/run-store-sql.d.ts +8 -3
  41. package/dist/plugins/run-store-sql.js +8 -3
  42. package/dist/run-local.js +2 -1
  43. package/dist/sema-registry.d.ts +18 -4
  44. package/dist/sema-registry.js +17 -3
  45. package/dist/session-titler.d.ts +3 -1
  46. package/dist/session-titler.js +2 -2
  47. package/dist/trace/project.js +4 -1
  48. package/package.json +6 -4
@@ -14,9 +14,11 @@ import { skillsForScenario } from "./skills.js";
14
14
  * the operator configured — not Claude/Anthropic), so the agent never inherits the model's training-time
15
15
  * "I am Claude" hallucination.
16
16
  *
17
- * Live-caught incident (a Qwen3.5 deployment self-described as "running on a DeepSeek model"):
18
- * NOTHING substrate-specific may be hardcoded here onboard is multi-provider (Qwen/GLM/Kimi/self-hosted
19
- * vLLM…), so a baked maker name makes every non-DeepSeek deployment misstate its own substrate. Same for
17
+ * Live-caught incident (a deployment served by one model family described itself as running on a DIFFERENT
18
+ * vendor's model, because that vendor's name was baked into this text):
19
+ * NOTHING substrate-specific may be hardcoded here onboard is multi-provider (any OpenAI-/Anthropic-
20
+ * compatible endpoint, self-hosted vLLM included), so a baked maker name makes every deployment that is not
21
+ * that maker's misstate its own substrate. Same for
20
22
  * the gateway protocol (the brain stack serves Anthropic-compatible routes too — routing brain +
21
23
  * createAnthropicBrain — so "OpenAI-compatible gateway" would be the same class of lie). Neutral wording
22
24
  * (zero core-seam need; interpolating the model id would need a `StablePromptContext.model`
@@ -25,9 +25,10 @@ function toModel(m, envDefaults = {}) {
25
25
  name: m.name,
26
26
  provider: m.provider,
27
27
  api: m.api,
28
- // 实机 /model Kimi 404 定谳: a catalog model MAY carry its own gateway URL — the
28
+ // 实机 /model 切换 404 定谳: a catalog model MAY carry its own gateway URL — the
29
29
  // hardcoded "" made core's brain fall back to the boot-env endpoint, so cross-endpoint `body.model`
30
- // selection hit the WRONG gateway (kimi id Qwen vLLM 404). Absent still = "" (brain env default;
30
+ // selection hit the WRONG gateway (a model id routed to a gateway that does not serve it 404).
31
+ // Absent still = "" (brain env default;
31
32
  // byte-compat for single-gateway rosters). core brain honors model.baseUrl; keys stay per-model via apiKeyEnv.
32
33
  baseUrl: m.baseUrl ?? "",
33
34
  // BL-8 extended (systematic-audit): fall back to the ENV-derived model's reasoning, not Boolean(undefined)=false —
@@ -220,7 +221,7 @@ export function applyEffective(config, eff, logger, opts = {}) {
220
221
  // NOTE: the wire face (this module's EffectiveConfig) carries the JSON body verbatim, so tierGroups ride
221
222
  // eff.models even though CenterModel doesn't type them — resolveActiveTiers reads them structurally.
222
223
  const activeTiers = resolveActiveTiers(eff.models) ?? {};
223
- // [865]①(clay 生产实锤:onboard 选 kimi 实跑 Qwen)显式默认解析——enabled[0] 会被任何改变 pool 顺序的写
224
+ // [865]①(生产实锤:面板上选了 A 模型,实际跑的是目录里的另一个)显式默认解析——enabled[0] 会被任何改变 pool 顺序的写
224
225
  // 静默翻转,default 必须消费 wire 里已有的显式意图。解析单源 = resolveDefaultModelName(dry-run 的
225
226
  // wouldDefaultModel 共用同一只,报数与真 apply 永不撕裂——codex M2)。
226
227
  const picked = resolveDefaultModelName(eff, (n) => models[n] !== undefined, enabled[0].name, (source, name) => logger?.warn("sema_registry_default_dangling", { source, name, hint: "explicit default names a model that is not in the enabled catalog — falling to the next source" }));
@@ -1,5 +1,13 @@
1
1
  import type { EffectiveConfig } from "./types.js";
2
- export type RestartSlice = "skills" | "mcp" | "scenarios" | "runtime-gates" | "models-tiers";
2
+ export type RestartSlice = "skills" | "mcp" | "scenarios" | "runtime-gates" | "models-tiers" | "degrade-route";
3
+ /** What the CALLER must contribute about boot-baked catalog consumers that live OUTSIDE the Runner — the
4
+ * slices cannot see them, because they are configured from env (`ServiceConfig`), not from the center's
5
+ * EffectiveConfig. Absent/empty = that lane is off ⇒ its slice is inert (null on both sides). */
6
+ export interface RestartSliceCtx {
7
+ /** `config.degrade.to` when REACTIVE degrade is on (env `MODEL_DEGRADE_TO` + `MODEL_DEGRADE_REACTIVE`);
8
+ * undefined = the lane is off ⇒ no degrading brain was composed ⇒ nothing is boot-frozen. */
9
+ reactiveDegradeTo?: string;
10
+ }
3
11
  /** Structured restart signal an orchestrator consumes (GET /health → `restart`). Change-detected against the
4
12
  * process's BOOT config, NOT presence — so it stays absent when nothing baked-at-boot changed (presence would
5
13
  * re-fire every refresh → a restart LOOP). `restartRequired` is always true when this object exists. */
@@ -16,7 +24,7 @@ export interface RestartSignal {
16
24
  * nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
17
25
  * can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
18
26
  * against boot (a refresh that re-fires the same diff is idempotent, not a fresh trigger). */
19
- export declare function restartReasons(boot: EffectiveConfig | undefined, current: EffectiveConfig): RestartSlice[];
27
+ export declare function restartReasons(boot: EffectiveConfig | undefined, current: EffectiveConfig, ctx?: RestartSliceCtx): RestartSlice[];
20
28
  /** codex R10 (models-tiers 窗收口): TRUE when the MODEL PLANE (enabled models + active tier table + resolved
21
29
  * default) of `next` differs from the last-APPLIED plane `prev`. Under a tier-frozen Runner (tiers non-empty at
22
30
  * construction → private expanded copy) main.ts DEFERS the whole plane mutation until restart when this is true —
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { resolveActiveTiers } from "@sema-agent/registry-core";
9
9
  import { RUNTIME_GATE_KEYS, runtimeGatePresent, resolveDefaultModelName } from "./apply-effective.js";
10
- const RESTART_SLICES = ["skills", "mcp", "scenarios", "runtime-gates", "models-tiers"];
10
+ const RESTART_SLICES = ["skills", "mcp", "scenarios", "runtime-gates", "models-tiers", "degrade-route"];
11
11
  /** Canonical, key-sorted JSON (array order preserved) so two semantically-equal effective slices fingerprint
12
12
  * identically regardless of object key order from the center serializer. */
13
13
  function stableStringify(v) {
@@ -31,7 +31,22 @@ function enabledOnly(rows) {
31
31
  return null;
32
32
  return rows.filter((r) => r.enabled !== false);
33
33
  }
34
- function restartSliceValue(eff, slice) {
34
+ /** The degrade hop is built from the FULLY-DERIVED Model (`toModel`), so almost every catalog field on the
35
+ * target reaches it (id → body.model, baseUrl → the endpoint, provider → which brain, maxTokens/compat/
36
+ * extraBody/input → the request body, apiKeyEnv → the construction-time credential). We therefore fingerprint
37
+ * the WHOLE entry minus the two fields that are provably HOT — `cost` (refreshed via
38
+ * `mutateInPlace(pricing, buildPricing(config.models))`) and `quotaWeight` (via
39
+ * `mutateInPlace(config.modelQuotaWeights, …)`) — so a pure re-pricing never restarts a worker. A DENY-list
40
+ * (not an allow-list) on purpose: a catalog field added later rides the fingerprint automatically, so the
41
+ * failure mode of drift is a spurious restart, never a silently missed one. */
42
+ function degradeRouteFingerprint(entry) {
43
+ // `enabled` is dropped too, but for a different reason: `enabledOnly` already filtered on it, so among the
44
+ // entries that reach here it is `true`-or-absent — two encodings of ONE state. Keeping it would let a center
45
+ // serializer that starts/stops emitting the explicit `true` manufacture a restart out of nothing.
46
+ const { cost: _cost, quotaWeight: _quotaWeight, enabled: _enabled, ...route } = entry;
47
+ return route;
48
+ }
49
+ function restartSliceValue(eff, slice, ctx) {
35
50
  if (!eff)
36
51
  return null;
37
52
  switch (slice) {
@@ -69,14 +84,23 @@ function restartSliceValue(eff, slice) {
69
84
  const def = resolveDefaultModelName(eff, (n) => names.has(n), enabled?.[0]?.name ?? "");
70
85
  return { models: enabled, tiers: active, default: def.name };
71
86
  }
87
+ case "degrade-route": {
88
+ const to = ctx?.reactiveDegradeTo;
89
+ if (to === undefined)
90
+ return null; // lane off — no degrading brain exists to go stale
91
+ // Present↔absent is itself a change: with no enabled entry named `to`, createBrain composes NO
92
+ // degrading shell at all, so the flip only takes effect at the next boot.
93
+ const target = (enabledOnly(eff.models?.models) ?? []).find((m) => m.name === to);
94
+ return target ? degradeRouteFingerprint(target) : null;
95
+ }
72
96
  }
73
97
  }
74
98
  /** Restart-to-apply slices that DIFFER between this process's BOOT config and a freshly-pulled one. Empty =
75
99
  * nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
76
100
  * can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
77
101
  * against boot (a refresh that re-fires the same diff is idempotent, not a fresh trigger). */
78
- export function restartReasons(boot, current) {
79
- return RESTART_SLICES.filter((s) => stableStringify(restartSliceValue(boot, s)) !== stableStringify(restartSliceValue(current, s)));
102
+ export function restartReasons(boot, current, ctx) {
103
+ return RESTART_SLICES.filter((s) => stableStringify(restartSliceValue(boot, s, ctx)) !== stableStringify(restartSliceValue(current, s, ctx)));
80
104
  }
81
105
  /** codex R10 (models-tiers 窗收口): TRUE when the MODEL PLANE (enabled models + active tier table + resolved
82
106
  * default) of `next` differs from the last-APPLIED plane `prev`. Under a tier-frozen Runner (tiers non-empty at
@@ -11,7 +11,8 @@ export interface PersistedLkg {
11
11
  * `<dir>/<sha-hex>` 落盘,读侧**必验 hash**(content-addressed 的全部意义;坏文件=忽略重拉)。 */
12
12
  export declare function defaultSkillCacheDir(): string;
13
13
  /** 默认路径(scheduler store 同门:SEMA_CONFIG_DIR → ~/.sema)。`CONFIG_LKG_PATH` 覆写;
14
- * `CONFIG_LKG_ENABLED=false` 关闭(读写双关;design/158 B4 正名前叫 `CONFIG_LKG_DISABLED=true`,兼容期内仍认)。 */
14
+ * `CONFIG_LKG_ENABLED=false` 关闭(读写双关)。🪦 正名前的 `CONFIG_LKG_DISABLED` 自 server 3.0.0 起是
15
+ * **fail-loud 墓碑**——设了(任何值)即拒启并指路新名,不再被读取。 */
15
16
  export declare function defaultLkgPath(worker: string | undefined): string;
16
17
  /** 原子落盘一个干净候选(tmp+rename,0600)。BEST-EFFORT:失败=一条 warn 的素材(调用方发),绝不
17
18
  * 影响在飞的应用链——LKG 缓存丢失的代价只是下次冷启动回到 env 兜底。 */
@@ -30,7 +30,8 @@ export function defaultSkillCacheDir() {
30
30
  return join(process.env.SEMA_CONFIG_DIR ?? join(homedir(), ".sema"), "skill-cache");
31
31
  }
32
32
  /** 默认路径(scheduler store 同门:SEMA_CONFIG_DIR → ~/.sema)。`CONFIG_LKG_PATH` 覆写;
33
- * `CONFIG_LKG_ENABLED=false` 关闭(读写双关;design/158 B4 正名前叫 `CONFIG_LKG_DISABLED=true`,兼容期内仍认)。 */
33
+ * `CONFIG_LKG_ENABLED=false` 关闭(读写双关)。🪦 正名前的 `CONFIG_LKG_DISABLED` 自 server 3.0.0 起是
34
+ * **fail-loud 墓碑**——设了(任何值)即拒启并指路新名,不再被读取。 */
34
35
  export function defaultLkgPath(worker) {
35
36
  const dir = process.env.SEMA_CONFIG_DIR ?? join(homedir(), ".sema");
36
37
  const key = (worker ?? "global").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 64);
@@ -34,7 +34,7 @@ export interface ImageBakeConfig {
34
34
  * 2026-07-13, SWR 弃用): `docker.io/claybobby`(海外主源;CN 兜底=内网 Gitea registry)。 */
35
35
  registry: string | null;
36
36
  /** The build-host promoted fetch-once cache base injected as `--cache-base` (NEVER caller-settable — a foreign
37
- * origin would defeat the domestic-only iron rule). `BAKE_CACHE_BASE` (e.g. http://172.17.0.1:8099). */
37
+ * origin would defeat the domestic-only iron rule). `BAKE_CACHE_BASE` (e.g. `http://<build-host>:<port>`). */
38
38
  cacheBase: string | null;
39
39
  /** The pinned current sandbox-base ref injected as `--base-ref` when the caller omits `baseRef` (§P2.4c default).
40
40
  * When NULL the server falls back to the index's latest published `sandbox-base` digest (design §P2.4c
@@ -575,8 +575,9 @@ export interface ServiceConfigFlat {
575
575
  /** design/113 C4: host-lane project-memory injection (CLAUDE.md + git narrative). Default **ON** (subject to the
576
576
  * host-lane + single-user gate), so "open like CC" works without config. `PROJECT_MEMORY_ENABLED=false` opts out.
577
577
  * design/158 B4 (lens4 N8): this was `projectMemoryDisabled` — the ONE negative boolean on a ServiceConfig whose
578
- * other 16 flags are positive `*Enabled`/`*Allowed`, so every read site had to invert twice. The retired env name
579
- * `PROJECT_MEMORY_DISABLED` still works for one deprecation window (inverted, with a boot notice). */
578
+ * other 16 flags are positive `*Enabled`/`*Allowed`, so every read site had to invert twice. 🪦 server 3.0.0:
579
+ * the retired env name `PROJECT_MEMORY_DISABLED` is a **fail-loud tombstone** setting it (any value) refuses
580
+ * boot with a message naming this knob; it is no longer read. */
580
581
  projectMemoryEnabled: boolean;
581
582
  /** R7: override the shared scheduler store path. Default `SEMA_CONFIG_DIR`→`~/.sema/scheduled_tasks.json` (the
582
583
  * server writer + the shell daemon reader MUST agree). `SCHEDULER_STORE_PATH`. */
@@ -689,8 +690,9 @@ export interface ServiceConfigFlat {
689
690
  * ON for the host lane (CC-parity); set `LSP_HOST_ENABLED=false` to opt out. (`REMOTE_EXEC=host` only — ssh/adb/
690
691
  * local-docker can't reach a local child process to the right filesystem.)
691
692
  * design/158 B4 (lens4 N7): this lane used to share `LSP_ENABLED` with `lspEnabled` above — one env name driving
692
- * two OPPOSITE defaults. It now has its own knob; `LSP_ENABLED=false` remains a back-compat host opt-out (with a
693
- * boot notice naming the replacement) and `LSP_HOST_ENABLED` wins whenever it is set. */
693
+ * two OPPOSITE defaults. It now has its own knob. 🪦 server 3.0.0: `LSP_ENABLED=false` (the pre-split host
694
+ * opt-out) is a **fail-loud tombstone** it refuses boot naming `LSP_HOST_ENABLED`; `LSP_ENABLED=true` keeps
695
+ * its own meaning (the sandbox lane's opt-in above) and is unaffected. */
694
696
  lspHostEnabled: boolean;
695
697
  /** Graceful drain: how long SIGTERM waits for in-flight turns (live streams + bg/resume legs on this
696
698
  * instance) to finish before the hard shutdown. During drain, new billable submissions get 503+Retry-After and
package/dist/config.d.ts CHANGED
@@ -43,33 +43,38 @@ export declare function logConfigDiagnostics(logger: {
43
43
  * Note the deliberate non-migrations: the posture family (`postureOn`) keeps its tri-state — its unset case is
44
44
  * neither `true` nor `false` but "single-user ∧ infra-ready" — and the sentinel-valued knobs (`MEMORY_ENGINE`,
45
45
  * `SANDBOX_PKG_SOURCE`, `MANUAL_MODE_SHELL_GATE`) are enums wearing a boolean's clothes, not booleans. */
46
- type ConfigKnobSource = "env" | "legacy-env" | "posture" | "default";
46
+ /** 复审 2026-07-29 B4:3.0.0 把负名 env 改成 fail-loud 墓碑之后,legacy-env 这个 source 取值**永不可达**——
47
+ * 留着它等于在类型上书面承诺"还有一条 legacy 取值路径"。同批摘掉的还有 `ConfigKnobRecord.legacyName`
48
+ * 与它在 boot 诊断行里的输出:运维在日志里看到 `legacyName: HOST_BG_DISABLED` 的合理结论是"旧名还能
49
+ * 用",而实际设了就拒启——诊断面撒的谎比没有诊断更贵(它会让人先去试那个必然拒启的开关)。 */
50
+ type ConfigKnobSource = "env" | "posture" | "default";
47
51
  export interface ConfigKnobRecord {
48
- /** The CANONICAL env name (the positive one a legacy alias appears in `legacyName`, never here). */
52
+ /** The env name. 3.0.0 起只有这一个名字 —— 负名别名已是墓碑(见 `boolEnvWithLegacyNegated`) */
49
53
  name: string;
50
54
  /** `opt-in` = default OFF, `opt-out` = default ON, `posture` = default derived from REQUIRE_PRINCIPAL ∧ infra. */
51
55
  polarity: "opt-in" | "opt-out" | "posture";
52
56
  value: boolean;
53
57
  source: ConfigKnobSource;
54
- /** Set when the value came from (or the knob still accepts) a deprecated negative-form alias. */
55
- legacyName?: string;
56
58
  }
57
59
  /** The registered boolean knobs, sorted by name — the data behind `logConfigDiagnostics`'s polarity table. */
58
60
  export declare function configKnobTable(): ConfigKnobRecord[];
59
- /** design/158 N8 (D-family) a knob whose CANONICAL name is positive (`X_ENABLED`, default ON) but which keeps
60
- * its retired negative name (`X_DISABLED`) working for one deprecation window.
61
+ /** design/158 N8 (D-family) + server 3.0.0 墓碑 —— a knob whose CANONICAL name is positive (`X_ENABLED`,
62
+ * default ON) and whose retired negative name (`X_DISABLED`) is now a **fail-loud tombstone**.
61
63
  *
62
64
  * Why the flip at all: `X_DISABLED !== "true"` is a double negative at the read site, so the four knobs that
63
65
  * used it (`PROJECT_MEMORY`, `CONFIG_LKG`, `HOST_BG`, `HOST_EXEC_SPOOL`) could not be read for their default
64
66
  * without inverting twice in your head, and `projectMemoryDisabled` was the ONE negative boolean on a
65
67
  * `ServiceConfig` that otherwise carries 16 positive `*Enabled`/`*Allowed` fields.
66
68
  *
67
- * Resolution order (new name wins, so there is never an ambiguous both-set state):
69
+ * 🔴 3.0.0([1989],clay 令「不做兼容」):旧名不再有兼容窗——**设了就拒启**。这个函数保留 legacy 形参
70
+ * 不是为了继续读它,而正是为了**认出它在场并拒绝启动**(把墓碑放在唯一知道这对名字关系的地方)。
71
+ *
72
+ * Resolution order:
68
73
  * 1. `X_ENABLED` set to a literal ⇒ that.
69
- * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the legacy name is NOT consulted — a typo in the
70
- * new name must not silently hand control back to the deprecated one).
71
- * 3. `X_ENABLED` unset ∧ `X_DISABLED` set ⇒ the legacy site's exact contract, `X_DISABLED !== "true"`, plus a
72
- * one-shot deprecation notice naming the replacement.
74
+ * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the tombstone is NOT consulted — a typo in the
75
+ * new name must not turn into a boot refusal about an env the operator never set).
76
+ * 3. `X_ENABLED` unset ∧ `X_DISABLED` set(**任何值**,含 `"false"`)⇒ throw,文案指路新名。选拒启不选
77
+ * 静默忽略:静默会让升级部署的旧开关名义在、实际归默认——比拒启危险,且下游冒烟测试测不出来。
73
78
  * 4. neither ⇒ default. */
74
79
  export declare function boolEnvWithLegacyNegated(name: string, legacyNegatedName: string, def: boolean): boolean;
75
80
  /** LKG (last-known-good) persistence of the remote effective-config fetch. Read LAZILY at both main.ts sites
package/dist/config.js CHANGED
@@ -132,7 +132,6 @@ export function logConfigDiagnostics(logger) {
132
132
  polarity: k.polarity,
133
133
  value: k.value,
134
134
  source: k.source,
135
- ...(k.legacyName ? { legacyName: k.legacyName } : {}),
136
135
  });
137
136
  }
138
137
  }
@@ -164,9 +163,6 @@ function optFinitePositiveEnv(name) {
164
163
  /** Keyed by canonical name so a knob read twice in one load (e.g. DURABLE_APPROVAL, once for the D-G boot
165
164
  * invariant and once for the returned field) registers one row, not two. Reset per `loadConfig()`. */
166
165
  const CONFIG_KNOBS = new Map();
167
- /** Deprecated aliases already announced for this load — the lazily-read twins (HOST_BG/HOST_EXEC_SPOOL) resolve
168
- * again on every use, and an operator does not need the same deprecation line once per command. */
169
- const KNOB_DEPRECATIONS_SEEN = new Set();
170
166
  /** The registered boolean knobs, sorted by name — the data behind `logConfigDiagnostics`'s polarity table. */
171
167
  export function configKnobTable() {
172
168
  return [...CONFIG_KNOBS.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -199,20 +195,23 @@ function boolEnv(name, def) {
199
195
  registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source });
200
196
  return value;
201
197
  }
202
- /** design/158 N8 (D-family) a knob whose CANONICAL name is positive (`X_ENABLED`, default ON) but which keeps
203
- * its retired negative name (`X_DISABLED`) working for one deprecation window.
198
+ /** design/158 N8 (D-family) + server 3.0.0 墓碑 —— a knob whose CANONICAL name is positive (`X_ENABLED`,
199
+ * default ON) and whose retired negative name (`X_DISABLED`) is now a **fail-loud tombstone**.
204
200
  *
205
201
  * Why the flip at all: `X_DISABLED !== "true"` is a double negative at the read site, so the four knobs that
206
202
  * used it (`PROJECT_MEMORY`, `CONFIG_LKG`, `HOST_BG`, `HOST_EXEC_SPOOL`) could not be read for their default
207
203
  * without inverting twice in your head, and `projectMemoryDisabled` was the ONE negative boolean on a
208
204
  * `ServiceConfig` that otherwise carries 16 positive `*Enabled`/`*Allowed` fields.
209
205
  *
210
- * Resolution order (new name wins, so there is never an ambiguous both-set state):
206
+ * 🔴 3.0.0([1989],clay 令「不做兼容」):旧名不再有兼容窗——**设了就拒启**。这个函数保留 legacy 形参
207
+ * 不是为了继续读它,而正是为了**认出它在场并拒绝启动**(把墓碑放在唯一知道这对名字关系的地方)。
208
+ *
209
+ * Resolution order:
211
210
  * 1. `X_ENABLED` set to a literal ⇒ that.
212
- * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the legacy name is NOT consulted — a typo in the
213
- * new name must not silently hand control back to the deprecated one).
214
- * 3. `X_ENABLED` unset ∧ `X_DISABLED` set ⇒ the legacy site's exact contract, `X_DISABLED !== "true"`, plus a
215
- * one-shot deprecation notice naming the replacement.
211
+ * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the tombstone is NOT consulted — a typo in the
212
+ * new name must not turn into a boot refusal about an env the operator never set).
213
+ * 3. `X_ENABLED` unset ∧ `X_DISABLED` set(**任何值**,含 `"false"`)⇒ throw,文案指路新名。选拒启不选
214
+ * 静默忽略:静默会让升级部署的旧开关名义在、实际归默认——比拒启危险,且下游冒烟测试测不出来。
216
215
  * 4. neither ⇒ default. */
217
216
  export function boolEnvWithLegacyNegated(name, legacyNegatedName, def) {
218
217
  const raw = process.env[name];
@@ -234,21 +233,17 @@ export function boolEnvWithLegacyNegated(name, legacyNegatedName, def) {
234
233
  source = "default";
235
234
  }
236
235
  else if (legacy !== undefined && legacy !== "") {
237
- value = legacy !== "true"; // verbatim legacy contract: only the literal "true" disabled the feature
238
- source = "legacy-env";
239
- if (!KNOB_DEPRECATIONS_SEEN.has(legacyNegatedName)) {
240
- KNOB_DEPRECATIONS_SEEN.add(legacyNegatedName);
241
- CONFIG_NOTICES.push({
242
- event: "config_env_deprecated_negated_name",
243
- fields: { deprecated: legacyNegatedName, replacement: name, effective: String(value) },
244
- });
245
- }
236
+ // server 3.0.0([1989],clay 令「不做兼容」):负名旧 env = fail-loud 墓碑。设了(任何值,含
237
+ // "false"——设它的人同样以为它还生效)即拒启并指路新名。选拒启不选静默忽略:静默会让升级部署的
238
+ // 旧开关名义在、实际归默认——比拒启危险,而且下游/运维的冒烟测试测不出来。
239
+ throw new Error(`${legacyNegatedName} was removed in server 3.0.0 — set ${name}=${legacy !== "true"} instead ` +
240
+ `(positive-name knob, default ${def ? "on" : "off"}; migration table in USAGE.md)`);
246
241
  }
247
242
  else {
248
243
  value = def;
249
244
  source = "default";
250
245
  }
251
- registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source, legacyName: legacyNegatedName });
246
+ registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source });
252
247
  return value;
253
248
  }
254
249
  /** LKG (last-known-good) persistence of the remote effective-config fetch. Read LAZILY at both main.ts sites
@@ -474,7 +469,15 @@ function parseStoreDomain(ctx) {
474
469
  function parseModelDomain() {
475
470
  // npm 卫生纪律:默认值不烤内网坐标 — localhost 占位(公开分发正确缺省);真网关一律显式 env。
476
471
  const gatewayBaseUrl = env("MODEL_GATEWAY_BASEURL", "http://127.0.0.1:8000/v1");
477
- const modelId = env("MODEL_ID", "Qwen3.5-35B");
472
+ // server 3.0.0([1992] 真机 400 案根治):MODEL_ID **无出厂缺省**。旧缺省是一个只在开发环境存在的
473
+ // 模型名,对任何外部部署都必炸——且炸在离根因最远的地方(gateway 400 + title hook 连环告警),
474
+ // 用户看不出是没配模型。fail-loud at boot 指路配置,与 env 墓碑同哲学:让问题在最早、最清楚处现形。
475
+ const modelId = env("MODEL_ID", "");
476
+ if (modelId === "") {
477
+ throw new Error("MODEL_ID is required (no factory default since server 3.0.0) — set MODEL_ID=<model name your gateway serves>, " +
478
+ "or configure models via the sema-registry catalog; the old silent default was an internal-only model name " +
479
+ "that made every external deployment fail far from the cause (gateway 400)");
480
+ }
478
481
  // Model.api must reflect the brain that actually serves it (routing is by provider): an "anthropic"
479
482
  // provider runs the Anthropic brain, whose usage reports `input` EXCLUDING cached tokens. core ≥1.22
480
483
  // normalizes the cache-hit-rate denominator per api family — a wrong api makes promptTokens too small
@@ -915,16 +918,11 @@ function parseOrchestrationDomain(ctx) {
915
918
  // `LSP_ENABLED=false` keeps working as a host opt-out (it was the only one operators ever had) with a notice
916
919
  // naming the replacement, and `LSP_HOST_ENABLED` always wins when set.
917
920
  const lspHostEnabled = (() => {
918
- const explicitHost = process.env.LSP_HOST_ENABLED;
919
- if (explicitHost === undefined || explicitHost === "") {
920
- if (process.env.LSP_ENABLED === "false") {
921
- CONFIG_NOTICES.push({
922
- event: "config_env_deprecated_lane_alias",
923
- fields: { deprecated: "LSP_ENABLED=false", replacement: "LSP_HOST_ENABLED=false", lane: "host", effective: "false" },
924
- });
925
- registerKnob({ name: "LSP_HOST_ENABLED", polarity: "opt-out", value: false, source: "legacy-env", legacyName: "LSP_ENABLED" });
926
- return false;
927
- }
921
+ // server 3.0.0([1989],不做兼容):`LSP_ENABLED=false` 的 host 腿别名形 = fail-loud 墓碑。
922
+ // `LSP_ENABLED=true`(沙箱腿 opt-in 正名)不受影响——墓碑只咬「用沙箱名关 host 腿」的旧姿势。
923
+ if (process.env.LSP_ENABLED === "false") {
924
+ throw new Error("LSP_ENABLED=false no longer drives the HOST lane (removed in server 3.0.0) — set LSP_HOST_ENABLED=false " +
925
+ "for the host lane; LSP_ENABLED remains the SANDBOX-lane opt-in only (migration table in USAGE.md)");
928
926
  }
929
927
  return boolEnv("LSP_HOST_ENABLED", true);
930
928
  })();
@@ -1302,7 +1300,6 @@ export function loadConfig() {
1302
1300
  CONFIG_WARNINGS.length = 0; // repeated loadConfig() calls (test setup) must not accumulate stale warnings
1303
1301
  CONFIG_NOTICES.length = 0;
1304
1302
  CONFIG_KNOBS.clear(); // the polarity table describes THIS load, never a previous one's env
1305
- KNOB_DEPRECATIONS_SEEN.clear();
1306
1303
  // Posture gate: single-user turnkey (`REQUIRE_PRINCIPAL !== "true"` = one trusted super-admin on their own
1307
1304
  // box/tenant, CC's trust model) ⇒ the CC capability set defaults ON, each composed with its infra prereq; multi-tenant
1308
1305
  // (requirePrincipal) stays opt-in (gated + secured). This is the SAME discriminator LSP's host-default-ON uses,
@@ -1,3 +1,4 @@
1
+ import { matchCatalogModel } from "../model-select.js";
1
2
  export function createHookLlm(deps) {
2
3
  const { config, metrics } = deps;
3
4
  // anthropic 路由自己的 baseUrl(与 core createAnthropicBrain 同一解析链:model.baseUrl || config.baseUrl
@@ -40,6 +41,14 @@ export function createHookLlm(deps) {
40
41
  }
41
42
  if (config.models[requested])
42
43
  return shape(requested);
44
+ // 复审 2026-07-29 D1:目录键是 **name** 形,而调用点会传 **provider id** 形——`http/routes/tasks.ts`
45
+ // 的 titler 腿([1992]②「标题跟 turn」)对对象形 spec 传的就是 `spec.model.id`。registry 热应用后
46
+ // name 与 id 常态分叉(目录键是运维起的短名,`Model.id` 是 provider 侧的真名),单键索引下这类请求全落到下面那条
47
+ // 「不在目录里」⇒ 用户每选一次非主模型,标题就 llm_failed(对话本身正常),与 [1992] 同族的病形。
48
+ // 双键索引沿用同仓先例 [865]② `matchCatalogModel`:name 优先、id 扫目录值、跳过 `default` 别名键。
49
+ const byId = matchCatalogModel(requested, config.models);
50
+ if (byId !== undefined)
51
+ return shape(byId);
43
52
  if (requested === config.model.id)
44
53
  return shapeFrom(requested, config.model, requested);
45
54
  return { ok: false, error: `hook model "${requested}" is not in this deployment's catalog` };
@@ -746,7 +746,7 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
746
746
  }
747
747
  prev = cur;
748
748
  if (Date.now() - start > MAX_MS) {
749
- res.write(`event: error\ndata: ${JSON.stringify({ type: "error", code: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
749
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
750
750
  break;
751
751
  }
752
752
  if (Date.now() - lastBeat > 15_000) {
@@ -61,7 +61,7 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
61
61
  }
62
62
  const allow = deps.config.attachmentMimeAllowlist;
63
63
  if (allow && !allow.some((a) => a === mime || (a.endsWith("/*") && mime.startsWith(a.slice(0, -1))))) {
64
- sendError(res, 415, "attachment.mime_not_allowed", `mime '${mime}' is not in this deployment's allowlist`, { code: "attachment.mime_not_allowed", allowed: allow });
64
+ sendError(res, 415, "attachment.mime_not_allowed", `mime '${mime}' is not in this deployment's allowlist`, { allowed: allow });
65
65
  return;
66
66
  }
67
67
  const max = deps.config.attachmentMaxBytes ?? 32 * 1024 * 1024;
@@ -71,7 +71,7 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
71
71
  }
72
72
  catch (err) {
73
73
  if (err instanceof HttpError && err.status === 413) {
74
- sendError(res, 413, "attachment.too_large", `attachment exceeds the per-file limit (${max} bytes)`, { code: "attachment.too_large", maxBytes: max });
74
+ sendError(res, 413, "attachment.too_large", `attachment exceeds the per-file limit (${max} bytes)`, { maxBytes: max });
75
75
  return;
76
76
  }
77
77
  throw err;
@@ -76,11 +76,11 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
76
76
  scope = decodeURIComponent(url.slice("/v1/memory/sync/".length));
77
77
  }
78
78
  catch {
79
- sendError(res, 400, "memory_sync_invalid_scope", "malformed percent-encoding in :scope", { code: "memory_sync_invalid_scope" });
79
+ sendError(res, 400, "memory_sync_invalid_scope", "malformed percent-encoding in :scope");
80
80
  return;
81
81
  }
82
82
  if (scope.length === 0) {
83
- sendError(res, 400, "memory_sync_invalid_scope", "missing :scope", { code: "memory_sync_invalid_scope" });
83
+ sendError(res, 400, "memory_sync_invalid_scope", "missing :scope");
84
84
  return;
85
85
  }
86
86
  if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
@@ -90,7 +90,7 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
90
90
  const body = await readJson(req); // 413/400 (oversize / bad JSON) via the typed top-level catch — auth already done
91
91
  const parsed = parseMemorySyncRequest(body, scope);
92
92
  if (!parsed.ok) {
93
- sendError(res, 422, "memory_sync_invalid_body", parsed.error, { code: "memory_sync_invalid_body" });
93
+ sendError(res, 422, "memory_sync_invalid_body", parsed.error);
94
94
  return;
95
95
  }
96
96
  try {
@@ -154,7 +154,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
154
154
  const taskId = clientTaskId ?? uuidv7();
155
155
  const created = await runStore.createRun(taskId, sessionId, prepared.auth?.principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
156
156
  if (created.ok)
157
- deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective); // fire-and-forget session auto-title
157
+ deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model turn([1992]②)
158
158
  if (!created.ok) {
159
159
  // race twin: two concurrent same-taskId submits (gateway retry racing the original) — the loser's
160
160
  // session claim collides with ITS OWN taskId → idempotent 202 replay, not a 409 (the run exists, once).
@@ -234,7 +234,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
234
234
  if (deps.snapshotBlobSqlCapBytes !== undefined && body.byteLength > deps.snapshotBlobSqlCapBytes) {
235
235
  // 复审 D-F1/B-F2:归一漏网(多行体,旧门正则扫不到)——A6 迁移到 sendError 后此站点由
236
236
  // errorCode 必填位置参数保结构(不再靠源级反射门兜底)。
237
- sendError(res, 413, "blob_too_large_for_sql", `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes — TiDB's txn-entry-size-limit is the low wall at 6MiB by default; the mysql-protocol packet limit sits above it) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs, or raise SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment lifted those limits`, { code: "blob_too_large_for_sql" });
237
+ sendError(res, 413, "blob_too_large_for_sql", `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes — TiDB's txn-entry-size-limit is the low wall at 6MiB by default; the mysql-protocol packet limit sits above it) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs, or raise SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment lifted those limits`);
238
238
  return;
239
239
  }
240
240
  if (createHash("sha256").update(body).digest("hex") !== hash) {
@@ -246,7 +246,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
246
246
  // blob as durably stored when it isn't.
247
247
  const put = await fs.putBlob(hash, new Uint8Array(body));
248
248
  if (!put.ok) {
249
- sendError(res, 502, put.error.code, "blob store write failed", { code: put.error.code });
249
+ sendError(res, 502, put.error.code, "blob store write failed");
250
250
  return;
251
251
  }
252
252
  res.writeHead(204).end(); // no body
@@ -796,7 +796,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
796
796
  if (typeof fs.blobSizes === "function") {
797
797
  const known = (await fs.blobSizes([hash])).get(hash);
798
798
  if (known !== undefined && known > cap) {
799
- sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: known, limit: cap });
799
+ sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { sizeBytes: known, limit: cap });
800
800
  return;
801
801
  }
802
802
  }
@@ -806,7 +806,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
806
806
  return;
807
807
  }
808
808
  if (bytes.byteLength > cap) {
809
- sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: bytes.byteLength, limit: cap });
809
+ sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { sizeBytes: bytes.byteLength, limit: cap });
810
810
  return;
811
811
  }
812
812
  // [1894]③:content-type 按扩展名(保守表,未知=octet-stream)+ 二进制判别头(首 8KiB NUL/控制
@@ -214,7 +214,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
214
214
  const tid = earlyDurableTid ?? uuidv7(); // reuse the id already emitted as the X-Task-Id header (defensive mint if absent)
215
215
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
216
216
  if (created.ok)
217
- deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective); // fire-and-forget; in-titler dedupe
217
+ deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget; in-titler dedupe;model 跟 turn([1992]②)
218
218
  if (!created.ok) {
219
219
  const conflict = { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId };
220
220
  // Headers are already SSE — encode the rejection as the stream's terminal event; the 409 in
@@ -966,7 +966,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
966
966
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
967
967
  if (!created.ok)
968
968
  return { status: 409, body: { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId } };
969
- deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective); // fire-and-forget session auto-title
969
+ deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model turn([1992]②)
970
970
  durableTaskId = tid;
971
971
  }
972
972
  if (deps.checkpointStore && prepared.spec.sessionId) {
@@ -269,13 +269,13 @@ async function streamTaskTrace(req, res, runStore, taskId, staleMs) {
269
269
  }
270
270
  const stale = Date.now() - new Date(run.updatedAt).getTime() > staleMs;
271
271
  if (stale && events.length === 0) {
272
- res.write(`event: error\ndata: ${JSON.stringify({ code: "WORKER_DOWN", message: "run stalled (instance lost?)" })}\n\n`);
272
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "WORKER_DOWN", message: "run stalled (instance lost?)" })}\n\n`);
273
273
  break;
274
274
  }
275
275
  if (Date.now() - start > MAX_MS) {
276
276
  // BL-16: the stream hit its 15-min cap but the RUN is still going — emit a terminal event so the client
277
277
  // can tell "reconnect to continue" from "task completed" (a silent close looked like completion).
278
- res.write(`event: error\ndata: ${JSON.stringify({ code: "STREAM_MAX_DURATION", message: "trace stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
278
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "trace stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
279
279
  break;
280
280
  }
281
281
  if (events.length === 0) {
@@ -485,8 +485,10 @@ async function streamWorkflowRun(req, res, runId, scope) {
485
485
  }
486
486
  }
487
487
  catch {
488
+ // A1/A6/B:归一 error 帧形(`type` + 机器码)。此前只发人话 message ⇒ 消费端要么锚文案、要么把
489
+ // "流断了"和"流正常结束"混为一谈;`workflow.stream_error` 是本帧的铸码(附录 A `*.stream_error` 族)。
488
490
  if (!closed && !res.writableEnded)
489
- res.write(`event: error\ndata: ${JSON.stringify({ message: "workflow stream error" })}\n\n`);
491
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "workflow.stream_error", message: "workflow stream error" })}\n\n`);
490
492
  }
491
493
  finally {
492
494
  clearInterval(hb);
@@ -197,7 +197,7 @@ export interface ServiceCoordinatorDeps {
197
197
  leaderEndpoint?: LeaderEndpoint;
198
198
  /** Fire-and-forget session auto-titler (first-submit hook; absent = feature dark). */
199
199
  sessionTitler?: {
200
- maybeTitle(sessionId: string, objective: string): void;
200
+ maybeTitle(sessionId: string, objective: string, model?: string): void;
201
201
  };
202
202
  }
203
203
  /** **函数/构件缝**:main.ts 把跨多个子系统的动作编译成一个可调用面交给路由(路由不该自己