@sema-agent/server 7.100.0 → 7.101.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/MIGRATION.md +22 -1
  2. package/README.md +12 -1
  3. package/README.zh-CN.md +9 -1
  4. package/USAGE.md +80 -9
  5. package/dist/approval-card.d.ts +24 -12
  6. package/dist/approval-card.js +4 -3
  7. package/dist/approval-policy-settlement.d.ts +31 -0
  8. package/dist/approval-policy-settlement.js +16 -0
  9. package/dist/approval-reconciler.d.ts +8 -1
  10. package/dist/approval-reconciler.js +5 -4
  11. package/dist/boot/config-center.js +86 -20
  12. package/dist/boot/execution-env.js +20 -2
  13. package/dist/boot/parked-revive-gate.js +1 -0
  14. package/dist/boot/reapers.js +1 -0
  15. package/dist/boot/resolve-spec.js +25 -8
  16. package/dist/boot/runner-deps.js +9 -3
  17. package/dist/boot/stage-02-registry-pull.js +9 -0
  18. package/dist/boot/stage-10-http-server.js +2 -0
  19. package/dist/boot/stores.js +4 -0
  20. package/dist/config-catalog.js +5 -3
  21. package/dist/config-center/apply-effective.d.ts +25 -0
  22. package/dist/config-center/apply-effective.js +144 -69
  23. package/dist/config-center/apply-ledger.d.ts +12 -2
  24. package/dist/config-center/apply-ledger.js +4 -1
  25. package/dist/config-center/facade.d.ts +1 -1
  26. package/dist/config-center/facade.js +1 -1
  27. package/dist/config-center/http-client.d.ts +4 -1
  28. package/dist/config-center/http-client.js +15 -10
  29. package/dist/config-center/read-warnings.d.ts +115 -6
  30. package/dist/config-center/read-warnings.js +115 -5
  31. package/dist/config-center/types.d.ts +6 -0
  32. package/dist/config-invariants.d.ts +2 -2
  33. package/dist/config-invariants.js +15 -0
  34. package/dist/config-provider.d.ts +4 -0
  35. package/dist/config-provider.js +64 -9
  36. package/dist/config-types.d.ts +37 -8
  37. package/dist/config.js +87 -22
  38. package/dist/deployment-governance.d.ts +45 -12
  39. package/dist/deployment-governance.js +37 -9
  40. package/dist/env-name-allowlist-knobs.d.ts +37 -0
  41. package/dist/env-name-allowlist-knobs.js +24 -0
  42. package/dist/fleet/fleet-bus.d.ts +30 -4
  43. package/dist/fleet/fleet-bus.js +20 -4
  44. package/dist/fleet/fleet-reconciler.d.ts +29 -6
  45. package/dist/fleet/fleet-reconciler.js +109 -79
  46. package/dist/fleet-client.d.ts +2 -1
  47. package/dist/hooks/hook-llm.js +13 -4
  48. package/dist/host-lsp-manager.d.ts +14 -2
  49. package/dist/host-lsp-manager.js +7 -6
  50. package/dist/http/admission.js +22 -7
  51. package/dist/http/memory-store-refusal-reply.d.ts +24 -0
  52. package/dist/http/memory-store-refusal-reply.js +10 -0
  53. package/dist/http/routes/approvals-assistant.js +1 -0
  54. package/dist/http/routes/capabilities.js +3 -3
  55. package/dist/http/routes/memory-bundle.js +3 -0
  56. package/dist/http/routes/memory-compliance.js +3 -5
  57. package/dist/http/routes/memory-origin.js +3 -5
  58. package/dist/http/routes/sessions.js +7 -6
  59. package/dist/http/routes/side-query.js +10 -4
  60. package/dist/instruction-source-notice.d.ts +97 -0
  61. package/dist/instruction-source-notice.js +78 -0
  62. package/dist/memory-layer-legacy-lock.d.ts +14 -0
  63. package/dist/memory-layer-legacy-lock.js +74 -0
  64. package/dist/memory-operator-faces.js +1 -17
  65. package/dist/memory-store-refusal.d.ts +37 -0
  66. package/dist/memory-store-refusal.js +14 -0
  67. package/dist/model-entry-refusal.d.ts +130 -0
  68. package/dist/model-entry-refusal.js +157 -0
  69. package/dist/model-provider.d.ts +9 -3
  70. package/dist/model-route-endpoint.d.ts +1 -1
  71. package/dist/model-select.d.ts +49 -4
  72. package/dist/model-select.js +48 -17
  73. package/dist/observability/fail-open.d.ts +13 -1
  74. package/dist/observability/fail-open.js +13 -1
  75. package/dist/observability/metrics.js +1 -0
  76. package/dist/observability/secret-env-scrub.d.ts +27 -4
  77. package/dist/observability/secret-env-scrub.js +22 -7
  78. package/dist/plugins/caching-session-store.d.ts +6 -0
  79. package/dist/plugins/caching-session-store.js +6 -0
  80. package/dist/plugins/local-session-store.d.ts +20 -25
  81. package/dist/plugins/local-session-store.js +111 -64
  82. package/dist/plugins/pg-session-storage.d.ts +7 -0
  83. package/dist/plugins/pg-session-storage.js +19 -0
  84. package/dist/plugins/remote-env-host.d.ts +3 -1
  85. package/dist/plugins/remote-env-host.js +3 -6
  86. package/dist/plugins/tidb-pool.js +5 -0
  87. package/dist/plugins/tidb-session-store.d.ts +13 -0
  88. package/dist/plugins/tidb-session-store.js +14 -0
  89. package/dist/plugins/web-search.d.ts +2 -1
  90. package/dist/plugins/web-search.js +43 -24
  91. package/dist/project-memory.d.ts +10 -0
  92. package/dist/project-memory.js +28 -14
  93. package/dist/run-local.d.ts +17 -15
  94. package/dist/run-local.js +115 -15
  95. package/dist/sealed-key.d.ts +5 -2
  96. package/dist/server-secret-env.d.ts +23 -1
  97. package/dist/server-secret-env.js +9 -0
  98. package/dist/tool-approval.d.ts +74 -31
  99. package/dist/tool-approval.js +102 -49
  100. package/dist/trace/core-keyset-guard.d.ts +27 -5
  101. package/dist/trace/engine-notice-wire.d.ts +27 -1
  102. package/dist/trace/engine-notice-wire.js +8 -0
  103. package/dist/trace/wire-projection-faces.d.ts +2 -2
  104. package/dist/trace/wire-projection-faces.js +2 -2
  105. package/package.json +2 -2
  106. package/dist/memory-layer-preflight.d.ts +0 -46
  107. package/dist/memory-layer-preflight.js +0 -121
