@sema-agent/server 7.1.0 → 7.3.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 (76) hide show
  1. package/README.md +3 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +6 -1
  4. package/dist/approval-ask-machine.d.ts +39 -0
  5. package/dist/approval-ask-machine.js +101 -0
  6. package/dist/approval-card.d.ts +244 -0
  7. package/dist/approval-card.js +237 -0
  8. package/dist/approval-deny-reasons.d.ts +56 -0
  9. package/dist/approval-deny-reasons.js +54 -0
  10. package/dist/approval-reconciler.d.ts +167 -0
  11. package/dist/approval-reconciler.js +307 -0
  12. package/dist/boot/coordinators.d.ts +1 -0
  13. package/dist/boot/coordinators.js +36 -3
  14. package/dist/boot/lexical-path-env.d.ts +14 -0
  15. package/dist/boot/lexical-path-env.js +116 -0
  16. package/dist/boot/org-memory.d.ts +8 -5
  17. package/dist/boot/org-memory.js +23 -12
  18. package/dist/boot/reapers.d.ts +34 -0
  19. package/dist/boot/reapers.js +198 -23
  20. package/dist/boot/resolve-spec.d.ts +16 -1
  21. package/dist/boot/resolve-spec.js +104 -46
  22. package/dist/config-center/facade.d.ts +1 -1
  23. package/dist/config-center/facade.js +1 -1
  24. package/dist/config-center/http-client.d.ts +19 -0
  25. package/dist/config-center/http-client.js +81 -32
  26. package/dist/config-types.d.ts +76 -7
  27. package/dist/config.d.ts +1 -0
  28. package/dist/config.js +134 -4
  29. package/dist/elicitation.d.ts +4 -0
  30. package/dist/elicitation.js +2 -2
  31. package/dist/http/routes/capabilities.js +18 -2
  32. package/dist/http/routes/runs.d.ts +1 -0
  33. package/dist/http/routes/runs.js +548 -16
  34. package/dist/http/routes/tasks.js +190 -12
  35. package/dist/http/server.d.ts +1 -1
  36. package/dist/http/server.js +124 -8
  37. package/dist/http/sse-log.d.ts +51 -0
  38. package/dist/http/sse-log.js +64 -0
  39. package/dist/http/wire-types.d.ts +20 -5
  40. package/dist/leader/wire.js +4 -0
  41. package/dist/main.js +7 -4
  42. package/dist/org-memory-admission.d.ts +2 -1
  43. package/dist/org-memory-admission.js +10 -4
  44. package/dist/parked-decide.js +5 -2
  45. package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
  46. package/dist/plugins/approval-ask-store-memory.js +299 -0
  47. package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
  48. package/dist/plugins/approval-ask-store-sql.js +705 -0
  49. package/dist/plugins/background-agent-store-sql.js +20 -1
  50. package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
  51. package/dist/plugins/checkpoint-store-sql.js +297 -16
  52. package/dist/plugins/local-checkpoint-store.d.ts +6 -5
  53. package/dist/plugins/local-checkpoint-store.js +4 -0
  54. package/dist/plugins/pg-pool.js +11 -0
  55. package/dist/plugins/store-backend.d.ts +18 -0
  56. package/dist/plugins/store-backend.js +10 -0
  57. package/dist/plugins/tidb-pool.js +27 -4
  58. package/dist/question.d.ts +18 -3
  59. package/dist/question.js +20 -6
  60. package/dist/runs.d.ts +16 -1
  61. package/dist/runs.js +61 -3
  62. package/dist/runtime-caps-resolver.d.ts +7 -1
  63. package/dist/runtime-caps-resolver.js +65 -3
  64. package/dist/runtime-governance.d.ts +11 -4
  65. package/dist/runtime-governance.js +16 -5
  66. package/dist/spec-fields.d.ts +4 -0
  67. package/dist/spec-fields.js +6 -0
  68. package/dist/task-settings.d.ts +35 -15
  69. package/dist/task-settings.js +19 -5
  70. package/dist/tool-approval.d.ts +296 -3
  71. package/dist/tool-approval.js +1066 -50
  72. package/dist/trace/core-keyset-guard.d.ts +2 -2
  73. package/dist/trace/ledger-sink.js +14 -1
  74. package/dist/trace/project.d.ts +55 -0
  75. package/dist/trace/project.js +135 -0
  76. package/package.json +4 -3
