@sema-agent/server 2.0.1 → 3.0.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 (38) 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 +391 -0
  5. package/dist/boot/config-center.js +7 -0
  6. package/dist/capabilities/scenarios.d.ts +5 -3
  7. package/dist/capabilities/scenarios.js +5 -3
  8. package/dist/config-center/apply-effective.js +4 -3
  9. package/dist/config-lkg.d.ts +2 -1
  10. package/dist/config-lkg.js +2 -1
  11. package/dist/config-types.d.ts +7 -5
  12. package/dist/config.d.ts +16 -11
  13. package/dist/config.js +30 -33
  14. package/dist/hooks/hook-llm.js +9 -0
  15. package/dist/http/routes/approvals-assistant.js +1 -1
  16. package/dist/http/routes/attachments.js +2 -2
  17. package/dist/http/routes/memory-policy.js +3 -3
  18. package/dist/http/routes/runs.js +1 -1
  19. package/dist/http/routes/session-sync.js +2 -2
  20. package/dist/http/routes/sessions.js +2 -2
  21. package/dist/http/routes/tasks.js +2 -2
  22. package/dist/http/routes/trace-usage.js +2 -2
  23. package/dist/http/routes/workflows.js +3 -1
  24. package/dist/http/server.d.ts +1 -1
  25. package/dist/http/server.js +25 -5
  26. package/dist/http/sse-log.js +1 -1
  27. package/dist/main.js +1 -1
  28. package/dist/model-select.d.ts +1 -1
  29. package/dist/model-select.js +1 -1
  30. package/dist/plugins/checkpoint-store-sql.d.ts +13 -5
  31. package/dist/plugins/checkpoint-store-sql.js +10 -3
  32. package/dist/plugins/local-checkpoint-store.js +8 -2
  33. package/dist/plugins/remote-env-host.d.ts +2 -1
  34. package/dist/run-local.js +2 -1
  35. package/dist/session-titler.d.ts +3 -1
  36. package/dist/session-titler.js +2 -2
  37. package/dist/trace/project.js +4 -1
  38. package/package.json +5 -3
@@ -34,7 +34,7 @@ export interface ImageBakeConfig {
34
34
  * 2026-07-13, SWR 弃用): `docker.io/claybobby`(海外主源;CN 兜底=内网 Gitea registry)。 */
35
35
  registry: string | null;
36
36
  /** The build-host promoted fetch-once cache base injected as `--cache-base` (NEVER caller-settable — a foreign
37
- * origin would defeat the domestic-only iron rule). `BAKE_CACHE_BASE` (e.g. http://172.17.0.1:8099). */
37
+ * origin would defeat the domestic-only iron rule). `BAKE_CACHE_BASE` (e.g. `http://<build-host>:<port>`). */
38
38
  cacheBase: string | null;
39
39
  /** The pinned current sandbox-base ref injected as `--base-ref` when the caller omits `baseRef` (§P2.4c default).
40
40
  * When NULL the server falls back to the index's latest published `sandbox-base` digest (design §P2.4c
@@ -575,8 +575,9 @@ export interface ServiceConfigFlat {
575
575
  /** design/113 C4: host-lane project-memory injection (CLAUDE.md + git narrative). Default **ON** (subject to the
576
576
  * host-lane + single-user gate), so "open like CC" works without config. `PROJECT_MEMORY_ENABLED=false` opts out.
577
577
  * design/158 B4 (lens4 N8): this was `projectMemoryDisabled` — the ONE negative boolean on a ServiceConfig whose
578
- * other 16 flags are positive `*Enabled`/`*Allowed`, so every read site had to invert twice. The retired env name
579
- * `PROJECT_MEMORY_DISABLED` still works for one deprecation window (inverted, with a boot notice). */
578
+ * other 16 flags are positive `*Enabled`/`*Allowed`, so every read site had to invert twice. 🪦 server 3.0.0:
579
+ * the retired env name `PROJECT_MEMORY_DISABLED` is a **fail-loud tombstone** setting it (any value) refuses
580
+ * boot with a message naming this knob; it is no longer read. */
580
581
  projectMemoryEnabled: boolean;
