@sema-agent/server 2.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/MIGRATION.md +74 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/USAGE.md +395 -0
  5. package/dist/boot/config-center.d.ts +2 -1
  6. package/dist/boot/config-center.js +28 -7
  7. package/dist/capabilities/scenarios.d.ts +5 -3
  8. package/dist/capabilities/scenarios.js +5 -3
  9. package/dist/config-center/apply-effective.js +4 -3
  10. package/dist/config-center/restart-signal.d.ts +10 -2
  11. package/dist/config-center/restart-signal.js +28 -4
  12. package/dist/config-lkg.d.ts +2 -1
  13. package/dist/config-lkg.js +2 -1
  14. package/dist/config-types.d.ts +7 -5
  15. package/dist/config.d.ts +16 -11
  16. package/dist/config.js +30 -33
  17. package/dist/hooks/hook-llm.js +9 -0
  18. package/dist/http/routes/approvals-assistant.js +1 -1
  19. package/dist/http/routes/attachments.js +2 -2
  20. package/dist/http/routes/memory-policy.js +3 -3
  21. package/dist/http/routes/runs.js +1 -1
  22. package/dist/http/routes/session-sync.js +2 -2
  23. package/dist/http/routes/sessions.js +2 -2
  24. package/dist/http/routes/tasks.js +2 -2
  25. package/dist/http/routes/trace-usage.js +2 -2
  26. package/dist/http/routes/workflows.js +3 -1
  27. package/dist/http/server.d.ts +1 -1
  28. package/dist/http/server.js +25 -5
  29. package/dist/http/sse-log.js +1 -1
  30. package/dist/main.js +1 -1
  31. package/dist/model-select.d.ts +1 -1
  32. package/dist/model-select.js +1 -1
  33. package/dist/plugins/checkpoint-store-sql.d.ts +13 -5
  34. package/dist/plugins/checkpoint-store-sql.js +10 -3
  35. package/dist/plugins/local-checkpoint-store.js +8 -2
  36. package/dist/plugins/remote-env-host.d.ts +34 -15
  37. package/dist/plugins/remote-env-host.js +30 -10
  38. package/dist/plugins/remote-env-local-docker.d.ts +14 -0
  39. package/dist/plugins/remote-env-local-docker.js +10 -3
  40. package/dist/plugins/run-store-sql.d.ts +8 -3
  41. package/dist/plugins/run-store-sql.js +8 -3
  42. package/dist/run-local.js +2 -1
  43. package/dist/sema-registry.d.ts +18 -4
  44. package/dist/sema-registry.js +17 -3
  45. package/dist/session-titler.d.ts +3 -1
  46. package/dist/session-titler.js +2 -2
  47. package/dist/trace/project.js +4 -1
  48. package/package.json +6 -4
@@ -281,7 +281,7 @@ export function createHttpServer(rawDeps) {
281
281
  // 4xx, not a 500. Everything else is a genuine internal error: log the cause, never echo it (info disclosure).
282
282
  if (err instanceof HttpError) {
283
283
  if (!res.headersSent)
284
- sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.code ? { code: err.code } : {}), ...(err.extra ?? {}) });
284
+ sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.extra ?? {}) });
285
285
  else
286
286
  res.end();
287
287
  return;
@@ -1091,7 +1091,7 @@ export function createHttpServer(rawDeps) {
1091
1091
  if (err instanceof HttpError) {
1092
1092
  // Typed rejection: echo the stable `code` + any structured extras (e.g. the scenario
1093
1093
  // allowlist) beside the human message — machine code on the wire, prose stays with the shell/web.
1094
- sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.code ? { code: err.code } : {}), ...(err.extra ?? {}) });
1094
+ sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.extra ?? {}) });
1095
1095
  return null;
1096
1096
  }
1097
1097
  throw err;
@@ -1181,6 +1181,27 @@ export function createHttpServer(rawDeps) {
1181
1181
  }
1182
1182
  if (!cp)
1183
1183
  return { status: 404, body: { error: "checkpoint not found", errorCode: "not_found.checkpoint" } };
1184
+ // 🔴 GATE-KIND GUARD ([1995]①,`gate_not_resumable` / `gate_not_plan_review` 的**反方向镜像**):这条腿
1185
+ // 只解 TOOL 审批门(core gateMatch:`human` / `irreversible_ask` ↔ outcome.gate "policy_ask")。此前它无
1186
+ // 条件铸 outcome{gate:"policy_ask"} 交给 core —— 一个 plan_review / resource_limit / task_done park 被误
1187
+ // 路由到 /decide(而 /v1/approvals 把它们与工具审批**混在同一个队列**里、toolName 同为 null)就会深入到
1188
+ // core 的 `checkpoint.gate_mismatch`。那是 core 的 **pre-CAS** 拒绝(checkpoint 仍 pending),但 server 的
1189
+ // CheckpointError 分类表把 gate_mismatch 归为 TERMINAL ⇒ 行被 setTerminal 成 failed + 释放 task_active,
1190
+ // 于是「行说这活死了、park 却还活着」:claim 泄漏(同 session 可并发起第二个 run)、事后用正确的腿救能
1191
+ // 200 真跑完却拿不到 taskId、run 行永停 failed(账本与现实分叉)。这里 pre-CAS 纯拒绝:不动 checkpoint、
1192
+ // 不动 run 行、不烧模型腿,与另外两条腿的守卫同姿势(core 的 gate-match 仍是 fail-closed 兜底)。
1193
+ // ⚠️ 门放在 parked 赎回腿**之前**:赎回腿同样只铸 policy_ask outcome,错门 cp 在那条链上只会更深地炸。
1194
+ // D-D SLA deny-sweep 不受影响:它的取行查询本就只选 gate_kind IN ('human','irreversible_ask')。
1195
+ const decideGateKind = cp.gate?.kind;
1196
+ if (decideGateKind !== "human" && decideGateKind !== "irreversible_ask") {
1197
+ return {
1198
+ status: 409,
1199
+ body: {
1200
+ error: `task is suspended on a '${decideGateKind ?? "unknown"}' gate, not a tool approval — a plan_review gate is resolved via POST /v1/assistant/tasks/:id/plan_review, a resource_limit suspension via POST /v1/assistant/tasks/:id/resume`,
1201
+ errorCode: "gate_not_tool_approval",
1202
+ },
1203
+ };
1204
+ }
1184
1205
  // design/153 件3d parked 赎回腿([1574]/[1584]/[1588] core 裁定;src/parked-decide.ts):该 pending