package/dist/config.js CHANGED
@@ -188,6 +188,30 @@ const CONFIG_NOTICES = [];
188
188
  export function drainConfigNotices() {
189
189
  return CONFIG_NOTICES.splice(0, CONFIG_NOTICES.length);
190
190
  }
191
+ /** #157([2762]§五,F 类观测,零行为变化)—— 本次 boot 的执行车道落点 + 它的来源。
192
+ * 为什么单开一格:REMOTE_EXEC 未设/空串时的落点由 CONFIG_PROVIDER 隐式决定(local ⇒ host 真机,其余 ⇒
193
+ * 进程内 stub),而这个落点此前在日志里零留痕 —— 运维拿到一台 worker,无从确认「它跑的是哪条执行通道、
194
+ * 是显式配的还是默认落的」。与 CONFIG_WARNINGS / CONFIG_NOTICES 同为 pre-logger 收集(config 解析早于
195
+ * logger 建立),但级别是 **info**:这是每次 boot 都成立的常态事实,不是异常,而 CONFIG_NOTICES 那一格
196
+ * 固定按 warn 排空。
197
+ * 单槽(不是队列):一次 boot 只有一个落点;重复 loadConfig(测试)覆写即可,不会堆积。
198
+ * ⚠️ 写入时机=装配尾(见 loadConfig),不是解析途中:写早了,任何**后置**拒启(limits 域数值、跨域
199
+ * 不变量)都会留下一条「其实没启起来」的落点;而 loadConfig 开头的清槽保证失败的一轮不会把**上一轮**
200
+ * 的落点串给下一次排空(交叉复审 medium,2026-08-06)。
201
+ * ⚠️ 边界:记的是 **ENV 解析出的**落点。local 形的 `config.d/remote-exec.json` 覆写发生在 main.ts 的
202
+ * applyLocalRemoteExec(晚于本行排空),它自己另记 `remote_exec_from_file` —— 两行合读才是最终 lane。 */
203
+ let EXEC_LANE_NOTICE;
204
+ /** 从**已装配好的** remoteExec 派生落点一行(而不是在 REMOTE_EXEC 那条三元链的各臂里分别记账):
205
+ * 分臂记账要靠「校验过了 ⇒ 必落对应那一臂」这条人肉维护的同源假设,链子哪天多一臂就悄悄失真;
206
+ * 取装配结果则天然跟随。`in-process` = REMOTE_EXEC 未设的进程内 stub 姿态,沿用既有词表
207
+ * (config-center/types.ts 的 provider 词表、run-local 的 `?? "in-process"` 同名)。 */
208
+ function buildExecLaneNotice(remoteExec) {
209
+ const explicit = process.env.REMOTE_EXEC !== undefined && process.env.REMOTE_EXEC !== "";
210
+ return {
211
+ lane: remoteExec?.provider ?? "in-process",
212
+ source: explicit ? "explicit-env" : process.env.CONFIG_PROVIDER === "local" ? "default-local-provider" : "default-unset",
213
+ };
214
+ }
191
215
  /** F10 (codex audit): drain + log BOTH pre-logger diagnostic collectors (S20 soft-knob warnings + [792]④ boot
192
216
  * notices) in one place. EVERY entrypoint that calls loadConfig() then builds a logger must call this right
193
217
  * after (main.ts, run-local.ts) — a new entry that forgets it would silently drop operator-facing diagnostics
@@ -197,6 +221,13 @@ export function logConfigDiagnostics(logger) {
197
221
  logger.warn("config_env_invalid_using_default", { env: w.env, raw: w.raw });
198
222
  for (const n of drainConfigNotices())
199
223
  logger.warn(n.event, n.fields);
224
+ // #157 F 类观测:执行车道落点一行(见 EXEC_LANE_NOTICE 头注)。无条件发 —— boot 期本来就只跑一次,
225
+ // 不需要 once-per-hit 抑制。先取后清:排空语义与上面两格一致(重复调用不重放同一条)。`info` 与
226
+ // `debug` 同为可选,理由同上 —— 老 embedder 传进来的 warn-only logger 仍能编译、仍拿得到警告。
227
+ const laneNotice = EXEC_LANE_NOTICE;
228
+ EXEC_LANE_NOTICE = undefined;
229
+ if (laneNotice && logger.info)
230
+ logger.info("exec_lane_selected", { lane: laneNotice.lane, source: laneNotice.source });
200
231
  // design/158 N9 (polarity self-report): a `*_ENABLED` suffix does NOT carry its default — the repo grew four
201
232
  // dialects for it (opt-in / opt-out / posture tri-state / negative name). Rather than force operators to read
202
233
  // config.ts to learn what a knob does when unset, boolEnv-parsed knobs register themselves and we print one
@@ -874,6 +905,45 @@ export function formatUnmatchableToolNames(source, bad) {
874
905
  return (`${source} names tool(s) that can never match a live tool (core 5.0.0 RB-476 — the alias/auto-prefix folds are gone): ` +
875
906
  bad.map((b) => `${b.name} → ${b.guidance}`).join("; "));
876
907
  }
908
+ /**
909
+ * #151 流内审批协议(design/172)的九键段(车3 刀 3a 五键 + 车5 收敛器四键)。
910
+ *
911
+ * **总开关默认 false** ⇒ 全链逐字现行为(不发 approval_request、不落 ask 行、不起收敛器腿、窗仍是
912
+ * `DEFAULT_APPROVAL_TTL_MS`)。窗/帽四键沿用 `numEnv` fail-loud 形(坏形启动期炸,不静默折 NaN);
913
+ * 车5 的四键一律 **`numEnvBounded`**(§9 C6:batch 整数 ≥1 有上帽,grace/TTL 非负有界)——它们直接
914
+ * 决定 reaper 每 tick 的库压与「多久算遗孤」,一个手滑的 `0` 或 `1e12` 都是运维事故面。
915
+ *
916
+ * 🔴 跨旋钮不变量(§9 C6,`#157` 三分类 P 族姿势):`ADHOC_GRACE < ORPHAN_TTL`。破坏形**拒启** ——
917
+ * 若 adhoc 宽限 ≥ 遗孤 TTL,判据 5 会先于判据 4 触发,一条 adhoc 腿拿到的归因就成了
918
+ * `orphan_ttl_exceeded`(「遗孤兜底」)而不是 `adhoc_leg_no_durable_domain`(「结构上无对账域」),
919
+ * 审计面从此读不出真实成因。默认值(60s vs 7d)自然满足;只有显式改坏才会撞上。
920
+ */
921
+ function streamApprovalConfig() {
922
+ const cfg = {
923
+ enabled: boolEnv("STREAM_APPROVAL_ENABLED", false),
924
+ windowMs: numEnv("STREAM_ASK_WINDOW_MS", "60000"),
925
+ replayMax: numEnv("STREAM_APPROVAL_REPLAY_MAX", "50"),
926
+ admitMaxPerTask: numEnv("STREAM_APPROVAL_ADMIT_MAX_PER_TASK", "32"),
927
+ admitMaxPerOwner: numEnv("STREAM_APPROVAL_ADMIT_MAX_PER_OWNER", "256"),
928
+ reconcileBatch: numEnvBounded("STREAM_APPROVAL_RECONCILE_BATCH", "200", 1, 10_000),
929
+ pendingGraceMs: numEnvBounded("STREAM_APPROVAL_PENDING_GRACE_MS", "30000", 0, 3_600_000),
930
+ adhocGraceMs: numEnvBounded("STREAM_APPROVAL_ADHOC_GRACE_MS", "60000", 0, 86_400_000),
931
+ orphanTtlMs: numEnvBounded("STREAM_APPROVAL_ORPHAN_TTL_MS", String(7 * 24 * 60 * 60 * 1000), 60_000, 90 * 24 * 60 * 60 * 1000),
932
+ };
933
+ // 🔴 不变量的**正确形**(codex 交叉复审 C7,2026-08-06 验真):两条臂量的不是同一个起点 ——
934
+ // 判据 4 在 `expiresAtMs + ADHOC_GRACE` 触发,而 `expiresAtMs ≈ createdAtMs + WINDOW`;判据 5 在
935
+ // `createdAtMs + ORPHAN_TTL` 触发。只比 `ADHOC_GRACE < ORPHAN_TTL` 会漏掉窗:例如 WINDOW 与
936
+ // ORPHAN_TTL 等长时校验放行,但遗孤兜底必定先到,adhoc 腿的归因被写成 `orphan_ttl_exceeded`。
937
+ // 正确判据 = `WINDOW + ADHOC_GRACE < ORPHAN_TTL`(两边都是有界有限值,不会溢出)。
938
+ if (cfg.windowMs + cfg.adhocGraceMs >= cfg.orphanTtlMs) {
939
+ throw new Error(`env STREAM_ASK_WINDOW_MS=${cfg.windowMs} + STREAM_APPROVAL_ADHOC_GRACE_MS=${cfg.adhocGraceMs} must be STRICTLY LESS than STREAM_APPROVAL_ORPHAN_TTL_MS=${cfg.orphanTtlMs} — the adhoc arm fires at (createdAt + window + grace) while the orphan backstop fires at (createdAt + ttl); if the backstop wins, every adhoc-leg row is attributed "orphan_ttl_exceeded" instead of "adhoc_leg_no_durable_domain" (attribution honesty, design/172 §6.1)`);
940
+ }
941
+ // 整数门(batch 是行数,不是时长):`numEnvBounded` 只管区间,`200.5` 会一路走到 SQL `LIMIT` 上。
942
+ if (!Number.isInteger(cfg.reconcileBatch)) {
943
+ throw new Error(`env STREAM_APPROVAL_RECONCILE_BATCH=${cfg.reconcileBatch} must be an INTEGER row count`);
944
+ }
945
+ return cfg;
946
+ }
877
947
  function parseApprovalDomain(ctx) {
878
948
  const { postureOn } = ctx; // 跨域入参②:posture 三态(single-user turnkey ⇒ HITL 面默认 ON)
879
949
  // design/80 D-G: the direct-connect approval door anchors (all must be present to ACTIVATE; fail-closed).
@@ -911,8 +981,10 @@ function parseApprovalDomain(ctx) {
911
981
  return [];
912
982
  return trimmed.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
913
983
  })(process.env.SENSITIVE_WRITE_PATTERNS),