@@ -0,0 +1,157 @@
1
+ import { CC_MODEL_TIER_ALIASES, DEFAULT_TIER_ORDER, expandTiers } from "@sema-agent/core";
2
+ import { ModelEntry } from "@sema-agent/settings-schema";
3
+ import { catalogBrainLane } from "./model-provider.js";
4
+ import { describeSchemaIssueFields, describeSchemaIssues } from "./config-center/read-warnings.js";
5
+ import { DEFAULT_MODEL_REF } from "./model-select.js";
6
+ export const MODEL_ENTRY_REFUSAL_CODES = {
7
+ no_brain: "config.model_no_brain",
8
+ entry_invalid: "config.model_entry_invalid",
9
+ };
10
+ export const DEFAULT_MODEL_MISSING_CODE = "config.default_model_missing";
11
+ export const REFUSED_NAME_MINTED_CODE = "config.model_refused_name_minted";
12
+ function laneMount(lane, ctx) {
13
+ switch (lane) {
14
+ case "gateway":
15
+ return { mounted: true, how: "" };
16
+ case "anthropic":
17
+ return { mounted: ctx.anthropicRouteMounted, how: "ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN) in this service's env" };
18
+ }
19
+ }
20
+ export function judgeCatalogEntry(m, ctx) {
21
+ const shape = ModelEntry.safeParse(m);
22
+ if (!shape.success) {
23
+ const fields = describeSchemaIssueFields(shape.error);
24
+ return entryInvalidRefusal(typeof m.id === "string" ? m.id : "", typeof m.provider === "string" ? m.provider : "", fields, describeSchemaIssues(shape.error) ?? fields.join(", "));
25
+ }
26
+ const lane = catalogBrainLane(m.provider);
27
+ const mount = laneMount(lane, ctx);
28
+ if (!mount.mounted) {
29
+ return {
30
+ reason: "no_brain",
31
+ code: MODEL_ENTRY_REFUSAL_CODES.no_brain,
32
+ id: m.id,
33
+ provider: m.provider,
34
+ fields: [],
35
+ detail: `provider "${m.provider}" rides the ${lane} brain, which is not mounted on this deployment — the entry is not served here ` +
36
+ `(it is never routed to another brain with its own key); set ${mount.how}, ` +
37
+ 'or change the entry\'s provider to an OpenAI-compatible route ("gateway" / "vllm") with its own baseUrl',
38
+ };
39
+ }
40
+ return undefined;
41
+ }
42
+ function entryInvalidRefusal(id, provider, fields, issues) {
43
+ return {
44
+ reason: "entry_invalid",
45
+ code: MODEL_ENTRY_REFUSAL_CODES.entry_invalid,
46
+ id,
47
+ provider,
48
+ fields: [...fields],
49
+ detail: `the catalog entry does not match the settings-schema ModelEntry shape (${issues}) — it is dropped from this ` +
50
+ "deployment's catalog on its own (the other entries keep serving) instead of being served with a value nothing validated; fix the entry to bring it back",
51
+ };
52
+ }
53
+ export function judgeCatalogEntries(enabled, ctx, dropped) {
54
+ const usable = [];
55
+ const refused = {};
56
+ for (const m of enabled) {
57
+ const r = judgeCatalogEntry(m, ctx);
58
+ if (r === undefined)
59
+ usable.push(m);
60
+ else
61
+ refused[String(m.name)] = r;
62
+ }
63
+ const unreadable = [];
64
+ for (const d of Array.isArray(dropped) ? dropped : []) {
65
+ const e = (typeof d === "object" && d !== null ? d : {});
66
+ const where = typeof e.entry === "string" ? e.entry : "(unknown coordinate)";
67
+ if (typeof e.name !== "string") {
68
+ unreadable.push(where);
69
+ continue;
70
+ }
71
+ if (Object.hasOwn(refused, e.name))
72
+ continue;
73
+ const fields = Array.isArray(e.fields) ? e.fields.filter((f) => typeof f === "string") : [];
74
+ refused[e.name] = entryInvalidRefusal(typeof e.id === "string" ? e.id : "", typeof e.provider === "string" ? e.provider : "", fields, typeof e.issues === "string" ? e.issues : fields.join(", "));
75
+ }
76
+ return { usable, refused, unreadable };
77
+ }
78
+ export function refusalEventOf(reason) {
79
+ switch (reason) {
80
+ case "no_brain":
81
+ return "sema_registry_model_no_brain";
82
+ case "entry_invalid":
83
+ return "sema_registry_model_entry_invalid";
84
+ }
85
+ }
86
+ export function refusalWarnFields(name, r) {
87
+ return {
88
+ model: name,
89
+ provider: r.provider,
90
+ code: r.code,
91
+ ...(r.fields.length > 0 ? { field: r.fields[0], fields: [...r.fields] } : {}),
92
+ hint: r.detail,
93
+ };
94
+ }
95
+ export function modelUnavailableMessage(field, requested, name, r) {
96
+ const alias = requested === name ? "" : ` (catalog entry "${name}")`;
97
+ return `${field} "${requested}"${alias} is published in the catalog but unavailable on this deployment [${r.code}] — ${r.detail}`;
98
+ }
99
+ export function defaultModelRefusal(view, leg) {
100
+ if (Object.hasOwn(view.models, DEFAULT_MODEL_REF) && view.models[DEFAULT_MODEL_REF] !== undefined)
101
+ return undefined;
102
+ const refused = Object.entries(view.modelRefusals).map(([n, r]) => `"${n}" [${r.code}]`);
103
+ const unavailable = refused.length > 0 ? ` (every enabled catalog entry is unavailable on this host: ${refused.join(", ")})` : "";
104
+ switch (leg) {
105
+ case "env":
106
+ return (`MODEL_ID is required when no catalog default resolves [${DEFAULT_MODEL_MISSING_CODE}] — this model table has no default model` +
107
+ unavailable +
108
+ ". Set MODEL_ID=<model name your gateway serves>, or enable at least one model this host can serve in the catalog " +
109
+ "(config.d/models.json, or the config center) — either one is enough (a config center that answers after the boot budget " +
110
+ "counts as absent at boot: raise CONFIG_BOOT_FETCH_BUDGET_MS to wait for it).");
111
+ case "catalog":
112
+ return (`this catalog candidate has no default model [${DEFAULT_MODEL_MISSING_CODE}]` +
113
+ unavailable +
114
+ " — fix the named entries (each has its own warn with the reason) or enable / bind a default this host can serve, in the catalog itself " +
115
+ "(a catalog in force replaces the env model table, so MODEL_ID cannot stand in for it).");
116
+ }
117
+ }
118
+ function mintedVia(name, own, tiers) {
119
+ if (own)
120
+ return "catalog-entry";
121
+ if (Object.hasOwn(CC_MODEL_TIER_ALIASES, name))
122
+ return `cc-alias of tier "${CC_MODEL_TIER_ALIASES[name]}"`;
123
+ if (name === "best")
124
+ return "best-tier alias";
125
+ if (DEFAULT_TIER_ORDER.includes(name) || Object.hasOwn(tiers, name))
126
+ return "tier word";
127
+ return "expansion key";
128
+ }
129
+ export function mintedRefusedNames(view) {
130
+ const refusedNames = Object.keys(view.modelRefusals);
131
+ if (refusedNames.length === 0)
132
+ return [];
133
+ const expanded = (expandTiers(view.models, view.tiers) ?? view.models);
134
+ const out = [];
135
+ for (const name of refusedNames) {
136
+ if (!Object.hasOwn(expanded, name) || expanded[name] === undefined)
137
+ continue;
138
+ const t = expanded[name]?.name;
139
+ out.push({ name, target: typeof t === "string" && t.length > 0 ? t : name, via: mintedVia(name, Object.hasOwn(view.models, name), view.tiers), code: view.modelRefusals[name].code });
140
+ }
141
+ return out;
142
+ }
143
+ export function refusedNameMintedRefusal(view) {
144
+ const minted = mintedRefusedNames(view);
145
+ if (minted.length === 0)
146
+ return undefined;
147
+ const pairs = minted.map((m) => `"${m.name}" [${m.code}] → "${m.target}" (${m.via})`);
148
+ return (`unavailable catalog entries would be re-minted onto other entries by the tier/alias expansion [${REFUSED_NAME_MINTED_CODE}]: ${pairs.join(", ")} — ` +
149
+ "an explicit pick of that name (or a role bound to it) would silently run on the other entry's endpoint and account; " +
150
+ "rename the unavailable entry, fix it (see its own warn), or rebind / drop the tier binding that mints the name.");
151
+ }
152
+ export function unreadableDroppedRefusal(entries) {
153
+ return (`catalog entries dropped by the settings-schema read could not be identified [${REFUSED_NAME_MINTED_CODE}]: ${entries.join(", ")} — ` +
154
+ "the raw document could not be matched to the snapshot that was parsed (e.g. the local file changed between two reads), so the candidate cannot prove the tier/alias expansion does not re-mint their names; " +
155
+ "the next read re-judges (fix the named entries if this persists).");
156
+ }
157
+ //# sourceMappingURL=model-entry-refusal.js.map
@@ -69,9 +69,11 @@ export declare function modelBrainLane(provider: ModelProvider): ModelBrainLane;
69
69
  * unrecognized provider)" 是它的原话)。本函数做的只是把那句散文变成**一个有名字的判词**,于是
70
70
  * 「目录里写了个没人认识的 provider」这件事在代码里有一处可指的坐标,而不是散在两个 `=== "anthropic"` 里。
71
71
  *
72
- * ⚠️ **未闭环**(S-461 收车档登记):目录词今天仍然**无声**地落进网关腿。要让它也响亮,需要的是一条
73
- * **热路径上的**告警/拒收(config-center 应用期逐条校验 + 运维可读的拒因),那是另一件事、另一台车 ——
74
- * 在这里加一个 throw 会让一次热发布打死在跑的副本,方向反了。
72
+ * ⚠️ **未闭环(收窄后如实)**:认不出的目录词今天仍然**无声**地落进网关腿(OpenAI 兼容;条目带自己的 baseUrl /
73
+ * 钥匙,打的是它自己的端点 —— 不是凭据越界,是词面失真)。S-704(7.101.0)已把**热路径上的逐条判据**落地
74
+ * (`model-entry-refusal.ts`:本部署没有对应 brain 的条目整条不可用 —— 今天那一形是「anthropic 词 + 本机没装
75
+ * anthropic 路由」),但**域外词**是否也该不可用是另一个问题(web-admin 的新条目缺省就写 `"openai"`,预设写厂商名;
76
+ * 把它们判不可用会让大量在跑的目录整条消失),另车带三问定。在这里加一个 throw 会让一次热发布打死在跑的副本,方向反了。
75
77
  */