1185
1206
  // checkpoint 若属于一个 parked 后台子代,decide 必须走 claim→裸 execute(reviveClaim) 链——legacy 腿的
1186
1207
  // resumeStream 会绕开 bg registry(无 consumeParkedFlip、行永 parked、生命周期分叉),故 parked-first。
@@ -1243,7 +1264,7 @@ export function createHttpServer(rawDeps) {
1243
1264
  if (e instanceof HttpError) {
1244
1265
  return {
1245
1266
  status: 409,
1246
- body: { error: `resume blocked by an admission policy change: ${e.message}`, code: "resume_blocked_by_policy", errorCode: "resume_blocked_by_policy", ...(e.code ? { blockedBy: e.code } : {}), ...(e.extra ?? {}) },
1267
+ body: { error: `resume blocked by an admission policy change: ${e.message}`, errorCode: "resume_blocked_by_policy", ...(e.code ? { blockedBy: e.code } : {}), ...(e.extra ?? {}) },
1247
1268
  };
1248
1269
  }
1249
1270
  throw e;
@@ -1871,7 +1892,6 @@ export function createHttpServer(rawDeps) {
1871
1892
  status,
1872
1893
  body: {
1873
1894
  error: e.message,
1874
- code: e.code,
1875
1895
  // invalid_outcome means DIFFERENT things per gate: for policy_ask it IS the D-1 action-binding
1876
1896
  // mismatch; for plan_review/dry_run_review it is a content-sanitize reject (editedPlan/reason carried a
1877
1897
  // forbidden tag) — give those a gate-appropriate code so the UI routes to the right recovery, not the
@@ -2210,7 +2230,7 @@ export function createHttpServer(rawDeps) {
2210
2230
  res.setHeader("retry-after", String(adm.retryAfterSec));
2211
2231
  // E4 定稿:429 带 typed code + center 结构化字段 verbatim 透传(windowType/remaining/resetAt/
2212
2232
  // isEstimate/pool)——人话文案归壳/web(契约如此),error 串保留旧消费者兼容。
2213
- sendError(res, 429, "quota_exhausted", "budget exhausted (quota lease)", { code: "quota_exhausted", retryAfterSec: adm.retryAfterSec, ...(adm.detail ?? {}) });
2233
+ sendError(res, 429, "quota_exhausted", "budget exhausted (quota lease)", { retryAfterSec: adm.retryAfterSec, ...(adm.detail ?? {}) });
2214
2234
  return true;
2215
2235
  }
2216
2236
  /** Apply the per-principal rate limit; responds 429 and returns true when the caller is over. */
@@ -55,7 +55,7 @@ export async function streamSseLog(req, res, provider, id, staleMs) {
55
55
  if (Date.now() - start > MAX_MS) {
56
56
  // BL-16: cap reached but the run continues — emit a terminal reconnect signal (not a silent close, which
57
57
  // a client couldn't distinguish from a completed run). Distinct from the `failed`/`done` run events.
58
- res.write(`event: error\ndata: ${JSON.stringify({ type: "error", code: "STREAM_MAX_DURATION", message: "stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
58
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
59
59
  break;
60
60
  }
61
61
  if (events.length === 0) {
package/dist/main.js CHANGED
@@ -237,7 +237,7 @@ async function main() {
237
237
  setTitleIfNull: titleCapable.setTitleIfNull.bind(sessionStore),
238
238
  probeTitle: titleCapable.probeTitle.bind(sessionStore),
239
239
  redact: redactSecrets, // audit 2026-07-13: scrub a model-echoed secret before persist/list/log
240
- llm: (o) => hookLlm({ prompt: o.prompt, timeoutMs: o.timeoutMs }),
240
+ llm: (o) => hookLlm({ prompt: o.prompt, timeoutMs: o.timeoutMs, model: o.model }),
241
241
  logger,
242
242
  metrics,
243
243
  })
@@ -10,7 +10,7 @@
10
10
  * untrusted body/text can pick WHICH configured model but can never inject a baseUrl/apiKey (parseModelMention
11
11
  * only ever returns an allow-listed catalog name).
12
12
  *
13
- * [865]②(clay 生产实锤:onboard 选 kimi 实跑 Qwen):
13
+ * [865]②(生产实锤:面板上选了 A 模型,实际跑的是目录里的另一个):
14
14
  * - explicit ref 走 name+id 双键索引({@link matchCatalogModel})——壳 boot 回读的 mainLoopModel 是 **id 形**,
15
15
  * 目录键是 **name 形**,单键索引把 id 形请求当「未知」静默落 default = session 中途静默换模型(最恶性的
16
16
  * 上下文污染路径)。
@@ -11,7 +11,7 @@ import { parseModelMention } from "@sema-agent/core";
11
11
  * untrusted body/text can pick WHICH configured model but can never inject a baseUrl/apiKey (parseModelMention
12
12
  * only ever returns an allow-listed catalog name).
13
13
  *
14
- * [865]②(clay 生产实锤:onboard 选 kimi 实跑 Qwen):
14
+ * [865]②(生产实锤:面板上选了 A 模型,实际跑的是目录里的另一个):
15
15
  * - explicit ref 走 name+id 双键索引({@link matchCatalogModel})——壳 boot 回读的 mainLoopModel 是 **id 形**,
16
16
  * 目录键是 **name 形**,单键索引把 id 形请求当「未知」静默落 default = session 中途静默换模型(最恶性的
17
17
  * 上下文污染路径)。
@@ -1,6 +1,6 @@
1
1
  import type { Pool as MySqlPool } from "mysql2/promise";
2
2
  import type { Pool as PgPool } from "pg";
3
- import { type Checkpoint, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type ResumeOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor } from "@sema-agent/core";
3
+ import { type Checkpoint, type CheckpointGate, type CheckpointStore, type CheckpointSummary, type CheckpointToken, type ResumeOutcome, type ResolveExpectation, type ReopenReason, type RiskDescriptor } from "@sema-agent/core";
4
4
  import { type SqlDriver } from "./sql-driver.js";
5
5
  /**
6
6
  * design/80 D-1 (§3 invariant #3 — crash-safe reaper backstop): an ABSOLUTE upper bound on a pending
@@ -38,10 +38,18 @@ export interface PendingCheckpoint {
38
38
  * resume binds to the EXACT action they saw (a stale view is then rejected fail-closed PRE-CAS). `boundCallId`
39
39
  * === `toolCallId` (the pending tool call); `boundInputHash` is the server-minted opaque hash of the shown
40
40
  * input (NEVER recomputed by the portal — echoed verbatim). Surfacing them here is what makes the D-1 TOCTOU
41
- * guard REACHABLE by the portal (without it the portal can only do the unbound legacy fallback). `null` on a
42
- * pre-D-1 / legacy-deserialized checkpoint the consumer falls back to the unbound resolve. */
43
- boundCallId: string | null;
44
- boundInputHash: string | null;
41
+ * guard REACHABLE by the portal (without it the portal can only do the unbound legacy fallback).
42
+ * 🔴 [1995]②: ABSENT (key omitted, never `null`) on a park with no tool action (plan_review /
43
+ * resource_limit / task_done) or a pre-D-1 legacy row ⇒ the consumer falls back to the unbound resolve.
44
+ * The wire contract (§1) is "an absent nullable field is OMITTED, not null" — a `null` here invited a
45
+ * consumer to treat the key's PRESENCE as "there is a binding" and echo `null` back into /decide. */
46
+ boundCallId?: string;
47
+ boundInputHash?: string;
48
+ /** 🔴 [1995]③ (additive): the checkpoint's gate kind — the operator queue mixes tool approvals with
49
+ * plan_review / resource_limit / task_done parks (all of which carry `toolName: null`), so a durable-recovery
50
+ * consumer needs this to ROUTE a row to the right wire (`/decide` vs `/plan_review` vs `/resume`). Absent
51
+ * only for a legacy SQL row suspended before the `gate_kind` column existed. */
52
+ gateKind?: CheckpointGate["kind"];
45
53
  /** The suspended run's taskId (task_active join) — the key to the run/trace ("view task context"). */
46
54
  taskId: string | null;
47
55
  /** The pending tool call's args (post-hook), redacted + bounded — for tool_approval the write payload, for a
@@ -305,13 +305,15 @@ export class SqlCheckpointStore {
305
305
  * joined from task_active (the JOIN KEY to the run/trace — a suspended run KEEPS its
306
306
  * session claim, so the join is live for every pending row; null only in pathological windows). */
307
307
  async listPending(scope) {
308
- const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.created_at, c.deadline, ta.task_id " +
308
+ const base = "SELECT c.session_id, c.scope, c.tool_name, c.tool_call_id, c.tool_input, c.bound_input_hash, c.risk_descriptor, c.created_at, c.deadline, c.gate_kind, ta.task_id " +
309
309
  "FROM checkpoint c LEFT JOIN task_active ta ON ta.session_id = c.session_id WHERE c.status='pending'";
310
310
  const { rows } = scope
311
311
  ? await this.db.query(`${base}${this.q(" AND c.scope=?", " AND c.scope=$1")} ORDER BY c.created_at ASC`, [scope])
312
312
  : await this.db.query(`${base} ORDER BY c.created_at ASC`);
313
313
  const out = rows.map((r) => {
314
314
  const toolCallId = r.tool_call_id ?? null;
315
+ const boundInputHash = r.bound_input_hash ?? null;
316
+ const gateKind = r.gate_kind ?? null;
315
317
  return {
316
318
  sessionId: String(r.session_id),
317
319
  scope: String(r.scope),
@@ -319,8 +321,13 @@ export class SqlCheckpointStore {
319
321
  toolCallId,
320
322
  // D-1: boundCallId === the pending tool call id; boundInputHash is the server-minted opaque hash. The
321
323
  // portal echoes BOTH on /decide so the resume binds to the exact action shown (TOCTOU guard reachable).
322
- boundCallId: toolCallId,
323
- boundInputHash: r.bound_input_hash ?? null,
324
+ // [1995]②: a park with no tool action (plan_review / resource_limit / task_done — both columns NULL)
325
+ // OMITS the keys entirely; the wire contract says absent, never `null` (LOCAL twin does the same).
326
+ ...(toolCallId !== null ? { boundCallId: toolCallId } : {}),
327
+ ...(boundInputHash !== null ? { boundInputHash } : {}),
328
+ // [1995]③ additive: the gate kind the row is parked on (NULL only on a pre-`gate_kind` legacy row) —
329
+ // lets a durable-recovery consumer route plan / tool / resource off this ONE queue.
330
+ ...(gateKind !== null ? { gateKind } : {}),
324
331
  taskId: r.task_id ?? null,
325
332
  // Both drivers return the JSON column already parsed; null for pre-migration rows. PG 库内恒干净
326
333
  // (拒绝式)——直读即审阅面=执行面。
@@ -192,8 +192,14 @@ export class LocalCheckpointStore {
192
192
  scope: cp.scope,
193
193
  toolName: pa?.toolName ?? null,
194
194
  toolCallId,
195
- boundCallId: toolCallId,
196
- boundInputHash: pa?.boundInputHash ?? null,
195
+ // [1995]②: the D-1 binding fields are OMITTED when the park has no tool action (plan_review /
196
+ // resource_limit / task_done) — the wire contract says an absent nullable field is ABSENT, not `null`
197
+ // (a consumer branching on "key present ⇒ there is a binding" would echo `null` back into /decide).
198
+ ...(toolCallId !== null ? { boundCallId: toolCallId } : {}),
199
+ ...(pa?.boundInputHash !== undefined ? { boundInputHash: pa.boundInputHash } : {}),
200
+ // [1995]③ additive: which gate this row is parked on, so a durable-recovery consumer can route
201
+ // plan / tool / resource off the SAME queue (toolName is null for every non-tool gate).
202
+ ...(gate?.kind !== undefined ? { gateKind: gate.kind } : {}),
197
203
  taskId: (await this.opts.getActiveTaskId?.(cp.sessionId)) ?? null,
198
204
  input: boundedToolInput(pa?.args),
199
205
  createdAt: cp.createdAt,
@@ -43,7 +43,8 @@ export interface HostEnvConfig {
43
43
  * reports `supported:false` → INERT (no scheduler tools). Injected at boot only on the single-user host lane. */
44
44
  scheduler?: SchedulerCapability;
45
45
  /** design/103 background shell: `false` → INERT (multi-tenant host lane gate). Default (undefined) = on for the
46
- * single-user TOC host lane. `HOST_BG_ENABLED=false` is an additional global kill switch(旧名 HOST_BG_DISABLED=true 兼容期内仍认)。 */
46
+ * single-user TOC host lane. `HOST_BG_ENABLED=false` is an additional global kill switch(🪦 旧名
47
+ * `HOST_BG_DISABLED` 自 server 3.0.0 起是 fail-loud 墓碑:设了即拒启并指路新名,不再被读取)。 */
47
48
  backgroundShell?: boolean;
48
49
  }
49
50
  export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, SchedulerCapability, BackgroundShellCapability {
@@ -279,22 +280,40 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
279
280
  * `ctx.sessionId` into the workspace dir id so two concurrent tasks never share a working directory.
280
281
  */
281
282
  export declare function hostExecutionEnvFactory(config?: HostEnvConfig): ExecutionEnvFactory;
282
- export { localDockerExecutionEnvFactory, type LocalDockerEnvConfig, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
283
- /** Config for the (not-yet-implemented) `remote-docker` provider: run each task in a container on a REMOTE
284
- * docker daemon (e.g. over a TLS docker endpoint). Fast-follow per DUAL-MODE-DESIGN §5. */
285
- export interface RemoteDockerEnvConfig {
286
- /** Container image to run the task in. */
287
- image: string;
288
- /** Workspace mount path inside the container. */
289
- mountPath?: string;
290
- /** Remote docker daemon endpoint (e.g. tcp://host:2376 or ssh://user@host). Required for remote-docker. */
283
+ export { localDockerExecutionEnvFactory, type LocalDockerEnvConfig, type LocalDockerEnvDeps, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
284
+ import { type LocalDockerEnvConfig, type LocalDockerEnvDeps } from "./remote-env-local-docker.js";
285
+ /**
286
+ * Config for the `remote-docker` provider: run each task in a container on a REMOTE docker daemon
287
+ * (DUAL-MODE-DESIGN §5 "a user-specified REMOTE host's docker"; registry-core `RemoteExecSpec`).
288
+ *
289
+ * It is {@link LocalDockerEnvConfig} with the endpoint made REQUIRED — that is the whole difference, by design:
290
+ * the container adapter is already daemon-agnostic (workspace `mkdir`'d INSIDE the container, never a host
291
+ * bind-mount; bytes over `docker cp` / `docker exec cat`, which stream through the daemon API), and the endpoint
292
+ * rides as `-H <dockerHost>` on every invocation. So every local-docker knob (memory/cpus/pidsLimit/dropAllCaps/
293
+ * network/timeouts/env/dockerPath) applies unchanged to the remote lane.
294
+ */
295
+ export interface RemoteDockerEnvConfig extends Omit<LocalDockerEnvConfig, "dockerHost" | "providerLabel"> {
296
+ /** Remote docker daemon endpoint (e.g. `tcp://host:2376` or `ssh://user@host`). REQUIRED for remote-docker. */
291
297
  dockerHost: string;
292
- /** Out-of-band env injected into the container (secrets resolved by the control plane). */
293
- env?: Record<string, string>;
294
298
  }
295
299
  /**
296
- * `remote-docker` factory entry point wired but an explicit TODO (DUAL-MODE-DESIGN §5 fast-follow). Throws
297
- * at factory-BUILD time so selecting it before it exists fails loud rather than degrading isolation silently.
300
+ * `ExecutionEnvFactory` for the TOC `remote-docker` backend (DUAL-MODE-DESIGN §5): a per-task container on
301
+ * SOMEONE ELSE'S docker daemon (offload the operator's box stays free; isolation:true, suspendable:false).
302
+ * Same lifetime contract as local-docker: unconnected/lazy, one container per task, `destroy()` = `docker rm -f`.
303
+ *
304
+ * 🔴 **Endpoint credentials are the docker CLI's own** — this adapter deliberately carries none. The docker CLI
305
+ * reads `DOCKER_CERT_PATH` + `DOCKER_TLS_VERIFY` from the worker's environment (inherited by every spawn) for a
306
+ * `tcp://` + TLS daemon, and uses `ssh-agent` / `~/.ssh/config` for an `ssh://` one. That keeps key material out
307
+ * of this process's config objects entirely. Consequence to know: `tcp://…:2376` WITHOUT those env vars fails
308
+ * loud at connect (the daemon refuses plaintext) — it does not silently downgrade; a plaintext `tcp://…:2375`
309
+ * daemon is unauthenticated and remains the operator's decision, exactly as in the registry-core schema.
310
+ * (The registry-core `remote-docker` arm additionally models `tlsCertEnv`/`tlsKeyEnv`/`tlsCaEnv`/`sshKeyEnv` =
311
+ * env-NAMEs the control plane resolves to PEM BYTES. Materializing those bytes to short-lived 0600 files and
312
+ * passing `--tlsverify --tlscert/--tlskey/--tlscacert` is the remaining config-surface follow-up, together with
313
+ * the `provider:"remote-docker"` arm on `AppConfig.remoteExec` — see the note on that union in config-types.ts.)
314
+ *
315
+ * Throws at factory-BUILD time on a missing/blank endpoint: a "remote" lane with no endpoint would otherwise
316
+ * quietly become the LOCAL daemon — an isolation-topology surprise, so it fails loud instead.
298
317
  */
299
- export declare function remoteDockerExecutionEnvFactory(_config: RemoteDockerEnvConfig): ExecutionEnvFactory;
318
+ export declare function remoteDockerExecutionEnvFactory(config: RemoteDockerEnvConfig, deps?: LocalDockerEnvDeps): ExecutionEnvFactory;
300
319
  //# sourceMappingURL=remote-env-host.d.ts.map
@@ -28,8 +28,9 @@
28
28
  * (`{ ok:true, exitCode }`), never an error.
29
29
  *
30
30
  * Platforms: macOS + Linux (POSIX shell). Forward-compatible with the `remoteExec` superset: this file also
31
- * scaffolds the `local-docker` / `remote-docker` providers' factory entry points (typed stubs that throw a
32
- * clear "not yet implemented" explicit fast-follow per DUAL-MODE-DESIGN §5; NOT faked green).
31
+ * hosts the `local-docker` / `remote-docker` providers' factory entry points both now IMPLEMENTED by the
32
+ * container adapter in remote-env-local-docker.ts (the two providers differ only in whether the `-H` daemon
33
+ * endpoint is off-box and required), re-exported/wrapped here so the discriminator has one canonical site.
33
34
  */
34
35
  import { spawn } from "node:child_process";
35
36
  import os from "node:os";
@@ -1647,19 +1648,38 @@ export function hostExecutionEnvFactory(config = {}) {
1647
1648
  // ─────────────────────────────── remoteExec superset: docker providers ───────────────────────────────
1648
1649
  //
1649
1650
  // DUAL-MODE-DESIGN §5 defines a `remoteExec.provider` superset. `host` (above) is implemented; `local-docker`
1650
- // is NOW IMPLEMENTED (the isolation lane) it lives in its own module (remote-env-local-docker.ts) and is
1651
- // re-exported here so the discriminator entry point + the historical import site stay stable. `remote-docker`
1652
- // (a REMOTE docker daemon over TLS/SSH) is still an explicit fast-follow stub — it throws a clear "not yet
1653
- // implemented" so selecting it fails loud at wiring time instead of silently degrading isolation. NOT faked green.
1651
+ // (the isolation lane) and `remote-docker` (that same container lane aimed at a REMOTE daemon) are BOTH
1652
+ // implemented by remote-env-local-docker.ts and re-exported / wrapped here so the discriminator entry points +
1653
+ // the historical import sites stay stable.
1654
1654
  // `local-docker` (the isolation lane): real implementation moved to remote-env-local-docker.ts. Re-export the
1655
1655
  // factory + config from here so existing wiring (`import { localDockerExecutionEnvFactory } from "./remote-env-host"`)
1656
1656
  // keeps working and the provider discriminator has one canonical entry point.
1657
1657
  export { localDockerExecutionEnvFactory, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
1658
+ import { localDockerExecutionEnvFactory } from "./remote-env-local-docker.js";
1658
1659
  /**
1659
- * `remote-docker` factory entry point wired but an explicit TODO (DUAL-MODE-DESIGN §5 fast-follow). Throws
1660
- * at factory-BUILD time so selecting it before it exists fails loud rather than degrading isolation silently.
1660
+ * `ExecutionEnvFactory` for the TOC `remote-docker` backend (DUAL-MODE-DESIGN §5): a per-task container on
1661
+ * SOMEONE ELSE'S docker daemon (offload the operator's box stays free; isolation:true, suspendable:false).
1662
+ * Same lifetime contract as local-docker: unconnected/lazy, one container per task, `destroy()` = `docker rm -f`.
1663
+ *
1664
+ * 🔴 **Endpoint credentials are the docker CLI's own** — this adapter deliberately carries none. The docker CLI
1665
+ * reads `DOCKER_CERT_PATH` + `DOCKER_TLS_VERIFY` from the worker's environment (inherited by every spawn) for a
1666
+ * `tcp://` + TLS daemon, and uses `ssh-agent` / `~/.ssh/config` for an `ssh://` one. That keeps key material out
1667
+ * of this process's config objects entirely. Consequence to know: `tcp://…:2376` WITHOUT those env vars fails
1668
+ * loud at connect (the daemon refuses plaintext) — it does not silently downgrade; a plaintext `tcp://…:2375`
1669
+ * daemon is unauthenticated and remains the operator's decision, exactly as in the registry-core schema.
1670
+ * (The registry-core `remote-docker` arm additionally models `tlsCertEnv`/`tlsKeyEnv`/`tlsCaEnv`/`sshKeyEnv` =
1671
+ * env-NAMEs the control plane resolves to PEM BYTES. Materializing those bytes to short-lived 0600 files and
1672
+ * passing `--tlsverify --tlscert/--tlskey/--tlscacert` is the remaining config-surface follow-up, together with
1673
+ * the `provider:"remote-docker"` arm on `AppConfig.remoteExec` — see the note on that union in config-types.ts.)
1674
+ *
1675
+ * Throws at factory-BUILD time on a missing/blank endpoint: a "remote" lane with no endpoint would otherwise
1676
+ * quietly become the LOCAL daemon — an isolation-topology surprise, so it fails loud instead.
1661
1677
  */
1662
- export function remoteDockerExecutionEnvFactory(_config) {
1663
- throw new RemoteExecutionError("unsupported", "remoteExec provider 'remote-docker' is not yet implemented (DUAL-MODE-DESIGN §5 fast-follow) — use 'host', 'local-docker', or 'e2b' until it ships");
1678
+ export function remoteDockerExecutionEnvFactory(config, deps = {}) {
1679
+ const dockerHost = config.dockerHost?.trim();
1680
+ if (!dockerHost) {
1681
+ throw new RemoteExecutionError("connect_failed", "remoteExec provider 'remote-docker' requires a non-empty dockerHost (the remote daemon endpoint, e.g. tcp://host:2376 or ssh://user@host) — use provider 'local-docker' for THIS machine's daemon");
1682
+ }
1683
+ return localDockerExecutionEnvFactory({ ...config, dockerHost, providerLabel: "remote-docker" }, deps);
1664
1684
  }
1665
1685
  //# sourceMappingURL=remote-env-host.js.map
@@ -17,8 +17,20 @@ export interface LocalDockerEnvConfig {
17
17
  /**
18
18
  * DOCKER_HOST override for the docker CLI (e.g. a non-default socket). Passed as `-H <value>` on EVERY docker
19
19
  * invocation (not the process env) so it can't leak / drift. Absent = the daemon DOCKER_HOST points at.
20
+ *
21
+ * Pointing this OFF-BOX (`tcp://host:2376`, `ssh://user@host`) is exactly the DUAL-MODE-DESIGN §5
22
+ * **`remote-docker`** provider — see {@link remoteDockerExecutionEnvFactory} (remote-env-host.ts), which is
23
+ * this adapter with the endpoint required. Nothing else about the class is host-local: the workspace is
24
+ * `mkdir`'d INSIDE the container (never a bind-mount of a host path) and bytes move by `docker cp` /
25
+ * `docker exec cat`, which stream over the daemon API.
20
26
  */
21
27
  dockerHost?: string;
28
+ /**
29
+ * Provider NAME reported on the {@link WorkspaceHandle}. Default `"local-docker"`; the `remote-docker` arm
30
+ * passes `"remote-docker"` so a handle never mislabels which lane it is (the CLASS is shared — `remote-docker`
31
+ * IS this adapter aimed at a remote daemon). Cosmetic/observability only: it selects no behavior.
32
+ */
33
+ providerLabel?: string;
22
34
  /** Container memory limit (docker `--memory`, e.g. "2g"). Absent = no limit. */
23
35
  memory?: string;
24
36
  /** Container CPU limit (docker `--cpus`, e.g. 1.5). Absent = no limit. */
@@ -65,6 +77,8 @@ export declare class RemoteLocalDockerExecutionEnv implements RemoteExecutionEnv
65
77
  private readonly spawnFn;
66
78
  /** Stable container NAME (also our handle id). Created on connect; targeted by every docker exec/cp/rm. */
67
79
  private readonly containerName;
80
+ /** Provider name put on the WorkspaceHandle ("local-docker" | "remote-docker"). Observability only. */
81
+ private readonly providerName;
68
82
  /** Set once the container is up + the workspace dir created. */
69
83
  private containerId?;
70
84
  private handle?;
@@ -66,6 +66,8 @@ export class RemoteLocalDockerExecutionEnv {
66
66
  spawnFn;
67
67
  /** Stable container NAME (also our handle id). Created on connect; targeted by every docker exec/cp/rm. */
68
68
  containerName;
69
+ /** Provider name put on the WorkspaceHandle ("local-docker" | "remote-docker"). Observability only. */
70
+ providerName;
69
71
  /** Set once the container is up + the workspace dir created. */
70
72
  containerId;
71
73
  handle;
@@ -92,6 +94,7 @@ export class RemoteLocalDockerExecutionEnv {
92
94
  const id = config.id ?? randomBytes(6).toString("hex");
93
95
  const suffix = randomBytes(4).toString("hex"); // always-unique tail so a reused id never collides
94
96
  this.containerName = `sema-docker-${sanitizeId(id)}-${suffix}`;
97
+ this.providerName = config.providerLabel?.trim() || PROVIDER;
95
98
  }
96
99
  resolve(p) {
97
100
  return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
@@ -165,7 +168,7 @@ export class RemoteLocalDockerExecutionEnv {
165
168
  return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env destroyed during connect") };
166
169
  }
167
170
  this.containerId = run.value.stdout.trim() || this.containerName;
168
- this.handle = { sandboxId: this.containerName, provider: PROVIDER, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
171
+ this.handle = { sandboxId: this.containerName, provider: this.providerName, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
169
172
  if (config?.secrets?.length) {
170
173
  // Secrets are injected per-command via cfg.env (resolved by the control plane), not at connect — record only.
171
174
  }
@@ -197,7 +200,7 @@ export class RemoteLocalDockerExecutionEnv {
197
200
  // re-run `docker run --name <same>` → name conflict → the failure path rm -f's the very container we just
198
201
  // re-attached to (destroying the running workspace). The name IS our exec/handle target.
199
202
  this.containerId = this.containerName;
200
- this.handle ??= { sandboxId: this.containerName, provider: PROVIDER, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
203
+ this.handle ??= { sandboxId: this.containerName, provider: this.providerName, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
201
204
  return ok(this.handle);
202
205
  }
203
206
  return { ok: false, error: new RemoteExecutionError("connect_failed", `container '${this.containerName}' is not running (gone or stopped) — no managed snapshot to resume`) };
@@ -609,7 +612,11 @@ export class RemoteLocalDockerExecutionEnv {
609
612
  return;
610
613
  this.containerShellProbed = true;
611
614
  try {
612
- const r = await this.docker(this.withHostFlag(["exec", this.containerName, "/bin/sh", "-c", "command -v bash"]), { timeoutMs: this.cfg.controlTimeoutMs });
615
+ // 🔴 NO withHostFlag here — `docker()` already prepends it. A second `-H` is not a harmless repeat: the
616
+ // docker CLI rejects the whole command (`invalid argument … specify only one -H`), so on every
617
+ // REMOTE-daemon container (`dockerHost` set — always, on the `remote-docker` arm) the probe used to fail
618
+ // and silently degrade the user shell to /bin/sh for a reason unrelated to the image.
619
+ const r = await this.docker(["exec", this.containerName, "/bin/sh", "-c", "command -v bash"], { timeoutMs: this.cfg.controlTimeoutMs });
613
620
  if (r.ok && r.value.exitCode === 0 && r.value.stdout.trim().length > 0)
614
621
  this.containerShell = "bash";
615
622
  }
@@ -126,9 +126,14 @@ export declare class SqlRunStore {
126
126
  * suspend, an INTENTIONAL durable pause" (design/76 §2.5) — the service MUST park it the same way, else the run is
127
127
  * terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets
128
128
  * taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review`
129
- * (reaper-revert guard, mirrors setSuspended). 🔴 The store SQL here is exercised by mocked unit tests only — a
130
- * real-TiDB integration test of the park→resume→claim invariant is a required-before-GA TODO (the lesson from the
131
- * P2 lease-SQL + this needs_review-park bug: mocked tests hide store-SQL defects).
129
+ * (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by
130
+ * `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two
131
+ * replicas racing one session exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked
132
+ * claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this
133
+ * needs_review-park bug were both born from store SQL that mocked unit tests pass — measured, not asserted, when
134
+ * the invariant set was built: an upserting (non-exclusive) claim reddened all four real-DB cases while all 111
135
+ * mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
136
+ * or a multi-table reaper DELETE.
132
137
  */
133
138
  setNeedsReview(taskId: string): Promise<void>;
134
139
  /**
@@ -206,9 +206,14 @@ export class SqlRunStore {
206
206
  * suspend, an INTENTIONAL durable pause" (design/76 §2.5) — the service MUST park it the same way, else the run is
207
207
  * terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets
208
208
  * taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review`
209
- * (reaper-revert guard, mirrors setSuspended). 🔴 The store SQL here is exercised by mocked unit tests only — a
210
- * real-TiDB integration test of the park→resume→claim invariant is a required-before-GA TODO (the lesson from the
211
- * P2 lease-SQL + this needs_review-park bug: mocked tests hide store-SQL defects).
209
+ * (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by
210
+ * `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two
211
+ * replicas racing one session exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked
212
+ * claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this
213
+ * needs_review-park bug were both born from store SQL that mocked unit tests pass — measured, not asserted, when
214
+ * the invariant set was built: an upserting (non-exclusive) claim reddened all four real-DB cases while all 111
215
+ * mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
216
+ * or a multi-table reaper DELETE.
212
217
  */
213
218
  async setNeedsReview(taskId) {
214
219
  await this.db.query(this.q("UPDATE task_run SET status = 'needs_review', updated_at = ? WHERE task_id = ? AND status IN ('running','needs_review')", "UPDATE task_run SET status = 'needs_review', updated_at = $1 WHERE task_id = $2 AND status IN ('running','needs_review')"), [new Date(), taskId]);
package/dist/run-local.js CHANGED
@@ -291,7 +291,8 @@ export async function runLocal(argv, deps = {}) {
291
291
  // TOC local LSP (core 1.190): run-local IS the pure-local host lane, so mount core's `NodeLspManager` (local
292
292
  // child_process language servers over stdio, CC `services/lsp` parity) on the host lane. Default ON (degrades
293
293
  // gracefully to grep/read if a server binary is absent); `LSP_HOST_ENABLED=false` opts out (design/158 B4 split
294
- // this lane off `LSP_ENABLED`, which still works as a back-compat opt-out). `requirePrincipal !== true`
294
+ // this lane off `LSP_ENABLED`; 🪦 server 3.0.0 `LSP_ENABLED=false` fail-loud 墓碑,拒启指路新名,
295
+ // 不再是可用的 opt-out)。`requirePrincipal !== true`
295
296
  // keeps it uniform with main.ts (a no-op here — run-local is single-user — but never relies on that). Both runners.
296
297
  const lspManager = hostRx && config.lspHostEnabled && config.requirePrincipal !== true ? new NodeLspManager({ log: (event, fields) => logger.info(event, fields) }) : undefined;
297
298
  // Long-term memory (design/138 S1): run-local IS the single-user lane, so the file-based memory ENGINE is
@@ -10,9 +10,23 @@
10
10
  * (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
11
11
  *
12
12
  * Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
13
- * core brain honors model.baseUrl, absent = "" = boot-env endpoint). TODO remaining: hot-reload
14
- * of models/roles needs Runner support — today teams hot-reload via the registry, while a models/roles
15
- * change is logged as "restart to apply".
13
+ * core brain honors model.baseUrl, absent = "" = boot-env endpoint).
14
+ *
15
+ * Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
16
+ * TODO, which went stale when `mutateInPlace` landed):
17
+ * - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
18
+ * `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
19
+ * reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
20
+ * call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
21
+ * next task with no restart.
22
+ * - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
23
+ * at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
24
+ * - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
25
+ * (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
26
+ * at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
27
+ * restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
28
+ * gateway silently on the rate-limit path.
29
+ * - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
16
30
  *
17
31
  * (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives in
18
32
  * `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig application /
@@ -21,7 +35,7 @@
21
35
  */
22
36
  export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./config-center/http-client.js";
23
37
  export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./config-center/apply-effective.js";
24
- export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSignal, } from "./config-center/restart-signal.js";
38
+ export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type RestartSignal, } from "./config-center/restart-signal.js";
25
39
  export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./config-center/skills-mcp.js";
26
40
  export type { CenterSkillManifest, CenterMcpServer, EffectiveConfig, ExecutionRuling, SessionMirrorRuling, } from "./config-center/types.js";
27
41
  //# sourceMappingURL=sema-registry.d.ts.map
@@ -10,9 +10,23 @@
10
10
  * (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
11
11
  *
12
12
  * Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
13
- * core brain honors model.baseUrl, absent = "" = boot-env endpoint). TODO remaining: hot-reload
14
- * of models/roles needs Runner support — today teams hot-reload via the registry, while a models/roles
15
- * change is logged as "restart to apply".
13
+ * core brain honors model.baseUrl, absent = "" = boot-env endpoint).
14
+ *
15
+ * Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
16
+ * TODO, which went stale when `mutateInPlace` landed):
17
+ * - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
18
+ * `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
19
+ * reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
20
+ * call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
21
+ * next task with no restart.
22
+ * - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
23
+ * at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
24
+ * - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
25
+ * (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
26
+ * at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
27
+ * restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
28
+ * gateway silently on the rate-limit path.
29
+ * - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
16
30
  *
17
31
  * (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives in
18
32
  * `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig application /
@@ -29,6 +29,7 @@ export interface SessionTitlerDeps {
29
29
  llm(opts: {
30
30
  prompt: string;
31
31
  timeoutMs: number;
32
+ model?: string;
32
33
  }): Promise<{
33
34
  ok: true;
34
35
  text: string;
@@ -47,7 +48,8 @@ export interface SessionTitlerDeps {
47
48
  }
48
49
  export interface SessionTitler {
49
50
  /** Fire-and-forget: maybe generate + persist a title for this session. Never throws, never blocks the caller. */
50
- maybeTitle(sessionId: string, objective: string): void;
51
+ /** `model`([1992]②)= turn 真实驱动的模型——带上则标题 hook 同链路,不再裸落 env 缺省。 */
52
+ maybeTitle(sessionId: string, objective: string, model?: string): void;
51
53
  }
52
54
  export declare const TITLE_MAX_CHARS = 80;
53
55
  /** Single line, control chars stripped, hard cap; surrounding quotes (a common LLM tic) trimmed. */
@@ -65,7 +65,7 @@ export function createSessionTitler(deps) {
65
65
  deps.logger?.warn?.("session_title_failed", { ...meta, note: "同 cause 后续降为 debug 级" });
66
66
  };
67
67
  return {
68
- maybeTitle(sessionId, objective) {
68
+ maybeTitle(sessionId, objective, model) {
69
69
  if (!sessionId || !objective.trim())
70
70
  return;
71
71
  if (seen.has(sessionId))
@@ -93,7 +93,7 @@ export function createSessionTitler(deps) {
93
93
  deps.metrics?.inc("session_title_total", { outcome: "already_titled" });
94
94
  return;
95
95
  }
96
- const r = await deps.llm({ prompt: buildTitlePrompt(objective), timeoutMs: LLM_TIMEOUT_MS });
96
+ const r = await deps.llm({ prompt: buildTitlePrompt(objective), timeoutMs: LLM_TIMEOUT_MS, model });
97
97
  if (!r.ok) {
98
98
  deps.metrics?.inc("session_title_total", { outcome: "llm_failed" });
99
99
  logFailure(sessionId, r.error);
@@ -510,7 +510,10 @@ export function mapTraceEvent(type, seq, data) {
510
510
  case "failed":
511
511
  // BL-13: redact at the read boundary too (defense-in-depth) — covers any errorMessage that reached the
512
512
  // log un-redacted via another write path; idempotent on the BL-12-redacted catch-path message.
513
- return { event: "error", data: { code: "WORKER_DOWN", message: redactSecrets(String(data.errorMessage ?? "run failed")) } };
513
+ // A1/A6/B(复审 2026-07-29):error 帧统一为 `{ type:"error", errorCode, message }` —— sse-log /
514
+ // approvals 的帧同形。这一处是 3.0.0 摘 legacy `code` 键时的漏网:帧在 src/trace/ 铸,而当时的门只扫
515
+ // src/http/,于是"SSE error 帧机器键统一为 errorCode"的公开声明与 wire 上跑的东西不一致(门是绿的)。
516
+ return { event: "error", data: { type: "error", errorCode: "WORKER_DOWN", message: redactSecrets(String(data.errorMessage ?? "run failed")) } };
514
517
  default:
515
518
  return null; // compacted etc. — not a client trace event
516
519
  }