914
- // [1557]§四: opt-in only — any value other than the two real gate shapes (incl. "off"/unset) stays undefined
915
- // (no field on the config object at all), so the fs-write gate wiring never even considers it.
984
+ // [1557]§四 → #153: opt-in only — any value other than the two real gate shapes (incl. "off"/unset) stays
985
+ // undefined (no field on the config object at all), so `applyRuntimeGovernance` never receives the knob and
986
+ // THIS knob contributes nothing to the governance pass (zero behavior change from it). Note that pass can
987
+ // still set `TaskSpec.shellGate` on its own account — `AUTONOMY=ask` derives "always" independently.
916
988
  ...(process.env.MANUAL_MODE_SHELL_GATE === "always" || process.env.MANUAL_MODE_SHELL_GATE === "classify"
917
989
  ? { manualModeShellGate: process.env.MANUAL_MODE_SHELL_GATE }
918
990
  : {}),
@@ -927,6 +999,13 @@ function parseApprovalDomain(ctx) {
927
999
  },
928
1000
  askQuestionEnabled: postureOn("ASK_QUESTION_ENABLED"), // §4④ AskUserQuestion live HITL; posture-gated (single-user → ON)
929
1001
  toolApprovalEnabled: postureOn("TOOL_APPROVAL_ENABLED"), // [816]/[820]② live tool-approval HITL; posture-gated (single-user → ON), mirrors askQuestion
