@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.
- package/MIGRATION.md +74 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +391 -0
- package/dist/boot/config-center.js +7 -0
- package/dist/capabilities/scenarios.d.ts +5 -3
- package/dist/capabilities/scenarios.js +5 -3
- package/dist/config-center/apply-effective.js +4 -3
- package/dist/config-lkg.d.ts +2 -1
- package/dist/config-lkg.js +2 -1
- package/dist/config-types.d.ts +7 -5
- package/dist/config.d.ts +16 -11
- package/dist/config.js +30 -33
- package/dist/hooks/hook-llm.js +9 -0
- package/dist/http/routes/approvals-assistant.js +1 -1
- package/dist/http/routes/attachments.js +2 -2
- package/dist/http/routes/memory-policy.js +3 -3
- package/dist/http/routes/runs.js +1 -1
- package/dist/http/routes/session-sync.js +2 -2
- package/dist/http/routes/sessions.js +2 -2
- package/dist/http/routes/tasks.js +2 -2
- package/dist/http/routes/trace-usage.js +2 -2
- package/dist/http/routes/workflows.js +3 -1
- package/dist/http/server.d.ts +1 -1
- package/dist/http/server.js +25 -5
- package/dist/http/sse-log.js +1 -1
- package/dist/main.js +1 -1
- package/dist/model-select.d.ts +1 -1
- package/dist/model-select.js +1 -1
- package/dist/plugins/checkpoint-store-sql.d.ts +13 -5
- package/dist/plugins/checkpoint-store-sql.js +10 -3
- package/dist/plugins/local-checkpoint-store.js +8 -2
- package/dist/plugins/remote-env-host.d.ts +2 -1
- package/dist/run-local.js +2 -1
- package/dist/session-titler.d.ts +3 -1
- package/dist/session-titler.js +2 -2
- package/dist/trace/project.js +4 -1
- package/package.json +5 -3
package/dist/config-types.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
579
|
-
* `PROJECT_MEMORY_DISABLED`
|
|
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
|
|
693
|
-
*
|
|
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
|
-
|
|
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
|
|
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)
|
|
60
|
-
*
|
|
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
|
-
*
|
|
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
|
|
70
|
-
* new name must not
|
|
71
|
-
* 3. `X_ENABLED` unset ∧ `X_DISABLED` set
|
|
72
|
-
*
|
|
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)
|
|
203
|
-
*
|
|
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
|
-
*
|
|
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
|
|
213
|
-
* new name must not
|
|
214
|
-
* 3. `X_ENABLED` unset ∧ `X_DISABLED` set
|
|
215
|
-
*
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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,
|
package/dist/hooks/hook-llm.js
CHANGED
|
@@ -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",
|
|
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`, {
|
|
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)`, {
|
|
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"
|
|
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"
|
|
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
|
|
93
|
+
sendError(res, 422, "memory_sync_invalid_body", parsed.error);
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
96
|
try {
|
package/dist/http/routes/runs.js
CHANGED
|
@@ -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
|
|
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
|
|
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"
|
|
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", {
|
|
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", {
|
|
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
|
|
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({
|
|
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({
|
|
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);
|
package/dist/http/server.d.ts
CHANGED
|
@@ -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 把跨多个子系统的动作编译成一个可调用面交给路由(路由不该自己
|
package/dist/http/server.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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}`,
|
|
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)", {
|
|
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. */
|
package/dist/http/sse-log.js
CHANGED
|
@@ -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",
|
|
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
|
})
|
package/dist/model-select.d.ts
CHANGED
|
@@ -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]②(
|
|
13
|
+
* [865]②(生产实锤:面板上选了 A 模型,实际跑的是目录里的另一个):
|
|
14
14
|
* - explicit ref 走 name+id 双键索引({@link matchCatalogModel})——壳 boot 回读的 mainLoopModel 是 **id 形**,
|
|
15
15
|
* 目录键是 **name 形**,单键索引把 id 形请求当「未知」静默落 default = session 中途静默换模型(最恶性的
|
|
16
16
|
* 上下文污染路径)。
|
package/dist/model-select.js
CHANGED
|
@@ -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]②(
|
|
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).
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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,
|