76
78
  export declare function catalogBrainLane(provider: string | undefined): ModelBrainLane;
77
79
  /**
@@ -85,6 +87,10 @@ export declare function catalogBrainLane(provider: string | undefined): ModelBra
85
87
  * 所以「这条路由要不要一个 openai 端点」的判据必须同时看词与装配:词判 {@link catalogBrainLane},
86
88
  * 装配判路由在不在。本函数是这一合取的**唯一**写法 —— 端点判官(`model-route-endpoint.ts`)、降级腿与
87
89
  * 启动摘要(`brain.ts`)都调它,不再各写一个 `=== "anthropic" && config.anthropic`。
90
+ *
91
+ * 🔴 S-704(7.101.0):**目录条目**已到不了「anthropic 词、路由没装」这一格 —— 模型面 stage 的逐条判据把它判成不可用
92
+ * (`config.model_no_brain`,不进 `config.models`)。这一格今天只剩 **env 腿**:`MODEL_PROVIDER=anthropic` 而没配 anthropic
93
+ * 凭据的部署,env 模型落网关腿、用的是网关自己的 `MODEL_API_KEY`(凭据与端点同属网关,不越界;是否也该拒启另车)。
88
94
  */
89
95
  export declare function dispatchBrainLane(provider: string | undefined, anthropicRouteMounted: boolean): ModelBrainLane;
90
96
  //# sourceMappingURL=model-provider.d.ts.map
@@ -17,7 +17,7 @@
17
17
  * ## 执法点(判据只在这里,执法点在调用处 —— 与 S-475 `gateway-url-userinfo` 同形)
18
18
  * · boot:`config.ts` 九域解析之后的跨域不变量区(锚 `CROSS-INVARIANT:model-route-endpoint`)⇒ 拒启;严格名册
19
19
  * 布防(`isRosterGateArmed`)时 env 腿是占位、从不被派发 ⇒ 不判它(备用网关那一条照判);
20
- * · 热路径:`config-center/apply-effective.ts` 的模型面 stage ⇒ 候选整批拒、上一代继续服务;
20
+ * · 热路径:`config-center/apply-effective.ts` 的模型面 stage ⇒ 模型面组拒(opus 腿 O3:同候选其余组照落)、上一代模型面继续服务;
21
21
  * · run-local:配置源解析完之后按真要派发的表再判一次(`run-local.ts`,7.98.0 合并复审 F2)—— 本入口没有名册门,
22
22
  * loadConfig 对严格名册布防的占位豁免在这里兑现不了;没布防时恒惰性;
23
23
  * · 登记:`config-invariants.ts` 的 `model-route-endpoint` 条(表的「不许有表外不变量」职责);
@@ -1,3 +1,4 @@
1
+ import type { ModelEntryRefusal } from "./model-entry-refusal.js";
1
2
  /**
2
3
  * Resolve the model ref for a task spec from the request (CLI `/model` picker + `run --model`).
3
4
  *
@@ -27,12 +28,53 @@
27
28
  * `resolveModel("default", catalog)` 解它)。**单点**:读面要说「这台部署的主模型是谁」时读同一个词
28
29
  * (`auto-mode-face.ts` 的席解析器入参),两处各写一个 `"default"` 字面串正是漂移的成因。 */
29
30
  export declare const DEFAULT_MODEL_REF = "default";
