@sema-agent/server 7.8.1 → 7.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,15 +32,23 @@ const RECONCILE_STORE_TIMEOUT_MS = 10_000;
32
32
  */
33
33
  const RECONCILE_SEGMENT_BUDGET_MS = 30_000;
34
34
  /** 给一次读/写套墙钟上限(超时以 Error 拒绝,由 per-row catch 接住)。定时器 `unref`,绝不持住进程;
35
- * 竞速输的那一路由 `Promise.race` 自己的 handler 接住,不会变成 unhandled rejection。 */
35
+ * 竞速输的那一路由 `Promise.race` 自己的 handler 接住,不会变成 unhandled rejection。
36
+ *
37
+ * A-002.10:竞速一结束就 `clearTimeout`。`unref` 只保证不吊住进程退出,**不**回收定时器本身——本函数在
38
+ * 逐行串行的 `runOnce` 里每行要走 1~6 次,不清就是「每行 1~6 只挂满 {@link RECONCILE_STORE_TIMEOUT_MS}」
39
+ * 的累积(batchLimit 默认 200 ⇒ 一轮几百只带闭包的定时器)。`finally` 对成功/失败两路都清。 */
36
40
  function withDeadline(op, label, timeoutMs = RECONCILE_STORE_TIMEOUT_MS) {
41
+ let timer;
37
42
  return Promise.race([
38
43
  op,
39
44
  new Promise((_resolve, reject) => {
40
- const t = setTimeout(() => reject(new Error(`approval reconcile ${label} timed out after ${timeoutMs}ms`)), timeoutMs);
41
- t.unref?.();
45
+ timer = setTimeout(() => reject(new Error(`approval reconcile ${label} timed out after ${timeoutMs}ms`)), timeoutMs);
46
+ timer.unref?.();
42
47
  }),
43
- ]);
48
+ ]).finally(() => {
49
+ if (timer !== undefined)
50
+ clearTimeout(timer);
51
+ });
44
52
  }
45
53
  /**
46
54
  * 这只 ask 是不是**祖先冻结 approver 层**在委派 fold 中途铸的那一份(#168 件1④,黑板 [2912]③)。
@@ -222,6 +222,12 @@ export function createExecutionEnv(ctx) {
222
222
  ...(config.remoteExec.dockerHost ? { dockerHost: config.remoteExec.dockerHost } : {}),
223
223
  ...(config.remoteExec.memory ? { memory: config.remoteExec.memory } : {}),
224
224
  ...(config.remoteExec.cpus != null ? { cpus: config.remoteExec.cpus } : {}),
225
+ // A-002.7: the two sandbox-hardening knobs were parsed by loadConfig and honored by the adapter, but this
226
+ // call site dropped them — so DOCKER_PIDS_LIMIT always resolved to the adapter's `?? 512` default and
227
+ // DOCKER_DROP_CAPS never emitted `--cap-drop ALL`. Absent stays absent (the adapter's defaults are the
228
+ // unconfigured shape); only a SET knob rides, same additive form as the siblings above.
229
+ ...(config.remoteExec.pidsLimit != null ? { pidsLimit: config.remoteExec.pidsLimit } : {}),
230
+ ...(config.remoteExec.dropAllCaps ? { dropAllCaps: true } : {}),
225
231
  ...(config.remoteExec.network ? { network: config.remoteExec.network } : {}),
226
232
  ...(config.remoteExec.commandTimeoutMs != null ? { commandTimeoutMs: config.remoteExec.commandTimeoutMs } : {}),
227
233
  ...(config.remoteExec.env ? { env: config.remoteExec.env } : {}),
@@ -407,6 +407,12 @@ function codeReview(deps, req) {
407
407
  // #196:两条腿(直评 / council)都声明 none —— 直评腿的 lead 只读仓库工具,council 腿的 lead 只调
408
408
  // run_council;lens/arbiter 子任务同样只读仓库,故 council 工具拿无手 subRunner。
409
409
  const hands = "none";
410
+ // [C143]:lenses 非 number 形响亮拒——此前 string[]/任意形静默滑过(typeof 门恒 false 整键落空),
411
+ // client-core 0.22.0 曾按想象契约钉 string[],用户发出的键被静默吞([C142] web-client wire 实证)。
412
+ // 与 (a) 案(client-core 改 number)对齐后客户端不会再发该形;本 400 是防第三方直调的纵深。
413
+ if (req.lenses !== undefined && typeof req.lenses !== "number") {
414
+ throw new HttpError(400, "lenses must be a number (council lens COUNT, 1-6) — a named lens subset is not a request-level knob", { code: "request.field_invalid" });
415
+ }
410
416
  if (req.council === true) {
411
417
  const rounds = clampRounds(req.rounds); // finite-guarded (NaN/±Inf → undefined → council default 1)
412
418
  return {
@@ -427,7 +433,10 @@ function codeReview(deps, req) {
427
433
  promptProvider: coordinatorPrompt,
428
434
  };
429
435
  }
430
- return { tools: repoTools, hands, skills, promptProvider: directReviewerPrompt };
436
+ // [C144]([3296]⑤ web-client 实证):直评腿补 nowTool scan 对齐——此前真装配无 Now 而 capabilities
437
+ // 声明 fallback 表 REPO_TOOLS 含 Now(repoClient 缺席时 probe 落 fallback 多报一枚=声明/装配错位;
438
+ // scan 当年照本场景抄还多加了 Now)。对齐后 fallback 表自动变准,评审报告带时间戳同样合理。
439
+ return { tools: [...repoTools, nowTool()], hands, skills, promptProvider: directReviewerPrompt };
431
440
  }
432
441
  const reviewBody = "Produce ONE prioritized review: a short summary, then findings grouped by severity " +
433
442
  "(blocker / major / minor), each with `path:line`, the problem, and a concrete fix. Cite real code — never invent files or symbols.";
@@ -634,11 +634,15 @@ export interface ServiceConfigFlat {
634
634
  * OFF (in-process, faster). `SELF_ORCHESTRATION_WORKER_ISOLATION=true`. */
635
635
  selfOrchestrationWorkerIsolation: boolean;
636
636
  /** SVC-1: the durable WorkflowRunStore backend for BACKGROUND workflow runs.
637
- * `file` (DEFAULT) = the crash-safe `FileWorkflowRunStore` ledger under `localDataRoot/workflows` a
638
- * background run's record SURVIVES a restart, which is what lets the at-least-once completion notify re-derive
639
- * a run's terminal state after a crash. `memory` = the ephemeral `InMemoryWorkflowRunStore` (process-local; no
640
- * crash recovery, no notify journal) an explicit opt-out for a single-instance/ephemeral deployment.
641
- * `WORKFLOW_RUN_STORE=file|memory`. Only consulted when SELF_ORCHESTRATION_ENABLED. */
637
+ * `auto` (DEFAULT) = the SQL-backed cross-replica store when a SQL backend is configured, else the
638
+ * crash-safe `FileWorkflowRunStore` ledger under `localDataRoot/workflows` (dispatch: main.ts, keyed off
639
+ * `sqlWorkflowRunStore` presence). `file` = force the file ledger even with SQL present (single-replica
640
+ * pinning). Either durable form means a background run's record SURVIVES a restart, which is what lets the
641
+ * at-least-once completion notify re-derive a run's terminal state after a crash. `memory` = the ephemeral
642
+ * `InMemoryWorkflowRunStore` (process-local; no crash recovery, no notify journal) — an explicit opt-out for
643
+ * a single-instance/ephemeral deployment. (A-002.18: the old docblock claimed `file` was the default and
644
+ * omitted `auto` entirely — a reader picking a backend off this comment would never learn the SQL twin
645
+ * exists.) `WORKFLOW_RUN_STORE=auto|file|memory`. Only consulted when SELF_ORCHESTRATION_ENABLED. */
642
646
  workflowRunStoreBackend: "auto" | "file" | "memory";