1002
+ // #151 车2(design/172 §3.3 D3):ToolApprovalCoordinator 窗长三元的安全余量。numEnv fail-loud 形,
1003
+ // 照邻居旋钮(approvalTimeoutSec 等)抄——坏形(非数字)在启动期炸,不静默折成 NaN。
1004
+ streamAskWindowMarginMs: numEnv("STREAM_ASK_WINDOW_MARGIN_MS", "10000"),
1005
+ // #151 车3 刀 3a(design/172 流内审批协议):五键。**总开关默认 false** ⇒ 全链逐字现行为(不发
1006
+ // approval_request、不落 ask 行、窗仍是 DEFAULT_APPROVAL_TTL_MS)。窗/帽四键与邻居旋钮同 numEnv
1007
+ // fail-loud 形——坏形在启动期炸,不静默折 NaN。语义与三问分析见 config-types.ts 的 StreamApprovalConfig。
1008
+ streamApproval: streamApprovalConfig(),
930
1009
  // [875]a 成文:0(缺省)= durable HITL 无限期等人,时间型 reapSuspended 不跑;file/memory lane 的回收
931
1010
  // 探针只在 DURABLE_APPROVAL=true 时注入(无 durable 的部署两只 suspended 回收器恒 NO-OP,parked 行的
932
1011
  // 恢复把手 = POST /v1/runs/:id/cancel,[868]①)。checkpoint 过期驱动的那只不受本旋钮门控。