30
- export declare function resolveTaskModel(bodyModel: unknown, objective: string, catalog: Record<string, unknown>, allowlist?: readonly string[]): {
31
+ export declare function resolveTaskModel(bodyModel: unknown, objective: string, catalog: Record<string, unknown>, allowlist?: readonly string[], refusals?: Readonly<Record<string, ModelEntryRefusal>>): {
31
32
  model: string;
32
33
  cleanedObjective: string;
33
34
  unknownExplicit?: string;
34
35
  notAllowed?: ModelNotAllowed;
36
+ unavailable?: ModelUnavailable;
35
37
  };
38
+ /**
39
+ * S-704 判别式拒因:这次**用户选择**点中的是一条已发布、但本部署不可用的目录条目(`config.modelRefusals`)。
40
+ * 与 {@link ModelNotAllowed} 同一种处置口:本函数不 throw,调用点按 leg 分流(fresh 400 `request.model_unavailable` /
41
+ * resume 留声降级)。拒因句由 `model-entry-refusal.ts` 的 `modelUnavailableMessage` 铸(一处)。
42
+ */
43
+ export interface ModelUnavailable {
44
+ /** 调用方发来的原始 ref(name / id / `@name`),已做过日志安全的 repr 归一。 */
45
+ requested: string;
46
+ /** 命中的不可用条目的 catalog name。 */
47
+ name: string;
48
+ refusal: ModelEntryRefusal;
49
+ /** 走的是哪条用户通道:`explicit`=body.model/settings.model,`mention`=objective 内联 `@name`。 */
50
+ source: "explicit" | "mention";
51
+ }
52
+ /**
53
+ * S-704 —— **用户可选目录**:服务的目录经 core `expandTiers` 增广(档位词 / CC 别名 / `best`),再把**不可用条目的名字**
54
+ * 从增广出来的别名键里**让出**。全部用户**显式**选择通道(body.model / `@name` / settings.model / compactionModel /
55
+ * agents[].model / side-query 的 body.model / 名单判定 / `/v1/models` 的可点性)都在这一只上解析 —— 一个优先序,不各写一份;
56
+ * 与之配套的另一半是 {@link matchCatalogModel} 的「保留名不走 id 回退」。
57
+ * ⚠️ 射程只到显式选择:引擎自己的隐式选路(角色的档位缺省、档位梯、workflow 名单在 core 里的解析)用的是 core 的增广目录,
58
+ * 本函数**不**进那里(codex 验修轮 V4:让出名字会改变只带 `modelRole` 的隐式选路)。不可用条目名撞上别名词时引擎侧的改绑
59
+ * (运维配置里点名它的角色绑定 / workflow 名单)自 S-712 起由不变量 `refused-names-not-minted`(`model-entry-refusal.ts`)在
60
+ * 源头收口:增广会把被拒名铸到别的条目上的候选,模型面整组被拒、进不了引擎 —— 于是本函数的「让出」在已采纳的配置上恒是
61
+ * 空操作,保留作 HTTP 选择面的第二道(绕过 stage 的写者若造出这种表,选择面仍不改绑)。
62
+ *
63
+ * 为什么要让出(codex r1 [high],真复现):一条已发布的条目占着它的名字;它可用时,core 的别名展开**让它**(`shadowed`,
64
+ * 同名别名不生成)。它因不可用被摘出目录之后,展开会把同名的别名(条目恰好叫 `sonnet` / `pro` …)重新指到另一只模型 ——
65
+ * 一次点它的选择被静默改绑到另一只模型(及其端点、账号),一张写着它的 @-名单也随之准了那一只。让出之后,它的名字在可选
66
+ * 目录里不存在,选择走 {@link refusedCatalogEntry} 拿到拒因码,名单里的它解析不到任何条目(准不了任何东西)。
67
+ * 可用条目的真名永远不会被让出:目录里 name 唯一,不可用条目的名字只可能撞上**展开生成**的别名键。
68
+ */
69
+ export declare function selectableCatalog<M>(models: Record<string, M>, tiers: Record<string, string> | undefined, refusals: Readonly<Record<string, ModelEntryRefusal>> | undefined): Record<string, M>;
70
+ /**
71
+ * S-704 —— ref(name 或上游 id)→ 它命中的**不可用条目**;未命中 ⇒ undefined。与 {@link matchCatalogModel} 同一种双键
72
+ * 索引(name 用 `Object.hasOwn`,id 扫值)。调用方**先**查可用目录、再查这里(一个 id 被可用与不可用条目共享时,可用那条胜)。
73
+ */
74
+ export declare function refusedCatalogEntry(ref: string, refusals: Readonly<Record<string, ModelEntryRefusal>> | undefined): {
75
+ name: string;
76
+ refusal: ModelEntryRefusal;
77
+ } | undefined;
36
78
  /** [ref] 判别式拒因:名单在场且这次**用户选择**落在名单外。`resolved` 已是目录 name 形(归一后)。 */
37
79
  export interface ModelNotAllowed {
38
80
  /** 调用方发来的原始 ref(name / 档位词 / 别名 / id / `@name`),已做过日志安全的 repr 归一。 */
@@ -64,7 +106,7 @@ export interface ModelNotAllowed {
64
106
  * ⚠️ 射程:本门是**提交时**门。core 运行时路由(degrade 降级链 / cascade 梯 / roles 解析)按 operator
65
107
  * 配置自己走,**不经本门**——那不是一次用户选择,如实声明,不假装是运行时执法。
66
108
  */
67
- export declare function isModelAllowlisted(ref: string, catalog: Record<string, unknown>, allowlist: readonly string[] | undefined): boolean;
109
+ export declare function isModelAllowlisted(ref: string, catalog: Record<string, unknown>, allowlist: readonly string[] | undefined, refusals?: Readonly<Record<string, ModelEntryRefusal>>): boolean;
68
110
  /**
69
111
  * [ref] —— ref → **终端 catalog name**(拒因/日志的可读形)。
70
112
  *
@@ -73,15 +115,18 @@ export declare function isModelAllowlisted(ref: string, catalog: Record<string,
73
115
  * 取条目自己的 `Model.name`(applyEffective 建目录时 `models[m.name].name === m.name`,env lane 同)
74
116
  * 才是终端名;拿不到时如实退回匹配键(诚实缺席,不猜)。
75
117
  */
76
- export declare function terminalCatalogName(ref: string, catalog: Record<string, unknown>): string | undefined;
118
+ export declare function terminalCatalogName(ref: string, catalog: Record<string, unknown>, refusals?: Readonly<Record<string, ModelEntryRefusal>>): string | undefined;
77
119
  /**
78
120
  * [ref]② name+id 双键目录索引:返回 ref 命中的目录 **name 键**(spec.model 的规范形),未知 → undefined。
79
121
  * - name 命中用 Object.hasOwn(🔴 不用真值索引:Object.prototype 继承键 "constructor"/"__proto__" 等在裸
80
122
  * 索引下是真值,会跳过 allow-list——沿袭原实现的纪律)。
81
123
  * - id 命中扫目录值(`Model.id`),跳过 "default" 别名键(它与其指向的真身共享 id,解析到别名会让 /model
82
124
  * 面板与实际路由再度撕裂);同 id 多 name 时取目录序第一个(与 /v1/models 列表序一致,确定性)。
125
+ * - 🔴 S-704(codex 验修轮 V1):**名字先于 id** 这条序对不可用条目同样成立 —— `refusals` 里的名字是一条已发布
126
+ * 条目的名字,它不在可用目录里时**不许**被 id 回退解析成另一条可用条目(v7.100.0 那条条目在目录里,name 命中先返回
127
+ * 它自己)。命中保留名 ⇒ undefined,调用方接着问 {@link refusedCatalogEntry} 拿拒因码。
83
128
  */
84
- export declare function matchCatalogModel(ref: string, catalog: Record<string, unknown>): string | undefined;
129
+ export declare function matchCatalogModel(ref: string, catalog: Record<string, unknown>, refusals?: Readonly<Record<string, ModelEntryRefusal>>): string | undefined;
85
130
  /**
86
131
  * vision precheck: a catalog model can read images iff its `input` modality list
87
132
  * includes `"image"` (the SAME test as the vision capability on GET /v1/models — `server.ts`). FALSE-SAFE on
@@ -1,31 +1,60 @@
1
- import { parseModelMention } from "@sema-agent/core";
1
+ import { expandTiers, parseModelMention } from "@sema-agent/core";
2
2
  export const DEFAULT_MODEL_REF = "default";
3
- export function resolveTaskModel(bodyModel, objective, catalog, allowlist) {
4
- const mention = parseModelMention(objective, Object.keys(catalog));
5
- const explicit = typeof bodyModel === "string" ? matchCatalogModel(bodyModel, catalog) : undefined;
3
+ export function resolveTaskModel(bodyModel, objective, catalog, allowlist, refusals) {
4
+ const mention = parseModelMention(objective, [...Object.keys(refusals ?? {}), ...Object.keys(catalog)]);
5
+ const mentionUsable = mention.model !== undefined && Object.hasOwn(catalog, mention.model) ? mention.model : undefined;
6
+ const explicit = typeof bodyModel === "string" ? matchCatalogModel(bodyModel, catalog, refusals) : undefined;
6
7
  const present = bodyModel !== undefined && bodyModel !== null;
7
- const unknown = present && explicit === undefined;
8
8
  const repr = typeof bodyModel !== "string" ? `<non-string:${typeof bodyModel}>` : bodyModel === "" ? "<empty-string>" : bodyModel;
9
- const model = explicit ?? mention.model ?? DEFAULT_MODEL_REF;
10
- const picked = explicit !== undefined ? { ref: explicit, source: "explicit", requested: repr } : mention.model !== undefined ? { ref: mention.model, source: "mention", requested: mention.model } : undefined;
11
- const notAllowed = picked !== undefined && !isModelAllowlisted(picked.ref, catalog, allowlist)
12
- ? { requested: picked.requested, resolved: terminalCatalogName(picked.ref, catalog) ?? picked.ref, source: picked.source }
9
+ const refusedExplicit = present && explicit === undefined && typeof bodyModel === "string" ? refusedCatalogEntry(bodyModel, refusals) : undefined;
10
+ const refusedMention = explicit === undefined && refusedExplicit === undefined && mention.model !== undefined && mentionUsable === undefined ? refusedCatalogEntry(mention.model, refusals) : undefined;
11
+ const unavailable = refusedExplicit !== undefined
12
+ ? { requested: repr, name: refusedExplicit.name, refusal: refusedExplicit.refusal, source: "explicit" }
13
+ : refusedMention !== undefined
14
+ ? { requested: mention.model, name: refusedMention.name, refusal: refusedMention.refusal, source: "mention" }
15
+ : undefined;
16
+ const unknown = present && explicit === undefined && refusedExplicit === undefined;
17
+ const model = explicit ?? mentionUsable ?? DEFAULT_MODEL_REF;
18
+ const picked = explicit !== undefined ? { ref: explicit, source: "explicit", requested: repr } : mentionUsable !== undefined ? { ref: mentionUsable, source: "mention", requested: mentionUsable } : undefined;
19
+ const notAllowed = picked !== undefined && !isModelAllowlisted(picked.ref, catalog, allowlist, refusals)
20
+ ? { requested: picked.requested, resolved: terminalCatalogName(picked.ref, catalog, refusals) ?? picked.ref, source: picked.source }
13
21
  : undefined;
14
22
  return {
15
23
  model,
16
24
  cleanedObjective: mention.cleanedText || objective,
17
25
  ...(unknown ? { unknownExplicit: repr } : {}),
18
26
  ...(notAllowed ? { notAllowed } : {}),
27
+ ...(unavailable ? { unavailable } : {}),
19
28
  };
20
29
  }
21
- export function isModelAllowlisted(ref, catalog, allowlist) {
30
+ export function selectableCatalog(models, tiers, refusals) {
31
+ const expanded = (expandTiers(models, tiers ?? {}) ?? models);
32
+ const reserved = Object.keys(refusals ?? {}).filter((n) => Object.hasOwn(expanded, n) && !Object.hasOwn(models, n));
33
+ if (reserved.length === 0)
34
+ return expanded;
35
+ const out = { ...expanded };
36
+ for (const n of reserved)
37
+ delete out[n];
38
+ return out;
39
+ }
40
+ export function refusedCatalogEntry(ref, refusals) {
41
+ if (refusals === undefined)
42
+ return undefined;
43
+ if (Object.hasOwn(refusals, ref))
44
+ return { name: ref, refusal: refusals[ref] };
45
+ for (const [name, r] of Object.entries(refusals))
46
+ if (r.id === ref)
47
+ return { name, refusal: r };
48
+ return undefined;
49
+ }
50
+ export function isModelAllowlisted(ref, catalog, allowlist, refusals) {
22
51
  if (allowlist === undefined || allowlist.length === 0)
23
52
  return true;
24
- const entry = catalogEntryOf(ref, catalog);
53
+ const entry = catalogEntryOf(ref, catalog, refusals);
25
54
  if (entry === undefined)
26
55
  return true;
27
56
  for (const name of allowlist) {
28
- const allowed = catalogEntryOf(name, catalog);
57
+ const allowed = catalogEntryOf(name, catalog, refusals);
29
58
  if (allowed === undefined)
30
59
  continue;
31
60
  if (allowed === entry)
@@ -33,20 +62,22 @@ export function isModelAllowlisted(ref, catalog, allowlist) {
33
62
  }
34
63
  return false;
35
64
  }
36
- function catalogEntryOf(ref, catalog) {
37
- const name = matchCatalogModel(ref, catalog);
65
+ function catalogEntryOf(ref, catalog, refusals) {
66
+ const name = matchCatalogModel(ref, catalog, refusals);
38
67
  return name === undefined ? undefined : catalog[name];
39
68
  }
40
- export function terminalCatalogName(ref, catalog) {
41
- const key = matchCatalogModel(ref, catalog);
69
+ export function terminalCatalogName(ref, catalog, refusals) {
70
+ const key = matchCatalogModel(ref, catalog, refusals);
42
71
  if (key === undefined)
43
72
  return undefined;
44
73
  const name = catalog[key]?.name;
45
74
  return typeof name === "string" && name.length > 0 ? name : key;
46
75
  }
47
- export function matchCatalogModel(ref, catalog) {
76
+ export function matchCatalogModel(ref, catalog, refusals) {
48
77
  if (Object.hasOwn(catalog, ref))
49
78
  return ref;
79
+ if (refusals !== undefined && Object.hasOwn(refusals, ref))
80
+ return undefined;
50
81
  for (const [name, m] of Object.entries(catalog)) {
51
82
  if (name === "default")
52
83
  continue;
@@ -123,7 +123,7 @@ export declare const FAIL_OPEN_TAGS: {
123
123
  };
124
124
  readonly "server.approvals.park-row-blob-unreadable": {
125
125
  readonly cls: "F";
126
- readonly note: "S-700(`plugins/checkpoint-store-sql.ts` 的 `readParkRowFactsCell`):运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint` 列里寄存行的 **`pendingAction` 子树**(NP-3 / 7.100.0 起引擎内只取这一棵、整只快照不出库;只为 park 行事实 `requiresRealApproval` / `ruleOffersAbsence` 而读)**在场却不是一只对象**(JSON `null` 字面 / 串 / 数 —— 手改 / 迁移脚本改过)。子树**缺席**(SQL NULL:成员不在,或整只文档不是对象 —— 后者 `get()` 照旧拒;MariaDB 文本承载上的非 JSON 字节同样答 SQL NULL)⇒ 事实缺席、不计数。放行的最坏后果 = **这一行**的两位 park 行事实缺席(展示/分诊材料;core 定性 display metadata,resume 的带子读 gate 自己的位),队列其余部分照常 —— 而这一行本来就 resume 不了(`get()` 对同一格拒)。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 blob 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),与同一 `rows.map` 里 `rule_suggestions` / `risk_descriptor` 两格同族同判。故 F 类;tag 与那两格**分开计**(哪一格在坏要读得出来)。计数非零 = 这一行的 decide 同样读不动(`get()` 对同一格拒),去按 session 查那一行。";
126
+ readonly note: "S-700(`plugins/checkpoint-store-sql.ts` 的 `readParkRowFactsCell`):运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint` 列里寄存行的 **`pendingAction` 子树**(NP-3 / 7.100.0 起引擎内只取这一棵、整只快照不出库;只为 park 行事实 `requiresRealApproval` / `ruleOffersAbsence` / `mandated`(S-663 二段,core #1079)而读 —— 事实位的真源是 `approval-card.ts` 的 `ParkRowFacts`)**在场却不是一只对象**(JSON `null` 字面 / 串 / 数 —— 手改 / 迁移脚本改过)。子树**缺席**(SQL NULL:成员不在,或整只文档不是对象 —— 后者 `get()` 照旧拒;MariaDB 文本承载上的非 JSON 字节同样答 SQL NULL)⇒ 事实缺席、不计数。放行的最坏后果 = **这一行**的 park 行事实(`ParkRowFacts` 各位)缺席(展示/分诊材料;core 定性 display metadata,resume 的带子读 gate 自己的位),队列其余部分照常 —— 而这一行本来就 resume 不了(`get()` 对同一格拒)。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 blob 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),与同一 `rows.map` 里 `rule_suggestions` / `risk_descriptor` 两格同族同判。故 F 类;tag 与那两格**分开计**(哪一格在坏要读得出来)。计数非零 = 这一行的 decide 同样读不动(`get()` 对同一格拒),去按 session 查那一行。";
127
127
  };
128
128
  readonly "server.sql-json.checkpoint.pending-steer-queue-lossy": {
129
129
  readonly cls: "F";
@@ -173,6 +173,10 @@ export declare const FAIL_OPEN_TAGS: {
173
173
  readonly cls: "F";
174
174
  readonly note: "A-032 P1-④:`runStatusToFleet` 收到**词表外**的 run 状态词(闭集签名之外的唯一来路 = `rowToRun` 把无约束的 status 文本列裸 cast,滚动升级里更新的副本写的新词被旧副本读到)⇒ 折成 `idle`。放行的最坏后果 = 该行在 fleet 面板上**永久滞留**(removal 集 {completed,failed,killed} 收不到 idle),纯展示面、不影响 run 本身与任何执法判据,故 F 类。方向刻意不改(把 miss 折成终局会把一条还在跑的任务从面板上摘掉,误摘比多一行更伤)。留痕是承重的:2026-06-28 on-box e2e 逮到的 `blocked` 泄漏当年只能靠人眼在面板上发现——计数让下一次词表漂移在遥测里当场显形。编译期一侧已由闭集入参 + `never` 臂执法。";
175
175
  };
176
+ readonly "server.fleet.agent-status-unmapped": {
177
+ readonly cls: "F";
178
+ readonly note: "S-751:fleet 对账腿问 bg registry「这只子代还活着吗」时,读到**词表外**的 `BackgroundAgentRecord.status`(闭集之外的唯一来路 = SQL 孪生把 status 文本列裸 cast 成联合,滚动升级里更新的 core 写下的新词被本副本读到)⇒ 按「属主作保」处置:子代行**留在面板上**。放行的最坏后果 = 一条其实已终局的子代行多留在面板上(它的终态通知仍会结算它;与 #261 之前「子代行永远不走」同形),纯展示面、不影响子代本身与任何执法判据,故 F 类。方向刻意不折成「死」:把不认识的词读成死会把一只正在跑的后台子代从面板上摘掉,而那正是 S-751 报案的症状(误摘比多一行更伤,与 `server.fleet.run-status-unmapped` 同一条判据)。编译期一侧由 `bgAgentVouches` 的穷举 switch + `never` 臂执法;留痕让词表漂移在遥测里当场显形。";
179
+ };
176
180
  readonly "server.run-store.stale-claim-quarantine-unremoved": {
177
181
  readonly cls: "F";
178
182
  readonly note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。";
@@ -193,6 +197,14 @@ export declare const FAIL_OPEN_TAGS: {
193
197
  readonly cls: "F";
194
198
  readonly note: "#310(codex 对抗复审 R1-[medium],验真后修):三条 run 腿把一条白名单通告写进 durable 账本时,那次 `appendEvent` **异步 reject**(store 抖动 / 连接断 / 行冲突)。结构上不能 await —— `onNotice` 是 core 的同步回调,拿一次账本写去阻塞引擎是更坏的交易 —— 所以写口只能 fire-and-forget,`route` 的同步 try 结构性观察不到这次失败 —— 「投出去了」与「真落盘了」不是同一件事。放行的最坏后果按腿分档,如实登记:**sync stream 腿**还有 live SSE 那一份(在线的人看得见,丢的是断连补看);**bg / resume 两腿账本是唯一的用户可见终点** ⇒ 那条通告对用户永久消失。仍判 F 而不是 P:通告是**披露**面不是执法面(不参与任何门/裁决),且运维面那一份(结构化日志 `engine_notice`)在分流之前就已经落定、一条不丢。此前是裸 `.catch(() => undefined)` —— 同文件 resume 腿的子代 forward 写口早在 C2/C5 批2 就因为「裸吞 = 事件从 durable log 永久消失且零信号」补过 `warnAppend`,本 tag 是同一条纪律在通告口的落实(warn 留细节 + 计数让「系统性在漏」显形)。不做重试队列是刻意的:账本里没有任何一族行做有界重试,单给通告开一条会造出第二套持久化语义。";
195
199
  };
200
+ readonly "server.memory.locked-layer-probe-partial": {
201
+ readonly cls: "F";
202
+ readonly note: "O2(7.101.0 合并复审,`memory-layer-legacy-lock.ts announceLockedLegacyMemoryLayers`,boot 期锁住记忆层探测;detail 只报 `unreadable=<条数>`):挂载面或某个层里的目录**读不出**(EACCES 之类 —— 不是 ≤7.30.x 引擎的锁形,那种 0555 目录可读)⇒ 那棵子树看不全,照常起服。放行的最坏后果 = 一个锁住的层在 boot 日志里没被点名 —— 探测是**披露**面,不参与任何门 / 裁决;core 在真正失败的那一次删除上仍给出自己的 `chmod -R u+w` 补救句。不放行的代价严重不对称:为一条 warn 打死起服(混跑窗里能服务的副本)。计数让「这台部署的记忆树有读不出的目录」显形。";
203
+ };
204
+ readonly "server.instruction-source.announce-degraded": {
205
+ readonly cls: "F";
206
+ readonly note: "S-690(`instruction-source-notice.ts announceInstructionSource`,项目指令换源通告的唯一铸点;detail 的 `op` 点名哪一步):基线**读**失败或超期(会话店抖动 / 列形坏 / 2s 期限到,detail 带 `timeout=`)⇒ 本 run 不比也不写;基线**写**失败或超期 ⇒ 本 run 的换源帧照发(它对本 run 为真),下一 run 可能再说一遍同一次换源(超期后才落地的写由 CAS 挡住,盖不掉更新的基线);通告**出口**抛 ⇒ 本 run 缺这一帧。放行的最坏后果 = 一次换源没被说出来或被多说一次 —— 通告是**披露**面,不参与任何门 / 裁决,模型读到的指令本身不受影响。不放行的代价严重不对称:铸点跑在 core 的 `loadProjectMemory` 席内,抛回去 core 会把这次 prepare 的项目记忆整块丢掉(不注入),为一条通告赔上指令本身。计数让「某台部署的会话店一直读不出基线」与「这台部署从来没换过源」在遥测上分得开。";
207
+ };
196
208
  readonly "server.engine-notice.wire-sink-threw": {
197
209
  readonly cls: "F";
198
210
  readonly note: "#310:`engine_notice` 分流器把一条白名单通告投给某条 run 腿的登记口时,那只口抛了(live 口写向已断/已撕裂的 SSE socket,或 durable 口的账本写同步抛)。放行的最坏后果 = **这一条通告的这一个终点**缺席:①日志终点在分流之前已经逐字打过(事实一条不丢);②两个终点注册成两只独立 sink ⇒ live 抛不牵连 durable(断连后仍看得见的那半保住);③同会话其余口照投。故 F 类。不放行的代价是把异常回抛给 core 的 `deliverEngineNotice` —— 它会吞掉,于是同一次失败**既没有留痕也没有第二只口**,正是本 tag 要根除的形。留痕是承重的:静默吞掉之后「wire 腿为什么总有几条通告不到」在遥测里与「core 本来就没发」同形。";
@@ -114,7 +114,7 @@ export const FAIL_OPEN_TAGS = {
114
114
  },
115
115
  "server.approvals.park-row-blob-unreadable": {
116
116
  cls: "F",
117
- note: "S-700(`plugins/checkpoint-store-sql.ts` 的 `readParkRowFactsCell`):运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint` 列里寄存行的 **`pendingAction` 子树**(NP-3 / 7.100.0 起引擎内只取这一棵、整只快照不出库;只为 park 行事实 `requiresRealApproval` / `ruleOffersAbsence` 而读)**在场却不是一只对象**(JSON `null` 字面 / 串 / 数 —— 手改 / 迁移脚本改过)。子树**缺席**(SQL NULL:成员不在,或整只文档不是对象 —— 后者 `get()` 照旧拒;MariaDB 文本承载上的非 JSON 字节同样答 SQL NULL)⇒ 事实缺席、不计数。放行的最坏后果 = **这一行**的两位 park 行事实缺席(展示/分诊材料;core 定性 display metadata,resume 的带子读 gate 自己的位),队列其余部分照常 —— 而这一行本来就 resume 不了(`get()` 对同一格拒)。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 blob 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),与同一 `rows.map` 里 `rule_suggestions` / `risk_descriptor` 两格同族同判。故 F 类;tag 与那两格**分开计**(哪一格在坏要读得出来)。计数非零 = 这一行的 decide 同样读不动(`get()` 对同一格拒),去按 session 查那一行。",
117
+ note: "S-700(`plugins/checkpoint-store-sql.ts` 的 `readParkRowFactsCell`):运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint` 列里寄存行的 **`pendingAction` 子树**(NP-3 / 7.100.0 起引擎内只取这一棵、整只快照不出库;只为 park 行事实 `requiresRealApproval` / `ruleOffersAbsence` / `mandated`(S-663 二段,core #1079)而读 —— 事实位的真源是 `approval-card.ts` 的 `ParkRowFacts`)**在场却不是一只对象**(JSON `null` 字面 / 串 / 数 —— 手改 / 迁移脚本改过)。子树**缺席**(SQL NULL:成员不在,或整只文档不是对象 —— 后者 `get()` 照旧拒;MariaDB 文本承载上的非 JSON 字节同样答 SQL NULL)⇒ 事实缺席、不计数。放行的最坏后果 = **这一行**的 park 行事实(`ParkRowFacts` 各位)缺席(展示/分诊材料;core 定性 display metadata,resume 的带子读 gate 自己的位),队列其余部分照常 —— 而这一行本来就 resume 不了(`get()` 对同一格拒)。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 blob 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),与同一 `rows.map` 里 `rule_suggestions` / `risk_descriptor` 两格同族同判。故 F 类;tag 与那两格**分开计**(哪一格在坏要读得出来)。计数非零 = 这一行的 decide 同样读不动(`get()` 对同一格拒),去按 session 查那一行。",
118
118
  },
119
119
  "server.sql-json.checkpoint.pending-steer-queue-lossy": {
120
120
  cls: "F",
@@ -164,6 +164,10 @@ export const FAIL_OPEN_TAGS = {
164
164
  cls: "F",
165
165
  note: "A-032 P1-④:`runStatusToFleet` 收到**词表外**的 run 状态词(闭集签名之外的唯一来路 = `rowToRun` 把无约束的 status 文本列裸 cast,滚动升级里更新的副本写的新词被旧副本读到)⇒ 折成 `idle`。放行的最坏后果 = 该行在 fleet 面板上**永久滞留**(removal 集 {completed,failed,killed} 收不到 idle),纯展示面、不影响 run 本身与任何执法判据,故 F 类。方向刻意不改(把 miss 折成终局会把一条还在跑的任务从面板上摘掉,误摘比多一行更伤)。留痕是承重的:2026-06-28 on-box e2e 逮到的 `blocked` 泄漏当年只能靠人眼在面板上发现——计数让下一次词表漂移在遥测里当场显形。编译期一侧已由闭集入参 + `never` 臂执法。",
166
166
  },
167
+ "server.fleet.agent-status-unmapped": {
168
+ cls: "F",
169
+ note: "S-751:fleet 对账腿问 bg registry「这只子代还活着吗」时,读到**词表外**的 `BackgroundAgentRecord.status`(闭集之外的唯一来路 = SQL 孪生把 status 文本列裸 cast 成联合,滚动升级里更新的 core 写下的新词被本副本读到)⇒ 按「属主作保」处置:子代行**留在面板上**。放行的最坏后果 = 一条其实已终局的子代行多留在面板上(它的终态通知仍会结算它;与 #261 之前「子代行永远不走」同形),纯展示面、不影响子代本身与任何执法判据,故 F 类。方向刻意不折成「死」:把不认识的词读成死会把一只正在跑的后台子代从面板上摘掉,而那正是 S-751 报案的症状(误摘比多一行更伤,与 `server.fleet.run-status-unmapped` 同一条判据)。编译期一侧由 `bgAgentVouches` 的穷举 switch + `never` 臂执法;留痕让词表漂移在遥测里当场显形。",
170
+ },
167
171
  "server.run-store.stale-claim-quarantine-unremoved": {
168
172
  cls: "F",
169
173
  note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。",
@@ -184,6 +188,14 @@ export const FAIL_OPEN_TAGS = {
184
188
  cls: "F",
185
189
  note: "#310(codex 对抗复审 R1-[medium],验真后修):三条 run 腿把一条白名单通告写进 durable 账本时,那次 `appendEvent` **异步 reject**(store 抖动 / 连接断 / 行冲突)。结构上不能 await —— `onNotice` 是 core 的同步回调,拿一次账本写去阻塞引擎是更坏的交易 —— 所以写口只能 fire-and-forget,`route` 的同步 try 结构性观察不到这次失败 —— 「投出去了」与「真落盘了」不是同一件事。放行的最坏后果按腿分档,如实登记:**sync stream 腿**还有 live SSE 那一份(在线的人看得见,丢的是断连补看);**bg / resume 两腿账本是唯一的用户可见终点** ⇒ 那条通告对用户永久消失。仍判 F 而不是 P:通告是**披露**面不是执法面(不参与任何门/裁决),且运维面那一份(结构化日志 `engine_notice`)在分流之前就已经落定、一条不丢。此前是裸 `.catch(() => undefined)` —— 同文件 resume 腿的子代 forward 写口早在 C2/C5 批2 就因为「裸吞 = 事件从 durable log 永久消失且零信号」补过 `warnAppend`,本 tag 是同一条纪律在通告口的落实(warn 留细节 + 计数让「系统性在漏」显形)。不做重试队列是刻意的:账本里没有任何一族行做有界重试,单给通告开一条会造出第二套持久化语义。",
186
190
  },
191
+ "server.memory.locked-layer-probe-partial": {
192
+ cls: "F",
193
+ note: "O2(7.101.0 合并复审,`memory-layer-legacy-lock.ts announceLockedLegacyMemoryLayers`,boot 期锁住记忆层探测;detail 只报 `unreadable=<条数>`):挂载面或某个层里的目录**读不出**(EACCES 之类 —— 不是 ≤7.30.x 引擎的锁形,那种 0555 目录可读)⇒ 那棵子树看不全,照常起服。放行的最坏后果 = 一个锁住的层在 boot 日志里没被点名 —— 探测是**披露**面,不参与任何门 / 裁决;core 在真正失败的那一次删除上仍给出自己的 `chmod -R u+w` 补救句。不放行的代价严重不对称:为一条 warn 打死起服(混跑窗里能服务的副本)。计数让「这台部署的记忆树有读不出的目录」显形。",
194
+ },
195
+ "server.instruction-source.announce-degraded": {
196
+ cls: "F",
197
+ note: "S-690(`instruction-source-notice.ts announceInstructionSource`,项目指令换源通告的唯一铸点;detail 的 `op` 点名哪一步):基线**读**失败或超期(会话店抖动 / 列形坏 / 2s 期限到,detail 带 `timeout=`)⇒ 本 run 不比也不写;基线**写**失败或超期 ⇒ 本 run 的换源帧照发(它对本 run 为真),下一 run 可能再说一遍同一次换源(超期后才落地的写由 CAS 挡住,盖不掉更新的基线);通告**出口**抛 ⇒ 本 run 缺这一帧。放行的最坏后果 = 一次换源没被说出来或被多说一次 —— 通告是**披露**面,不参与任何门 / 裁决,模型读到的指令本身不受影响。不放行的代价严重不对称:铸点跑在 core 的 `loadProjectMemory` 席内,抛回去 core 会把这次 prepare 的项目记忆整块丢掉(不注入),为一条通告赔上指令本身。计数让「某台部署的会话店一直读不出基线」与「这台部署从来没换过源」在遥测上分得开。",
198
+ },
187
199
  "server.engine-notice.wire-sink-threw": {
188
200
  cls: "F",
189
201
  note: "#310:`engine_notice` 分流器把一条白名单通告投给某条 run 腿的登记口时,那只口抛了(live 口写向已断/已撕裂的 SSE socket,或 durable 口的账本写同步抛)。放行的最坏后果 = **这一条通告的这一个终点**缺席:①日志终点在分流之前已经逐字打过(事实一条不丢);②两个终点注册成两只独立 sink ⇒ live 抛不牵连 durable(断连后仍看得见的那半保住);③同会话其余口照投。故 F 类。不放行的代价是把异常回抛给 core 的 `deliverEngineNotice` —— 它会吞掉,于是同一次失败**既没有留痕也没有第二只口**,正是本 tag 要根除的形。留痕是承重的:静默吞掉之后「wire 腿为什么总有几条通告不到」在遥测里与「core 本来就没发」同形。",
@@ -239,6 +239,7 @@ export function createMetrics() {
239
239
  m.counter("cost_quota_rejected_total", "Tasks refused because the principal is over its cumulative cost quota");
240
240
  m.counter("degraded_total", "Tasks degraded to a cheaper model (1.40), by reason (budget/rate_limit/breaker_open)");
241
241
  m.counter("task_model_not_allowlisted_resume_total", "Resumed legs whose model/compactionModel is outside the @-mention allowlist (allowed to keep running by design — the allowlist gates fresh picks only)");
242
+ m.counter("task_model_unavailable_fallback_total", "Resumed legs whose explicit model pick names a catalog entry that is unavailable on this deployment (no brain mounted for its provider / entry fails its schema) — the leg runs on the default model; fresh submits get 400 request.model_unavailable");
242
243
  m.counter("prompt_constitution_total", "Prompt assembly constitution mode per task (core 1.243 提示词主权): steady state = core; provider-assembled/replaced/legacy warrant eyes");
243
244
  m.counter("rewind_snapshot_failed_total", "Per-turn rewind file snapshots that failed, by the engine's FileHistoryResult.error.code (snapshot_failed/aborted/…; `unknown` = the disclosure text carried no readable code). COARSE on purpose — whether a failure is permanent (the same file will fail every turn until the deployment changes) is counted by file_history_permanent_failure_total instead, from the typed error object rather than from prose");
244
245
  m.counter("verifications_total", "Tasks run through the verification gate (1.44), by final verdict");
@@ -1,11 +1,32 @@
1
1
  import type { ServerSecretEnvFinding } from "../server-secret-env.js";
2
2
  import type { Logger } from "./logger.js";
3
3
  import type { Metrics } from "./metrics.js";
4
+ /**
5
+ * 一个剥除现场的登记:`note` = 这条腿上泄漏意味着什么;`remedy` = 部署方能不能自己把某个被剥的键放回来。
6
+ *
7
+ * 🔴 S-699:`remedy` 在场与否决定披露行的**级别**,规则一句 —— **有出路的现场用 warn,并把出路句与当前放行名单写进同一行**;
8
+ * 没有出路的现场是纯保护留痕,照旧 info。理由:没有出路的剥除(模型可驱动的 shell、git 钩子)是本服务替部署方做的决定,
9
+ * 读者只需要知道它生效了;有出路的剥除(语言服务器)可能让一件功能静默变差(补全 / 诊断缺了私有 registry 的依赖),
10
+ * 那是部署方要自己决定的事 —— 看不见 = 无从归因。出路句不带任何值。
11
+ */
12
+ export interface SecretEnvScrubSiteSpec {
13
+ readonly note: string;
14
+ readonly remedy: string | null;
15
+ }
4
16
  /** 登记过的剥除现场。闭集:未登记的 site 传不进 {@link recordSecretEnvScrub}(编译期拒,同 FAIL_OPEN_TAGS 纪律)。 */
5
17
  export declare const SECRET_ENV_SCRUB_SITES: {
6
- readonly "server.remote-env-host.inherit-scrub": "模型可驱动的 host shell 继承 env(`inheritEnv:\"scrub\"` 默认腿)——这条腿上的一次泄漏等于把编排器的脑钥/仓库令牌交给模型选的命令";
7
- readonly "server.project-memory.git-subprocess": "project-memory 读仓叙事时的 git 子进程 env —— 仓内的钩子/凭证助手会以本进程身份运行";
8
- readonly "server.lsp.spawn-env": "宿主车道本地语言服务器子进程 env(core 7.30.1 #1073 的 `NodeLspManagerOptions.env`)—— 语言服务器会执行仓里的代码(构建脚本/插件/工作区配置),一次泄漏等于把本进程的凭据交给仓";
18
+ readonly "server.remote-env-host.inherit-scrub": {
19
+ readonly note: "模型可驱动的 host shell 继承 env(`inheritEnv:\"scrub\"` 默认腿)——这条腿上的一次泄漏等于把编排器的脑钥/仓库令牌交给模型选的命令";
20
+ readonly remedy: null;
21
+ };
22
+ readonly "server.project-memory.git-subprocess": {
23
+ readonly note: "project-memory 读仓叙事时的 git 子进程 env —— 仓内的钩子/凭证助手会以本进程身份运行";
24
+ readonly remedy: null;
25
+ };
26
+ readonly "server.lsp.spawn-env": {
27
+ readonly note: "宿主车道本地语言服务器子进程 env(core 7.30.1 #1073 的 `NodeLspManagerOptions.env`)—— 语言服务器会执行仓里的代码(构建脚本/插件/工作区配置),一次泄漏等于把本进程的凭据交给仓";
28
+ readonly remedy: `LSP_ENV_ALLOW=NAME,NAME \u70B9\u540D\u653E\u884C(\u539F\u6837\u4EA4\u7ED9\u8BED\u8A00\u670D\u52A1\u5668,\u90E8\u7F72\u65B9\u663E\u5F0F\u627F\u62C5;${string});\u503C\u4E0D\u8FDB\u65E5\u5FD7`;
29
+ };
9
30
  };
10
31
  /** 词表键推导的闭集类型。 */
11
32
  export type SecretEnvScrubSite = keyof typeof SECRET_ENV_SCRUB_SITES;
@@ -16,8 +37,10 @@ interface Sink {
16
37
  /**
17
38
  * 记一次剥除。**总不抛** —— 观测本身绝不能变成故障(同 `recordFailOpen` / reapers 的判据)。
18
39
  * `findings` 为空(这一次没丢任何 key)时什么也不做:那不是事件,是常态。
40
+ * `findings` 只许装**最终没进子进程**的键 —— 点名放回的键不是剥除(不计数、不披露)。
41
+ * `allowed` = 这一次生效的点名放行名单(S-699),只在有出路的现场(`remedy` 在场)写进披露行;没有放行面的腿传 `[]`。
19
42
  */
20
- export declare function recordSecretEnvScrub(site: SecretEnvScrubSite, findings: readonly ServerSecretEnvFinding[]): void;
43
+ export declare function recordSecretEnvScrub(site: SecretEnvScrubSite, findings: readonly ServerSecretEnvFinding[], allowed: readonly string[]): void;
21
44
  /** 装配层把 logger/metrics 接上(main.ts,与 `installFailOpenRecorder` 同一处)。装配前累计的次数在此补进 metrics。 */
22
45
  export declare function installSecretEnvScrubRecorder(deps: Sink): void;
23
46
  /** 已留驻的「现场×键」条数(测试/诊断读面)。恒 ≤ {@link DISCLOSED_CAP}。 */
@@ -1,8 +1,18 @@
1
1
  import { summarizeRedactions } from "@sema-agent/core";
2
+ import { describeAllowlistPostures } from "../env-name-allowlist-knobs.js";
2
3
  export const SECRET_ENV_SCRUB_SITES = {
3
- "server.remote-env-host.inherit-scrub": "模型可驱动的 host shell 继承 env(`inheritEnv:\"scrub\"` 默认腿)——这条腿上的一次泄漏等于把编排器的脑钥/仓库令牌交给模型选的命令",
4
- "server.project-memory.git-subprocess": "project-memory 读仓叙事时的 git 子进程 env —— 仓内的钩子/凭证助手会以本进程身份运行",
5
- "server.lsp.spawn-env": "宿主车道本地语言服务器子进程 env(core 7.30.1 #1073 的 `NodeLspManagerOptions.env`)—— 语言服务器会执行仓里的代码(构建脚本/插件/工作区配置),一次泄漏等于把本进程的凭据交给仓",
4
+ "server.remote-env-host.inherit-scrub": {
5
+ note: "模型可驱动的 host shell 继承 env(`inheritEnv:\"scrub\"` 默认腿)——这条腿上的一次泄漏等于把编排器的脑钥/仓库令牌交给模型选的命令",
6
+ remedy: null,
7
+ },
8
+ "server.project-memory.git-subprocess": {
9
+ note: "project-memory 读仓叙事时的 git 子进程 env —— 仓内的钩子/凭证助手会以本进程身份运行",
10
+ remedy: null,
11
+ },
12
+ "server.lsp.spawn-env": {
13
+ note: "宿主车道本地语言服务器子进程 env(core 7.30.1 #1073 的 `NodeLspManagerOptions.env`)—— 语言服务器会执行仓里的代码(构建脚本/插件/工作区配置),一次泄漏等于把本进程的凭据交给仓",
14
+ remedy: `LSP_ENV_ALLOW=NAME,NAME 点名放行(原样交给语言服务器,部署方显式承担;${describeAllowlistPostures()});值不进日志`,
15
+ },
6
16
  };
7
17
  const SCRUB_COUNTER = "secret_env_scrubbed_total";
8
18
  const DISCLOSED_CAP = 512;
@@ -31,7 +41,7 @@ function drainToMetrics() {
31
41
  }
32
42
  }
33
43
  }
34
- export function recordSecretEnvScrub(site, findings) {
44
+ export function recordSecretEnvScrub(site, findings, allowed) {
35
45
  if (findings.length === 0)
36
46
  return;
37
47
  try {
@@ -49,12 +59,17 @@ export function recordSecretEnvScrub(site, findings) {
49
59
  const fresh = findings.filter((f) => !disclosed.has(`${site}${SEP}${f.key}`)).slice(0, room);
50
60
  if (fresh.length === 0)
51
61
  return;
52
- logger.info("secret_env_scrubbed", {
62
+ const spec = SECRET_ENV_SCRUB_SITES[site];
63
+ const fields = {
53
64
  site,
54
- note: SECRET_ENV_SCRUB_SITES[site],
65
+ note: spec.note,
55
66
  keys: fresh.map((f) => f.key),
56
67
  summary: summarizeRedactions(fresh),
57
- });
68
+ };
69
+ if (spec.remedy === null)
70
+ logger.info("secret_env_scrubbed", fields);
71
+ else
72
+ logger.warn("secret_env_scrubbed", { ...fields, allowed: [...allowed], hint: spec.remedy });
58
73
  for (const f of fresh)
59
74
  disclosed.add(`${site}${SEP}${f.key}`);
60
75
  }
@@ -2,6 +2,7 @@ import type { AcquiredSession, PlacedSessionRow, RetentionDeclaration, SessionPl
2
2
  import type { SessionListItem } from "../security.js";
3
3
  import type { StagingHandle } from "../session-sync-kernel.js";
4
4
  import { type PlacementAcquireOpts } from "./session-placement.js";
5
+ import type { InstructionSourceStore } from "../instruction-source-notice.js";
5
6
  export interface CachingOptions {
6
7
  /** Idle seconds a woken session is kept warm. 0 disables caching (pure passthrough). */
7
8
  ttlSec: number;
@@ -64,6 +65,11 @@ export declare class CachingSessionStore implements SessionStore {
64
65
  /** Write-once auto-title (SQL twins + local sidecar carry it; pure pass-through). */
65
66
  readonly setTitleIfNull?: (sessionId: string, title: string) => Promise<boolean>;
66
67
  readonly probeTitle?: (sessionId: string) => Promise<"none" | "untitled" | "titled">;
68
+ /** S-690 —— 换源通告的基线面(`session_meta.instruction_source` / local sidecar),纯转发、不经暖缓存。
69
+ * 🔴 必须转发(与 retention / placements 同一条教训):默认 `SESSION_CACHE_TTL_SEC=300` 把真店包进本类,
70
+ * 而装配点按「店上两个方法都在」判席 —— 不转发 = 默认部署上换源通告**静默不发**,且没有任何一处报错。 */
71
+ readonly readInstructionSource?: InstructionSourceStore["readInstructionSource"];
72
+ readonly recordInstructionSource?: InstructionSourceStore["recordInstructionSource"];
67
73
  /** E18 — delegate the leaf read straight to inner (a cache-bypassing single SELECT; the resume-at turn-capture
68
74
  * reads the DURABLE leaf, not a possibly-stale cached view — same discipline as the §0.5 repo ops above). */
69
75
  readonly getLeafId?: (sessionId: string) => Promise<string | null>;