643
647
  /** SVC-1 (adversarial-review): a workflow still `running` past this age is deemed a crash-orphan by the periodic
644
648
  * notify-recovery sweep (core never resumes/reaps a prior `running` row) → finalized-as-abandoned so the
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { CODE_AGENT_PROMPT, formatUserScope, isThinkingLevel, PROTOCOL_TABLE, pr
5
5
  import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES } from "@sema-agent/registry-core";
6
6
  import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js"; // design/158 A4: the parser leaf — NOT security.js (base config layer must not value-import the 55KiB auth module)
7
7
  import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
8
+ import { isV2ScopeKey } from "./memory-scope.js"; // A-002.4: v2 scope 前缀词表的单一属主(纯谓词,无反向依赖)
8
9
  import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
9
10
  function csv(name) {
10
11
  return (process.env[name] ?? "")
@@ -1109,7 +1110,9 @@ function parseMemoryDomain(ctx) {
1109
1110
  if (memorySyncToken === undefined) {
1110
1111
  throw new Error("MEMORY_SYNC_URL is set but MEMORY_SYNC_TOKEN is not — refusing to start half-configured (the central sync face requires a bearer token)");
1111
1112
  }
1112
- const isV2ScopeKey = (k) => k.startsWith("user:") || k.startsWith("org:") || k.startsWith("proj:") || k.startsWith("userproj:");
1113
+ // A-002.4:前缀判据的**唯一属主**是 memory-scope.ts(此处曾有一份逐字副本;两处咬合用途不同,
1114
+ // 但 core 加第五种 typed 前缀时必须一起动,否则同一个键在「挂 v2 标」和「算不算已是 v2 键」两处
1115
+ // 得出相反答案 ⇒ 这里会把它再包成 `user:user%3A…` 双包键)。
1113
1116
  const scope = memorySyncScopeRaw ?? (memoryScope !== undefined ? (isV2ScopeKey(memoryScope) ? memoryScope : formatUserScope(memoryScope)) : undefined);
1114
1117
  if (scope === undefined) {
1115
1118
  throw new Error("MEMORY_SYNC_URL is set but no sync scope is derivable (memory engine off or multi-tenant without an explicit scope) — set MEMORY_SYNC_SCOPE, or run the single-user file memory engine so MEMORY_SCOPE (default \"local\") provides one");
@@ -499,35 +499,50 @@ async function runLlmHook(entry, payload, ctx, extra) {
499
499
  // 顺序是承重的 —— 包装句逐字预设会话在上文,放反了那句话本身就是在骗模型。
500
500
  const prompt = extra ? `${extra.transcript}\n\n---\n\n${wrapCondition("Stop", substituted)}` : substituted;
501
501
  // 载体外再包一层硬顶:契约说载体自己兜超时,但一个部署组装 bug 不该能挂死工具门(纵深)。
502
- const hardTop = new Promise((r) => setTimeout(() => r({ ok: false, error: "hook llm hard-timeout backstop fired (carrier did not settle within timeoutMs+5s — deployment assembly bug)", code: "hard_timeout" }), timeoutMs + 5_000).unref?.());
503
- const invoke = () => Promise.race([
504
- call({
505
- prompt,
506
- ...(entry.model ? { model: entry.model } : {}),
507
- timeoutMs,
508
- ...(extra ? { system: extra.system, maxOutputTokens: CC_EVALUATOR_MAX_OUTPUT_TOKENS } : {}),
509
- }),
510
- hardTop,
511
- ]).catch((e) => ({ ok: false, error: String(e) }));
512
- let res = await invoke();
513
- // 🔴 **"一个字都没吐出来"重试一次**(仅 CC 评估者路)。这个失败形的后果是**判词被丢弃 fail-open**
514
- // (该拦没拦),而它是**瞬态**的(推理档模型偶尔把额度用在 thinking 上)。
515
- // ⚠️ 只重试**无内容**,不重试"有内容但读不懂" —— 后者重试一次多半还是读不懂,而且那一格按设计就该放行。
516
- if (extra && !res.ok && res.code === "no_content") { // B8:判别走码不走文案(v3.1 批2)
517
- ctx.logger.warn("hook_llm_no_content_retry", { event: "Stop" });
518
- res = await invoke();
519
- // 重试之后**仍然**没有内容 ⇒ 这一轮的守卫确实没能评估。发观测帧(纯 observe,不改变运行)——
520
- // 方向仍是 fail-open,但用户/壳侧要能知道「这轮没看住」,否则那个放行与「已达成」无法区分。
521
- if (!res.ok && res.code === "no_content") {
522
- ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "no_content", detail: "carrier returned no content (after one retry)" });
502
+ // A-002.12:硬顶只 unref 不清 每次被门到的工具调用留一只带闭包的定时器,最长挂
503
+ // MAX_HOOK_TIMEOUT_SECONDS+5s=605s(工具门是热路径,一个任务几十上百次)。整个函数收在
504
+ // try/finally 里,任何出口都 clearTimeout。
505
+ // 🔴 硬顶**一只到底**、两次 invoke 共用(语义原样不动):它兜的是「本次 hook 判决整体不许挂死」,
506
+ // 不是「每次载体调用各给一个新窗」——改成 per-invoke 会把最坏墙钟翻倍。
507
+ let hardTopTimer;
508
+ const hardTop = new Promise((r) => {
509
+ hardTopTimer = setTimeout(() => r({ ok: false, error: "hook llm hard-timeout backstop fired (carrier did not settle within timeoutMs+5s — deployment assembly bug)", code: "hard_timeout" }), timeoutMs + 5_000);
510
+ hardTopTimer.unref?.();
511
+ });
512
+ try {
513
+ const invoke = () => Promise.race([
514
+ call({
515
+ prompt,
516
+ ...(entry.model ? { model: entry.model } : {}),
517
+ timeoutMs,
518
+ ...(extra ? { system: extra.system, maxOutputTokens: CC_EVALUATOR_MAX_OUTPUT_TOKENS } : {}),
519
+ }),
520
+ hardTop,
521
+ ]).catch((e) => ({ ok: false, error: String(e) }));
522
+ let res = await invoke();
523
+ // 🔴 **"一个字都没吐出来"重试一次**(仅 CC 评估者路)。这个失败形的后果是**判词被丢弃 ⇒ fail-open**
524
+ // (该拦没拦),而它是**瞬态**的(推理档模型偶尔把额度用在 thinking 上)。
525
+ // ⚠️ 只重试**无内容**,不重试"有内容但读不懂" —— 后者重试一次多半还是读不懂,而且那一格按设计就该放行。
526
+ if (extra && !res.ok && res.code === "no_content") { // B8:判别走码不走文案(v3.1 批2)
527
+ ctx.logger.warn("hook_llm_no_content_retry", { event: "Stop" });
528
+ res = await invoke();
529
+ // 重试之后**仍然**没有内容 ⇒ 这一轮的守卫确实没能评估。发观测帧(纯 observe,不改变运行)——
530
+ // 方向仍是 fail-open,但用户/壳侧要能知道「这轮没看住」,否则那个放行与「已达成」无法区分。
531
+ if (!res.ok && res.code === "no_content") {
532
+ ctx.onHookNotice?.({ kind: "hook_decision_unavailable", event: "Stop", reason: "no_content", detail: "carrier returned no content (after one retry)" });
533
+ }
523
534
  }
535
+ if (!res.ok) {
536
+ return res.code === "hard_timeout"
537
+ ? { code: null, stdout: "", stderr: "", timedOut: true }
538
+ : { code: null, stdout: "", stderr: "", timedOut: false, spawnError: clip(res.error, 500) };
539
+ }
540
+ return { code: 0, stdout: clip(res.text, 1024 * 1024), stderr: "", timedOut: false };
524
541
  }
525
- if (!res.ok) {
526
- return res.code === "hard_timeout"
527
- ? { code: null, stdout: "", stderr: "", timedOut: true }
528
- : { code: null, stdout: "", stderr: "", timedOut: false, spawnError: clip(res.error, 500) };
542
+ finally {
543
+ if (hardTopTimer !== undefined)
544
+ clearTimeout(hardTopTimer);
529
545
  }
530
- return { code: 0, stdout: clip(res.text, 1024 * 1024), stderr: "", timedOut: false };
531
546
  }
532
547
  async function runHttpHook(entry, payload, ctx) {
533
548
  const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
@@ -32,6 +32,13 @@ export declare function handleApprovalsAssistant(req: IncomingMessage, res: Serv
32
32
  * heartbeats + retries (never kills the stream); a 15-min cap + req-close end it (parity with streamTaskTrace).
33
33
  * Poll-granularity caveat: a pending that resolves AND re-suspends on the same (session,toolCallId) within one
34
34
  * interval shows no delta — acceptable (the live snapshot is always eventually correct; no decision is missed).
35
+ *
36
+ * A-002.19(2026-08-09 亲验定性=设计接受,非欠账):每连接自跑 pollMs 轮询打共享 checkpoint 表,
37
+ * 之所以不建 fan-out/单轮询器基建——①消费方=portal 运营面,并发连接数量级为个位(不是用户面);
38
+ * ②listPending 走 (scope,status) 索引,单查成本与一次 /v1/approvals 轮询同阶,而本路正是替代客户端
39
+ * ~10s 轮询的(净负载持平或更低);③DB blip 已 fail-soft(心跳+下拍重试),15-min cap 兜顶。连接
40
+ * 数假设若被打破(portal 多开成常态),届时把 poll 收敛为进程内单轮询器多路复用——那是量级触发的
41
+ * 演化,不是现在的缺陷。
35
42
  */