@@ -1097,6 +1176,37 @@ function parseOrchestrationDomain(ctx) {
1097
1176
  }
1098
1177
  return boolEnv("LSP_HOST_ENABLED", true);
1099
1178
  })();
1179
+ // ── #157([2755] tag `server.exec-lane.silent-downgrade-to-host`,P 类车道选择)——执行车道的
1180
+ // fail-closed 校验。旧形:REMOTE_EXEC 点名了一条 lane 但必需 env 不全(或值拼错不在闭集里)时,下面
1181
+ // 三元链的合取逐臂失败、静默滑穿到尾部 ⇒ CONFIG_PROVIDER=local 落 host(worker 本机、无隔离)、否则落
1182
+ // in-process stub,且两种跑法的成功回执同形(status=completed,test [2753]§2 实测「在哪儿跑的」对外
1183
+ // 零信息量)。操作员配了沙箱却在真机上跑 = P 类判据表的样板案,必须拒启点名,不静默降级。
1184
+ // 语义边界:REMOTE_EXEC **未设**(或空串,env 管理器的显式「不选」形)不是降级,是文档化的部署姿态
1185
+ // (local ⇒ host 默认,fleet ⇒ stub),照旧;拒启只咬「点了名却配不齐/认不出」。缺失点名要**准**——
1186
+ // 只列缺的件,不列 lane 的全量需求(WORKFLOW_SIZE_GUIDELINE / MEMORY_SYNC_* 半配拒启同族先例)。
1187
+ {
1188
+ const lane = process.env.REMOTE_EXEC;
1189
+ if (lane !== undefined && lane !== "") {
1190
+ const REQUIRED_ENVS_BY_LANE = {
1191
+ e2b: ["E2B_API_KEY"],
1192
+ k8s: ["K8S_SANDBOX_IMAGE"],
1193
+ ssh: ["SSH_HOST", "SSH_USER", "SSH_PRIVATE_KEY"],
1194
+ adb: ["ADB_SERIAL"],
1195
+ host: [],
1196
+ "local-docker": ["DOCKER_IMAGE"],
1197
+ };
1198
+ const required = Object.hasOwn(REQUIRED_ENVS_BY_LANE, lane) ? REQUIRED_ENVS_BY_LANE[lane] : undefined;
1199
+ if (required === undefined) {
1200
+ throw new Error(`REMOTE_EXEC="${lane}" is not an execution lane — valid: e2b | k8s | ssh | adb | host | local-docker. ` +
1201
+ `An unrecognized value used to fall through to the host/in-process lane silently; refusing to start instead (#157).`);
1202
+ }
1203
+ const missing = required.filter((name) => !process.env[name]);
1204
+ if (missing.length > 0) {
1205
+ throw new Error(`REMOTE_EXEC=${lane} but required env ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} missing/empty — ` +
1206
+ `refusing to start rather than silently downgrading to the host/in-process lane (#157; unset REMOTE_EXEC if you meant no sandbox lane).`);
1207
+ }
1208
+ }
1209
+ }
1100
1210
  return {
1101
1211
  remoteExec: process.env.REMOTE_EXEC === "e2b" && process.env.E2B_API_KEY
1102
1212
  ? {
@@ -1373,8 +1483,8 @@ const MODEL_PLANE_GROUP_KEYS = [
1373
1483
  const APPROVAL_GROUP_KEYS = [
1374
1484
  "approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
1375
1485
  "approvalHmacKeys", "durableApproval", "directApprovalDoor", "directDoorActive", "resourceSuspend",
1376
- "resourceSuspendTtlSec", "askQuestionEnabled", "toolApprovalEnabled", "mcpElicitation", "sensitiveWritePatterns",
1377
- "manualModeShellGate",
1486
+ "resourceSuspendTtlSec", "askQuestionEnabled", "toolApprovalEnabled", "streamAskWindowMarginMs", "streamApproval", "mcpElicitation",
1487
+ "sensitiveWritePatterns", "manualModeShellGate",
1378
1488
  ];
1379
1489
  const MEMORY_GROUP_KEYS = [
1380
1490
  "memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
@@ -1457,6 +1567,11 @@ export function attachConfigGroups(flat) {
1457
1567
  return flat;
1458
1568
  }
1459
1569
  export function loadConfig() {
1570
+ // #157:落点清槽必须排在**第一条**语句 —— 比下面那组同类复位还早一格,因为 rejectRetiredEnvs 本身会抛,
1571
+ // 而一轮抛出的 loadConfig 绝不能把**上一轮**成功解析的落点留在槽里被下一次排空读成本轮事实
1572
+ // (交叉复审第二轮,2026-08-06)。下面三格同族收集器在这条最早拒启路径上仍有同形残留,那是既有面、
1573
+ // 且都按 warn 排空,不在本件射程内。
1574
+ EXEC_LANE_NOTICE = undefined;
1460
1575
  rejectRetiredEnvs(); // [2354] 退役 env 墓碑:在场即拒启带新名指引
1461
1576
  CONFIG_WARNINGS.length = 0; // repeated loadConfig() calls (test setup) must not accumulate stale warnings
1462
1577
  CONFIG_NOTICES.length = 0;
@@ -1526,6 +1641,18 @@ export function loadConfig() {
1526
1641
  throw new Error(`DIRECT_APPROVAL_DOOR=true but D-G anchors are missing: ${missing.join(", ")} — refusing to start a half-open direct door (design/80 D-G boot invariant)`);
1527
1642
  }
1528
1643
  }
1644
+ // 🔴 boot invariant (#157-①, silent-downgrade-batch-20260805 普查候选 1/2): the BFF multi-tenant durable form
1645
+ // (DURABLE_APPROVAL=true + REQUIRE_PRINCIPAL=true, direct door OFF) had NO invariant — the D-G one above only
1646
+ // binds when DIRECT_APPROVAL_DOOR=true. On that form the four READ surfaces in approvals-assistant.ts
1647
+ // (GET /v1/approvals, /stream, /v1/assistant/inbox, /tasks) scope by the bare isOperator, whose empty-list
1648
+ // "true-for-all" lets ANY authenticated tenant principal list/subscribe every other tenant's pending queue
1649
+ // (redacted tool-input previews + decide-binding fields) and inspect arbitrary owners via ?owner. The WRITE
1650
+ // surfaces (decide/preempt/resume/plan_review) already use explicitOperatorOk and were never exposed. The
1651
+ // single-tenant legacy semantic (REQUIRE_PRINCIPAL unset — the shared service token IS the operator boundary)
1652
+ // is deliberately preserved: only the multi-tenant form is a hole. Refuse to start it.
1653
+ if (dgDurable && requirePrincipal && dgOperators.length === 0) {
1654
+ throw new Error("DURABLE_APPROVAL=true + REQUIRE_PRINCIPAL=true but OPERATOR_PRINCIPALS is empty — on a multi-tenant worker the empty operator set makes isOperator() true-for-all, so every authenticated tenant could read every other tenant's pending-approval queue (GET /v1/approvals & siblings). Set OPERATOR_PRINCIPALS, or unset REQUIRE_PRINCIPAL if this is a single-tenant deployment (#157-① boot invariant)");
1655
+ }
1529
1656
  // 🔴 boot invariant (IMAGE-API-DESIGN.md §P2.4a): the bake door is build-host-RCE-capable, and isOperator([],p)
1530
1657
  // is true-for-all — so enabling bakes with an empty OPERATOR_PRINCIPALS = a WORLD-WRITABLE RCE door. Refuse to
1531
1658
  // start (never a half-open bake door). The explicit-operator gate in server.ts is the runtime twin of this.
@@ -1539,6 +1666,9 @@ export function loadConfig() {
1539
1666
  if (imageBakesEnabled && !Object.values(authTokens).includes(bakeRunnerPrincipal)) {
1540
1667
  throw new Error(`IMAGE_BAKES_ENABLED=true but no SERVICE_AUTH_TOKENS entry maps to the runner principal '${bakeRunnerPrincipal}' — the bake-runner authenticates by its credential (token-derived source), so a '<BAKE_RUNNER_TOKEN>=${bakeRunnerPrincipal}' SERVICE_AUTH_TOKENS entry is required or claim/ingest/heartbeat all 403 (IMAGE-API-DESIGN.md §P2.12 boot invariant)`);
1541
1668
  }
1669
+ // #157 F 类观测:执行车道落点记账放在这里 —— 九域解析与上面全部跨域不变量都过了之后,即「这一轮
1670
+ // 确实启得起来」才成立的位置(写在 REMOTE_EXEC 解析途中的话,后置拒启会留下一条没启起来的落点)。
1671
+ EXEC_LANE_NOTICE = buildExecLaneNotice(orchestration.remoteExec);
1542
1672
  // 平铺面**单点展开**九域产物(唯一存储处),再挂上九个组视图。组视图是对这同一个对象的取景器,
1543
1673
  // 不是第二份拷贝 —— 故 `config.limitsHttp.port === config.port` 恒成立,含热应用之后(见
1544
1674
  // attachConfigGroups / config-types.ts 分组段)。
@@ -15,6 +15,10 @@ export interface ElicitationFrame {
15
15
  mode?: "form";
16
16
  /** "elicitation_complete" only: how it resolved. */
17
17
  action?: McpElicitResponse["action"];
18
+ /** Server-signed emit timestamp (additive, [2805]§五→[2806]). These side-frames are SERVER-minted — they have
19
+ * no core-stream anchor, so no `eventId` (absence is honest, not an omission); this is the frame's own time
20
+ * coordinate instead, same posture as the stream-approval frames' `serverNowMs`. */
21
+ serverNowMs?: number;
18
22
  }
19
23
  /** The per-run context `onElicit` recovers via ALS. `emit` delivers a frame to the run's live stream;
20
24
  * `abortSignal` (the run's own cancel signal) lets a run-abort promptly cancel an elicitation parked awaiting a
@@ -182,7 +182,7 @@ export class ElicitationCoordinator {
182
182
  // cursor (an invisible prompt that then TTL-declines). On a successful emit, charge the per-run-tree budget; a
183
183
  // FAILED emit declines (nobody can answer) and must NOT burn maxTotal / the per-server interval.
184
184
  try {
185
- await ctx.emit({ type: "elicitation", elicitationId: id, mcpServerName: req.server, message, ...(requestedSchema !== undefined ? { requestedSchema } : {}), mode: "form" });
185
+ await ctx.emit({ type: "elicitation", elicitationId: id, mcpServerName: req.server, message, ...(requestedSchema !== undefined ? { requestedSchema } : {}), mode: "form", serverNowMs: this.now() });
186
186
  rc.total += 1;
187
187
  rc.lastByServer.set(req.server, t);
188
188
  }
@@ -194,7 +194,7 @@ export class ElicitationCoordinator {
194
194
  // returning the answer to core (which replies to the MCP server on the still-open connection). Best-effort +
195
195
  // ordering-uncritical: the shell already has the answer via respond; a late/lost dismiss is cosmetic.
196
196
  void Promise.resolve()
197
- .then(() => ctx.emit({ type: "elicitation_complete", elicitationId: id, mcpServerName: req.server, action: answer.action }))
197
+ .then(() => ctx.emit({ type: "elicitation_complete", elicitationId: id, mcpServerName: req.server, action: answer.action, serverNowMs: this.now() }))
198
198
  .catch(() => undefined);
199
199
  return answer;
200
200
  };
@@ -2,6 +2,7 @@ import { DEFAULT_EFFORT_LEVELS } from "@sema-agent/core";
2
2
  import { cwdHonored } from "../../task-cwd.js";
3
3
  import { mcpInjectionHonored } from "../../task-mcp.js";
4
4
  import { sendJson, sendError } from "../send.js";
5
+ import { resolveStreamApprovalGate } from "../../tool-approval.js";
5
6
  export async function handleCapabilities(req, res, url, ctx) {
6
7
  const miss = { fell: false };
7
8
  await handleCapabilitiesBody(req, res, url, ctx, miss);
@@ -200,8 +201,10 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
200
201
  mcpElicitation: Boolean(deps.elicitation),
201
202
  // §4④: AskUserQuestion LIVE HITL — true ONLY when the onQuestion producing path is wired
202
203
  // (ASK_QUESTION_ENABLED). Same SCOPE as mcpElicitation (streaming + durable legs; a synchronous /v1/tasks or a
203
- // sub-task with no live stream headless-defaults — honest, not a hang). The durable-disconnected-human leg is
204
- // always available (createDurableQuestionPolicy on the durable path), independent of this live capability.
204
+ // sub-task with no live stream headless-defaults — honest, not a hang). #152: on a DURABLE deployment the
205
+ // disconnected-human leg (checkpoint park + /decide) is no longer unconditional — it is what a leg with NO
206
+ // reachable live face adjudicates to, while a live-stream leg asks the human over the stream instead. So this
207
+ // flag being true also means "a live leg's question does NOT park"; a leg with no live face still parks.
205
208
  askUserQuestion: Boolean(deps.question),
206
209
  // [816]/[820]②: live tool-approval HITL — true ONLY when the onAsk producing path is wired
207
210
  // (TOOL_APPROVAL_ENABLED). LIVE sync-stream leg only; since [879] G1 (OnAsk 三值化) every other leg
@@ -209,6 +212,19 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
209
212
  // exists, and only fail-closed denies without one ([819]⑤). The durable-approval leg (checkpoint park +
210
213
  // /decide) is independent of this.
211
214
  toolApproval: Boolean(deps.toolApproval),
215
+ // #151 车3 刀 3b(设计稿 §2.4):design/172 **流内审批协议**(`approval_request` 呈卡帧 + 开流
216
+ // preamble 对账基准 + durable 回决端点)。判据走**单一谓词** `resolveStreamApprovalGate` —— 与协调器
217
+ // 的 askStore 注入、与 `POST /v1/tasks/:taskId/asks/:askId/decision` 的 501 门同一个符号,所以
218
+ // 「这里说 true」⟺「协议真的在场」是结构成立的,壳不必 trial-by-帧(诚实缺席纪律,同上文各格)。
219
+ // 五个合取项里最容易被忽略的两条:**账必须是持久的**(local 的 InMemory 形重启即丢已接受的决议)
220
+ // 与 **park 设施必须在场**(缺席时窗到期没有降级目的地,结局是 core fail-closed deny —— 比现行
221
+ // 5min 活卡更差,所以协议诚实地不上场、活卡腿逐字保留)。
222
+ streamApproval: resolveStreamApprovalGate({
223
+ toolApprovalEnabled: Boolean(deps.toolApproval),
224
+ streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
225
+ backend: deps.backend,
226
+ parkFacility: deps.checkpointStore !== undefined,
227
+ }).active,
212
228
  // [1469] POST /v1/side-query(core 1.361 Runner.sideQuery 包装):一次性 brain 路由问答,无 session
213
229
  // 副作用。恒可用(runner 自带)——探测位供壳判「引擎腿在」而非 trial-by-404。
214
230
  sideQuery: true,
@@ -22,6 +22,7 @@ export declare const RUN_COMPACT_RE: RegExp;
22
22
  export declare const ELICIT_RESPOND_RE: RegExp;
23
23
  export declare const QUESTION_RESPOND_RE: RegExp;
24
24
  export declare const TOOL_APPROVAL_RESPOND_RE: RegExp;
25
+ export declare const TASK_ASK_DECISION_RE: RegExp;
25
26
  export declare const RUN_SUBAGENT_STEER_RE: RegExp;
26
27
  export declare const RUN_SUBAGENT_RESUME_RE: RegExp;
27
28
  export declare const RUN_SUBAGENT_OUTPUT_RE: RegExp;