@sema-agent/server 7.15.0 → 7.16.0-rc.2
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 +36 -2
- package/dist/approval-card.d.ts +65 -0
- package/dist/approval-card.js +54 -6
- package/dist/approval-reconciler.d.ts +17 -1
- package/dist/boot/resolve-spec.js +31 -4
- package/dist/boot/runner-deps.d.ts +41 -2
- package/dist/boot/runner-deps.js +43 -0
- package/dist/boot/session-faces.js +26 -4
- package/dist/boot/shutdown.js +17 -0
- package/dist/boot/stores.js +20 -0
- package/dist/config-center/apply-effective.d.ts +14 -0
- package/dist/config-center/apply-effective.js +81 -1
- package/dist/config-types.d.ts +28 -2
- package/dist/config.js +71 -37
- package/dist/fleet/fleet-terminal-window.d.ts +12 -0
- package/dist/fleet/fleet-terminal-window.js +27 -4
- package/dist/http/active-run-conflict.d.ts +41 -1
- package/dist/http/active-run-conflict.js +24 -10
- package/dist/http/routes/approvals-assistant.d.ts +11 -3
- package/dist/http/routes/approvals-assistant.js +97 -20
- package/dist/http/routes/capabilities.js +8 -1
- package/dist/http/routes/diagnostics.d.ts +18 -0
- package/dist/http/routes/diagnostics.js +26 -0
- package/dist/http/routes/runs.js +63 -17
- package/dist/http/routes/side-query.js +15 -1
- package/dist/http/routes/tasks.js +32 -3
- package/dist/http/server.js +67 -13
- package/dist/leader/fanout.d.ts +18 -0
- package/dist/leader/fanout.js +34 -1
- package/dist/leader/leader.js +9 -5
- package/dist/leader/wire.js +16 -5
- package/dist/main.js +1 -0
- package/dist/model-select.d.ts +43 -1
- package/dist/model-select.js +70 -2
- package/dist/observability/fail-open.d.ts +8 -0
- package/dist/observability/fail-open.js +8 -0
- package/dist/observability/metrics.js +3 -0
- package/dist/parent-watch.d.ts +57 -0
- package/dist/parent-watch.js +108 -0
- package/dist/plugins/checkpoint-store-sql.d.ts +67 -0
- package/dist/plugins/checkpoint-store-sql.js +133 -7
- package/dist/plugins/local-checkpoint-store.js +11 -1
- package/dist/plugins/memory-embedder-fingerprint.d.ts +119 -0
- package/dist/plugins/memory-embedder-fingerprint.js +280 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +0 -3
- package/dist/plugins/permission-rule-store-sql.js +1 -7
- package/dist/plugins/pg-pool.js +3 -0
- package/dist/plugins/store-backend.d.ts +5 -6
- package/dist/plugins/store-backend.js +4 -1
- package/dist/plugins/store-contracts.d.ts +17 -0
- package/dist/plugins/store-contracts.js +33 -0
- package/dist/plugins/tidb-pool.js +7 -0
- package/dist/plugins/tool-result-store-sql.d.ts +18 -13
- package/dist/plugins/tool-result-store-sql.js +50 -29
- package/dist/run-local.js +1 -0
- package/dist/tool-approval.d.ts +9 -0
- package/dist/tool-approval.js +74 -3
- package/dist/trace/project.js +8 -0
- package/dist/trace/redact.d.ts +14 -1
- package/dist/trace/redact.js +14 -2
- package/package.json +3 -3
|
@@ -87,6 +87,73 @@ const appliedGeneration = new WeakMap();
|
|
|
87
87
|
* 反自评等),且旧守卫 `length>0` 粘住的不是 env 底而是**上一个 center 世代**(applyRuntimeHot 头注
|
|
88
88
|
* NON-STICKY 论证同族,roles 曾漏在纪律外——cli [2311] 实测「用户撤销被静默吞掉」即此)。 */
|
|
89
89
|
const envRolesBaseline = new WeakMap();
|
|
90
|
+
/**
|
|
91
|
+
* #233 / A-002.8 —— 无法解读的名单条目的**代表元**。
|
|
92
|
+
*
|
|
93
|
+
* 为什么需要它(codex 复审 R1 真 finding):把坏条目**丢掉**会让 `[7, null]` 这种「在场但一条都读不
|
|
94
|
+
* 懂」的名单塌成**空数组**,而空数组在消费端的语义恰恰是「治理关闭 = 全放行」—— 一个表达"我要限制"
|
|
95
|
+
* 的配置,因为写错了类型,反而把门整个打开。方向必须反过来:名单在场 = operator 意图限制;读不懂的
|
|
96
|
+
* 条目 ⇒ 换成一个**永不可能是 catalog name** 的代表元(前导 NUL,registry-core 的 name 词法不可能产
|
|
97
|
+
* 出),于是它在归一时解不到任何目录条目 ⇒ 谁都准不了。
|
|
98
|
+
*
|
|
99
|
+
* 后果是**有界的**:被拒的只有「用户逐 turn 显式点模型」这一件事;`default` / roles / 档位表照常,
|
|
100
|
+
* 任务照跑。写错配置的代价是"点不了模型"而不是"服务不可用",且 warn 当场点名成因。
|
|
101
|
+
* 🔴 只活在内存判定面:不进 wire(`/v1/models` 只发布尔)、不进日志(warn 打的是原始坏值的类型标记)。
|
|
102
|
+
*/
|
|
103
|
+
export const UNPARSABLE_AT_MODEL_ALLOWLIST_ENTRY = "\u0000<unparsable-at-model-allowlist-entry>";
|
|
104
|
+
/**
|
|
105
|
+
* #233 / A-002.8 —— `models.atModelAllowlist` 的 STAGE 侧归一(纯函数,可抛的事都不做)。
|
|
106
|
+
*
|
|
107
|
+
* 语义(registry-core `ModelsDomain.atModelAllowlist` 的 JSDoc 同源):**缺席/空 = 全放行**。返回值恒是
|
|
108
|
+
* 一个新数组,调用点每次 apply 整表重算 ⇒ **非粘**。
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ 入参刻意收 `unknown`:`EffectiveConfig` 把它声明成 `string[]`,但 **local lane 的 eff 来自
|
|
111
|
+
* `FileConfigStore` 的原始 JSON**(不是 zod 输出),类型在那条腿上不是运行时保证。
|
|
112
|
+
*
|
|
113
|
+
* 坏形处置(不静默,三条各有一句;共同的方向 = **在场即意图限制,读不懂就更严不更松**):
|
|
114
|
+
* - 字段在场但**不是数组** ⇒ 响亮 warn + 落**一条代表元**(见上)= 用户逐 turn 选择全拒。刻意不按
|
|
115
|
+
* "缺席"处置:那会把一个写坏了的**限制**声明翻译成"不限制",正是治理轴上最不该有的失败方向。
|
|
116
|
+
* 也刻意不整代拒绝:那会把整个模型面(models/roles/tiers/密钥)一起拖下水,爆炸半径不成比例。
|
|
117
|
+
* - 数组里的**非串/空串**成员 ⇒ **逐条换成代表元**(不是丢弃)+ warn:名单只会更窄,不会更宽。
|
|
118
|
+
* - 成员名**不在本世代启用目录里**(悬空 ref)⇒ warn(照 `sema_registry_default_dangling` 先例)。
|
|
119
|
+
* 保留原串:悬空成员在消费侧本就归一不到任何条目;留声必须有——名单整条悬空时"谁都点不了"是
|
|
120
|
+
* fail-closed 的真后果,运维要能一眼看见成因。
|
|
121
|
+
*/
|
|
122
|
+
function stageAtModelAllowlist(raw, models, logger) {
|
|
123
|
+
if (raw === undefined || raw === null)
|
|
124
|
+
return [];
|
|
125
|
+
if (!Array.isArray(raw)) {
|
|
126
|
+
logger?.warn("sema_registry_at_model_allowlist_invalid", {
|
|
127
|
+
type: typeof raw,
|
|
128
|
+
hint: "models.atModelAllowlist must be an array of catalog model names — an unreadable allowlist is treated as ALLOWING NOTHING (fail-closed: a broken restriction must not read as 'no restriction'); tasks still run on the default/role models",
|
|
129
|
+
});
|
|
130
|
+
return [UNPARSABLE_AT_MODEL_ALLOWLIST_ENTRY];
|
|
131
|
+
}
|
|
132
|
+
const names = [];
|
|
133
|
+
const bad = [];
|
|
134
|
+
for (const entry of raw) {
|
|
135
|
+
if (typeof entry === "string" && entry.length > 0)
|
|
136
|
+
names.push(entry);
|
|
137
|
+
else {
|
|
138
|
+
bad.push(typeof entry === "string" ? "<empty-string>" : `<non-string:${typeof entry}>`);
|
|
139
|
+
names.push(UNPARSABLE_AT_MODEL_ALLOWLIST_ENTRY);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (bad.length > 0) {
|
|
143
|
+
logger?.warn("sema_registry_at_model_allowlist_bad_entries", {
|
|
144
|
+
bad,
|
|
145
|
+
hint: "non-string / empty entries in models.atModelAllowlist allow NOTHING (they are kept as unresolvable placeholders, never dropped — dropping them would silently WIDEN the allowlist, and an all-bad list would collapse to the empty = allow-everything form)",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const dangling = names.filter((n) => n !== UNPARSABLE_AT_MODEL_ALLOWLIST_ENTRY && models[n] === undefined);
|
|
149
|
+
if (dangling.length > 0) {
|
|
150
|
+
logger?.warn("sema_registry_at_model_allowlist_dangling", {
|
|
151
|
+
dangling,
|
|
152
|
+
hint: "these @-mention allowlist entries name a model that is not in the enabled catalog — they allow nothing; an allowlist that is ENTIRELY dangling refuses every user model pick",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return names;
|
|
156
|
+
}
|
|
90
157
|
/** 返回值=是否 COMMIT(false 仅在 [2283]③ 世代序 CAS 拒绝时)。caller 收到 false 必须把**同一世代的
|
|
91
158
|
* 旁路消费**(prompts 采用/pricing/keyResolver/etag 推进/LKG 落盘)一并跳过——否则 applyEffective 拒了
|
|
92
159
|
* 主面、旁路却半应用同一个被拒世代,混合世代从侧门回来。首次 apply(live 未登记)恒 true。 */
|
|
@@ -130,6 +197,14 @@ function commitStaged(config, staged) {
|
|
|
130
197
|
config.model = config.models.default;
|
|
131
198
|
mutateInPlace(config.roles, mp.roles); // IN PLACE: Runner reads this.deps.roles live([2314] 裁①:staged roles 已是 env 底+center 逐键 merge 的整表,无条件落——旧 `length>0` 守卫=空表粘住上一 center 世代)
|
|
132
199
|
mutateInPlace(config.tiers, mp.activeTiers);
|
|
200
|
+
// #233:整表重赋值(不是 mutateInPlace)—— 本键的全部消费点(resolveSpec / side-query / GET /v1/models /
|
|
201
|
+
// 未知模型 400 的 available)都是**逐请求**从 `config` 上读,没有任何一处在 boot 期捕获这个数组本身
|
|
202
|
+
// (与 `config.modelApiKeyEnv` 的 "reassign ok" 同判据)。非粘由 stage 侧整表重算保证。
|
|
203
|
+
// 🔴 刻意与整个模型面**同生共死**(在 `if (mp)` 里,不在外面):名单说的是"这本目录里哪些可点",
|
|
204
|
+
// 所以它必须与它描述的那本目录同世代。plane 被跳过时(deferModelPlane 的 tier-frozen 窗口 /
|
|
205
|
+
// 本世代零启用模型 ⇒ 目录仍是上一世代或 env 底),名单跟着一起停在同一世代——把新名单配到旧目录上
|
|
206
|
+
// 才是真正的错配(按新名字判旧目录 = 全员悬空 = 谁都点不了)。
|
|
207
|
+
config.atModelAllowlist = mp.atModelAllowlist;
|
|
133
208
|
}
|
|
134
209
|
if (staged.gateAssignments) {
|
|
135
210
|
for (const [k, v] of staged.gateAssignments)
|
|
@@ -300,6 +375,10 @@ function stageEffective(config, eff, logger, opts) {
|
|
|
300
375
|
// wouldDefaultModel 共用同一只,报数与真 apply 永不撕裂——codex M2)。
|
|
301
376
|
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" }));
|
|
302
377
|
models.default = models[picked.name];
|
|
378
|
+
// #233 / A-002.8 `@`-mention 允许名单:与 roles 同族的**非粘整表重算**——每次 modelPlane apply 都从
|
|
379
|
+
// 本世代的 eff 重算,center 撤名单/发空表 ⇒ 本表清空 ⇒ 回「全放行」。粘写法(`length>0` 才写)会让
|
|
380
|
+
// 上一个 center 世代的名单常驻 = **收不回的治理锁**(roles 那条 [2314] 裁①同族病,不重犯)。
|
|
381
|
+
const atModelAllowlist = stageAtModelAllowlist(eff.models?.atModelAllowlist, models, logger);
|
|
303
382
|
modelPlane = {
|
|
304
383
|
models,
|
|
305
384
|
modelApiKeyEnv,
|
|
@@ -307,7 +386,8 @@ function stageEffective(config, eff, logger, opts) {
|
|
|
307
386
|
quotaWeights,
|
|
308
387
|
roles,
|
|
309
388
|
activeTiers,
|
|
310
|
-
|
|
389
|
+
atModelAllowlist,
|
|
390
|
+
infoLine: { count: enabled.length, default: models.default.id, defaultSource: picked.source, roles: Object.keys(roles), tiers: Object.keys(activeTiers), atModelAllowlist: atModelAllowlist.length, sealedKeys: Object.values(modelApiKeys).filter((v) => typeof v === "string").length, sealedPoisoned: Object.values(modelApiKeys).filter((v) => typeof v !== "string").length },
|
|
311
391
|
};
|
|
312
392
|
}
|
|
313
393
|
// Runtime governance/limit gates (phase-2): STARTUP only (restart-to-apply) — the live RateLimiter / CostQuota
|
package/dist/config-types.d.ts
CHANGED
|
@@ -213,6 +213,21 @@ export interface ServiceConfigFlat {
|
|
|
213
213
|
/** Role → model map (1.24): `subagent`/`summarize` → the cheap model when `MODEL_CHEAP_ID` is set
|
|
214
214
|
* (council lenses + compaction get cheap), `default`/`team`/`synthesize` stay on the main model. */
|
|
215
215
|
roles: ModelRoles;
|
|
216
|
+
/** #233 / A-002.8 —— **`@`-mention 允许名单**(registry-core `models.atModelAllowlist` 的服务端消费面)。
|
|
217
|
+
* 语义(registry-core `ModelsDomain.atModelAllowlist` 的 JSDoc 同源):**缺席/空 = 全部启用模型都可被用户点选**;非空 = 用户
|
|
218
|
+
* 逐 turn 选择通道只能落在名单内的 catalog name 上。
|
|
219
|
+
*
|
|
220
|
+
* 辖域(设计稿 233 v2 终裁,施工侧成文):
|
|
221
|
+
* - **受限**=用户通道:`body.model` / `settings.model`(同属请求体,untrusted) / objective 内联
|
|
222
|
+
* `@name` / `body.compactionModel` / `POST /v1/side-query` 的 `body.model`;
|
|
223
|
+
* - **不受限**=operator 配置通道:`models.roles` / `models.default` / 档位绑定表 / side-query 的
|
|
224
|
+
* `modelRole`(角色表选路)——名单管「用户可点什么」,不管「operator 配什么」。
|
|
225
|
+
* - **提交时门**:core 运行时路由(degrade / cascade / roles 解析)**不在射程**——那些是引擎按配置
|
|
226
|
+
* 自己走的路,不是一次用户选择(诚实声明,非运行时执法)。
|
|
227
|
+
*
|
|
228
|
+
* NO env source —— env lane 恒空(INERT=全放行,ship-dark 缺省=旧行为);只由 applyEffective 从
|
|
229
|
+
* `models.atModelAllowlist` 整表重算填充(**非粘**:center 撤名单 ⇒ 本表清空 ⇒ 回全放行)。 */
|
|
230
|
+
atModelAllowlist: readonly string[];
|
|
216
231
|
/**
|
|
217
232
|
* Session backend: in-memory (default), tidb (durable L1 event-log center), or `auto` — probe a
|
|
218
233
|
* configured DB at startup and fall back to in-memory if it's unreachable (for local/intranet binaries:
|
|
@@ -919,6 +934,17 @@ export interface ServiceConfigFlat {
|
|
|
919
934
|
* T11 stands, just bounded instead of immediate. A SECOND SIGHUP (or SIGTERM) escalates to the normal drain
|
|
920
935
|
* path. Default 120s, floor 5s. Env: SIGHUP_IDLE_GRACE_MS. */
|
|
921
936
|
sighupIdleGraceMs: number;
|
|
937
|
+
/** #219(黑板 [3617]):引擎 **parent 监视**。壳自 spawn 引擎的同机形里,壳被 SIGKILL(崩溃/强杀,
|
|
938
|
+
* 没有任何清理钩子)后引擎被 reparent 到 init 而**继续活着** —— 孤儿引擎占端口、占库连接、占模型
|
|
939
|
+
* 配额,而且再没有人会给它发 SIGTERM(T11 的进程组腿只覆盖信号送得到的形)。设了本键 = 引擎每 15s
|
|
940
|
+
* 用 `process.kill(pid, 0)` 探一拍这个 pid,**连续两拍** ESRCH(父不在)才判死;判死后走与 SIGTERM
|
|
941
|
+
* **逐字同一条** graceful drain 腿(in-flight run 的结算 / park 语义与人工停机一致),不是裸
|
|
942
|
+
* `process.exit`。`EPERM` = 进程在、只是本进程无权给它发信号 ⇒ **算活着**。
|
|
943
|
+
* **缺席 ⇒ 整件不装配**(opt-in;存量部署逐字零变化);**非正整数 ⇒ 拒启**(带指路文案)。
|
|
944
|
+
* 拍频固定 15s,不开 interval 旋钮(有真需求再议,防旋钮增殖)。
|
|
945
|
+
* 已知残余(成文,不收窄):两拍窗口内 pid 被系统复用 ⇒ 探活看到的是**新**进程,监视判"父还活着"
|
|
946
|
+
* 而不自退。监视是**尽力自愈**、不是强一致的父子生命周期绑定。 Env: `SEMA_PARENT_PID`。 */
|
|
947
|
+
parentPid?: number;
|
|
922
948
|
/** E23 (shell-host contract): inbound MCP elicitation HITL. When enabled, the service mounts `RunnerDeps.onElicit` + the
|
|
923
949
|
* `POST /v1/elicitations/:id/respond` route so an opted-in MCP server (`McpServerSpec.elicitation`, itself default
|
|
924
950
|
* OFF) can ask the END USER for input mid-tool-call. LIVE-ONLY (no durable suspend; lost on crash/replica-change).
|
|
@@ -1118,7 +1144,7 @@ export interface ServiceConfigFlat {
|
|
|
1118
1144
|
/** 组:store(持久化)—— DB 引擎三态、session 后端、SQL coords、快照 BLOB / SendUserFile 对象存储。 */
|
|
1119
1145
|
export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "sessionCacheTtlSec" | "storeProbeIntervalMs" | "rewindSnapshotMaxMb" | "dbBackend" | "dbBackendExplicit" | "localDataRoot" | "tidb" | "pg" | "dbQueryTimeoutMs" | "snapshotBlobStore" | "snapshotBlobSqlMaxBytes" | "snapshotBlobAllowSql" | "sendUserFile">;
|
|
1120
1146
|
/** 组:modelPlane(模型面)—— 网关坐标、Anthropic 路线、韧性旋钮、主/廉价 model entry、role 表、降级梯。 */
|
|
1121
|
-
export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "cascadeLadder" | "degrade">;
|
|
1147
|
+
export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "atModelAllowlist" | "cascadeLadder" | "degrade">;
|
|
1122
1148
|
/** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
|
|
1123
1149
|
* 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
|
|
1124
1150
|
export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
|
|
@@ -1130,7 +1156,7 @@ export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authToken
|
|
|
1130
1156
|
/** 组:orchestration(编排 + 执行车道 + 沙箱面 + workflow/后台 agent 存留)。 */
|
|
1131
1157
|
export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "rosterRetentionMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes">;
|
|
1132
1158
|
/** 组:limitsHttp(HTTP 面 + 各类上限/配额/回收窗)。 */
|
|
1133
|
-
export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
|
|
1159
|
+
export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
|
|
1134
1160
|
/** 组:observability(可观测)。 */
|
|
1135
1161
|
export type ServiceObservabilityConfig = Pick<ServiceConfigFlat, "metricsToken" | "traceToken" | "toolTrace" | "traceThinking" | "logLevel" | "otel">;
|
|
1136
1162
|
/** 组:integrations(外部集成)。`mcpServers` 由 sema-registry 适配器填(无 env 腿),归本组。 */
|
package/dist/config.js
CHANGED
|
@@ -395,28 +395,33 @@ export function configKnobTable() {
|
|
|
395
395
|
function registerKnob(rec) {
|
|
396
396
|
CONFIG_KNOBS.set(rec.name, rec);
|
|
397
397
|
}
|
|
398
|
+
/** #241-rc2([3740]③→[3742] 裁定,双臂不留静默):布尔 env 的**闭集词表**解析。
|
|
399
|
+
* 放宽臂=收 true/false/1/0/yes/no/on/off(大小写不敏感)——`=1` 是运维直觉写法,意图明确按意图执行
|
|
400
|
+
* (operator-knob 宪法方向);拒启臂=未知词**抛错**(boot 拒启带词表指路,照 #186 响亮拒先例)——
|
|
401
|
+
* 行为承重键(DURABLE_APPROVAL 决定 checkpointStore 存在性 ⇒ /v1/approvals 路由存在性)静默回默认的
|
|
402
|
+
* 真实后果是「审批面整条静默缺席」([3740]② cli/test 真机链)。返回 undefined = raw 未设/空串(沿用
|
|
403
|
+
* `env()` 的空串=未设约定),由调用方走默认/legacy 分派。 */
|
|
404
|
+
const BOOL_TRUE_WORDS = new Set(["true", "1", "yes", "on"]);
|
|
405
|
+
const BOOL_FALSE_WORDS = new Set(["false", "0", "no", "off"]);
|
|
406
|
+
function parseBoolWord(name, raw) {
|
|
407
|
+
if (raw === undefined || raw === "")
|
|
408
|
+
return undefined;
|
|
409
|
+
const w = raw.toLowerCase();
|
|
410
|
+
if (BOOL_TRUE_WORDS.has(w))
|
|
411
|
+
return true;
|
|
412
|
+
if (BOOL_FALSE_WORDS.has(w))
|
|
413
|
+
return false;
|
|
414
|
+
throw new Error(`${name}=${JSON.stringify(raw)} is not a boolean — accepted (case-insensitive): true/false/1/0/yes/no/on/off. ` +
|
|
415
|
+
`Refusing to boot: a silently defaulted behavior knob is worse than a loud one ([3742]; e.g. DURABLE_APPROVAL ` +
|
|
416
|
+
`decides whether the checkpoint store — and with it /v1/approvals — exists at all).`);
|
|
417
|
+
}
|
|
398
418
|
function boolEnv(name, def) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
value = true;
|
|
406
|
-
source = "env";
|
|
407
|
-
}
|
|
408
|
-
else if (raw === "false") {
|
|
409
|
-
value = false;
|
|
410
|
-
source = "env";
|
|
411
|
-
}
|
|
412
|
-
else if (!CONFIG_KNOBS.has(name)) {
|
|
413
|
-
// Previously a silent default at every hand-written site. Guarded on first registration: the LAZILY
|
|
414
|
-
// re-read knobs (boolEnvWithLegacyNegated's host twins, read per exec) must not grow this array for the
|
|
415
|
-
// process lifetime on one typo — one typo, one warning. (复审 2026-07-30 F6:早期理由「IMAGE_BAKES_
|
|
416
|
-
// ENABLED/DURABLE_APPROVAL 一次 load 读两次」已随九域拆分的去重失效,懒读族成为本守卫的现行理由。)
|
|
417
|
-
CONFIG_WARNINGS.push({ env: name, raw });
|
|
418
|
-
}
|
|
419
|
-
}
|
|
419
|
+
// #241-rc2:词表解析(见 parseBoolWord 顶注)。未知词在 parseBoolWord 里抛 = boot 拒启——旧形的
|
|
420
|
+
// 「CONFIG_WARNINGS 一条 warn + 回默认」臂就此退役(懒读族的防重复 push 守卫随臂一起退役:未知词
|
|
421
|
+
// 现在到不了任何 push)。
|
|
422
|
+
const parsed = parseBoolWord(name, process.env[name]);
|
|
423
|
+
const value = parsed ?? def;
|
|
424
|
+
const source = parsed !== undefined ? "env" : "default";
|
|
420
425
|
registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source });
|
|
421
426
|
return value;
|
|
422
427
|
}
|
|
@@ -443,20 +448,11 @@ export function boolEnvWithLegacyNegated(name, legacyNegatedName, def) {
|
|
|
443
448
|
const legacy = process.env[legacyNegatedName];
|
|
444
449
|
let value;
|
|
445
450
|
let source;
|
|
446
|
-
|
|
447
|
-
|
|
451
|
+
const parsedCanonical = parseBoolWord(name, raw); // #241-rc2:与 boolEnv 同一词表同一拒启臂(单一属主)
|
|
452
|
+
if (parsedCanonical !== undefined) {
|
|
453
|
+
value = parsedCanonical;
|
|
448
454
|
source = "env";
|
|
449
455
|
}
|
|
450
|
-
else if (raw !== undefined && raw !== "") {
|
|
451
|
-
if (!CONFIG_KNOBS.has(name)) {
|
|
452
|
-
// Same first-registration guard as boolEnv — the three consumers of this helper are the LAZILY re-read
|
|
453
|
-
// ones (remote-env-host reads per exec/probe), so an unguarded push would grow CONFIG_WARNINGS for the
|
|
454
|
-
// whole process lifetime on a single operator typo (复审 2026-07-29 #1).
|
|
455
|
-
CONFIG_WARNINGS.push({ env: name, raw });
|
|
456
|
-
}
|
|
457
|
-
value = def;
|
|
458
|
-
source = "default";
|
|
459
|
-
}
|
|
460
456
|
else if (legacy !== undefined && legacy !== "") {
|
|
461
457
|
// server 3.0.0([1989],clay 令「不做兼容」):负名旧 env = fail-loud 墓碑。设了(任何值,含
|
|
462
458
|
// "false"——设它的人同样以为它还生效)即拒启并指路新名。选拒启不选静默忽略:静默会让升级部署的
|
|
@@ -967,6 +963,11 @@ function parseModelDomain() {
|
|
|
967
963
|
tiers: {}, // 档位表:env lane 恒空(INERT);applyEffective 从 models.tierGroups 解析填充(保引用)
|
|
968
964
|
projects: {}, // 142-S4 项目登记簿:env lane 恒空(INERT);applyEffective 从 projects 域填充(保引用)
|
|
969
965
|
roles,
|
|
966
|
+
// #233 / A-002.8 `@`-mention 允许名单:env lane 恒空(INERT = 全放行,ship-dark 缺省 = 旧行为);
|
|
967
|
+
// applyEffective 从 models.atModelAllowlist 整表重算填充(非粘,见 config-types.ts 键注)。
|
|
968
|
+
// 刻意**没有** env 腿:名单是 center 治理面的字段(registry-core 侧还有 name⊆catalog 的 cross-domain
|
|
969
|
+
// 校验),给它开一条 env 后门 = 两个写者一个语义面,而 env 侧没有那道校验。
|
|
970
|
+
atModelAllowlist: [],
|
|
970
971
|
cascadeLadder: csv("MODEL_CASCADE_LADDER"),
|
|
971
972
|
degrade: process.env.MODEL_DEGRADE_TO
|
|
972
973
|
? {
|
|
@@ -1304,6 +1305,36 @@ function syncEntryCap(name, field) {
|
|
|
1304
1305
|
throw new Error(`env ${name}="${raw}" must be an integer >= 1 (it is an entries-per-batch cap; an ignored cap means NO cap)`);
|
|
1305
1306
|
return { [field]: n };
|
|
1306
1307
|
}
|
|
1308
|
+
/**
|
|
1309
|
+
* #219(黑板 [3617]):`SEMA_PARENT_PID` —— 引擎 parent 监视的**唯一**开关(壳侧 cli 已确认这个名字)。
|
|
1310
|
+
*
|
|
1311
|
+
* 三态,与 `syncEntryCap` 同族(#210 A 档「坏值拒启」):
|
|
1312
|
+
* · 未设 / 空串 ⇒ **键缺席**,整件不装配(opt-in;存量部署逐字零变化,零 warn);
|
|
1313
|
+
* · 正整数 ⇒ 原样进配置;
|
|
1314
|
+
* · 其余(0 / 负数 / 小数 / 非数字 / 带单位后缀)⇒ **拒启**并指路。
|
|
1315
|
+
*
|
|
1316
|
+
* 为什么坏值不静默回默认:本键的"默认"就是**整件不装配**,于是静默回默认 = operator 设了监视、进程
|
|
1317
|
+
* 照常起、监视根本不在 —— 恰恰是这个键要防的那类"孤儿引擎"在无人知晓的情况下继续发生。而且坏值的
|
|
1318
|
+
* 另一半后果更隐蔽:`0` 在 `process.kill(0, 0)` 里的语义是**当前进程组**(探活恒成功=监视永不触发),
|
|
1319
|
+
* 负数是**进程组** id(探到的根本不是父进程);这两个值不是"差一点对",是语义完全不同的另一件事。
|
|
1320
|
+
*/
|
|
1321
|
+
function parentPidEnv() {
|
|
1322
|
+
const raw = process.env.SEMA_PARENT_PID;
|
|
1323
|
+
if (raw === undefined || raw.trim() === "")
|
|
1324
|
+
return {};
|
|
1325
|
+
const n = Number(raw);
|
|
1326
|
+
// 上界 = int32 最大值:`process.kill` 的 pid 形参是 int32,`2147483648` 抛的是 `ERR_INVALID_ARG_TYPE`
|
|
1327
|
+
// 而**不是** ESRCH(codex 复审 M,实测证实)。落在探活的"未知 errno ⇒ 保守判活"支上,后果是:配置
|
|
1328
|
+
// 过了校验、`parent_watch_armed` 照打、之后每一拍都被当成"父还活着" —— 监视永久失效。校验面必须只
|
|
1329
|
+
// 放行 process.kill 真吃得下的范围,才对得起"设了就一定在监视"这句承诺。
|
|
1330
|
+
if (!Number.isInteger(n) || n < 1 || n > 2_147_483_647) {
|
|
1331
|
+
throw new Error(`env SEMA_PARENT_PID="${raw}" must be a POSITIVE INTEGER process id in [1, 2147483647] — it is the pid this ` +
|
|
1332
|
+
`engine watches (the shell/supervisor that spawned it); 0 and negatives mean "process GROUP" to ` +
|
|
1333
|
+
`process.kill, and anything past 2147483647 is not a pid process.kill can even be asked about. ` +
|
|
1334
|
+
`UNSET it to turn the parent watch off (absent = not wired at all).`);
|
|
1335
|
+
}
|
|
1336
|
+
return { parentPid: n };
|
|
1337
|
+
}
|
|
1307
1338
|
/**
|
|
1308
1339
|
* #228(黑板 [3590]/[3606] 两裁):`MEMORY_EMBEDDER_*` 族 —— 记忆检索向量面的**唯一**开关。
|
|
1309
1340
|
*
|
|
@@ -1849,6 +1880,7 @@ function parseLimitsHttpDomain() {
|
|
|
1849
1880
|
},
|
|
1850
1881
|
drainGraceMs: clampEnvWithWarn("DRAIN_GRACE_MS", String(10 * 60 * 1000), 10_000), // SIGTERM drain window, default 10min, floor 10s(#210 B 档:把 600 当成"秒"填进来的手滑最常见,夹取后必须点名)
|
|
1851
1882
|
sighupIdleGraceMs: clampEnvWithWarn("SIGHUP_IDLE_GRACE_MS", String(120_000), 5_000), // B3: SIGHUP idle-shutdown window, default 2min, floor 5s
|
|
1883
|
+
...parentPidEnv(), // #219:壳 pid 存活监视,缺席 ⇒ 键缺席 ⇒ 整件不装配
|
|
1852
1884
|
// BL-9: numEnv (not bare Number) — a non-numeric typo FAILS at startup instead of becoming NaN, which would
|
|
1853
1885
|
// silently DISABLE the operator cost/rate ceiling (every `> ceiling` comparison against NaN is false).
|
|
1854
1886
|
rateLimitPerMin: numEnv("RATE_LIMIT_RPM", "0"),
|
|
@@ -1956,7 +1988,7 @@ const STORE_GROUP_KEYS = [
|
|
|
1956
1988
|
];
|
|
1957
1989
|
const MODEL_PLANE_GROUP_KEYS = [
|
|
1958
1990
|
"gatewayBaseUrl", "gatewayApiKey", "gatewayFallbackUrls", "gatewayMaxRetries", "anthropic", "resilience", "model", "models",
|
|
1959
|
-
"modelApiKeyEnv", "modelApiKeys", "modelQuotaWeights", "tiers", "projects", "roles", "cascadeLadder", "degrade",
|
|
1991
|
+
"modelApiKeyEnv", "modelApiKeys", "modelQuotaWeights", "tiers", "projects", "roles", "atModelAllowlist", "cascadeLadder", "degrade",
|
|
1960
1992
|
];
|
|
1961
1993
|
const APPROVAL_GROUP_KEYS = [
|
|
1962
1994
|
"approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
|
|
@@ -1986,7 +2018,7 @@ const ORCHESTRATION_GROUP_KEYS = [
|
|
|
1986
2018
|
];
|
|
1987
2019
|
const LIMITS_HTTP_GROUP_KEYS = [
|
|
1988
2020
|
"port", "attachmentOrphanGraceMs", "workspaceFileMaxBytes", "attachmentMaxBytes", "attachmentMimeAllowlist",
|
|
1989
|
-
"attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "sighupIdleGraceMs", "rateLimitPerMin", "maxTaskCostUsd",
|
|
2021
|
+
"attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "sighupIdleGraceMs", "parentPid", "rateLimitPerMin", "maxTaskCostUsd",
|
|
1990
2022
|
"maxTaskTokens", "maxPrincipalCostUsd", "costQuotaWindowSec", "reapIntervalSec", "runStaleSec", "toolResultTtlSec",
|
|
1991
2023
|
];
|
|
1992
2024
|
const OBSERVABILITY_GROUP_KEYS = ["metricsToken", "traceToken", "toolTrace", "traceThinking", "logLevel", "otel"];
|
|
@@ -2069,8 +2101,10 @@ export function loadConfig() {
|
|
|
2069
2101
|
// self-report would omit the worst offenders. Its tri-state semantics are unchanged: an explicit literal wins,
|
|
2070
2102
|
// anything else (including a typo — deliberately NOT warned here, unlike boolEnv) falls to the posture default.
|
|
2071
2103
|
const postureOn = (name, infraReady = true) => {
|
|
2072
|
-
|
|
2073
|
-
|
|
2104
|
+
// #241-rc2:显式臂与 boolEnv 同词表同拒启(parseBoolWord 单一属主)——三态语义不变
|
|
2105
|
+
// (显式 true/显式 false/未设 ⇒ posture 默认),变的只是「显式」的判读:`=1` 是显式 true,
|
|
2106
|
+
// 未知词拒启而不是静默滑进 posture 默认。
|
|
2107
|
+
const explicit = parseBoolWord(name, process.env[name]);
|
|
2074
2108
|
const value = explicit ?? (singleUserTurnkey && infraReady);
|
|
2075
2109
|
registerKnob({ name, polarity: "posture", value, source: explicit === undefined ? "posture" : "env" });
|
|
2076
2110
|
return value;
|
|
@@ -95,4 +95,16 @@ export declare function recentTerminalWorkflowRows(store: WorkflowRunStore | und
|
|
|
95
95
|
export declare function clearTerminalWindowCooldownForTest(store: WorkflowRunStore, scope: string): void;
|
|
96
96
|
/** test-only:这个 store 目前还记着几个 scope(codex R4-M2 的空闲销号有没有真的销)。 */
|
|
97
97
|
export declare function trackedTerminalWindowScopesForTest(store: WorkflowRunStore): number;
|
|
98
|
+
/**
|
|
99
|
+
* test-only:下一次开流会不会**合流到**当前这代在飞的读(与 {@link sharedRead} 同一份判据,不另抄常数)。
|
|
100
|
+
*
|
|
101
|
+
* 为什么需要它:挂死的读永不 settle,那一代会一直留在表上、只被复用窗按龄挡掉。测试要断言"后端恢复后
|
|
102
|
+
* 的下一次开流读得到行",前置条件恰恰是"毒代已过复用窗"——那条前置条件必须**等出来**(轮询本谓词),
|
|
103
|
+
* 不能靠"上一条连接自己吃满预算 + 一次客户端往返"凑出的那几毫秒墙钟余量(实测 8~28ms,既无因果保证
|
|
104
|
+
* 也无下界 ⇒ 负载敏感偶发红)。读到的是快照:表里没有这一 scope 就是"不会合流"。
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ 刻意**不走** `scopeState`(它会顺手建表项)——观测口不许改被观测的状态(`trackedTerminalWindow
|
|
107
|
+
* ScopesForTest` 钉的空闲销号计数会被它污染)。
|
|
108
|
+
*/
|
|
109
|
+
export declare function terminalWindowJoinsInFlightForTest(store: WorkflowRunStore, scope: string): boolean;
|
|
98
110
|
//# sourceMappingURL=fleet-terminal-window.d.ts.map
|
|
@@ -216,8 +216,12 @@ export async function recentTerminalWorkflowRows(store, scope, pullScope) {
|
|
|
216
216
|
// 再发就是加压"。两个反例都被这条判据排除:① 我等的那代其实已经完成/失败并销号(快失败的店不累积
|
|
217
217
|
// 在飞的活,没有加压问题,不该罚 10 秒);② 我超时时表上挂的已经是**别人**开的新一代(我这代早settle
|
|
218
218
|
// 了),拿它当证据会让一个已经健康的 scope 被上一代的迟到超时按住。
|
|
219
|
-
|
|
220
|
-
|
|
219
|
+
// 🔴 只**查表**、不铸造(2026-08-12 合并码重扫 confirmed):快失败路径上 probe 的 .finally 先跑
|
|
220
|
+
// (清 inFlight + evictIfIdle 销号),这里若用 scopeState() 会**重建**一个新空条目——它 inFlight
|
|
221
|
+
// 恒 undefined ⇒ 冷却不开、又没人再销,每个快失败的 scope 就永久留一条(R4-M2 那条只增内存线
|
|
222
|
+
// 换了个入口)。观测口 terminalWindowJoinsInFlightForTest 的头注同判据:检查者不许创建它要检查的状态。
|
|
223
|
+
const st = stateByStore.get(store)?.get(scopeForPull);
|
|
224
|
+
if (st !== undefined && st.inFlight === probe)
|
|
221
225
|
openCooldown(store, scopeForPull, st);
|
|
222
226
|
// detail 用 `err.message` 而不是 `String(err)`:后者会调用抛出值自己的 toString,而那本身可能再抛
|
|
223
227
|
// (逃出本兜底,把降级变成故障)。非 Error 抛出值就如实说"不是 Error",不去问它任何问题。
|
|
@@ -243,12 +247,31 @@ export function clearTerminalWindowCooldownForTest(store, scope) {
|
|
|
243
247
|
export function trackedTerminalWindowScopesForTest(store) {
|
|
244
248
|
return stateByStore.get(store)?.size ?? 0;
|
|
245
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* test-only:下一次开流会不会**合流到**当前这代在飞的读(与 {@link sharedRead} 同一份判据,不另抄常数)。
|
|
252
|
+
*
|
|
253
|
+
* 为什么需要它:挂死的读永不 settle,那一代会一直留在表上、只被复用窗按龄挡掉。测试要断言"后端恢复后
|
|
254
|
+
* 的下一次开流读得到行",前置条件恰恰是"毒代已过复用窗"——那条前置条件必须**等出来**(轮询本谓词),
|
|
255
|
+
* 不能靠"上一条连接自己吃满预算 + 一次客户端往返"凑出的那几毫秒墙钟余量(实测 8~28ms,既无因果保证
|
|
256
|
+
* 也无下界 ⇒ 负载敏感偶发红)。读到的是快照:表里没有这一 scope 就是"不会合流"。
|
|
257
|
+
*
|
|
258
|
+
* ⚠️ 刻意**不走** `scopeState`(它会顺手建表项)——观测口不许改被观测的状态(`trackedTerminalWindow
|
|
259
|
+
* ScopesForTest` 钉的空闲销号计数会被它污染)。
|
|
260
|
+
*/
|
|
261
|
+
export function terminalWindowJoinsInFlightForTest(store, scope) {
|
|
262
|
+
return joinsInFlight(stateByStore.get(store)?.get(scope)?.inFlight, Date.now());
|
|
263
|
+
}
|
|
264
|
+
/** 后来者是否加入这一代在飞的读:仍在自己的预算之内才加入(见上方取舍段);更老的那代已被放弃,不加入。
|
|
265
|
+
* 抽成具名谓词的唯一理由是**单一属主**——测试的观测口({@link terminalWindowJoinsInFlightForTest})与
|
|
266
|
+
* 生产的合流决策必须由同一行代码回答,否则测试里那份常数复制件会在实现改判据的那天悄悄失效。 */
|
|
267
|
+
function joinsInFlight(probe, now) {
|
|
268
|
+
return probe !== undefined && now - probe.startedAt < FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS;
|
|
269
|
+
}
|
|
246
270
|
/** 合流层:同 (store, scope) 上**同时只有一代**在飞的读;后来者加入仍在预算内的那一代,settle 即销号。 */
|
|
247
271
|
function sharedRead(store, scope) {
|
|
248
272
|
const st = scopeState(store, scope);
|
|
249
273
|
const existing = st.inFlight;
|
|
250
|
-
|
|
251
|
-
if (existing && Date.now() - existing.startedAt < FLEET_SNAPSHOT_TERMINAL_READ_BUDGET_MS)
|
|
274
|
+
if (joinsInFlight(existing, Date.now()))
|
|
252
275
|
return existing;
|
|
253
276
|
const entry = {
|
|
254
277
|
startedAt: Date.now(),
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
|
|
15
15
|
*/
|
|
16
16
|
import { type Autonomy } from "../runtime-governance.js";
|
|
17
|
+
import { type RunRecord } from "../plugins/store-contracts.js";
|
|
17
18
|
/** #220 归因的部署侧入参(两个治理旋钮;判据属主见 `governanceMandatesShellGateAlways`)。 */
|
|
18
19
|
export interface GovernancePosture {
|
|
19
20
|
autonomy?: Autonomy;
|
|
@@ -50,6 +51,42 @@ export declare function resumeEntryForGate(kind: string, ids: {
|
|
|
50
51
|
sessionId: string;
|
|
51
52
|
taskId: string;
|
|
52
53
|
}): string | null;
|
|
54
|
+
/**
|
|
55
|
+
* #220:推「这道门是运维治理层下的吗」。判据是**合取**,两半各管一件事:
|
|
56
|
+
*
|
|
57
|
+
* 行上的取证格 `gate.riskDescriptor.shellGateDoctrine === "always"`
|
|
58
|
+
* ∧ 本部署的治理层本来就要求 always(`governanceMandatesShellGateAlways`,单一判据属主在
|
|
59
|
+
* `runtime-governance.ts`,那里写着为什么单看行不够 —— SUP 路由姿态同样产 `"always"`)
|
|
60
|
+
*
|
|
61
|
+
* 第一半:core 在 mint 这道门时把「当时活着的 doctrine」如实写进 `riskDescriptor.shellGateDoctrine`
|
|
62
|
+
* (2026-08-05 取证裁定;`prepare-task` 只在**被 shell 门铸**的 ask 上写它),所以持久行自己带着证据 ——
|
|
63
|
+
* park 腿天然跨副本、跨重启,活卡腿那张 ALS 标记表在这里结构上够不着。
|
|
64
|
+
* 第二半:把「有效档 always」收窄到「治理层是真成因」,否则一条 SUP 路由出来的门会被谎报成治理强制。
|
|
65
|
+
*
|
|
66
|
+
* ⚠️ 残留(双向失真的窄在场向 + 根治路径)登记在 `governanceMandatesShellGateAlways` 的顶注,别在这里
|
|
67
|
+
* 复述;本位只承担「分诊提示」的分量,不参与任何门/CAS/resume 判定。
|
|
68
|
+
*
|
|
69
|
+
* 🔴 `"classify"` 档**刻意不标**:那一档由 core 的分类器逐调用裁决,一次 shell ask 可能出自分类器(治理)
|
|
70
|
+
* 也可能出自 `APPROVAL_REQUIRE` 这类别的门,行上无从分辨 —— 与活卡腿同一条「宁缺毋假」纪律。
|
|
71
|
+
*
|
|
72
|
+
* 🔴 **导出的理由 = 单一属主**([3684]②):这条推法此前只服务 409 体,于是 `GET /v1/approvals` /
|
|
73
|
+
* `/v1/approvals/stream` 两条 durable 读面的行上**没有**出身格 —— 同一道 park 门,从 409 看得见出身、
|
|
74
|
+
* 从运维队列看不见([3683]-2 实测)。补法**不是**在读面另写一份判据(两份必漂,而这一位恰恰是靠
|
|
75
|
+
* 「行上取证 ∧ 部署姿态」的合取才不谎报的),而是让三条面共用本函数。
|
|
76
|
+
*
|
|
77
|
+
* ⚠️ **askId 为什么不在这里**(#220 的另一半,如实记账):待批 ask 的 `askId` 只活在 `approval_ask` 行上
|
|
78
|
+
* (checkpoint 行没有这一列,core 的 `CheckpointGate`/`PendingAction`/`Checkpoint` 顶层都不带它)。反向
|
|
79
|
+
* (checkpoint → ask)今天**没有读口**:两条 list 读口都硬过滤 `state='STREAM_PENDING'`,而 park 完的行是
|
|
80
|
+
* `PARKED`;唯一精确的连接键是 ask 行上的 `gate_token`,要按它反查得给店加一个新读口(SQL 面 = 双库集成
|
|
81
|
+
* 门)。派生 `deriveAskId(...)` 也不行:它吃 `legKey`(= 上一腿 resume token 的摘要)与 `parentToolCallId`,
|
|
82
|
+
* 这两维从 checkpoint 行推不出来,二腿/子代形上会算出一个**错**的 id —— 在「等的谁」这条通道上,错 id
|
|
83
|
+
* 比缺席坏得多(本文件 `resumeEntryForGate` 的 default 臂是同一条纪律)。故本批诚实缺席。
|
|
84
|
+
*/
|
|
85
|
+
export declare function governanceOriginOf(gate: {
|
|
86
|
+
riskDescriptor?: {
|
|
87
|
+
shellGateDoctrine?: "classify" | "always";
|
|
88
|
+
};
|
|
89
|
+
} | undefined, governance: GovernancePosture | undefined): true | undefined;
|
|
53
90
|
/** token 泛型:真身是 branded CheckpointToken(秘密能力,只在本函数内部流转,绝不进响应体)。 */
|
|
54
91
|
/**
|
|
55
92
|
* SSE 车道的 done 帧 result(拒绝形)。与 409 body **同一铸体处** —— 此前 tasks.ts 在 res.write 里
|
|
@@ -67,9 +104,12 @@ export interface ActiveRunConflictDoneResult {
|
|
|
67
104
|
}
|
|
68
105
|
export declare function toDoneFrameResult(body: ActiveRunConflictBody): ActiveRunConflictDoneResult;
|
|
69
106
|
interface ConflictProbeDeps<TToken> {
|
|
107
|
+
/** 结构形入参,但 `status` 取 run 行的**闭词表**(合并码重扫):判 park 要走词表属主
|
|
108
|
+
* `isParkedRunStatus`,而它的入参是穷举联合 —— 这一格写成开放 `string` 就只能在调用点裸 cast,
|
|
109
|
+
* 绕开那道编译期执法(core 加一个 park 类成员时本文件不会红)。 */
|
|
70
110
|
runStore?: {
|
|
71
111
|
getRun?: (id: string) => Promise<{
|
|
72
|
-
status?:
|
|
112
|
+
status?: RunRecord["status"];
|
|
73
113
|
} | null | undefined>;
|
|
74
114
|
} | undefined;
|
|
75
115
|
checkpointStore?: {
|
|
@@ -14,24 +14,30 @@
|
|
|
14
14
|
* test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
|
|
15
15
|
*/
|
|
16
16
|
import { governanceMandatesShellGateAlways } from "../runtime-governance.js";
|
|
17
|
+
import { isParkedRunStatus } from "../plugins/store-contracts.js";
|
|
17
18
|
/** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
|
|
18
19
|
export const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
|
|
19
20
|
/** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
|
|
20
21
|
export function resumeEntryForGate(kind, ids) {
|
|
21
22
|
switch (kind) {
|
|
22
|
-
// tool_approval / policy_ask
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
23
|
+
// 🔴 tool_approval / policy_ask 历史臂已撤([2373] D 类容忍的翻案,扫描修复批 2026-08-12):
|
|
24
|
+
// 当年这两臂指 /decide,但 /decide 入口守卫按 APPROVAL_GATE_KINDS 闭集(human/irreversible_ask,
|
|
25
|
+
// A-002.1 单一属主)判 kind,旧 kind 名打过去必 409 gate_not_tool_approval——指一条会拒绝自己的
|
|
26
|
+
// 路径,与 needs_review 臂同「铸假门」病族;且 D-D SLA deny-sweep 的 SQL 取行也只选闭集两 kind,
|
|
27
|
+
// 旧 kind 历史行在系统内已无任何处理臂。诚实缺席(落 default null)> 假指路;若真要救历史行,
|
|
28
|
+
// 正解是扩 APPROVAL_GATE_KINDS 单一属主(连带 SQL IN/守卫/sweep 全族),不是在这里单独铸路。
|
|
26
29
|
case "human":
|
|
27
30
|
case "irreversible_ask":
|
|
28
|
-
case "policy_ask":
|
|
29
31
|
return `/v1/approvals/${ids.sessionId}/decide`;
|
|
30
32
|
case "plan_review":
|
|
31
|
-
// needs_review 与 plan_review 同入口(TASKS-RUNS-8 附注,[2400] 审计补臂):routes/tasks.ts 的
|
|
32
|
-
// needs_review park 注明文「resumable via /plan_review」——此前缺臂 ⇒ 409 体 pendingGate 丢指引。
|
|
33
|
-
case "needs_review":
|
|
34
33
|
return `/v1/assistant/tasks/${ids.taskId}/plan_review`;
|
|
34
|
+
// 🔴 needs_review **gate kind** 刻意无臂([3683]-1 属主定谳,2026-08-12,翻转 [2400] 补臂):
|
|
35
|
+
// core 三向拆分里 {kind:"needs_review"}(dry-run 拦截)的合法 outcome=dry_run_review,而 server
|
|
36
|
+
// 生产路由没有 dry_run_review 决议端点、/plan_review 端点守卫会 409 gate_not_plan_review 拒它
|
|
37
|
+
// ——旧映射把消费端指到一个会拒绝它的端点(铸假门)。[2400] 当年引的 tasks.ts 注明文说的是
|
|
38
|
+
// run **status** needs_review(plan_review/dry_run 两 gate 共享该 status),被误当 gate **kind**
|
|
39
|
+
// 语义抄进本表。落 default null=诚实缺席(客户端仍有 activeTaskStatus + cancel 保底真路);
|
|
40
|
+
// 将来若接 dry-run/shadow profile 的决议端点,在此补臂并同步 wire 契约。
|
|
35
41
|
case "resource_limit":
|
|
36
42
|
return `/v1/assistant/tasks/${ids.taskId}/resume`;
|
|
37
43
|
case "task_done":
|
|
@@ -58,6 +64,11 @@ export function resumeEntryForGate(kind, ids) {
|
|
|
58
64
|
* 🔴 `"classify"` 档**刻意不标**:那一档由 core 的分类器逐调用裁决,一次 shell ask 可能出自分类器(治理)
|
|
59
65
|
* 也可能出自 `APPROVAL_REQUIRE` 这类别的门,行上无从分辨 —— 与活卡腿同一条「宁缺毋假」纪律。
|
|
60
66
|
*
|
|
67
|
+
* 🔴 **导出的理由 = 单一属主**([3684]②):这条推法此前只服务 409 体,于是 `GET /v1/approvals` /
|
|
68
|
+
* `/v1/approvals/stream` 两条 durable 读面的行上**没有**出身格 —— 同一道 park 门,从 409 看得见出身、
|
|
69
|
+
* 从运维队列看不见([3683]-2 实测)。补法**不是**在读面另写一份判据(两份必漂,而这一位恰恰是靠
|
|
70
|
+
* 「行上取证 ∧ 部署姿态」的合取才不谎报的),而是让三条面共用本函数。
|
|
71
|
+
*
|
|
61
72
|
* ⚠️ **askId 为什么不在这里**(#220 的另一半,如实记账):待批 ask 的 `askId` 只活在 `approval_ask` 行上
|
|
62
73
|
* (checkpoint 行没有这一列,core 的 `CheckpointGate`/`PendingAction`/`Checkpoint` 顶层都不带它)。反向
|
|
63
74
|
* (checkpoint → ask)今天**没有读口**:两条 list 读口都硬过滤 `state='STREAM_PENDING'`,而 park 完的行是
|
|
@@ -66,7 +77,7 @@ export function resumeEntryForGate(kind, ids) {
|
|
|
66
77
|
* 这两维从 checkpoint 行推不出来,二腿/子代形上会算出一个**错**的 id —— 在「等的谁」这条通道上,错 id
|
|
67
78
|
* 比缺席坏得多(本文件 `resumeEntryForGate` 的 default 臂是同一条纪律)。故本批诚实缺席。
|
|
68
79
|
*/
|
|
69
|
-
function governanceOriginOf(gate, governance) {
|
|
80
|
+
export function governanceOriginOf(gate, governance) {
|
|
70
81
|
if (gate?.riskDescriptor?.shellGateDoctrine !== "always")
|
|
71
82
|
return undefined;
|
|
72
83
|
return governanceMandatesShellGateAlways(governance ?? {}) ? true : undefined;
|
|
@@ -92,7 +103,10 @@ export async function buildActiveRunConflict(deps, sessionId, activeTaskId) {
|
|
|
92
103
|
try {
|
|
93
104
|
const row = await deps.runStore?.getRun?.(activeTaskId);
|
|
94
105
|
const status = row?.status;
|
|
95
|
-
|
|
106
|
+
// 判据取词表属主(合并码重扫):park 是两个词,由 `isParkedRunStatus` 的穷举 switch + `never` 臂看管;
|
|
107
|
+
// 手抄词表绕开那道编译期执法(core 新增一个 park 类成员时这里会静默判「非 park」⇒ 409 体少 activeTaskStatus
|
|
108
|
+
// 与 pendingGate 两格指路材料)。`running` 分支照旧单列(它不是 park,文案与出路都不同)。
|
|
109
|
+
if (status === undefined || (!isParkedRunStatus(status) && status !== "running"))
|
|
96
110
|
return base;
|
|
97
111
|
if (status === "running") {
|
|
98
112
|
return {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
15
15
|
import type { QuestionAnswer } from "@sema-agent/core";
|
|
16
16
|
import type { CheckpointStoreFull } from "../../plugins/store-backend.js";
|
|
17
|
+
import { type GovernancePosture } from "../active-run-conflict.js";
|
|
17
18
|
import type { RouteCtx } from "../route-ctx.js";
|
|
18
19
|
export declare const ASSISTANT_PREEMPT_RE: RegExp;
|
|
19
20
|
export declare const ASSISTANT_RESUME_RE: RegExp;
|
|
@@ -30,8 +31,12 @@ export declare function handleApprovalsAssistant(req: IncomingMessage, res: Serv
|
|
|
30
31
|
* for a decided/expired/gone one. Cross-replica BY CONSTRUCTION — the poll reads the SHARED checkpoint table,
|
|
31
32
|
* so a suspend on ANY replica is seen by an operator streaming on a DIFFERENT replica. A transient DB blip
|
|
32
33
|
* heartbeats + retries (never kills the stream); a 15-min cap + req-close end it (parity with streamTaskTrace).
|
|
33
|
-
* Poll-granularity caveat
|
|
34
|
-
*
|
|
34
|
+
* Poll-granularity caveat (**narrowed** by [3684]②): the delta now re-emits a `pending` frame whenever a key's
|
|
35
|
+
* WIRE PROJECTION changes (upsert), not only when the key enters — so a same-key re-suspend within one interval
|
|
36
|
+
* IS visible as long as anything on the row differs. What still shows no delta is the strictly degenerate case:
|
|
37
|
+
* a resolve + re-suspend on the same (session,toolCallId) whose projection is BYTE-IDENTICAL — indistinguishable
|
|
38
|
+
* by construction, and harmless (the card the operator sees is the card that is pending). Pins: the two halves of
|
|
39
|
+
* `test/wire-pairing-approvals-stream.test.ts` S3b (changed ⇒ re-emit with the NEW payload; unchanged ⇒ silence).
|
|
35
40
|
*
|
|
36
41
|
* A-002.19(2026-08-09 亲验定性=设计接受,非欠账):每连接自跑 pollMs 轮询打共享 checkpoint 表,
|
|
37
42
|
* 之所以不建 fan-out/单轮询器基建——①消费方=portal 运营面,并发连接数量级为个位(不是用户面);
|
|
@@ -40,5 +45,8 @@ export declare function handleApprovalsAssistant(req: IncomingMessage, res: Serv
|
|
|
40
45
|
* 数假设若被打破(portal 多开成常态),届时把 poll 收敛为进程内单轮询器多路复用——那是量级触发的
|
|
41
46
|
* 演化,不是现在的缺陷。
|
|
42
47
|
*/
|
|
43
|
-
export declare function streamApprovals(req: IncomingMessage, res: ServerResponse, cs: Pick<CheckpointStoreFull, "listPending">, scope: string | undefined,
|
|
48
|
+
export declare function streamApprovals(req: IncomingMessage, res: ServerResponse, cs: Pick<CheckpointStoreFull, "listPending">, scope: string | undefined,
|
|
49
|
+
/** [3684]②:出身归因的部署侧一半(缺席 ⇒ 归不出治理出身 ⇒ 键缺席,与 best-effort 同方向)。**位置在
|
|
50
|
+
* `pollMs` 之前**是刻意的:`pollMs` 只有测试传,而本参数是每个生产调用点都必须给的那一个。 */
|
|
51
|
+
governance?: GovernancePosture, pollMs?: number): Promise<void>;
|
|
44
52
|
//# sourceMappingURL=approvals-assistant.d.ts.map
|