581
582
  /** R7: override the shared scheduler store path. Default `SEMA_CONFIG_DIR`→`~/.sema/scheduled_tasks.json` (the
582
583
  * server writer + the shell daemon reader MUST agree). `SCHEDULER_STORE_PATH`. */
@@ -689,8 +690,9 @@ export interface ServiceConfigFlat {
689
690
  * ON for the host lane (CC-parity); set `LSP_HOST_ENABLED=false` to opt out. (`REMOTE_EXEC=host` only — ssh/adb/
690
691
  * local-docker can't reach a local child process to the right filesystem.)
691
692
  * design/158 B4 (lens4 N7): this lane used to share `LSP_ENABLED` with `lspEnabled` above — one env name driving
692
- * two OPPOSITE defaults. It now has its own knob; `LSP_ENABLED=false` remains a back-compat host opt-out (with a
693
- * boot notice naming the replacement) and `LSP_HOST_ENABLED` wins whenever it is set. */
693
+ * two OPPOSITE defaults. It now has its own knob. 🪦 server 3.0.0: `LSP_ENABLED=false` (the pre-split host
694
+ * opt-out) is a **fail-loud tombstone** it refuses boot naming `LSP_HOST_ENABLED`; `LSP_ENABLED=true` keeps
695
+ * its own meaning (the sandbox lane's opt-in above) and is unaffected. */
694
696
  lspHostEnabled: boolean;
695
697
  /** Graceful drain: how long SIGTERM waits for in-flight turns (live streams + bg/resume legs on this
696
698
  * instance) to finish before the hard shutdown. During drain, new billable submissions get 503+Retry-After and
package/dist/config.d.ts CHANGED
@@ -43,33 +43,38 @@ export declare function logConfigDiagnostics(logger: {
43
43
  * Note the deliberate non-migrations: the posture family (`postureOn`) keeps its tri-state — its unset case is
44
44
  * neither `true` nor `false` but "single-user ∧ infra-ready" — and the sentinel-valued knobs (`MEMORY_ENGINE`,
45
45
  * `SANDBOX_PKG_SOURCE`, `MANUAL_MODE_SHELL_GATE`) are enums wearing a boolean's clothes, not booleans. */
46
- type ConfigKnobSource = "env" | "legacy-env" | "posture" | "default";
46
+ /** 复审 2026-07-29 B4:3.0.0 把负名 env 改成 fail-loud 墓碑之后,legacy-env 这个 source 取值**永不可达**——
47
+ * 留着它等于在类型上书面承诺"还有一条 legacy 取值路径"。同批摘掉的还有 `ConfigKnobRecord.legacyName`
48
+ * 与它在 boot 诊断行里的输出:运维在日志里看到 `legacyName: HOST_BG_DISABLED` 的合理结论是"旧名还能
49
+ * 用",而实际设了就拒启——诊断面撒的谎比没有诊断更贵(它会让人先去试那个必然拒启的开关)。 */
50
+ type ConfigKnobSource = "env" | "posture" | "default";
47
51
  export interface ConfigKnobRecord {
48
- /** The CANONICAL env name (the positive one a legacy alias appears in `legacyName`, never here). */
52
+ /** The env name. 3.0.0 起只有这一个名字 —— 负名别名已是墓碑(见 `boolEnvWithLegacyNegated`) */
49
53
  name: string;
50
54
  /** `opt-in` = default OFF, `opt-out` = default ON, `posture` = default derived from REQUIRE_PRINCIPAL ∧ infra. */
51
55
  polarity: "opt-in" | "opt-out" | "posture";
52
56
  value: boolean;
53
57
  source: ConfigKnobSource;
54
- /** Set when the value came from (or the knob still accepts) a deprecated negative-form alias. */
55
- legacyName?: string;
56
58
  }
57
59
  /** The registered boolean knobs, sorted by name — the data behind `logConfigDiagnostics`'s polarity table. */
58
60
  export declare function configKnobTable(): ConfigKnobRecord[];
59
- /** design/158 N8 (D-family) a knob whose CANONICAL name is positive (`X_ENABLED`, default ON) but which keeps
60
- * its retired negative name (`X_DISABLED`) working for one deprecation window.
61
+ /** design/158 N8 (D-family) + server 3.0.0 墓碑 —— a knob whose CANONICAL name is positive (`X_ENABLED`,
62
+ * default ON) and whose retired negative name (`X_DISABLED`) is now a **fail-loud tombstone**.
61
63
  *
62
64
  * Why the flip at all: `X_DISABLED !== "true"` is a double negative at the read site, so the four knobs that
63
65
  * used it (`PROJECT_MEMORY`, `CONFIG_LKG`, `HOST_BG`, `HOST_EXEC_SPOOL`) could not be read for their default
64
66
  * without inverting twice in your head, and `projectMemoryDisabled` was the ONE negative boolean on a
65
67
  * `ServiceConfig` that otherwise carries 16 positive `*Enabled`/`*Allowed` fields.
66
68
  *
67
- * Resolution order (new name wins, so there is never an ambiguous both-set state):
69
+ * 🔴 3.0.0([1989],clay 令「不做兼容」):旧名不再有兼容窗——**设了就拒启**。这个函数保留 legacy 形参
70
+ * 不是为了继续读它,而正是为了**认出它在场并拒绝启动**(把墓碑放在唯一知道这对名字关系的地方)。
71
+ *
72
+ * Resolution order:
68
73
  * 1. `X_ENABLED` set to a literal ⇒ that.
69
- * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the legacy name is NOT consulted — a typo in the
70
- * new name must not silently hand control back to the deprecated one).
71
- * 3. `X_ENABLED` unset ∧ `X_DISABLED` set ⇒ the legacy site's exact contract, `X_DISABLED !== "true"`, plus a
72
- * one-shot deprecation notice naming the replacement.
74
+ * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the tombstone is NOT consulted — a typo in the
75
+ * new name must not turn into a boot refusal about an env the operator never set).
76
+ * 3. `X_ENABLED` unset ∧ `X_DISABLED` set(**任何值**,含 `"false"`)⇒ throw,文案指路新名。选拒启不选
77
+ * 静默忽略:静默会让升级部署的旧开关名义在、实际归默认——比拒启危险,且下游冒烟测试测不出来。
73
78
  * 4. neither ⇒ default. */
74
79
  export declare function boolEnvWithLegacyNegated(name: string, legacyNegatedName: string, def: boolean): boolean;
75
80
  /** LKG (last-known-good) persistence of the remote effective-config fetch. Read LAZILY at both main.ts sites
package/dist/config.js CHANGED
@@ -132,7 +132,6 @@ export function logConfigDiagnostics(logger) {
132
132
  polarity: k.polarity,
133
133
  value: k.value,
134
134
  source: k.source,
135
- ...(k.legacyName ? { legacyName: k.legacyName } : {}),
136
135
  });
137
136
  }
138
137
  }
@@ -164,9 +163,6 @@ function optFinitePositiveEnv(name) {
164
163
  /** Keyed by canonical name so a knob read twice in one load (e.g. DURABLE_APPROVAL, once for the D-G boot
165
164
  * invariant and once for the returned field) registers one row, not two. Reset per `loadConfig()`. */
166
165
  const CONFIG_KNOBS = new Map();
167
- /** Deprecated aliases already announced for this load — the lazily-read twins (HOST_BG/HOST_EXEC_SPOOL) resolve
168
- * again on every use, and an operator does not need the same deprecation line once per command. */
169
- const KNOB_DEPRECATIONS_SEEN = new Set();
170
166
  /** The registered boolean knobs, sorted by name — the data behind `logConfigDiagnostics`'s polarity table. */
171
167
  export function configKnobTable() {
172
168
  return [...CONFIG_KNOBS.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -199,20 +195,23 @@ function boolEnv(name, def) {
199
195
  registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source });
200
196
  return value;
201
197
  }
202
- /** design/158 N8 (D-family) a knob whose CANONICAL name is positive (`X_ENABLED`, default ON) but which keeps
203
- * its retired negative name (`X_DISABLED`) working for one deprecation window.
198
+ /** design/158 N8 (D-family) + server 3.0.0 墓碑 —— a knob whose CANONICAL name is positive (`X_ENABLED`,
199
+ * default ON) and whose retired negative name (`X_DISABLED`) is now a **fail-loud tombstone**.
204
200
  *
205
201
  * Why the flip at all: `X_DISABLED !== "true"` is a double negative at the read site, so the four knobs that
206
202
  * used it (`PROJECT_MEMORY`, `CONFIG_LKG`, `HOST_BG`, `HOST_EXEC_SPOOL`) could not be read for their default
207
203
  * without inverting twice in your head, and `projectMemoryDisabled` was the ONE negative boolean on a
208
204
  * `ServiceConfig` that otherwise carries 16 positive `*Enabled`/`*Allowed` fields.
209
205
  *
210
- * Resolution order (new name wins, so there is never an ambiguous both-set state):
206
+ * 🔴 3.0.0([1989],clay 令「不做兼容」):旧名不再有兼容窗——**设了就拒启**。这个函数保留 legacy 形参
207
+ * 不是为了继续读它,而正是为了**认出它在场并拒绝启动**(把墓碑放在唯一知道这对名字关系的地方)。
208
+ *
209
+ * Resolution order:
211
210
  * 1. `X_ENABLED` set to a literal ⇒ that.
212
- * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the legacy name is NOT consulted — a typo in the
213
- * new name must not silently hand control back to the deprecated one).
214
- * 3. `X_ENABLED` unset ∧ `X_DISABLED` set ⇒ the legacy site's exact contract, `X_DISABLED !== "true"`, plus a
215
- * one-shot deprecation notice naming the replacement.
211
+ * 2. `X_ENABLED` set to a non-literal ⇒ default + warning (the tombstone is NOT consulted — a typo in the
212
+ * new name must not turn into a boot refusal about an env the operator never set).
213
+ * 3. `X_ENABLED` unset ∧ `X_DISABLED` set(**任何值**,含 `"false"`)⇒ throw,文案指路新名。选拒启不选
214
+ * 静默忽略:静默会让升级部署的旧开关名义在、实际归默认——比拒启危险,且下游冒烟测试测不出来。
216
215
  * 4. neither ⇒ default. */
217
216
  export function boolEnvWithLegacyNegated(name, legacyNegatedName, def) {
218
217
  const raw = process.env[name];
@@ -234,21 +233,17 @@ export function boolEnvWithLegacyNegated(name, legacyNegatedName, def) {
234
233
  source = "default";
235
234
  }
236
235
  else if (legacy !== undefined && legacy !== "") {
237
- value = legacy !== "true"; // verbatim legacy contract: only the literal "true" disabled the feature
238
- source = "legacy-env";
239
- if (!KNOB_DEPRECATIONS_SEEN.has(legacyNegatedName)) {
240
- KNOB_DEPRECATIONS_SEEN.add(legacyNegatedName);
241
- CONFIG_NOTICES.push({
242
- event: "config_env_deprecated_negated_name",
243
- fields: { deprecated: legacyNegatedName, replacement: name, effective: String(value) },
244
- });
245
- }
236
+ // server 3.0.0([1989],clay 令「不做兼容」):负名旧 env = fail-loud 墓碑。设了(任何值,含
237
+ // "false"——设它的人同样以为它还生效)即拒启并指路新名。选拒启不选静默忽略:静默会让升级部署的
238
+ // 旧开关名义在、实际归默认——比拒启危险,而且下游/运维的冒烟测试测不出来。
239
+ throw new Error(`${legacyNegatedName} was removed in server 3.0.0 — set ${name}=${legacy !== "true"} instead ` +
240
+ `(positive-name knob, default ${def ? "on" : "off"}; migration table in USAGE.md)`);
246
241
  }
247
242
  else {
248
243
  value = def;
249
244
  source = "default";
250
245
  }
251
- registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source, legacyName: legacyNegatedName });
246
+ registerKnob({ name, polarity: def ? "opt-out" : "opt-in", value, source });
252
247
  return value;
253
248
  }
254
249
  /** LKG (last-known-good) persistence of the remote effective-config fetch. Read LAZILY at both main.ts sites
@@ -474,7 +469,15 @@ function parseStoreDomain(ctx) {
474
469
  function parseModelDomain() {
475
470
  // npm 卫生纪律:默认值不烤内网坐标 — localhost 占位(公开分发正确缺省);真网关一律显式 env。
476
471
  const gatewayBaseUrl = env("MODEL_GATEWAY_BASEURL", "http://127.0.0.1:8000/v1");
477
- const modelId = env("MODEL_ID", "Qwen3.5-35B");
472
+ // server 3.0.0([1992] 真机 400 案根治):MODEL_ID **无出厂缺省**。旧缺省是一个只在开发环境存在的
473
+ // 模型名,对任何外部部署都必炸——且炸在离根因最远的地方(gateway 400 + title hook 连环告警),
474
+ // 用户看不出是没配模型。fail-loud at boot 指路配置,与 env 墓碑同哲学:让问题在最早、最清楚处现形。
475
+ const modelId = env("MODEL_ID", "");
476
+ if (modelId === "") {
477
+ throw new Error("MODEL_ID is required (no factory default since server 3.0.0) — set MODEL_ID=<model name your gateway serves>, " +
478
+ "or configure models via the sema-registry catalog; the old silent default was an internal-only model name " +
479
+ "that made every external deployment fail far from the cause (gateway 400)");
480
+ }
478
481
  // Model.api must reflect the brain that actually serves it (routing is by provider): an "anthropic"
479
482
  // provider runs the Anthropic brain, whose usage reports `input` EXCLUDING cached tokens. core ≥1.22
480
483
  // normalizes the cache-hit-rate denominator per api family — a wrong api makes promptTokens too small
@@ -915,16 +918,11 @@ function parseOrchestrationDomain(ctx) {
915
918
  // `LSP_ENABLED=false` keeps working as a host opt-out (it was the only one operators ever had) with a notice
916
919
  // naming the replacement, and `LSP_HOST_ENABLED` always wins when set.
917
920
  const lspHostEnabled = (() => {
918
- const explicitHost = process.env.LSP_HOST_ENABLED;
919
- if (explicitHost === undefined || explicitHost === "") {
920
- if (process.env.LSP_ENABLED === "false") {
921
- CONFIG_NOTICES.push({
922
- event: "config_env_deprecated_lane_alias",
923
- fields: { deprecated: "LSP_ENABLED=false", replacement: "LSP_HOST_ENABLED=false", lane: "host", effective: "false" },
924
- });
925
- registerKnob({ name: "LSP_HOST_ENABLED", polarity: "opt-out", value: false, source: "legacy-env", legacyName: "LSP_ENABLED" });
926
- return false;
927
- }
921
+ // server 3.0.0([1989],不做兼容):`LSP_ENABLED=false` 的 host 腿别名形 = fail-loud 墓碑。
922
+ // `LSP_ENABLED=true`(沙箱腿 opt-in 正名)不受影响——墓碑只咬「用沙箱名关 host 腿」的旧姿势。
923
+ if (process.env.LSP_ENABLED === "false") {
924
+ throw new Error("LSP_ENABLED=false no longer drives the HOST lane (removed in server 3.0.0) — set LSP_HOST_ENABLED=false " +
925
+ "for the host lane; LSP_ENABLED remains the SANDBOX-lane opt-in only (migration table in USAGE.md)");
928
926
  }
929
927
  return boolEnv("LSP_HOST_ENABLED", true);
930
928
  })();
@@ -1302,7 +1300,6 @@ export function loadConfig() {
1302
1300
  CONFIG_WARNINGS.length = 0; // repeated loadConfig() calls (test setup) must not accumulate stale warnings
1303
1301
  CONFIG_NOTICES.length = 0;
1304
1302
  CONFIG_KNOBS.clear(); // the polarity table describes THIS load, never a previous one's env
1305
- KNOB_DEPRECATIONS_SEEN.clear();
1306
1303
  // Posture gate: single-user turnkey (`REQUIRE_PRINCIPAL !== "true"` = one trusted super-admin on their own
1307
1304
  // box/tenant, CC's trust model) ⇒ the CC capability set defaults ON, each composed with its infra prereq; multi-tenant
1308
1305
  // (requirePrincipal) stays opt-in (gated + secured). This is the SAME discriminator LSP's host-default-ON uses,
@@ -1,3 +1,4 @@
1
+ import { matchCatalogModel } from "../model-select.js";
1
2
  export function createHookLlm(deps) {
2
3
  const { config, metrics } = deps;
3
4
  // anthropic 路由自己的 baseUrl(与 core createAnthropicBrain 同一解析链:model.baseUrl || config.baseUrl
@@ -40,6 +41,14 @@ export function createHookLlm(deps) {
40
41
  }
41
42
  if (config.models[requested])
42
43
  return shape(requested);
44
+ // 复审 2026-07-29 D1:目录键是 **name** 形,而调用点会传 **provider id** 形——`http/routes/tasks.ts`
45
+ // 的 titler 腿([1992]②「标题跟 turn」)对对象形 spec 传的就是 `spec.model.id`。registry 热应用后
46
+ // name 与 id 常态分叉(目录键是运维起的短名,`Model.id` 是 provider 侧的真名),单键索引下这类请求全落到下面那条
47
+ // 「不在目录里」⇒ 用户每选一次非主模型,标题就 llm_failed(对话本身正常),与 [1992] 同族的病形。
48
+ // 双键索引沿用同仓先例 [865]② `matchCatalogModel`:name 优先、id 扫目录值、跳过 `default` 别名键。
49
+ const byId = matchCatalogModel(requested, config.models);
50
+ if (byId !== undefined)
51
+ return shape(byId);
43
52
  if (requested === config.model.id)
44
53
  return shapeFrom(requested, config.model, requested);
45
54
  return { ok: false, error: `hook model "${requested}" is not in this deployment's catalog` };
@@ -746,7 +746,7 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
746
746
  }
747
747
  prev = cur;
748
748
  if (Date.now() - start > MAX_MS) {
749
- res.write(`event: error\ndata: ${JSON.stringify({ type: "error", code: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
749
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
750
750
  break;
751
751
  }
752
752
  if (Date.now() - lastBeat > 15_000) {
@@ -61,7 +61,7 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
61
61
  }
62
62
  const allow = deps.config.attachmentMimeAllowlist;
63
63
  if (allow && !allow.some((a) => a === mime || (a.endsWith("/*") && mime.startsWith(a.slice(0, -1))))) {
64
- sendError(res, 415, "attachment.mime_not_allowed", `mime '${mime}' is not in this deployment's allowlist`, { code: "attachment.mime_not_allowed", allowed: allow });
64
+ sendError(res, 415, "attachment.mime_not_allowed", `mime '${mime}' is not in this deployment's allowlist`, { allowed: allow });
65
65
  return;
66
66
  }
67
67
  const max = deps.config.attachmentMaxBytes ?? 32 * 1024 * 1024;
@@ -71,7 +71,7 @@ async function handleAttachmentsBody(req, res, url, ctx, miss) {
71
71
  }
72
72
  catch (err) {
73
73
  if (err instanceof HttpError && err.status === 413) {
74
- sendError(res, 413, "attachment.too_large", `attachment exceeds the per-file limit (${max} bytes)`, { code: "attachment.too_large", maxBytes: max });
74
+ sendError(res, 413, "attachment.too_large", `attachment exceeds the per-file limit (${max} bytes)`, { maxBytes: max });
75
75
  return;
76
76
  }
77
77
  throw err;
@@ -76,11 +76,11 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
76
76
  scope = decodeURIComponent(url.slice("/v1/memory/sync/".length));
77
77
  }
78
78
  catch {
79
- sendError(res, 400, "memory_sync_invalid_scope", "malformed percent-encoding in :scope", { code: "memory_sync_invalid_scope" });
79
+ sendError(res, 400, "memory_sync_invalid_scope", "malformed percent-encoding in :scope");
80
80
  return;
81
81
  }
82
82
  if (scope.length === 0) {
83
- sendError(res, 400, "memory_sync_invalid_scope", "missing :scope", { code: "memory_sync_invalid_scope" });
83
+ sendError(res, 400, "memory_sync_invalid_scope", "missing :scope");
84
84
  return;
85
85
  }
86
86
  if (scope !== formatUserScope(principal) && !explicitOperatorOk(principal, deps.config.operatorPrincipals)) {
@@ -90,7 +90,7 @@ async function handleMemoryPolicyBody(req, res, url, ctx, miss) {
90
90
  const body = await readJson(req); // 413/400 (oversize / bad JSON) via the typed top-level catch — auth already done
91
91
  const parsed = parseMemorySyncRequest(body, scope);
92
92
  if (!parsed.ok) {
93
- sendError(res, 422, "memory_sync_invalid_body", parsed.error, { code: "memory_sync_invalid_body" });
93
+ sendError(res, 422, "memory_sync_invalid_body", parsed.error);
94
94
  return;
95
95
  }
96
96
  try {
@@ -154,7 +154,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
154
154
  const taskId = clientTaskId ?? uuidv7();
155
155
  const created = await runStore.createRun(taskId, sessionId, prepared.auth?.principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
156
156
  if (created.ok)
157
- deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective); // fire-and-forget session auto-title
157
+ deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model turn([1992]②)
158
158
  if (!created.ok) {
159
159
  // race twin: two concurrent same-taskId submits (gateway retry racing the original) — the loser's
160
160
  // session claim collides with ITS OWN taskId → idempotent 202 replay, not a 409 (the run exists, once).
@@ -234,7 +234,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
234
234
  if (deps.snapshotBlobSqlCapBytes !== undefined && body.byteLength > deps.snapshotBlobSqlCapBytes) {
235
235
  // 复审 D-F1/B-F2:归一漏网(多行体,旧门正则扫不到)——A6 迁移到 sendError 后此站点由
236
236
  // errorCode 必填位置参数保结构(不再靠源级反射门兜底)。
237
- sendError(res, 413, "blob_too_large_for_sql", `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes — TiDB's txn-entry-size-limit is the low wall at 6MiB by default; the mysql-protocol packet limit sits above it) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs, or raise SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment lifted those limits`, { code: "blob_too_large_for_sql" });
237
+ sendError(res, 413, "blob_too_large_for_sql", `blob of ${body.byteLength} bytes exceeds this deployment's SQL snapshot-blob cap (${deps.snapshotBlobSqlCapBytes} bytes — TiDB's txn-entry-size-limit is the low wall at 6MiB by default; the mysql-protocol packet limit sits above it) — configure MinIO object storage (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY) for large snapshot blobs, or raise SNAPSHOT_BLOB_SQL_MAX_BYTES if your deployment lifted those limits`);
238
238
  return;
239
239
  }
240
240
  if (createHash("sha256").update(body).digest("hex") !== hash) {
@@ -246,7 +246,7 @@ async function handleSessionSyncBody(req, res, url, ctx, miss) {
246
246
  // blob as durably stored when it isn't.
247
247
  const put = await fs.putBlob(hash, new Uint8Array(body));
248
248
  if (!put.ok) {
249
- sendError(res, 502, put.error.code, "blob store write failed", { code: put.error.code });
249
+ sendError(res, 502, put.error.code, "blob store write failed");
250
250
  return;
251
251
  }
252
252
  res.writeHead(204).end(); // no body
@@ -796,7 +796,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
796
796
  if (typeof fs.blobSizes === "function") {
797
797
  const known = (await fs.blobSizes([hash])).get(hash);
798
798
  if (known !== undefined && known > cap) {
799
- sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: known, limit: cap });
799
+ sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { sizeBytes: known, limit: cap });
800
800
  return;
801
801
  }
802
802
  }
@@ -806,7 +806,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
806
806
  return;
807
807
  }
808
808
  if (bytes.byteLength > cap) {
809
- sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { code: "workspace_file_too_large", sizeBytes: bytes.byteLength, limit: cap });
809
+ sendError(res, 413, "workspace_file_too_large", "file exceeds the single-file read limit — download it via the archive endpoint", { sizeBytes: bytes.byteLength, limit: cap });
810
810
  return;
811
811
  }
812
812
  // [1894]③:content-type 按扩展名(保守表,未知=octet-stream)+ 二进制判别头(首 8KiB NUL/控制
@@ -214,7 +214,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
214
214
  const tid = earlyDurableTid ?? uuidv7(); // reuse the id already emitted as the X-Task-Id header (defensive mint if absent)
215
215
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
216
216
  if (created.ok)
217
- deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective); // fire-and-forget; in-titler dedupe
217
+ deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget; in-titler dedupe;model 跟 turn([1992]②)
218
218
  if (!created.ok) {
219
219
  const conflict = { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId };
220
220
  // Headers are already SSE — encode the rejection as the stream's terminal event; the 409 in
@@ -966,7 +966,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
966
966
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
967
967
  if (!created.ok)
968
968
  return { status: 409, body: { error: "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)", errorCode: "conflict.session_active_run", activeTaskId: created.activeTaskId } };
969
- deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective); // fire-and-forget session auto-title
969
+ deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model turn([1992]②)
970
970
  durableTaskId = tid;
971
971
  }
972
972
  if (deps.checkpointStore && prepared.spec.sessionId) {
@@ -269,13 +269,13 @@ async function streamTaskTrace(req, res, runStore, taskId, staleMs) {
269
269
  }
270
270
  const stale = Date.now() - new Date(run.updatedAt).getTime() > staleMs;
271
271
  if (stale && events.length === 0) {
272
- res.write(`event: error\ndata: ${JSON.stringify({ code: "WORKER_DOWN", message: "run stalled (instance lost?)" })}\n\n`);
272
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "WORKER_DOWN", message: "run stalled (instance lost?)" })}\n\n`);
273
273
  break;
274
274
  }
275
275
  if (Date.now() - start > MAX_MS) {
276
276
  // BL-16: the stream hit its 15-min cap but the RUN is still going — emit a terminal event so the client
277
277
  // can tell "reconnect to continue" from "task completed" (a silent close looked like completion).
278
- res.write(`event: error\ndata: ${JSON.stringify({ code: "STREAM_MAX_DURATION", message: "trace stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
278
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "trace stream reached its 15-minute cap — reconnect with Last-Event-ID to continue (the run is still active)" })}\n\n`);
279
279
  break;
280
280
  }
281
281
  if (events.length === 0) {
@@ -485,8 +485,10 @@ async function streamWorkflowRun(req, res, runId, scope) {
485
485
  }
486
486
  }
487
487
  catch {
488
+ // A1/A6/B:归一 error 帧形(`type` + 机器码)。此前只发人话 message ⇒ 消费端要么锚文案、要么把
489
+ // "流断了"和"流正常结束"混为一谈;`workflow.stream_error` 是本帧的铸码(附录 A `*.stream_error` 族)。
488
490
  if (!closed && !res.writableEnded)
489
- res.write(`event: error\ndata: ${JSON.stringify({ message: "workflow stream error" })}\n\n`);
491
+ res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "workflow.stream_error", message: "workflow stream error" })}\n\n`);
490
492
  }
491
493
  finally {
492
494
  clearInterval(hb);
@@ -197,7 +197,7 @@ export interface ServiceCoordinatorDeps {
197
197
  leaderEndpoint?: LeaderEndpoint;
198
198
  /** Fire-and-forget session auto-titler (first-submit hook; absent = feature dark). */
199
199
  sessionTitler?: {
200
- maybeTitle(sessionId: string, objective: string): void;
200
+ maybeTitle(sessionId: string, objective: string, model?: string): void;
201
201
  };
202
202
  }
203
203
  /** **函数/构件缝**:main.ts 把跨多个子系统的动作编译成一个可调用面交给路由(路由不该自己
@@ -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,