36
43
  export declare function streamApprovals(req: IncomingMessage, res: ServerResponse, cs: Pick<CheckpointStoreFull, "listPending">, scope: string | undefined, pollMs?: number): Promise<void>;
37
44
  //# sourceMappingURL=approvals-assistant.d.ts.map
@@ -667,6 +667,13 @@ const APPROVALS_STREAM_POLL_MS = 3000;
667
667
  * heartbeats + retries (never kills the stream); a 15-min cap + req-close end it (parity with streamTaskTrace).
668
668
  * Poll-granularity caveat: a pending that resolves AND re-suspends on the same (session,toolCallId) within one
669
669
  * interval shows no delta — acceptable (the live snapshot is always eventually correct; no decision is missed).
670
+ *
671
+ * A-002.19(2026-08-09 亲验定性=设计接受,非欠账):每连接自跑 pollMs 轮询打共享 checkpoint 表,
672
+ * 之所以不建 fan-out/单轮询器基建——①消费方=portal 运营面,并发连接数量级为个位(不是用户面);
673
+ * ②listPending 走 (scope,status) 索引,单查成本与一次 /v1/approvals 轮询同阶,而本路正是替代客户端
674
+ * ~10s 轮询的(净负载持平或更低);③DB blip 已 fail-soft(心跳+下拍重试),15-min cap 兜顶。连接
675
+ * 数假设若被打破(portal 多开成常态),届时把 poll 收敛为进程内单轮询器多路复用——那是量级触发的
676
+ * 演化,不是现在的缺陷。
670
677
  */
