@sema-agent/server 7.14.0 → 7.16.0-rc.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.
- package/MIGRATION.md +16 -1
- package/USAGE.md +38 -4
- package/dist/approval-ask-machine.d.ts +10 -0
- package/dist/approval-ask-machine.js +10 -0
- 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/memory-boundary.d.ts +6 -0
- package/dist/boot/memory-boundary.js +10 -2
- 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 +28 -7
- package/dist/capabilities/memory-notice.d.ts +13 -9
- package/dist/capabilities/memory-notice.js +35 -15
- 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 +38 -2
- 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 +20 -1
- package/dist/http/routes/diagnostics.d.ts +18 -0
- package/dist/http/routes/diagnostics.js +26 -0
- package/dist/http/routes/runs.js +86 -19
- package/dist/http/routes/side-query.js +15 -1
- package/dist/http/routes/tasks.js +32 -3
- package/dist/http/routes/trace-usage.js +38 -37
- 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/memory-scope.d.ts +20 -0
- package/dist/memory-scope.js +45 -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 +7 -1
- 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 +10 -0
- package/dist/tool-approval.js +143 -9
- 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
package/dist/leader/fanout.d.ts
CHANGED
|
@@ -98,6 +98,24 @@ export interface FanOutResult {
|
|
|
98
98
|
/** True if the overall deadline fired and cancelAll ran. */
|
|
99
99
|
cancelled: boolean;
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* 「这条 worker 的 TaskResult 停在门上(durable park)了吗」——`TaskStatus` 的穷举判据。
|
|
103
|
+
*
|
|
104
|
+
* 🔴 park 是**两个词**(扫描P2,status/kind 同名词混淆病族):`suspended`(工具审批 / human /
|
|
105
|
+
* resource_limit 门)与 `needs_review`(`plan_review` 与 dry-run 拦截门)。真源 = core
|
|
106
|
+
* `agents/suspend-guard.js` 的 `isDurablePause`(逐字 `status === "suspended" || status === "needs_review"`),
|
|
107
|
+
* `mapNestedSuspend` / `verify.js` 的 `unverifiedReason` 两处都按这张表分词。
|
|
108
|
+
*
|
|
109
|
+
* 只认 `suspended` 的后果不是「少一个标签」:一条 plan_review 停下的 worker 会被报成 `failed` ⇒
|
|
110
|
+
* fanOut 的 C4 抢占({@link FanOutOptions.cancelOnSuspend})不触发(其余 worker 继续烧钱)、leader 的
|
|
111
|
+
* replan「suspended 波」看不见它、`suspendedSessions` 的豁免名单漏掉它 ⇒ 它那张还能被决议的 checkpoint
|
|
112
|
+
* 与暂停中的环境会被当失败清掉。带验证器的那条腿早已改用「还带着 checkpointToken 就是 park」的判据
|
|
113
|
+
* (leader.ts round-3 review BLOCKER),这里是它当时漏掉的另外半场。
|
|
114
|
+
*
|
|
115
|
+
* `never` 收敛 = core `TaskStatus` 增删成员的**编译期**执行点(同 `store-contracts.ts` 两条判据的姿势);
|
|
116
|
+
* 运行时未知词保守判**非 park**(与既有 `else → failed` 同行为,不静默扩大 park 面)。
|
|
117
|
+
*/
|
|
118
|
+
export declare function isDurablePauseStatus(s: TaskResult["status"]): boolean;
|
|
101
119
|
/**
|
|
102
120
|
* Fan out `subtasks` in parallel. Never throws — every worker resolves to a WorkerReport (failures captured).
|
|
103
121
|
* The caller (Coordinator) merges only `status==="completed"` reports.
|
package/dist/leader/fanout.js
CHANGED
|
@@ -19,8 +19,41 @@ class Semaphore {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* 「这条 worker 的 TaskResult 停在门上(durable park)了吗」——`TaskStatus` 的穷举判据。
|
|
24
|
+
*
|
|
25
|
+
* 🔴 park 是**两个词**(扫描P2,status/kind 同名词混淆病族):`suspended`(工具审批 / human /
|
|
26
|
+
* resource_limit 门)与 `needs_review`(`plan_review` 与 dry-run 拦截门)。真源 = core
|
|
27
|
+
* `agents/suspend-guard.js` 的 `isDurablePause`(逐字 `status === "suspended" || status === "needs_review"`),
|
|
28
|
+
* `mapNestedSuspend` / `verify.js` 的 `unverifiedReason` 两处都按这张表分词。
|
|
29
|
+
*
|
|
30
|
+
* 只认 `suspended` 的后果不是「少一个标签」:一条 plan_review 停下的 worker 会被报成 `failed` ⇒
|
|
31
|
+
* fanOut 的 C4 抢占({@link FanOutOptions.cancelOnSuspend})不触发(其余 worker 继续烧钱)、leader 的
|
|
32
|
+
* replan「suspended 波」看不见它、`suspendedSessions` 的豁免名单漏掉它 ⇒ 它那张还能被决议的 checkpoint
|
|
33
|
+
* 与暂停中的环境会被当失败清掉。带验证器的那条腿早已改用「还带着 checkpointToken 就是 park」的判据
|
|
34
|
+
* (leader.ts round-3 review BLOCKER),这里是它当时漏掉的另外半场。
|
|
35
|
+
*
|
|
36
|
+
* `never` 收敛 = core `TaskStatus` 增删成员的**编译期**执行点(同 `store-contracts.ts` 两条判据的姿势);
|
|
37
|
+
* 运行时未知词保守判**非 park**(与既有 `else → failed` 同行为,不静默扩大 park 面)。
|
|
38
|
+
*/
|
|
39
|
+
export function isDurablePauseStatus(s) {
|
|
40
|
+
switch (s) {
|
|
41
|
+
case "suspended":
|
|
42
|
+
case "needs_review":
|
|
43
|
+
return true;
|
|
44
|
+
case "completed":
|
|
45
|
+
case "failed":
|
|
46
|
+
case "blocked":
|
|
47
|
+
return false;
|
|
48
|
+
default: {
|
|
49
|
+
const unhandled = s;
|
|
50
|
+
void unhandled;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
22
55
|
function mapStatus(s) {
|
|
23
|
-
return s === "completed" ? "completed" : s
|
|
56
|
+
return s === "completed" ? "completed" : isDurablePauseStatus(s) ? "suspended" : "failed";
|
|
24
57
|
}
|
|
25
58
|
/**
|
|
26
59
|
* Fan out `subtasks` in parallel. Never throws — every worker resolves to a WorkerReport (failures captured).
|
package/dist/leader/leader.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Deps injected → mock-tested; the real-infra wiring (E2B envs + deepseek runners + git push) lives in the
|
|
13
13
|
* joint dogfood, which this generalizes.
|
|
14
14
|
*/
|
|
15
|
-
import { fanOut } from "./fanout.js";
|
|
15
|
+
import { fanOut, isDurablePauseStatus } from "./fanout.js";
|
|
16
16
|
import { pullDiff } from "./diffout.js";
|
|
17
17
|
import { mergeBranches } from "./merge.js";
|
|
18
18
|
import { Runner, runWithVerification, resumeWithVerification, delimitUntrusted } from "@sema-agent/core";
|
|
@@ -105,10 +105,14 @@ export async function runLeaderTask(task, durableBaseSha, deps) {
|
|
|
105
105
|
}
|
|
106
106
|
return {
|
|
107
107
|
...base,
|
|
108
|
-
// Map the TaskResult status directly (like fanOut's own mapStatus) — a
|
|
109
|
-
// auto-resume left unresumed (a non-resource gate, e.g. human-approval
|
|
110
|
-
// leader's C4 surface/cancel reaction still fires; only completed/timeout pass
|
|
111
|
-
|
|
108
|
+
// Map the TaskResult status directly (like fanOut's own mapStatus) — a durably PARKED result that
|
|
109
|
+
// the auto-resume left unresumed (a non-resource gate, e.g. human-approval or a plan review) stays
|
|
110
|
+
// `suspended` so the leader's C4 surface/cancel reaction still fires; only completed/timeout pass
|
|
111
|
+
// through, else failed. 🔴 park 的判据取自 `fanout.ts` 的 `isDurablePauseStatus`(扫描P2):
|
|
112
|
+
// 原地手抄的 `=== "suspended"` 漏掉 `needs_review`(plan_review / dry-run 门),会把一条还
|
|
113
|
+
// 拿着 checkpoint、环境还暂停着的 worker 报成 failed —— 与本文件带验证器那条腿早就修过的
|
|
114
|
+
// 是同一个缺陷(round-3 review BLOCKER),当时只修了那半场。
|
|
115
|
+
status: r.status === "completed" ? "completed" : isDurablePauseStatus(r.status) ? "suspended" : "failed", // core 5.8.0:TaskResult 无 "timeout"(内部词表的 timeout 仍由 fanout 墙钟臂生产)
|
|
112
116
|
stats: r.stats,
|
|
113
117
|
...(r.status === "completed" ? {} : { error: r.errorMessage ?? r.blockedReason ?? `worker ${r.status}` }),
|
|
114
118
|
...(r.checkpointGate ? { checkpointGate: r.checkpointGate } : {}),
|
package/dist/leader/wire.js
CHANGED
|
@@ -25,6 +25,7 @@ import { pullDiff, safeRev } from "./diffout.js";
|
|
|
25
25
|
import { runLeaderTask } from "./leader.js";
|
|
26
26
|
import { attachRepairLoopDeps } from "./repair-wire.js";
|
|
27
27
|
import { routePlanWithFallback, validateSubtasks } from "./planner.js";
|
|
28
|
+
import { createEngineNoticeSeat } from "../boot/runner-deps.js";
|
|
28
29
|
/** The single-agent `runRepairLoop` is now WIRED LIVE (the canary-enable slice): when `LEADER_REPAIR_LOOP=true`
|
|
29
30
|
* the wire provisions a DEDICATED oracle-seeded grader sandbox per solo worker and attaches a real async
|
|
30
31
|
* `resolve` to `attachRepairLoopDeps` (below). With this `true`, flipping `LEADER_REPAIR_LOOP` engages BOTH the
|
|
@@ -245,6 +246,16 @@ export function createLeaderRunner(cfg) {
|
|
|
245
246
|
const ident = cfg.git ?? { name: "leader", email: "leader@local" };
|
|
246
247
|
const { leaderTimeoutMs, workerLimits, presignTtlSec, resourceSuspend: resourceCfg } = leaderResourceConfig();
|
|
247
248
|
const { repairRounds, repairBudgetUsd, conflictRounds, repairLoopOn, measureGatesOn, repairLoopAttempts, oracleFlakyK, replanBudgetUsd, } = leaderLoopConfig({ repairRounds: cfg.repairRounds, conflictRounds: cfg.conflictRounds });
|
|
249
|
+
// #240② onNotice 席(合并码重扫):本车道的每一只 Runner 都要带,否则 core 的引擎通告在这条腿上落回
|
|
250
|
+
// 裸 `console.warn`(主车道进结构化 `engine_notice` 行)—— 同一部署两条通告通道,按 code 采集的运维面
|
|
251
|
+
// 只看得见一半。转发体取共享基座的**那一个**具名导出,禁在这里另写一份(两份必漂)。
|
|
252
|
+
// ⚠️ 判据是 `warn` **在场**,不是 `cfg.logger` 在场:leader 的 logger 两格都可选,只装了 `info` 的部署
|
|
253
|
+
// 铸这一席等于把通告吞掉 —— 那比不铸还坏(不铸时 core 至少还打自己的 `console.warn`)。
|
|
254
|
+
// 🔴 这条判据现在**整条住在** `createEngineNoticeSeat` 里(codex 复审 R1-[medium],验真后修):此前它是
|
|
255
|
+
// 这里的一行三元,于是只能靠「扫这段源码文本里有没有 `warn`」去守,而 `cfg.logger ? ((m,x)=>cfg.logger?.warn?.(m,x)) : undefined`
|
|
256
|
+
// 这一族变异**文本上照样有 `warn`**、值上却是 truthy 闭包(info-only 部署照铸席、通告被可选链吞掉)。
|
|
257
|
+
// 搬进函数之后判据可被**行为**测,门不再是关键字搜索。
|
|
258
|
+
const onNoticeSeat = createEngineNoticeSeat(cfg.logger);
|
|
248
259
|
// worker resource bounds (limits/maxCostUsd) + presign ttl are derived in leaderResourceConfig(), above.
|
|
249
260
|
// Bounded integration-repair (search 2026-06-14): when the merged tree compiles-clean-but-fails, run a strong
|
|
250
261
|
// agent WITH HANDS in the live merged sandbox to fix the cross-worker integration, then mergeBranches re-runs
|
|
@@ -271,7 +282,7 @@ export function createLeaderRunner(cfg) {
|
|
|
271
282
|
// rootPath: repoDir (Codex review #4) — without it core roots the file tools at executionEnv.cwd
|
|
272
283
|
// (/workspace on Kata), one level above the repo (/workspace/repo). bash can cd, but the dedicated
|
|
273
284
|
// read/edit tools would start too high + lose containment.
|
|
274
|
-
const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, executionEnv: rawEnv, rootPath: repoDir });
|
|
285
|
+
const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, executionEnv: rawEnv, rootPath: repoDir });
|
|
275
286
|
const objective = [
|
|
276
287
|
`The integrated project at ${repoDir} fails its build/test. Make the MINIMAL change to the working tree so this command exits 0 (cd into the repo and run it yourself to confirm):`,
|
|
277
288
|
` ${testCmd}`,
|
|
@@ -327,7 +338,7 @@ export function createLeaderRunner(cfg) {
|
|
|
327
338
|
throw new Error(`oracle still present after remove (${f.path}) — refusing conflict-resolve (measurement integrity)`);
|
|
328
339
|
}
|
|
329
340
|
try {
|
|
330
|
-
const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, executionEnv: rawEnv, rootPath: repoDir });
|
|
341
|
+
const runner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, executionEnv: rawEnv, rootPath: repoDir });
|
|
331
342
|
const objective = [
|
|
332
343
|
`A parallel worker '${workerId}' ported a module on its own branch, but applying its patch onto the already-integrated tree at ${repoDir} produced a MERGE CONFLICT (git apply --3way). The OTHER workers' patches already applied cleanly — integrate THIS worker's changes too, resolving the overlap.`,
|
|
333
344
|
`Resolve EVERY conflict in the working tree: open each file containing conflict markers (<<<<<<< / ======= / >>>>>>>) and merge BOTH sides' real intent (keep both workers' behaviour — never drop one side just to make it apply). Then apply any rejected hunks recorded in *.rej files by hand, and DELETE every *.rej and *.orig file.`,
|
|
@@ -415,7 +426,7 @@ export function createLeaderRunner(cfg) {
|
|
|
415
426
|
// the Coordinator's parallel merge relies on. Was a straight map() that skipped all of it.
|
|
416
427
|
return validateSubtasks(body.subtasks);
|
|
417
428
|
}
|
|
418
|
-
const planRunner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing });
|
|
429
|
+
const planRunner = new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat });
|
|
419
430
|
const runRoute = async (prompt) => {
|
|
420
431
|
const res = await planRunner.runTaskStream({
|
|
421
432
|
objective: prompt,
|
|
@@ -566,7 +577,7 @@ export function createLeaderRunner(cfg) {
|
|
|
566
577
|
workerId: sub.workerId, sessionId, branch: sub.branch,
|
|
567
578
|
baseSha, // seedCmd reproduces the durable base — same sha for every worker
|
|
568
579
|
runner: keepCtxWarm(new Runner({
|
|
569
|
-
brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing,
|
|
580
|
+
brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat,
|
|
570
581
|
...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}),
|
|
571
582
|
...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}),
|
|
572
583
|
executionEnvFactory: (ctx) => withStaging(envFactory(ctx), stage, async (e) => {
|
|
@@ -589,7 +600,7 @@ export function createLeaderRunner(cfg) {
|
|
|
589
600
|
const baseSha = await sh(env)(`cd ${repo} && git rev-parse HEAD`);
|
|
590
601
|
return {
|
|
591
602
|
workerId: sub.workerId, sessionId, branch: sub.branch, baseSha,
|
|
592
|
-
runner: keepCtxWarm(new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}), ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}), executionEnv: env })),
|
|
603
|
+
runner: keepCtxWarm(new Runner({ brain: cfg.brain, models: cfg.models, roles: cfg.roles, pricing: cfg.pricing, ...onNoticeSeat, ...(cfg.toolResultStore ? { toolResultStore: cfg.toolResultStore } : {}), ...(cfg.sessionStore ? { sessionStore: cfg.sessionStore } : {}), executionEnv: env })),
|
|
593
604
|
diffEnv: env,
|
|
594
605
|
destroy: () => env.destroy().then(() => { }),
|
|
595
606
|
spec: { ...workerLimits, ...sub.spec, ...durableSpec, ...resourceSpec },
|
package/dist/main.js
CHANGED
|
@@ -443,6 +443,7 @@ async function main() {
|
|
|
443
443
|
// 基座只有一份)。差异键在展开后显式列出,每个都有为何不同的理由(见 boot/runner-deps.ts 头注)。
|
|
444
444
|
...createSharedRunnerDeps({
|
|
445
445
|
config,
|
|
446
|
+
logger, // #240②:onNotice 席落共享基座 ⇒ 两 Runner 同源(EngineNotice→logger.warn)
|
|
446
447
|
hands: commitHands, // 交接件⑤:与主 runner 同一个值(基座保证同源)
|
|
447
448
|
brain,
|
|
448
449
|
pricing,
|
package/dist/memory-scope.d.ts
CHANGED
|
@@ -44,6 +44,26 @@ export declare function memoryEngineRemoteLanePosture(config: ServiceConfig): {
|
|
|
44
44
|
lane: string;
|
|
45
45
|
posture: "dark" | "forced";
|
|
46
46
|
} | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* N0 启动告警的**文案**(纯,可单测;boot/stores.ts 的装配点只负责喂「引擎这一腿真的接上了吗」并打日志)。
|
|
49
|
+
*
|
|
50
|
+
* 为什么要按引擎真身分腿(2026-08-12 复扫,已核真):{@link memoryEngineRemoteLanePosture} 的 `dark` 只对
|
|
51
|
+
* **file** 引擎腿有裁决权 —— 车道门的唯一消费者是 {@link memoryEngineBackendFor}。`MEMORY_ENGINE_BACKEND=pg|tidb`
|
|
52
|
+
* 的两条腿在 boot/stores.ts 里**先于**任何车道判断就被点亮(库是持久真身,与手的文件平面无关),于是同一次
|
|
53
|
+
* 启动会先打 `memory_engine_enabled {enabled:true, backend:"pg"}`,几行之后再打一句「memory dark
|
|
54
|
+
* (fail-closed) … Set MEMORY_ENGINE_REMOTE_LANE=allow」—— 两句直接对撞,而且把运维指向一个在该腿上**不接线**
|
|
55
|
+
* 的旋钮(`memoryEngineRemoteLaneAllowed` 的读者只有本文件与那条日志)。日志是运维唯一能看见的部署事实,
|
|
56
|
+
* 一句谎比没有这句更贵。
|
|
57
|
+
*
|
|
58
|
+
* 返回 `undefined` = 本部署没什么可说的(无平面分裂车道 / 记忆整体关 / 多租户 file 腿的 dark 成因是租户隔离,
|
|
59
|
+
* 已由 `memory_engine_enabled` 的 reason 位报因,不在这里重复)。
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildMemoryRemoteLaneWarn(config: ServiceConfig,
|
|
62
|
+
/** 记忆引擎这一腿**真的**接上了吗(`memoryEngine !== undefined`)——装配结果,不在本函数里重算。 */
|
|
63
|
+
engineWired: boolean): {
|
|
64
|
+
lane: string;
|
|
65
|
+
effect: string;
|
|
66
|
+
} | undefined;
|
|
47
67
|
/**
|
|
48
68
|
* design/138 S1 wiring gate (pure — unit-testable without booting main): build the file-based memory-engine
|
|
49
69
|
* backend for this deployment, or `undefined` when memory must stay dark.
|
package/dist/memory-scope.js
CHANGED
|
@@ -98,6 +98,51 @@ export function memoryEngineRemoteLanePosture(config) {
|
|
|
98
98
|
return undefined; // hands work THIS machine's fs — same plane
|
|
99
99
|
return { lane, posture: config.memoryEngineRemoteLaneAllowed === true ? "forced" : "dark" };
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* N0 启动告警的**文案**(纯,可单测;boot/stores.ts 的装配点只负责喂「引擎这一腿真的接上了吗」并打日志)。
|
|
103
|
+
*
|
|
104
|
+
* 为什么要按引擎真身分腿(2026-08-12 复扫,已核真):{@link memoryEngineRemoteLanePosture} 的 `dark` 只对
|
|
105
|
+
* **file** 引擎腿有裁决权 —— 车道门的唯一消费者是 {@link memoryEngineBackendFor}。`MEMORY_ENGINE_BACKEND=pg|tidb`
|
|
106
|
+
* 的两条腿在 boot/stores.ts 里**先于**任何车道判断就被点亮(库是持久真身,与手的文件平面无关),于是同一次
|
|
107
|
+
* 启动会先打 `memory_engine_enabled {enabled:true, backend:"pg"}`,几行之后再打一句「memory dark
|
|
108
|
+
* (fail-closed) … Set MEMORY_ENGINE_REMOTE_LANE=allow」—— 两句直接对撞,而且把运维指向一个在该腿上**不接线**
|
|
109
|
+
* 的旋钮(`memoryEngineRemoteLaneAllowed` 的读者只有本文件与那条日志)。日志是运维唯一能看见的部署事实,
|
|
110
|
+
* 一句谎比没有这句更贵。
|
|
111
|
+
*
|
|
112
|
+
* 返回 `undefined` = 本部署没什么可说的(无平面分裂车道 / 记忆整体关 / 多租户 file 腿的 dark 成因是租户隔离,
|
|
113
|
+
* 已由 `memory_engine_enabled` 的 reason 位报因,不在这里重复)。
|
|
114
|
+
*/
|
|
115
|
+
export function buildMemoryRemoteLaneWarn(config,
|
|
116
|
+
/** 记忆引擎这一腿**真的**接上了吗(`memoryEngine !== undefined`)——装配结果,不在本函数里重算。 */
|
|
117
|
+
engineWired) {
|
|
118
|
+
const posture = memoryEngineRemoteLanePosture(config);
|
|
119
|
+
if (posture === undefined || !config.memoryEngineEnabled)
|
|
120
|
+
return undefined;
|
|
121
|
+
if (config.memoryEngineBackend !== "file") {
|
|
122
|
+
if (!engineWired)
|
|
123
|
+
return undefined; // DB 腿没接上只可能是拒启路径(半配 fail-loud),不在这里猜
|
|
124
|
+
return {
|
|
125
|
+
lane: posture.lane,
|
|
126
|
+
effect: `memory ON over a remote lane: the "${config.memoryEngineBackend}" memory plane is durable in the DB and is NOT gated by ` +
|
|
127
|
+
`MEMORY_ENGINE_REMOTE_LANE (that knob gates the FILE engine only). What IS split here is the file plane — the ` +
|
|
128
|
+
`engine's materialization dir lives on the WORKER fs while this lane routes the model's file tools to the sandbox fs, ` +
|
|
129
|
+
`so harvest only sees what lands on the worker. Declare MEMORY_PERSISTENCE_CAPABLE=true|false to state which one it is.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (config.requirePrincipal === true)
|
|
133
|
+
return undefined; // 多租户 file 腿:dark 的成因是隔离,不是车道
|
|
134
|
+
return posture.posture === "dark"
|
|
135
|
+
? {
|
|
136
|
+
lane: posture.lane,
|
|
137
|
+
effect: "memory dark (fail-closed): the file engine works the worker's local fs while this lane routes model file tools " +
|
|
138
|
+
"to the sandbox fs — sandbox writes are never harvested. Set MEMORY_ENGINE_REMOTE_LANE=allow ONLY if both are one fs.",
|
|
139
|
+
}
|
|
140
|
+
: {
|
|
141
|
+
lane: posture.lane,
|
|
142
|
+
effect: "MEMORY_ENGINE_REMOTE_LANE=allow: memory engine ON over a remote lane — harvest only sees files landing on the " +
|
|
143
|
+
"WORKER fs; verify the lane really shares it.",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
101
146
|
/**
|
|
102
147
|
* design/138 S1 wiring gate (pure — unit-testable without booting main): build the file-based memory-engine
|
|
103
148
|
* backend for this deployment, or `undefined` when memory must stay dark.
|
package/dist/model-select.d.ts
CHANGED
|
@@ -23,11 +23,53 @@
|
|
|
23
23
|
* 🔴 Was a real defect: resolveSpec previously read ONLY the inline @mention, so `body.model` (the picker + the
|
|
24
24
|
* `--model` flag) was SILENTLY DROPPED — the picker UX was a no-op server-side. This restores it.
|
|
25
25
|
*/
|
|
26
|
-
export declare function resolveTaskModel(bodyModel: unknown, objective: string, catalog: Record<string, unknown
|
|
26
|
+
export declare function resolveTaskModel(bodyModel: unknown, objective: string, catalog: Record<string, unknown>, allowlist?: readonly string[]): {
|
|
27
27
|
model: string;
|
|
28
28
|
cleanedObjective: string;
|
|
29
29
|
unknownExplicit?: string;
|
|
30
|
+
notAllowed?: ModelNotAllowed;
|
|
30
31
|
};
|
|
32
|
+
/** #233 判别式拒因:名单在场且这次**用户选择**落在名单外。`resolved` 已是目录 name 形(归一后)。 */
|
|
33
|
+
export interface ModelNotAllowed {
|
|
34
|
+
/** 调用方发来的原始 ref(name / 档位词 / 别名 / id / `@name`),已做过日志安全的 repr 归一。 */
|
|
35
|
+
requested: string;
|
|
36
|
+
/** 归一到的目录 name(即这次真会跑的模型)。 */
|
|
37
|
+
resolved: string;
|
|
38
|
+
/** 走的是哪条用户通道:`explicit`=body.model/settings.model,`mention`=objective 内联 `@name`。 */
|
|
39
|
+
source: "explicit" | "mention";
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* #233 / A-002.8 —— **归一条款**:membership 判定前把 ref 归一到**终端 catalog 条目**再比。
|
|
43
|
+
*
|
|
44
|
+
* 为什么不能拿字符串直接跟名单比:`expandTiers` 的增广目录里,档位词(`pro`)与 CC 别名(`sonnet`)
|
|
45
|
+
* 的值与 `config.models[<name>]` 是**同一个对象引用**——裸串比较会让 `@pro` 绕过一张按 name 写的名单
|
|
46
|
+
* (同一只模型,两个键)。所以两边(ref 与每个名单条目)都先经 {@link matchCatalogModel} 解到目录条目,
|
|
47
|
+
* 再按 **引用相等** 判同。id 形的 ref 不需要特别处理:`matchCatalogModel` 本身就是 name+id 双键索引,
|
|
48
|
+
* 它会把 id 解成目录键,引用相等接着就对上了。
|
|
49
|
+
*
|
|
50
|
+
* 🔴 **刻意没有「id 相等」退路**(codex 复审 R1 [high],已采纳):目录**允许两条不同 name 的条目共享
|
|
51
|
+
* 同一个上游 `Model.id`,却各自带不同的 `baseUrl` / `apiKeyEnv` / `cost` / provider** —— 那正是多网关、
|
|
52
|
+
* 多账号部署形。按 id 判同会把 `safe` 与 `premium` 合并,于是一张只含 `safe` 的名单会放行 `premium`,
|
|
53
|
+
* 而真正执行的是 `premium` 那份坐标与计费。判据只认**目录条目本身**:两条条目就是两只模型,哪怕上游
|
|
54
|
+
* id 撞车。(引用相等因此不是"优化",是唯一正确的判据;它天然覆盖 tier 词/别名/id 三形。)
|
|
55
|
+
*
|
|
56
|
+
* 缺席/空名单 ⇒ 恒 `true`(ship-dark:缺省 = 旧行为,全放行)。
|
|
57
|
+
* 目录里根本没有的 ref ⇒ 恒 `true`:那是「未知模型」那道门的辖区(fresh 400 / resume 降级留声),
|
|
58
|
+
* 本门只答「在目录里,但准不准点」——两道门同码同拒会让消费端分不清"没这模型"和"不让你点"。
|
|
59
|
+
*
|
|
60
|
+
* ⚠️ 射程:本门是**提交时**门。core 运行时路由(degrade 降级链 / cascade 梯 / roles 解析)按 operator
|
|
61
|
+
* 配置自己走,**不经本门**——那不是一次用户选择,如实声明,不假装是运行时执法。
|
|
62
|
+
*/
|
|
63
|
+
export declare function isModelAllowlisted(ref: string, catalog: Record<string, unknown>, allowlist: readonly string[] | undefined): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* #233 —— ref → **终端 catalog name**(拒因/日志的可读形)。
|
|
66
|
+
*
|
|
67
|
+
* 为什么不是 `matchCatalogModel` 的返回值:增广目录里 `pro`/`opus`/`default` 都是**真实存在的键**,
|
|
68
|
+
* 于是 `matchCatalogModel("pro")` 回的就是 `"pro"` —— 那是用户点的那个别名,不是他真会跑的模型。
|
|
69
|
+
* 取条目自己的 `Model.name`(applyEffective 建目录时 `models[m.name].name === m.name`,env lane 同)
|
|
70
|
+
* 才是终端名;拿不到时如实退回匹配键(诚实缺席,不猜)。
|
|
71
|
+
*/
|
|
72
|
+
export declare function terminalCatalogName(ref: string, catalog: Record<string, unknown>): string | undefined;
|
|
31
73
|
/**
|
|
32
74
|
* [865]② name+id 双键目录索引:返回 ref 命中的目录 **name 键**(spec.model 的规范形),未知 → undefined。
|
|
33
75
|
* - name 命中用 Object.hasOwn(🔴 不用真值索引:Object.prototype 继承键 "constructor"/"__proto__" 等在裸
|
package/dist/model-select.js
CHANGED
|
@@ -24,7 +24,7 @@ import { parseModelMention } from "@sema-agent/core";
|
|
|
24
24
|
* 🔴 Was a real defect: resolveSpec previously read ONLY the inline @mention, so `body.model` (the picker + the
|
|
25
25
|
* `--model` flag) was SILENTLY DROPPED — the picker UX was a no-op server-side. This restores it.
|
|
26
26
|
*/
|
|
27
|
-
export function resolveTaskModel(bodyModel, objective, catalog) {
|
|
27
|
+
export function resolveTaskModel(bodyModel, objective, catalog, allowlist) {
|
|
28
28
|
const mention = parseModelMention(objective, Object.keys(catalog));
|
|
29
29
|
const explicit = typeof bodyModel === "string" ? matchCatalogModel(bodyModel, catalog) : undefined;
|
|
30
30
|
// 未匹配的「在场」ref 一律透出(codex L1:历史 checkpoint 里的空串/非字符串 model 在 RESUME 重放也不许
|
|
@@ -32,12 +32,80 @@ export function resolveTaskModel(bodyModel, objective, catalog) {
|
|
|
32
32
|
const present = bodyModel !== undefined && bodyModel !== null;
|
|
33
33
|
const unknown = present && explicit === undefined;
|
|
34
34
|
const repr = typeof bodyModel !== "string" ? `<non-string:${typeof bodyModel}>` : bodyModel === "" ? "<empty-string>" : bodyModel;
|
|
35
|
+
const model = explicit ?? mention.model ?? "default";
|
|
36
|
+
// #233 / A-002.8:名单只裁**用户逐 turn 选择通道**——显式 ref(body.model / settings.model)与 objective
|
|
37
|
+
// 内联 `@name`。两者都缺席时落 `default`(operator 配置通道),本门**不碰**:名单管"用户可点什么",
|
|
38
|
+
// 不管"operator 配什么"(registry-core 也只校验名单 ⊆ catalog,从不要求 default ∈ 名单)。
|
|
39
|
+
// 不 throw:resume 重放持久化 body 不过 HTTP 门,砖死 resume 比降级糟(本文件 [865]② 同判据)——
|
|
40
|
+
// 判别式拒因交给调用点按 leg 分流(fresh 400 / resume warn 续跑)。
|
|
41
|
+
const picked = explicit !== undefined ? { ref: explicit, source: "explicit", requested: repr } : mention.model !== undefined ? { ref: mention.model, source: "mention", requested: mention.model } : undefined;
|
|
42
|
+
const notAllowed = picked !== undefined && !isModelAllowlisted(picked.ref, catalog, allowlist)
|
|
43
|
+
? { requested: picked.requested, resolved: terminalCatalogName(picked.ref, catalog) ?? picked.ref, source: picked.source }
|
|
44
|
+
: undefined;
|
|
35
45
|
return {
|
|
36
|
-
model
|
|
46
|
+
model,
|
|
37
47
|
cleanedObjective: mention.cleanedText || objective,
|
|
38
48
|
...(unknown ? { unknownExplicit: repr } : {}),
|
|
49
|
+
...(notAllowed ? { notAllowed } : {}),
|
|
39
50
|
};
|
|
40
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* #233 / A-002.8 —— **归一条款**:membership 判定前把 ref 归一到**终端 catalog 条目**再比。
|
|
54
|
+
*
|
|
55
|
+
* 为什么不能拿字符串直接跟名单比:`expandTiers` 的增广目录里,档位词(`pro`)与 CC 别名(`sonnet`)
|
|
56
|
+
* 的值与 `config.models[<name>]` 是**同一个对象引用**——裸串比较会让 `@pro` 绕过一张按 name 写的名单
|
|
57
|
+
* (同一只模型,两个键)。所以两边(ref 与每个名单条目)都先经 {@link matchCatalogModel} 解到目录条目,
|
|
58
|
+
* 再按 **引用相等** 判同。id 形的 ref 不需要特别处理:`matchCatalogModel` 本身就是 name+id 双键索引,
|
|
59
|
+
* 它会把 id 解成目录键,引用相等接着就对上了。
|
|
60
|
+
*
|
|
61
|
+
* 🔴 **刻意没有「id 相等」退路**(codex 复审 R1 [high],已采纳):目录**允许两条不同 name 的条目共享
|
|
62
|
+
* 同一个上游 `Model.id`,却各自带不同的 `baseUrl` / `apiKeyEnv` / `cost` / provider** —— 那正是多网关、
|
|
63
|
+
* 多账号部署形。按 id 判同会把 `safe` 与 `premium` 合并,于是一张只含 `safe` 的名单会放行 `premium`,
|
|
64
|
+
* 而真正执行的是 `premium` 那份坐标与计费。判据只认**目录条目本身**:两条条目就是两只模型,哪怕上游
|
|
65
|
+
* id 撞车。(引用相等因此不是"优化",是唯一正确的判据;它天然覆盖 tier 词/别名/id 三形。)
|
|
66
|
+
*
|
|
67
|
+
* 缺席/空名单 ⇒ 恒 `true`(ship-dark:缺省 = 旧行为,全放行)。
|
|
68
|
+
* 目录里根本没有的 ref ⇒ 恒 `true`:那是「未知模型」那道门的辖区(fresh 400 / resume 降级留声),
|
|
69
|
+
* 本门只答「在目录里,但准不准点」——两道门同码同拒会让消费端分不清"没这模型"和"不让你点"。
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ 射程:本门是**提交时**门。core 运行时路由(degrade 降级链 / cascade 梯 / roles 解析)按 operator
|
|
72
|
+
* 配置自己走,**不经本门**——那不是一次用户选择,如实声明,不假装是运行时执法。
|
|
73
|
+
*/
|
|
74
|
+
export function isModelAllowlisted(ref, catalog, allowlist) {
|
|
75
|
+
if (allowlist === undefined || allowlist.length === 0)
|
|
76
|
+
return true;
|
|
77
|
+
const entry = catalogEntryOf(ref, catalog);
|
|
78
|
+
if (entry === undefined)
|
|
79
|
+
return true;
|
|
80
|
+
for (const name of allowlist) {
|
|
81
|
+
const allowed = catalogEntryOf(name, catalog);
|
|
82
|
+
if (allowed === undefined)
|
|
83
|
+
continue; // 悬空/不可解名单条目(apply 期已 warn):它准不了任何东西
|
|
84
|
+
if (allowed === entry)
|
|
85
|
+
return true; // 引用相等 —— 增广目录里 tier 词/别名/id 最终都落到这同一只条目
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/** ref(name / 档位词 / 别名 / id)→ 它在目录里的**条目对象**;未知 ⇒ undefined。 */
|
|
90
|
+
function catalogEntryOf(ref, catalog) {
|
|
91
|
+
const name = matchCatalogModel(ref, catalog);
|
|
92
|
+
return name === undefined ? undefined : catalog[name];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* #233 —— ref → **终端 catalog name**(拒因/日志的可读形)。
|
|
96
|
+
*
|
|
97
|
+
* 为什么不是 `matchCatalogModel` 的返回值:增广目录里 `pro`/`opus`/`default` 都是**真实存在的键**,
|
|
98
|
+
* 于是 `matchCatalogModel("pro")` 回的就是 `"pro"` —— 那是用户点的那个别名,不是他真会跑的模型。
|
|
99
|
+
* 取条目自己的 `Model.name`(applyEffective 建目录时 `models[m.name].name === m.name`,env lane 同)
|
|
100
|
+
* 才是终端名;拿不到时如实退回匹配键(诚实缺席,不猜)。
|
|
101
|
+
*/
|
|
102
|
+
export function terminalCatalogName(ref, catalog) {
|
|
103
|
+
const key = matchCatalogModel(ref, catalog);
|
|
104
|
+
if (key === undefined)
|
|
105
|
+
return undefined;
|
|
106
|
+
const name = catalog[key]?.name;
|
|
107
|
+
return typeof name === "string" && name.length > 0 ? name : key;
|
|
108
|
+
}
|
|
41
109
|
/**
|
|
42
110
|
* [865]② name+id 双键目录索引:返回 ref 命中的目录 **name 键**(spec.model 的规范形),未知 → undefined。
|
|
43
111
|
* - name 命中用 Object.hasOwn(🔴 不用真值索引:Object.prototype 继承键 "constructor"/"__proto__" 等在裸
|
|
@@ -49,6 +49,14 @@ export declare const FAIL_OPEN_TAGS: {
|
|
|
49
49
|
readonly cls: "F";
|
|
50
50
|
readonly note: "CC 规则导入票的**认领回滚**失败(认领之后的某一步没成 ⇒ 本该把认领放回去,而这次放回本身也抛了)。放行的最坏后果 = 这张票留在已认领态、属主这一轮不能重试 —— 恰好等于加认领回滚**之前**的行为,不是新损失;票随 TTL 自然消失,属主重走一次 prepare 即可拿新票(导入按 core 的设计幂等:同一条规则再兑付只是同一个 dot 的重放)。方向上没有任何权限被放宽(规则**没有**落地才走到这条臂),故 F 类;必须留痕,否则「票为什么突然不能用了」在遥测里没有任何痕迹。";
|
|
51
51
|
};
|
|
52
|
+
readonly "server.approvals.rule-suggestions-cell-unreadable": {
|
|
53
|
+
readonly cls: "F";
|
|
54
|
+
readonly note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.rule_suggestions` 这一格**不是合法 JSON 文本**(手改过的行 / 未来列语义漂了 / 回滚残留)。放行的最坏后果 = **这一行**的「不再询问」候选缺席(展示/分诊材料,永不参与 resume、也不是任何判据),队列其余部分照常;不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空)。故 F 类,但必须留痕:静默吞掉之后「候选为什么消失了」在遥测里没有任何痕迹,而本键存在的全部理由就是「缺席 = 真的没有候选」。";
|
|
55
|
+
};
|
|
56
|
+
readonly "server.approvals.risk-descriptor-cell-unreadable": {
|
|
57
|
+
readonly cls: "F";
|
|
58
|
+
readonly note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。";
|
|
59
|
+
};
|
|
52
60
|
readonly "server.parked-revive.ancestor-classifier-unreachable": {
|
|
53
61
|
readonly cls: "P-DEBT";
|
|
54
62
|
readonly note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。";
|
|
@@ -72,6 +72,14 @@ export const FAIL_OPEN_TAGS = {
|
|
|
72
72
|
cls: "F",
|
|
73
73
|
note: "CC 规则导入票的**认领回滚**失败(认领之后的某一步没成 ⇒ 本该把认领放回去,而这次放回本身也抛了)。放行的最坏后果 = 这张票留在已认领态、属主这一轮不能重试 —— 恰好等于加认领回滚**之前**的行为,不是新损失;票随 TTL 自然消失,属主重走一次 prepare 即可拿新票(导入按 core 的设计幂等:同一条规则再兑付只是同一个 dot 的重放)。方向上没有任何权限被放宽(规则**没有**落地才走到这条臂),故 F 类;必须留痕,否则「票为什么突然不能用了」在遥测里没有任何痕迹。",
|
|
74
74
|
},
|
|
75
|
+
"server.approvals.rule-suggestions-cell-unreadable": {
|
|
76
|
+
cls: "F",
|
|
77
|
+
note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.rule_suggestions` 这一格**不是合法 JSON 文本**(手改过的行 / 未来列语义漂了 / 回滚残留)。放行的最坏后果 = **这一行**的「不再询问」候选缺席(展示/分诊材料,永不参与 resume、也不是任何判据),队列其余部分照常;不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空)。故 F 类,但必须留痕:静默吞掉之后「候选为什么消失了」在遥测里没有任何痕迹,而本键存在的全部理由就是「缺席 = 真的没有候选」。",
|
|
78
|
+
},
|
|
79
|
+
"server.approvals.risk-descriptor-cell-unreadable": {
|
|
80
|
+
cls: "F",
|
|
81
|
+
note: "运维待批队列(`GET /v1/approvals` / `/stream`)的某一行,`checkpoint.risk_descriptor` 这一格**不是合法 JSON 文本**(手改过的行 / 回滚残留 / 列语义漂;与同一 `rows.map` 里的 `rule_suggestions` 是同族坏法,两方言都是 TEXT 列)。放行的最坏后果 = **这一行**的分诊 descriptor 落 null:severity 折 0 排到有 descriptor 的行之后、`shadowedRule` 无可脱敏、读面派生的 `governanceForced` 徽章不亮 —— 三者都是**展示/分诊**,不是执法判据(门早已 park,真按 `shellGateDoctrine` 判的 `active-run-conflict.ts` 读的是 checkpoint blob 的 `get()`,不经本读面),且 `governanceForced` 的成文语义本就是「缺席 = 没有治理来源的**证据**」。不放行的代价是整只 `rows.map` 抛出 ⇒ 一行坏 cell 打掉整个租户的队列(列表 500、SSE 心跳照常而队列永远空),连同其余所有行一起消失。故 F 类;tag 与候选那格**分开计**,并计会让「哪一格在坏」在遥测里读不出来。",
|
|
82
|
+
},
|
|
75
83
|
"server.parked-revive.ancestor-classifier-unreachable": {
|
|
76
84
|
cls: "P-DEBT",
|
|
77
85
|
note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。",
|
|
@@ -253,6 +253,9 @@ export function createMetrics() {
|
|
|
253
253
|
m.counter("budget_exceeded_total", "Tasks failed by a budget/limit ceiling, by errorCode");
|
|
254
254
|
m.counter("cost_quota_rejected_total", "Tasks refused because the principal is over its cumulative cost quota");
|
|
255
255
|
m.counter("degraded_total", "Tasks degraded to a cheaper model (1.40), by reason (budget/rate_limit/breaker_open)");
|
|
256
|
+
// #233 / A-002.8:名单外的模型选择在 **resume 腿**被放行(fresh 提交是 400)。读法:非零 = 还有存量
|
|
257
|
+
// 会话跑在 @-mention 名单之外 —— 那是刻意的(砖死 resume 比降级糟),但治理面必须看得见它有多少。
|
|
258
|
+
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)");
|
|
256
259
|
m.counter("prompt_constitution_total", "Prompt assembly constitution mode per task (core 1.243 提示词主权): steady state = core; provider-assembled/replaced/legacy warrant eyes");
|
|
257
260
|
m.counter("rewind_snapshot_failed_total", "Per-turn rewind file snapshots that failed, by code (too_large = big working tree, expected/benign; other codes warrant eyes)");
|
|
258
261
|
m.counter("verifications_total", "Tasks run through the verification gate (1.44), by final verdict");
|
|
@@ -320,7 +323,10 @@ export function createMetrics() {
|
|
|
320
323
|
// S21 — which backend snapshot blobs actually land on (minio|sql). Partial MINIO_* config silently falls to sql.
|
|
321
324
|
m.gauge("snapshot_blob_backend", "1 on the active snapshot-blob backend series (S21), by backend (minio|sql)");
|
|
322
325
|
// S22 — memory embed/KNN runtime failures (boot said vector; runtime silently degrades to lexical).
|
|
323
|
-
|
|
326
|
+
// HELP 文案按**唯一活发射点**写(#228 接线后的复扫):stores.ts 的 onFailure 腿打 `{ backend: dialect }`,
|
|
327
|
+
// 从没有过 `where` 维度(那是退役的 MEMORY_BACKEND/EMBEDDING_* store 面的形)—— 运维照旧文案写
|
|
328
|
+
// `sum by (where)` 得到的是空序列。USAGE 的 `memory_embed_failed_total{backend}` 才是对的那一份。
|
|
329
|
+
m.counter("memory_embed_failed_total", "Memory embedding calls that failed at runtime (S22), by backend (the memory-engine dialect that owns the embedder)");
|
|
324
330
|
m.counter("memory_knn_query_failed_total", "Memory KNN searchScored queries that failed at runtime (S22)");
|
|
325
331
|
// LOW — dedup fold vs new insert (a fold UPDATEs the existing row; previously indistinguishable), and the
|
|
326
332
|
// dedup probe failing over to a plain INSERT.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #219(黑板 [3617]):引擎 **parent 监视**。
|
|
3
|
+
*
|
|
4
|
+
* ## 防的是什么
|
|
5
|
+
*
|
|
6
|
+
* 壳(cli/TUI/桌面)自 spawn 引擎的**同机形**里,壳被 SIGKILL(崩溃、`kill -9`、OOM killer)之后没有
|
|
7
|
+
* 任何清理钩子跑得起来:引擎被 reparent 到 init,**继续活着** —— 占端口、占库连接池、占模型配额,而且
|
|
8
|
+
* 再没有人会给它发 SIGTERM(T11 的"骑进程组"腿只覆盖信号送得到的形;SIGKILL 那一路本来就无钩)。
|
|
9
|
+
* 于是引擎给自己装一只**自查**腿:定期问一句"当年生我的那个 pid 还在吗",不在就**自己**走正常停机。
|
|
10
|
+
*
|
|
11
|
+
* ## 机制(逐条都是契约)
|
|
12
|
+
*
|
|
13
|
+
* 1. **opt-in**:`SEMA_PARENT_PID` 缺席 ⇒ 整件不装配(`boot/shutdown.ts` 连 `createParentWatch` 都不调)。
|
|
14
|
+
* 存量部署逐字零变化。坏值拒启在 `config.ts` 的 `parentPidEnv()`。
|
|
15
|
+
* 2. **探活 = `process.kill(pid, 0)`**:0 号信号不投递任何信号,只做"这个 pid 存在吗 + 我能不能给它
|
|
16
|
+
* 发信号"的检查。两种失败方向**语义相反**,写反了后果不对称:
|
|
17
|
+
* · `ESRCH` = 没有这个进程 ⇒ 父**不在**(这是我们要抓的);
|
|
18
|
+
* · `EPERM` = 进程**在**,只是本进程无权给它发信号(父跑在另一个 uid 下 —— systemd/su/容器里
|
|
19
|
+
* 很常见)⇒ **算活着**。把 EPERM 当"不在",每台这种机器上的引擎会在开机后两拍内自杀。
|
|
20
|
+
* 3. **连续两拍才判死**:单拍抖动(探活恰好撞上一次瞬时错误)不误杀。任何一拍看到"活着"就清零。
|
|
21
|
+
* 4. **判死后走 `drain`**,即 `boot/shutdown.ts` 里 SIGTERM 用的那一条 `drainThenShutdown` —— in-flight
|
|
22
|
+
* run 的结算 / park 语义与人工停机**逐字一致**。本模块自己**从不** `process.exit`。
|
|
23
|
+
* 5. **interval 必须 unref**:监视腿不得成为"进程本该退出却退不掉"的理由(与 drain tick 刻意不 unref
|
|
24
|
+
* 的取向相反 —— 那一只 tick 就是停机驱动,这一只只是观察者)。
|
|
25
|
+
*
|
|
26
|
+
* ## 已知残余(成文,现版不收窄)
|
|
27
|
+
*
|
|
28
|
+
* · **pid 复用**:两拍窗口(≤30s)内父 pid 被系统复用,探活看到的是新进程,监视会判"父还活着"而不自退。
|
|
29
|
+
* · **僵尸父进程**(codex 复审 H,本机 perl fork 实测证实):`kill(pid, 0)` 只证明 **pid 表项**在,不证明
|
|
30
|
+
* 进程还在跑 —— 父退出但**没被它自己的父进程 wait 回收**时留下 zombie(`ps STAT=Z`),表项还在,于是
|
|
31
|
+
* 每一拍都判"活着",监视永不触发。触发条件是壳的**上级**不回收子进程(容器里的朴素 PID 1 是典型),
|
|
32
|
+
* 终端 / launchd / systemd 起的壳不在此列。
|
|
33
|
+
*
|
|
34
|
+
* 两条都源于同一个根:`kill(pid, 0)` 是**弱身份**判据。收窄需要一个真身份锚(`process.ppid` 变化、
|
|
35
|
+
* starttime、pidfd、或壳侧持有型管道),那是**机制换代**而不是参数微调,归设计属主裁。本件在定稿里
|
|
36
|
+
* 就是**尽力自愈**、不是强一致的父子生命周期绑定 —— 要强一致,该由壳侧开一条持有型管道并在断开时收尸。
|
|
37
|
+
*/
|
|
38
|
+
/** 探活拍频。**固定值,不开 env 旋钮**(设计定稿:有真需求再议,防旋钮增殖)。 */
|
|
39
|
+
export declare const PARENT_WATCH_INTERVAL_MS = 15000;
|
|
40
|
+
export interface ParentWatchDeps {
|
|
41
|
+
/** 被监视的父 pid(`config.parentPid`,已在 config 层校验为正整数)。 */
|
|
42
|
+
pid: number;
|
|
43
|
+
/** 探活 seam:抛错 = 探活失败(`.code` 携 errno)。缺省 = `process.kill(pid, 0)`。
|
|
44
|
+
* 存在的理由不是"可配置",是 `ESRCH`/`EPERM` 两支在测试里没有确定性的自然触发法。 */
|
|
45
|
+
probe?: (pid: number) => void;
|
|
46
|
+
/** 已有停机属主(`closing || draining`)时监视腿站下:一次正在进行的优雅 drain 不该被"父也没了"
|
|
47
|
+
* 升级成第二信号硬停(那会斩掉 in-flight leg —— 与本件"和人工停机逐字一致"的取向相反)。 */
|
|
48
|
+
isStopped: () => boolean;
|
|
49
|
+
/** 判死出口 = SIGTERM 那条 graceful drain。 */
|
|
50
|
+
drain: () => void;
|
|
51
|
+
log: (event: string, fields: Record<string, unknown>) => void;
|
|
52
|
+
}
|
|
53
|
+
/** 装配并**立刻**起监视(装配即 `parent_watch_armed`)。返回停机链用的 `stop()`。 */
|
|
54
|
+
export declare function createParentWatch(deps: ParentWatchDeps): {
|
|
55
|
+
stop(): void;
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=parent-watch.d.ts.map
|