671
678
  export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_STREAM_POLL_MS) {
672
679
  // JSON-encode the (sessionId, toolCallId) pair so distinct pendings can NEVER collide into one Map key,
@@ -8,5 +8,9 @@
8
8
  */
9
9
  import type { IncomingMessage, ServerResponse } from "node:http";
10
10
  import type { RouteCtx } from "../route-ctx.js";
11
+ /** 表内=core 的前置校验形(客户端可改请求)⇒ 400;表外一律 500。 */
12
+ export declare function isCorePreflightValidationError(message: string): boolean;
13
+ /** 供跨仓锚门读:门要拿这张表去装机的 core 里逐条对账(见上方 doc)。 */
14
+ export declare const CORE_PREFLIGHT_VALIDATION_ANCHORS: readonly string[];
11
15
  export declare function handleSideQuery(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
12
16
  //# sourceMappingURL=side-query.d.ts.map
@@ -4,6 +4,79 @@ import { cacheFamilyOfMirror } from "../../budget.js";
4
4
  import { redactSecrets } from "../../trace/redact.js";
5
5
  import { sendJson, sendError, httpErrorCode, msg } from "../send.js";
6
6
  import { gatedPrincipal } from "../principal-gate.js";
7
+ /**
8
+ * A-002.2 —— core 前置校验异常的分类词表(400 客户端可改 / 500 我方缺陷)。
9
+ *
10
+ * 🔴 **亲验记账(core 5.x dist,本批复核)**:core 在这条路径上抛的是**裸 `Error`**,异常对象上
11
+ * 没有 `code`/`name`/`status` 任何结构化判别位 ——
12
+ * · `core/roles.js:8` `throw new Error(\`Unknown model ref "\${ref}". …\`)`
13
+ * · `core/roles.js:179` `throw new Error(\`No model for role "\${start}": …\`)`
14
+ * 所以「优先消费结构化位、正则降兜底」在当前 core 上**无位可消费**:这里如实停在文案匹配,并把跨仓
15
+ * 耦合交给一道机器门看着 —— test/side-query-http.test.ts 的「A-002.2 跨仓锚」**调装机 core 的两个导出
16
+ * 解析器把异常真抛出来**,拿真 message 过本谓词。
17
+ * 🔴 为什么不是「扫 core 源码里有没有这两句字面」:那种锚有一个致命假绿形(codex 复审 medium,已采纳)
18
+ * ——core 完全可以保留字面却在抛出时加前缀/包一层,源码锚照绿而 `startsWith` 当场失效,客户端错误静默
19
+ * 变成 500。真抛出预言机没有这个缝(它当场就揪出了下面 `modelRole` 那条连带缺陷)。
20
+ * core 哪天给这两处加了 code,本函数就该改成读那个 code、文案匹配退成兜底 —— 那道门会先红,提醒动这里。
21
+ *
22
+ * 🪦 **第三臂已删(死支确认)**:原表还有 `requires a non-empty messages array`(core
23
+ * `core/side-query.js:10`)。它**不可达** —— 本路由在调 core 之前就有
24
+ * `!Array.isArray(body.messages) || body.messages.length === 0 ⇒ 400 request.body_shape` 的前拦
25
+ * (见下方路由体),两个条件与 core 那条判据逐字同集,所以 core 那句永远轮不到抛。留着=养一条永假的
26
+ * 分支,删了并立此墓碑;前拦一旦被摘,side-query-http 的「空/非数组 messages → 400 request.body_shape」
27
+ * 两格先红。
28
+ *
29
+ * 失败方向:表外一律 500(响亮、可告警、可重试),绝不把不认识的异常猜成客户端错误。
30
+ */
31
+ const CORE_PREFLIGHT_VALIDATION_PREFIXES = ["Unknown model ref ", "No model for role "];
32
+ /** 表内=core 的前置校验形(客户端可改请求)⇒ 400;表外一律 500。 */
33
+ export function isCorePreflightValidationError(message) {
34
+ return CORE_PREFLIGHT_VALIDATION_PREFIXES.some((prefix) => message.startsWith(prefix));
35
+ }
36
+ /** 供跨仓锚门读:门要拿这张表去装机的 core 里逐条对账(见上方 doc)。 */
37
+ export const CORE_PREFLIGHT_VALIDATION_ANCHORS = CORE_PREFLIGHT_VALIDATION_PREFIXES;
38
+ /**
39
+ * A-002.2 连带 —— `modelRole` 的**选路可达性**门。
40
+ *
41
+ * 🔴 由来(A-002.2 的真抛出预言机当场揪出来的,源码扫描锚与 mock 都照不到):core 的 `ModelRole` 是
42
+ * 闭集(8 值),而 core 的 `resolveTaskModel` 在**无显式 model** 时要走 `FALLBACK[start]` —— 表外角色
43
+ * 在那张表里是 `undefined`,于是真抛 `TypeError: FALLBACK[start] is not iterable`。一条**客户端可改**的
44
+ * 输入错误,以「服务器内部错误 + 可重试」的面目回给客户端;而且这一条连 400/500 分类都救不了(那句
45
+ * TypeError 文案里没有任何可分类的痕迹)。所以拒在本层。
46
+ *
47
+ * ⚠️ **只在这个字段真参与选路时才拒**(codex 复审 R2 medium,已采纳并收窄):`spec.model` 在场时
48
+ * `resolveTaskModel` 走**短路分支**,压根不碰 `FALLBACK` —— `{model:"<有效 ref>", modelRole:"cheap"}`
49
+ * 是一条**今天就能跑通**的既有形,而 SDK 的 `SideQueryRequest.modelRole` 又逐字声明为开放 `string`
50
+ * (spec/openapi.yaml)。对它一并拒 = 无版本协商地收窄已发布契约。首版正是这么写的,复审逮到。
51
+ * 同理也不能「有 model 时把表外角色丢掉」:core 会拿 `merged[start]` 取该角色上的 thinking/systemPrompt
52
+ * (`runSideQuery` 里 `spec.systemPrompt ?? resolved.systemPrompt`),丢掉它会让配置好的 `default` 角色的
53
+ * systemPrompt 意外生效 —— 那也是行为改动。⇒ 有 model 时**原样透传**,行为逐字不变。
54
+ *
55
+ * 表是 `Record<ModelRole, true>` 穷举:core 增删一个角色词,本表编译期先红(闭集词表的成文纪律)。
56
+ */
57
+ const MODEL_ROLE_TABLE = {
58
+ default: true,
59
+ summarize: true,
60
+ subagent: true,
61
+ team: true,
62
+ synthesize: true,
63
+ advisor: true,
64
+ verifier: true,
65
+ classifier: true,
66
+ };
67
+ /** 表内 ⇒ 收窄成 `ModelRole`(类型守卫)。门用它判「这个角色能不能自己选出模型」。 */
68
+ function isModelRole(value) {
69
+ return typeof value === "string" && Object.hasOwn(MODEL_ROLE_TABLE, value);
70
+ }
71
+ /**
72
+ * 线上契约(SDK `SideQueryRequest.modelRole: string`)比 core 的 `ModelRole` 闭集**宽**,而 core 对该字段的
73
+ * 实际用法是**开放键查表**(`mergeRoles(...)[start]`,查不到即空)——所以表外值在有显式 model 时是合法且
74
+ * 惰性的。这处窄化断言是那道宽窄差的**唯一**落点(其余路径已由上面的门挡住),集中在此并说明缘由,
75
+ * 而不是散在装配字面量里当作无声的裸 cast。
76
+ */
77
+ function passthroughModelRole(value) {
78
+ return value;
79
+ }
7
80
  export async function handleSideQuery(req, res, url, ctx) {
8
81
  const miss = { fell: false };
9
82
  await handleSideQueryBody(req, res, url, ctx, miss);
@@ -55,12 +128,19 @@ async function handleSideQueryBody(req, res, url, ctx, miss) {
55
128
  sendError(res, 400, "request.field_invalid", "invalid thinking level");
56
129
  return;
57
130
  }
131
+ // 选路可达性门(见 MODEL_ROLE_TABLE 顶注):**仅当该字段真参与选路**(无显式 model)时,表外角色
132
+ // 才 fail-closed 在这里 —— 那条路上 core 抛的是无从分类的 TypeError,400/500 分类救不回来。
133
+ // 有显式 model ⇒ core 走短路分支,该值惰性,原样透传(既有可用形,不许无版本协商地收窄)。
134
+ if (typeof body.model !== "string" && body.modelRole !== undefined && !isModelRole(body.modelRole)) {
135
+ sendError(res, 400, "request.field_invalid", `invalid modelRole (expected one of: ${Object.keys(MODEL_ROLE_TABLE).join(", ")}) — a role only resolves a model when 'model' is absent`);
136
+ return;
137
+ }
58
138
  if (ac.signal.aborted)
59
139
  return; // client already gone — don't start the brain call at all
60
140
  const spec = {
61
141
  messages: body.messages,
62
142
  ...(typeof body.model === "string" ? { model: body.model } : {}),
63
- ...(typeof body.modelRole === "string" ? { modelRole: body.modelRole } : {}),
143
+ ...(typeof body.modelRole === "string" ? { modelRole: passthroughModelRole(body.modelRole) } : {}),
64
144
  ...(isThinkingLevel(body.thinking) ? { thinking: body.thinking } : {}),
65
145
  ...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
66
146
  ...(Array.isArray(body.tools) ? { tools: body.tools } : {}),
@@ -91,13 +171,13 @@ async function handleSideQueryBody(req, res, url, ctx, miss) {
91
171
  sendJson(res, 200, result);
92
172
  }
93
173
  catch (e) {
94
- // 400 仅限 core 前置校验的**已知形**(messages 空我们已前拦;模型解析两句是 roles.ts 的稳定文案);
95
- // 其余异常=server/adapter/config 缺陷,走 500(可告警、可重试语义)。stream 后错误按契约进
96
- // result.errorMessage 不 throw;断连 abort 的抛出无人收,writableEnded/aborted 双判后静默。
174
+ // 400 仅限 core 前置校验的**已知形**;其余异常=server/adapter/config 缺陷,走 500(可告警、可重试
175
+ // 语义)。stream 后错误按契约进 result.errorMessage 不 throw;断连 abort 的抛出无人收,
176
+ // writableEnded/aborted 双判后静默。
97
177
  deps.metrics?.inc?.("side_query_total", { result: "error" });
98
178
  if (!res.writableEnded && !ac.signal.aborted) {
99
179
  const m = msg(e);
100
- const isValidation = /^Unknown model ref |^No model for role |requires a non-empty messages array/.test(m);
180
+ const isValidation = isCorePreflightValidationError(m);
101
181
  // B7:机器码随状态码同分——已知前置校验形=`request.field_invalid`(客户端可改请求),其余=`internal.error`。
102
182
  sendError(res, isValidation ? 400 : 500, isValidation ? "request.field_invalid" : "internal.error", redactSecrets(m));
103
183
  }
@@ -108,12 +108,27 @@ async function handleWorkflowsReadBody(req, res, url, ctx, miss) {
108
108
  const jOffset = Math.max(0, Number(jq.get("offset") ?? 0) || 0);
109
109
  const MAX_ROW_BYTES = 64 * 1024;
110
110
  const firstLine = (t) => (t ? redactSecrets(t.split("\n")[0].slice(0, 300)) : undefined);
111
+ const MAX_RESULT_CHARS = 2000;
112
+ // A-002.6(披露双标,P1):>64KiB 的行诚实标 `truncated`/`resultBytes`(下方两臂),而 2000 字符~
113
+ // 64KiB 之间的行此前被 `slice(0, 2000)` **静默**截断 —— 同一个响应数组里一行说得出「我被截了」、
114
+ // 另一行说不出,消费者也无从区分「结果正好 2000 字」与「结果被截到 2000 字」。
115
+ // 🔴 键刻意**不复用** `truncated`:兄弟臂的 `truncated:true` 语义是「整行没内容,只有尺寸存根」,
116
+ // 盖到一行有内容的行上就是把兄弟臂的判别位改义(按它分支的消费者会把有内容的行当空存根)。
117
+ // `resultTruncated`/`resultChars` 描述的是 `result` **这个字段**,无歧义。原始长度量的是
118
+ // redact 之前的字符数(与兄弟臂 `resultBytes` 同精神,基准不同故不同名)。
119
+ // ⚠️ wire 记账:SDK spec 的 `WorkflowJournalEntry` 是 `additionalProperties: false`,两枚 additive
120
+ // 键需要 sema-sdk spec/openapi.yaml 同源补声明(SDK 件另走)。
111
121
  const projectResult = (callKey, r) => ({
112
122
  callKey,
113
123
  ordinal: callKeyOrdinal(callKey),
114
124
  status: r.status,
115
125
  ...(r.errorMessage ? { error: firstLine(r.errorMessage) } : {}),
116
- ...(r.result ? { result: redactSecrets(r.result.slice(0, 2000)) } : {}),
126
+ ...(r.result
127
+ ? {
128
+ result: redactSecrets(r.result.slice(0, MAX_RESULT_CHARS)),
129
+ ...(r.result.length > MAX_RESULT_CHARS ? { resultTruncated: true, resultChars: r.result.length } : {}),
130
+ }
131
+ : {}),
117
132
  ...(r.stats ? { tokens: r.stats.tokens, turns: r.stats.turns } : {}),
118
133
  });
119
134
  const js = deps.workflowJournalStore;
@@ -18,7 +18,7 @@ import {} from "../orchestration/workflow-agent-steer.js";
18
18
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../orchestration/workflow-completion-inbox.js";
19
19
  import { fleetRunPublisher, fleetRunLabels, fleetRunResiduals, isFleetAgentTerminalNotification } from "../fleet/fleet-bus.js";
20
20
  import { defaultSubagentTailBus, projectTailFrame } from "../fleet/subagent-tail-bus.js";
21
- import { createApprovalCardEmitter, resolveApprovalLeg, resolveStreamApprovalGate } from "../tool-approval.js";
21
+ import { createApprovalCardEmitter, isApprovalGateKind, resolveApprovalLeg, resolveStreamApprovalGate } from "../tool-approval.js";
22
22
  import {} from "../observability/rate-limit.js";
23
23
  import { withPrincipal } from "../observability/principal-context.js";
24
24
  import { turnEndEventData, contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, humanInputEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "../trace/project.js";
@@ -1322,9 +1322,9 @@ export function createHttpServer(rawDeps) {
1322
1322
  // 200 真跑完却拿不到 taskId、run 行永停 failed(账本与现实分叉)。这里 pre-CAS 纯拒绝:不动 checkpoint、
1323
1323
  // 不动 run 行、不烧模型腿,与另外两条腿的守卫同姿势(core 的 gate-match 仍是 fail-closed 兜底)。
1324
1324
  // ⚠️ 门放在 parked 赎回腿**之前**:赎回腿同样只铸 policy_ask outcome,错门 cp 在那条链上只会更深地炸。
1325
- // D-D SLA deny-sweep 不受影响:它的取行查询本就只选 gate_kind IN ('human','irreversible_ask')。
1325
+ // D-D SLA deny-sweep 不受影响:它的取行查询本就只选 gate_kind APPROVAL_GATE_KINDS(tool-approval.ts 单一属主)。
1326
1326
  const decideGateKind = cp.gate?.kind;
1327
- if (decideGateKind !== "human" && decideGateKind !== "irreversible_ask") {
1327
+ if (!isApprovalGateKind(decideGateKind)) { // A-002.1 单一属主
1328
1328
  // [2400] HITL-6(clay 裁 2026-08-03,不做兼容):补机读指路——与 [2255]① 的 409 材料同形
1329
1329
  // (pendingGate{kind, decidePath},resumeEntryForGate 单源表),客户端凭 decidePath 直达对的
1330
1330
  // resume 入口,不再解析人话文案。taskId 源=cp.sourceTaskId;缺席 ⇒ 只带 kind,诚实不铸假路径。
@@ -1,5 +1,16 @@
1
1
  import { FileMemoryEngineBackend } from "@sema-agent/core";
2
2
  import type { ServiceConfig } from "./config-types.js";
3
+ /** 前缀词表本体(供门/诊断读;判定一律走 {@link isV2ScopeKey},别在别处重写 startsWith 链)。 */
4
+ export declare const V2_SCOPE_PREFIXES: readonly string[];
5
+ /**
6
+ * 一个 scope 键是否带 v2 typed 前缀。
7
+ *
8
+ * ⚠️ 判据刻意停在**前缀**层,不升级成 core 的 `parseScopeKey`:后者对「带可识别前缀但结构坏」的键
9
+ * 是 fail-loud 抛错,而本谓词的两个调用点都只是在决定「要不要挂 v2 标 / 要不要再包一层」——把抛点
10
+ * 挪到这里会把 core 的校验时机整体前移(行为面改动,不在本条案的射程内)。坏键仍由 core 在它自己的
11
+ * 校验点 fail-loud。
12
+ */
13
+ export declare function isV2ScopeKey(key: string): boolean;
3
14
  /** Resolve the memory scope for a request (design/138 S1 — the file-based memory engine is the only plane).
4
15
  *
5
16
  * SINGLE-USER (REQUIRE_PRINCIPAL !== "true"): ONE shared scope — `config.memoryScope` (default "local"),
@@ -11,6 +22,11 @@ import type { ServiceConfig } from "./config-types.js";
11
22
  * fail-closed. The engine's file basement carries NO tenant isolation (one shared directory tree), so
12
23
  * handing tenants per-principal scopes over it would be a cross-tenant bleed surface; until a
13
24
  * tenant-isolated backend exists, multi-tenant memory is off by construction. */
25
+ /** R5(批γ):memory scope 列宽(两方言 VARCHAR(190) 同宽)。principal 上限 190 **字符**在
26
+ * security.ts 把身份轴守住了,但 `formatUserScope`/`formatProjScope` 的段编码(百分号转义)会
27
+ * **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。 */
28
+ export declare const MEMORY_SCOPE_COLUMN_CHARS = 190;
29
+ export declare function assertMemoryScopeWidth(scope: string): void;
14
30
  export declare function memoryScopeFor(config: ServiceConfig, principal?: string, projectId?: string): string | undefined;
15
31
  /**
16
32
  * N0 (通宵测试 2026-07-09,三重坐实 e2b+kata+代码): the file memory engine (core design/138) materializes and
@@ -5,6 +5,34 @@
5
5
  // "exports" only ever exposed "." and "./main" — dist/security.js was never a reachable deep-import path).
6
6
  import { join } from "node:path";
7
7
  import { FileMemoryEngineBackend, resolveMemoryEngineRoot, deriveControlPlaneDir, formatUserScope, formatProjScope } from "@sema-agent/core";
8
+ /**
9
+ * design/142 §1.2 的 v2 typed scope-key 前缀表 —— **单一属主**(A-002.4:`config.ts` 的
10
+ * MEMORY_SYNC_SCOPE 派生处曾有一份逐字副本,两处咬合用途不同[盖 v2 契约标 vs 判要不要再包一层
11
+ * `formatUserScope`],但判据必须是同一份,否则 core 加第五种 typed 前缀时两处各自漂)。
12
+ *
13
+ * 🔴 表按 core 的 {@link ParsedScopeKey} 判别词**穷举**:`Record<Exclude<kind,"legacy">, …>` 少一格
14
+ * 编译红、多一格也编译红 —— core 新增/改名一种 typed kind,本表在编译期就先红,而不是等某个运行期
15
+ * 分支静默走错。(`legacy` 排除在外正是本谓词要判的反面:无可识别前缀 = 不透明旧键。)
16
+ */
17
+ const V2_SCOPE_PREFIX_BY_KIND = {
18
+ user: "user:",
19
+ org: "org:",
20
+ proj: "proj:",
21
+ userproj: "userproj:",
22
+ };
23
+ /** 前缀词表本体(供门/诊断读;判定一律走 {@link isV2ScopeKey},别在别处重写 startsWith 链)。 */
24
+ export const V2_SCOPE_PREFIXES = Object.freeze(Object.values(V2_SCOPE_PREFIX_BY_KIND));
25
+ /**
26
+ * 一个 scope 键是否带 v2 typed 前缀。
27
+ *
28
+ * ⚠️ 判据刻意停在**前缀**层,不升级成 core 的 `parseScopeKey`:后者对「带可识别前缀但结构坏」的键
29
+ * 是 fail-loud 抛错,而本谓词的两个调用点都只是在决定「要不要挂 v2 标 / 要不要再包一层」——把抛点
30
+ * 挪到这里会把 core 的校验时机整体前移(行为面改动,不在本条案的射程内)。坏键仍由 core 在它自己的
31
+ * 校验点 fail-loud。
32
+ */
33
+ export function isV2ScopeKey(key) {
34
+ return V2_SCOPE_PREFIXES.some((prefix) => key.startsWith(prefix));
35
+ }
8
36
  /** Resolve the memory scope for a request (design/138 S1 — the file-based memory engine is the only plane).
9
37
  *
10
38
  * SINGLE-USER (REQUIRE_PRINCIPAL !== "true"): ONE shared scope — `config.memoryScope` (default "local"),
@@ -16,6 +44,16 @@ import { FileMemoryEngineBackend, resolveMemoryEngineRoot, deriveControlPlaneDir
16
44
  * fail-closed. The engine's file basement carries NO tenant isolation (one shared directory tree), so
17
45
  * handing tenants per-principal scopes over it would be a cross-tenant bleed surface; until a
18
46
  * tenant-isolated backend exists, multi-tenant memory is off by construction. */
47
+ /** R5(批γ):memory scope 列宽(两方言 VARCHAR(190) 同宽)。principal 上限 190 **字符**在
48
+ * security.ts 把身份轴守住了,但 `formatUserScope`/`formatProjScope` 的段编码(百分号转义)会
49
+ * **膨胀**——非 ASCII principal 编码后可超列宽,此前落裸 SQL 错。产出处响亮拒,错误可分类。 */
50
+ export const MEMORY_SCOPE_COLUMN_CHARS = 190;
51
+ export function assertMemoryScopeWidth(scope) {
52
+ if (scope.length > MEMORY_SCOPE_COLUMN_CHARS) {
53
+ throw new Error(`memory scope exceeds ${MEMORY_SCOPE_COLUMN_CHARS} characters after segment encoding (the scope column width ` +
54
+ `both SQL dialects pin); got ${scope.length} — use a shorter principal/projectId (non-ASCII characters expand ~9x when encoded)`);
55
+ }
56
+ }
19
57
  export function memoryScopeFor(config, principal, projectId) {
20
58
  if (config.requirePrincipal === true) {
21
59
  // S3-TOB(设计 §1.3):multi-tenant lights up ONLY on a DB backend (scope column = tenant
@@ -34,7 +72,9 @@ export function memoryScopeFor(config, principal, projectId) {
34
72
  if (config.memoryEngineBackend === "pg" || config.memoryEngineBackend === "tidb") {
35
73
  if (!principal)
36
74
  return undefined;
37
- return projectId ? formatProjScope(principal, projectId) : formatUserScope(principal);
75
+ const scope = projectId ? formatProjScope(principal, projectId) : formatUserScope(principal);
76
+ assertMemoryScopeWidth(scope); // R5:编码膨胀在产出处接住,不落 store 裸错
77
+ return scope;
38
78
  }
39
79
  return undefined; // file backend: dark, fail-closed (no tenant isolation in the file engine)
40
80
  }
@@ -113,7 +153,7 @@ originPolicy) {
113
153
  // OPERATOR's center-registry declaration (config.projects — 登记簿说明其形态=v2 scope 键串),so the
114
154
  // prefix is a self/operator-produced marker, never a guess about caller data; a legacy opaque scope
115
155
  // (single-user config.memoryScope) with no defaultScopes stays contract-less byte-identical.
116
- const isV2 = (k) => k.startsWith("user:") || k.startsWith("org:") || k.startsWith("proj:") || k.startsWith("userproj:");
156
+ const isV2 = isV2ScopeKey; // A-002.4:词表单一属主(见文件顶部 V2_SCOPE_PREFIX_BY_KIND)
117
157
  // 142-S4 defaultScopes 种子(config.projects[projectId].defaultScopes):额外的 READ 层(去重、去 scope
118
158
  // 自身、去空串;保 center 声明顺序)。写路由不变:core normalizeMemorySpec 的 writeScope 缺省=scopes 的
119
159
  // 最后一层,所以带种子时必须 EXPLICIT 钉 writeScope=派生 scope(否则 harvest 会写进登记簿的最后一个默认
@@ -27,6 +27,7 @@
27
27
  * - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL centrally in pg-pool.ts (neither store creates tables).
28
28
  */
29
29
  import { createHash } from "node:crypto";
30
+ import { APPROVAL_GATE_KINDS_SQL_IN } from "../tool-approval.js"; // A-002.1 单一属主(SQL IN 片段从闭集数组派生)
30
31
  import { CheckpointError, validatePendingSteer, appendPendingSteer, checkpointVersionOf, winnerFromOutcome, summarizeCheckpoint, MAX_SUPPORTED_CHECKPOINT_VERSION, } from "@sema-agent/core";
31
32
  import { redactDeep } from "../trace/redact.js";
32
33
  import { parseJsonStrict as parseJson } from "./sql-row-helpers.js";
@@ -534,8 +535,8 @@ export class SqlCheckpointStore {
534
535
  */
535
536
  async reapExpired(cutoff) {
536
537
  const res = await this.db.query(this.q("UPDATE checkpoint SET status = 'expired', decided_at = ? " +
537
- "WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ('human','irreversible_ask') OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= ?))", "UPDATE checkpoint SET status = 'expired', decided_at = $1 " +
538
- "WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ('human','irreversible_ask') OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= $3))"), [Date.now(), cutoff, cutoff]);
538
+ `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= ? AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= ?))`, "UPDATE checkpoint SET status = 'expired', decided_at = $1 " +
539
+ `WHERE status = 'pending' AND ((deadline IS NOT NULL AND deadline <= $2 AND (gate_kind IS NULL OR gate_kind NOT IN ${APPROVAL_GATE_KINDS_SQL_IN} OR COALESCE(tool_name,'') = 'AskUserQuestion')) OR (terminal_at IS NOT NULL AND terminal_at <= $3))`), [Date.now(), cutoff, cutoff]);
539
540
  return res.affected;
540
541
  }
541
542
  /**
@@ -549,8 +550,8 @@ export class SqlCheckpointStore {
549
550
  * reapExpired's abort-expire path instead of this graceful-deny path.
550
551
  */
551
552
  async listExpiredApprovalGates(cutoff, limit = 100) {
552
- const { rows } = await this.db.query(this.q("SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ('human','irreversible_ask') " +
553
- "AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= ? ORDER BY deadline ASC LIMIT ?", "SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ('human','irreversible_ask') " +
553
+ const { rows } = await this.db.query(this.q(`SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ${APPROVAL_GATE_KINDS_SQL_IN} ` +
554
+ "AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= ? ORDER BY deadline ASC LIMIT ?", `SELECT session_id, scope FROM checkpoint WHERE status = 'pending' AND gate_kind IN ${APPROVAL_GATE_KINDS_SQL_IN} ` +
554
555
  "AND COALESCE(tool_name,'') <> 'AskUserQuestion' AND deadline IS NOT NULL AND deadline <= $1 ORDER BY deadline ASC LIMIT $2"), [cutoff, limit]);
555
556
  return rows.map((r) => ({ sessionId: String(r.session_id), scope: String(r.scope) }));
556
557
  }
@@ -23,6 +23,7 @@
23
23
  import { createHash } from "node:crypto";
24
24
  import { existsSync, readFileSync, readdirSync, renameSync, unlinkSync, mkdirSync } from "node:fs";
25
25
  import { join } from "node:path";
26
+ import { isApprovalGateKind } from "../tool-approval.js"; // A-002.1 单一属主
26
27
  import { FileCheckpointStore, atomicWriteFile, sanitizePathComponent, } from "@sema-agent/core";
27
28
  import { boundedToolInput, TERMINAL_BACKSTOP_MS, TERMINAL_GRACE_MS } from "./checkpoint-store-sql.js";
28
29
  /** design/80 D-D read-time twin of the TiDB put-time `terminal_at` column (same formula — anti-drift). */
@@ -294,7 +295,7 @@ export class LocalCheckpointStore {
294
295
  const toolName = cp.pendingAction?.toolName ?? "";
295
296
  const deadlineBranch = cp.deadline !== undefined &&
296
297
  cp.deadline <= cutoff &&
297
- (gateKind === undefined || !["human", "irreversible_ask"].includes(gateKind) || toolName === "AskUserQuestion");
298
+ (gateKind === undefined || !isApprovalGateKind(gateKind) || toolName === "AskUserQuestion");
298
299
  const terminalBranch = terminalAtOf(cp) <= cutoff;
299
300
  if ((deadlineBranch || terminalBranch) && (await this.inner.expire(token, cp.scope)))
300
301
  n++;
@@ -308,7 +309,7 @@ export class LocalCheckpointStore {
308
309
  const gateKind = cp.gate?.kind;
309
310
  const toolName = cp.pendingAction?.toolName ?? "";
310
311
  if (gateKind !== undefined &&
311
- ["human", "irreversible_ask"].includes(gateKind) &&
312
+ isApprovalGateKind(gateKind) &&
312
313
  toolName !== "AskUserQuestion" &&
313
314
  cp.deadline !== undefined &&
314
315
  cp.deadline <= cutoff)
@@ -23,6 +23,7 @@ import { cosineDistance, jaccardDistance, termSet } from "@sema-agent/core";
23
23
  import { isUniqueViolation } from "./memory-engine-vector-util.js";
24
24
  import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
25
25
  import { pgSafeJsonStringify, pgHasUnstorable } from "./pg-safe-json.js";
26
+ import { assertSlugWidth } from "./memory-key-guards.js"; // R5 批γ:slug 写前宽守卫
26
27
  /** Table names (single source). Deliberately DISJOINT from the legacy `agent_memory*` tables —
27
28
  * the retired MemoryStore plane and this entry plane must never cross-write. */
28
29
  export const PG_MEMORY_ENGINE_TABLES = {
@@ -278,6 +279,15 @@ export class PgMemoryEngineBackend {
278
279
  report.conflicts.push({ op: "add", id: rawEntry.id, reason: "unstorable_bytes (PG cannot store NUL/lone surrogates; strip them at the source — the store never rewrites content)" });
279
280
  return;
280
281
  }
282
+ // R5(批γ):slug 超列宽写前拒(reject-not-rewrite 同形;tidb 版同注)。守卫常量与 DDL 同源门
283
+ // 在 test/key-width-guards.test.ts。
284
+ try {
285
+ assertSlugWidth(rawEntry.slug);
286
+ }
287
+ catch (e) {
288
+ report.conflicts.push({ op: "add", id: rawEntry.id, reason: e instanceof Error ? e.message : String(e) });
289
+ return;
290
+ }
281
291
  const entry = rawEntry;
282
292
  // opus 审 C3: an add whose id already lives in a DIFFERENT scope is refused explicitly — the bare
283
293
  // `ON CONFLICT (id) DO UPDATE SET scope=…` would silently MOVE the row across scopes (and diverge
@@ -24,6 +24,7 @@
24
24
  import { jaccardDistance, termSet } from "@sema-agent/core";
25
25
  import { computeEntryRev, serializeEntryFile } from "@sema-agent/core";
26
26
  import { pgHasUnstorable } from "./pg-safe-json.js";
27
+ import { assertSlugWidth } from "./memory-key-guards.js"; // R5 批γ:slug 写前宽守卫
27
28
  /** Table names (single source) — SAME names as PG_MEMORY_ENGINE_TABLES (the two dialects never share
28
29
  * one database), deliberately DISJOINT from the legacy `agent_memory*` MemoryStore plane. */
29
30
  export const TIDB_MEMORY_ENGINE_TABLES = {
@@ -234,6 +235,16 @@ export class TiDBMemoryEngineBackend {
234
235
  report.conflicts.push({ op: "add", id: entry.id, reason: "unstorable_bytes (utf8mb4 cannot store NUL/lone surrogates without mangling; strip them at the source — the store never rewrites content)" });
235
236
  return;
236
237
  }
238
+ // R5(批γ):slug 超列宽写前拒(reject-not-rewrite 同形)——非严格 MySQL 会静默截断,
239
+ // 截断=两个不同 slug 折叠成一行互串;PG/严格 MySQL 落裸错不可分类。守卫常量与 DDL 同源门在
240
+ // test/key-width-guards.test.ts。
241
+ try {
242
+ assertSlugWidth(entry.slug);
243
+ }
244
+ catch (e) {
245
+ report.conflicts.push({ op: "add", id: entry.id, reason: e instanceof Error ? e.message : String(e) });
246
+ return;
247
+ }
237
248
  // Cross-scope add refusal (opus 审 C3,Pg 版同注): an add whose id already lives in a DIFFERENT
238
249
  // scope must not silently MOVE the row; same-scope re-add stays the idempotent overwrite.
239
250
  // One probe serves BOTH the E-02 guard and the cross-scope refusal (File/Pg parity).
@@ -0,0 +1,8 @@
1
+ /** R5(车A [3191] 欠账,批γ 落地):memory-engine 键宽写前守卫。
2
+ * 列宽收窄后,模型可控的条目名(slug)超宽此前落裸 SQL 错(PG `value too long`)或非严格 MySQL
3
+ * 静默截断(截断=两个不同 slug 折叠成一行=条目互串,最危险形)。写前响亮拒,错误可分类。
4
+ * 列宽同源门=test/key-width-guards.test.ts(守卫常量 vs 两方言 DDL 逐字对表)。 */
5
+ /** memory-engine entry 表 `slug` 列宽(两方言 VARCHAR(512) 同宽;(scope,slug) UNIQUE 键预算注在 DDL)。 */
6
+ export declare const MEMORY_SLUG_COLUMN_CHARS = 512;
7
+ export declare function assertSlugWidth(slug: string): void;
8
+ //# sourceMappingURL=memory-key-guards.d.ts.map
@@ -0,0 +1,13 @@
1
+ /** R5(车A [3191] 欠账,批γ 落地):memory-engine 键宽写前守卫。
2
+ * 列宽收窄后,模型可控的条目名(slug)超宽此前落裸 SQL 错(PG `value too long`)或非严格 MySQL
3
+ * 静默截断(截断=两个不同 slug 折叠成一行=条目互串,最危险形)。写前响亮拒,错误可分类。
4
+ * 列宽同源门=test/key-width-guards.test.ts(守卫常量 vs 两方言 DDL 逐字对表)。 */
5
+ /** memory-engine entry 表 `slug` 列宽(两方言 VARCHAR(512) 同宽;(scope,slug) UNIQUE 键预算注在 DDL)。 */
6
+ export const MEMORY_SLUG_COLUMN_CHARS = 512;
7
+ export function assertSlugWidth(slug) {
8
+ if (slug.length > MEMORY_SLUG_COLUMN_CHARS) {
9
+ throw new Error(`memory entry slug exceeds ${MEMORY_SLUG_COLUMN_CHARS} characters (the slug column width both SQL dialects pin); ` +
10
+ `got ${slug.length} — shorten the entry name`);
11
+ }
12
+ }
13
+ //# sourceMappingURL=memory-key-guards.js.map
@@ -949,10 +949,21 @@ export class RemoteK8sExecutionEnv {
949
949
  if (finished)
950
950
  break;
951
951
  const waitMs = Math.max(1, idleMs - (Date.now() - lastChunk));
952
- await new Promise((r) => {
953
- wake = r;
954
- setTimeout(r, Math.min(waitMs, 30_000));
955
- });
952
+ // A-002.11: the wait races the drain timer against `wake` (a chunk / completion). When `wake` wins —
953
+ // the common case on a chatty command — the timer stays armed for up to 30s unless it is cleared, so a
954
+ // long-running command leaks one per drain cycle. Same `finally` cleanup form the E2B twin already uses
955
+ // for its idle timer (remote-env-e2b.ts execStream). Clearing an already-fired timer is a no-op.
956
+ let waitTimer;
957
+ try {
958
+ await new Promise((r) => {
959
+ wake = r;
960
+ waitTimer = setTimeout(r, Math.min(waitMs, 30_000));
961
+ });
962
+ }
963
+ finally {
964
+ if (waitTimer !== undefined)
965
+ clearTimeout(waitTimer);
966
+ }
956
967
  if (!finished && Date.now() - lastChunk >= idleMs) {
957
968
  throw new RemoteExecutionError("timeout", `execStream idle > ${idleMs}ms (suspected hang)`);
958
969
  }
package/dist/run-local.js CHANGED
@@ -60,7 +60,7 @@ import { pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.
60
60
  import { HttpError } from "./security.js";
61
61
  import { memoryEngineBackendFor, memorySpecForRequest } from "./memory-scope.js";
62
62
  import { createMemorySyncRunner, createMemorySyncTransport } from "./memory-sync-client.js";
63
- import { buildPricing } from "./budget.js";
63
+ import { buildPricing, cappedCeiling } from "./budget.js";
64
64
  import { createKeyResolver } from "./key-resolver.js";
65
65
  import { createLogger } from "./observability/logger.js";
66
66
  import { createMetrics } from "./observability/metrics.js";
@@ -598,6 +598,19 @@ export async function runLocal(argv, deps = {}) {
598
598
  const taskTimeoutSec = Math.max(0, Math.floor(numEnv("TASK_TIMEOUT_SEC", "0")));
599
599
  const timeoutSec = taskWallClockSec(taskTimeoutSec, false, scenarioName === "team");
600
600
  const mcp = mcpForScenario(config.mcpServers, scenarioName);
601
+ // A-002.9(#180 governance 缺口的同文件兄弟残余):运营方预算天花板两枚 —— 与 server 主路径
602
+ // (boot/resolve-spec.ts)**同键同算法**,共用 budget.ts 的 `cappedCeiling`。此腿无 body(见上一段
603
+ // 的 [854]④ 记账),所以 requested 恒缺席 ⇒ env 天花板在场时**直接成为** spec 值,天花板为 0/未设时
604
+ // 仍是 undefined(键缺席=无预算,与改前逐字等价)。
605
+ // ⚠️ 只接这两枚:`degrade`(MODEL_DEGRADE_*)是另一条旋钮线,不在本条案射程内。
606
+ const maxCostUsd = cappedCeiling(undefined, config.maxTaskCostUsd);
607
+ const maxTokens = cappedCeiling(undefined, config.maxTaskTokens);
608
+ // core 5.8.0:预算族与墙钟同住 limits;三键各自缺席就不写(全缺 ⇒ 整个 limits 键缺席)。
609
+ const limits = {
610
+ ...(timeoutSec !== undefined ? { maxWalltimeMs: timeoutSec * 1000 } : {}),
611
+ ...(maxCostUsd !== undefined ? { maxCostUsd } : {}),
612
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
613
+ };
601
614
  // design/181 件三:自建的 spec 字面量经**同一条**部署治理链(见 {@link applyLocalGovernance})——
602
615
  // 审批基线 + autonomy/commandPolicy/MANUAL_MODE_SHELL_GATE/守卫集,tighten-only,折叠属主仍是 core。
603
616
  const spec = applyLocalGovernance({
@@ -618,8 +631,8 @@ export async function runLocal(argv, deps = {}) {
618
631
  // [849]→[2400] 场景层定死终验已无生产者(autonomous 退役);OR 折入形保留,与 resolveSpec 同语义。
619
632
  ...(cap.finalVerification === true ? { finalVerification: true } : {}),
620
633
  ...(mcp ? { mcp } : {}),
621
- // core 5.8.0:timeoutSec 键退役 → maxWalltimeMs(毫秒);taskWallClockSec 仍产秒,此处换算一次。
622
- ...(timeoutSec !== undefined ? { limits: { maxWalltimeMs: timeoutSec * 1000 } } : {}),
634
+ // core 5.8.0:timeoutSec 键退役 → maxWalltimeMs(毫秒);taskWallClockSec 仍产秒,换算在上方 limits 合成处。
635
+ ...(Object.keys(limits).length > 0 ? { limits } : {}),
623
636
  }, config, workspaceDir);
624
637
  logger.info("run_local_start", { scenario: scenarioName, model: spec.model, sessionId, exec: config.remoteExec?.provider ?? "in-process" });
625
638
  // ── Run ONE task to completion (the sync /v1/tasks path: plain runTask, no verify/cascade). ──
@@ -2,6 +2,15 @@ import { type AskRequest, type AskOutcome } from "@sema-agent/core";
2
2
  import { type ApprovalRequestFrame, type ApprovalRevokeFrame } from "./approval-card.js";
3
3
  import type { ApprovalAskStore } from "./plugins/approval-ask-store-sql.js";
4
4
  import { type GovernanceAskMarks } from "./governance-ask-marks.js";
5
+ /** A-002.1:审批 gate kind 闭集(core gateMatch 的 `human`/`irreversible_ask` ↔ outcome.gate "policy_ask")。
6
+ * 此前 7 份手写副本散在两只 checkpoint store 的数组/SQL 字面与 /decide 守卫——core 加审批味 kind 时
7
+ * 全部静默漂移。core 无导出词表(全大写导出面零命中,2026-08-09 亲验),属主落此;SQL IN 片段从
8
+ * 数组派生保证同源。门=test/approval-gate-kinds-single-owner.test.ts(副本回潮即红)。 */
9
+ export declare const APPROVAL_GATE_KINDS: readonly ["human", "irreversible_ask"];
10
+ export type ApprovalGateKind = (typeof APPROVAL_GATE_KINDS)[number];
11
+ export declare function isApprovalGateKind(k: string | undefined): k is ApprovalGateKind;
12
+ /** 两方言同形的 SQL IN 片段(值为闭集常量字面,无注入面)。 */
13
+ export declare const APPROVAL_GATE_KINDS_SQL_IN: string;
5
14
  /** A live approval frame delivered to whoever tails this run's stream. `type` IS the SSE event name (named-event
6
15
  * convention, same as question). The shell renders `tool_approval` as the CC three-choice card and dismisses on
7
16
  * `tool_approval_complete`. */
@@ -54,6 +54,16 @@ import { governanceAskMarksFor, runWithGovernanceAskScope } from "./governance-a
54
54
  * DI logger,构造签名是设计定稿钉死的三键 options bag,加第四个 logger 键属于重议已裁事项)。仅用于
55
55
  * D5 的一次性 store-故障 warn。 */
56
56
  const defaultLogger = createLogger();
57
+ /** A-002.1:审批 gate kind 闭集(core gateMatch 的 `human`/`irreversible_ask` ↔ outcome.gate "policy_ask")。
58
+ * 此前 7 份手写副本散在两只 checkpoint store 的数组/SQL 字面与 /decide 守卫——core 加审批味 kind 时
59
+ * 全部静默漂移。core 无导出词表(全大写导出面零命中,2026-08-09 亲验),属主落此;SQL IN 片段从
60
+ * 数组派生保证同源。门=test/approval-gate-kinds-single-owner.test.ts(副本回潮即红)。 */
61
+ export const APPROVAL_GATE_KINDS = ["human", "irreversible_ask"];
62
+ export function isApprovalGateKind(k) {
63
+ return k !== undefined && APPROVAL_GATE_KINDS.includes(k);
64
+ }
65
+ /** 两方言同形的 SQL IN 片段(值为闭集常量字面,无注入面)。 */
66
+ export const APPROVAL_GATE_KINDS_SQL_IN = `(${APPROVAL_GATE_KINDS.map((k) => `'${k}'`).join(",")})`;
57
67
  /** Size bound on the redacted args payload in a `tool_approval` frame (parity with question's MAX_QUESTIONS_BYTES).
58
68
  * Over the cap ⇒ the frame still goes out WITHOUT args (`argsOmitted: true`). The sibling lane makes the OPPOSITE
59
69
  * call and that asymmetry is deliberate: an over-cap question is DROPPED and the ask reports `unavailable`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.8.1",
3
+ "version": "7.9.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -69,7 +69,7 @@
69
69
  "sharp": "^0.35.3"
70
70
  },
71
71
  "devDependencies": {
72
- "@sema-agent/sdk": "^6.11.0",
72
+ "@sema-agent/sdk": "^6.12.0",
73
73
  "@types/libsodium-wrappers": "^0.7.14",
74
74
  "@types/node": "22.10.2",
75
75
  "@types/pg": "^8.20.0",