@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
@@ -8,6 +8,7 @@ import { FileRunStore } from "./file-run-store.js";
8
8
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
9
9
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
10
10
  import { type ApprovalExemptionStore } from "./approval-exemption-store.js";
11
+ import { type ApprovalAskStore as ApprovalAskStoreType } from "./approval-ask-store-sql.js";
11
12
  import { type SendFileLedger } from "./send-file-ledger.js";
12
13
  import { TiDBResumeAnchorStore, PgResumeAnchorStore } from "./resume-anchor-store-sql.js";
13
14
  import type { ServiceConfig } from "../config-types.js";
@@ -27,6 +28,9 @@ export type RunStore = TiDBRunStore | PgRunStore | FileRunStore;
27
28
  * FileResumeAnchorStore is the FILE-backed local twin (P0.5 variant-1; survives restart). */
28
29
  export type ResumeAnchorStore = TiDBResumeAnchorStore | PgResumeAnchorStore | FileResumeAnchorStore;
29
30
  export type { ApprovalExemptionStore } from "./approval-exemption-store.js";
31
+ /** #151(design/172 v3.1 §3.0-§3.2 流内审批协议持久层):`ApprovalAskStore` 接口 + 行/补丁类型。
32
+ * 车3 刀 3a 起 `StoreBackend.approvalAsk()` 已在(见接口体的那条注)。 */
33
+ export type { ApprovalAskStore, AskRow, NewAskRow, AskTransitionPatch, DecideAskInput, DecideResult, ExpireResult, BindGateInput, BatchRow, AskDecision } from "./approval-ask-store-sql.js";
30
34
  /** E6 SessionPolicyStore — core's interface PLUS the service `deleteBySession` (E21 purge; only the durable twins carry
31
35
  * it, core's InMemory omits it → optional). tidb/pg = durable twins; local = core's InMemorySessionPolicyStore (works
32
36
  * local — rules need no cloud-only infra). The shared env-gated equivalence suite keeps the twins byte-matched to InMemory. */
@@ -107,6 +111,20 @@ export interface StoreBackend {
107
111
  /** Per-session per-toolName approval exemption ("本会话不再询问") — an approval MEMORY the F4
108
112
  * ask-gate consults after deny/neverAuto. REQUIRED on all backends (works local, like resumeAnchor). */
109
113
  approvalExemption(): ApprovalExemptionStore;
114
+ /** #151(design/172 §3.0-§3.2)流内审批协议持久层:每个流内工具调用一行的 ask 状态机 + 批行。
115
+ * REQUIRED on all backends — tidb/pg = SQL 双方言 twin;local = {@link InMemoryApprovalAskStore}。
116
+ *
117
+ * 🔴 local 形的代价必须在这里说清楚(而不是留给读者自己发现):InMemory 的 STREAM_PENDING/DECIDED/
118
+ * PARKING 全在两个进程内 Map 里 ⇒ **重启同时丢重放基准、丢已接受的决议、丢审计**——「一次真实的人类
119
+ * 批准凭空消失」和「对账便利丢失」不是一个量级。因此协议在 local 车道**默认不上场**:能力面
120
+ * (`streamApproval`)的判据带一条「ask 账必须是持久的」合取项,local 不报 true(诚实缺席,同
121
+ * `park 设施缺席 ⇒ 协议不上场` 的姿势)。要在 local 真上协议,先落 File 形(现成模子 =
122
+ * {@link FileApprovalExemptionStore}:core `AppendLog`/`readJsonlRecords`,内存索引 + 追加日志即可
123
+ * 满足单实例 boot-lock 下的 CAS),那时这里换成 File twin、能力面自然翻真。
124
+ *
125
+ * 无 backend 的 env-only worker 压根没有 `StoreBackend` ⇒ 协调器拿到 undefined askStore ⇒ 车2 的
126
+ * D1 逐字现行为(store 缺席 = 现行 `tool_approval` 活卡腿一字不变)。 */
127
+ approvalAsk(): ApprovalAskStoreType;
110
128
  /** SendUserFile scope↔object ledger (multi-tenant list/revoke handle; the hashed key segment hides the
111
129
  * mapping from URLs). REQUIRED on all backends (works local — one JSONL, like approvalExemption). */
112
130
  sendFileLedger(): SendFileLedger;
@@ -18,6 +18,8 @@ import { LocalSessionStore } from "./local-session-store.js";
18
18
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
19
19
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
20
20
  import { TiDBApprovalExemptionStore, PgApprovalExemptionStore, FileApprovalExemptionStore } from "./approval-exemption-store.js";
21
+ import { TiDBApprovalAskStore, PgApprovalAskStore } from "./approval-ask-store-sql.js";
22
+ import { InMemoryApprovalAskStore } from "./approval-ask-store-memory.js";
21
23
  import { TiDBSendFileLedger, PgSendFileLedger, FileSendFileLedger } from "./send-file-ledger.js";
22
24
  import { TiDBResumeAnchorStore, PgResumeAnchorStore } from "./resume-anchor-store-sql.js";
23
25
  import { TiDBSessionPolicyStore, PgSessionPolicyStore } from "./session-policy-store-sql.js";
@@ -94,6 +96,7 @@ class TiDBBackend {
94
96
  run() { return new TiDBRunStore(this.pool); }
95
97
  resumeAnchor() { return new TiDBResumeAnchorStore(this.pool); }
96
98
  approvalExemption() { return new TiDBApprovalExemptionStore(this.pool); }
99
+ approvalAsk() { return new TiDBApprovalAskStore(this.pool); } // #151 流内审批协议持久层(无状态 store,每次新建同兄弟)
97
100
  sendFileLedger() { return new TiDBSendFileLedger(this.pool); }
98
101
  sessionPolicy() { return new TiDBSessionPolicyStore(this.pool); }
99
102
  fileSnapshot() { return new TiDBFileSnapshotStore(this.pool, snapshotBlobBackend(this.config, "tidb", this.pool), snapshotBoundsFromConfig(this.config)); }
@@ -127,6 +130,7 @@ class PgBackend {
127
130
  run() { return new PgRunStore(this.pool); }
128
131
  resumeAnchor() { return new PgResumeAnchorStore(this.pool); }
129
132
  approvalExemption() { return new PgApprovalExemptionStore(this.pool); }
133
+ approvalAsk() { return new PgApprovalAskStore(this.pool); } // #151 流内审批协议持久层(SQL twin)
130
134
  sendFileLedger() { return new PgSendFileLedger(this.pool); }
131
135
  sessionPolicy() { return new PgSessionPolicyStore(this.pool); }
132
136
  fileSnapshot() { return new PgFileSnapshotStore(this.pool, snapshotBlobBackend(this.config, "pg", this.pool), snapshotBoundsFromConfig(this.config)); }
@@ -178,6 +182,8 @@ class LocalBackend {
178
182
  sessionStore;
179
183
  resumeAnchorStore;
180
184
  approvalExemptionStore;
185
+ /** #151 —— 进程内易失(见 approvalAsk() 的注);File 形落地前它就是 local 车道的占位。 */
186
+ approvalAskStore = new InMemoryApprovalAskStore();
181
187
  sendFileLedgerStore;
182
188
  // SVC-2: FILE-backed durable journal (was core's InMemory — a TOC restart silently resolved
183
189
  // resumeFromRunId to an empty journal → full live re-run). Now restart-durable on one box; crash-safe JSONL,
@@ -223,6 +229,10 @@ class LocalBackend {
223
229
  run() { return this.runStore; }
224
230
  resumeAnchor() { return this.resumeAnchorStore; } // singleton — state lives in the instance (mirrors run()/session())
225
231
  approvalExemption() { return this.approvalExemptionStore; } // singleton — same posture
232
+ /** #151 流内审批协议持久层的 local 形。**必须**是 singleton:状态全在实例的两个 Map 里,每次
233
+ * new 一个就等于每个消费者各拿一份互不可见的账(而 SQL twin 天然共享一张表)。易失代价 +
234
+ * 「local 车道默认不上协议」的裁定见 StoreBackend.approvalAsk() 的接口注。 */
235
+ approvalAsk() { return this.approvalAskStore; }
226
236
  sendFileLedger() { return this.sendFileLedgerStore; } // singleton — append fd lives in the instance
227
237
  /** Durable HITL / plan-review parking on the LOCAL lane. Lazy singleton (the inner core store holds
228
238
  * an append fd). taskId join ← FileRunStore; E21 owner guard ← LocalSessionStore.ownerOf (same root). */
@@ -1,4 +1,5 @@
1
1
  import mysql from "mysql2/promise";
2
+ import { TIDB_APPROVAL_ASK_STATEMENTS } from "./approval-ask-store-sql.js";
2
3
  /**
3
4
  * Shared TiDB (MySQL wire-compatible) connection pool for the durable Session center (L1).
4
5
  * Owned by `main.ts`; closed on shutdown.
@@ -244,11 +245,28 @@ export const SCHEMA_STATEMENTS = [
244
245
  -- D-1 TOCTOU binding guard is UNREACHABLE by the portal without it — it can then only do the unbound legacy
245
246
  -- fallback). NULL on PRE-D-1 rows ⇒ that unbound fallback.
246
247
  bound_input_hash VARCHAR(190) NULL,
247
- -- pending_steer (design/80 D-A, durable steering): the parked steer ({text,trusted} JSON) set by
248
- -- setPendingSteer on a STILL-PENDING checkpoint (last-writer-wins, CAS on status='pending'), surfaced via
249
- -- get() as state.pendingSteer so core injects it on resume. NULL when no steer is parked.
250
- -- Never touches status/resolve (design/80 inv #4).
248
+ -- pending_steer (design/80 D-A, durable steering):**队列化之前**的单座列,存 {text,trusted}
249
+ -- core 5.14.0(#147 BREAKING)之后本服务**只读不写**它:存量行的那一座由 core readPendingSteerQueue
250
+ -- 折成 member 0,零迁移。NULL = 没有旧座。Never touches status/resolve (design/80 inv #4)。
251
251
  pending_steer TEXT NULL,
252
+ -- pending_steer_queue:core 5.14.0(#147)的**有界有序队列**(PendingSteerEntry[] 的 JSON)。
253
+ -- 为什么队列另起一列、而不是把信封写回 pending_steer(codex 复审 2026-08-06 F3,采纳):滚动升级/
254
+ -- 回滚窗里旧副本仍在跑,它对 pending_steer 是**无条件覆盖**写 —— 队列若躺在同一列,旧副本接的下一条
255
+ -- steer 会把 5.14 排好的整队悄悄抹掉,正是 #147 要消灭的那种静默丢失换了个位置复发。分列之后:
256
+ -- 旧副本写它的单座 ⇒ 落 pending_steer,新读侧把它折成 member 0,**两边都活着**;旧副本读不到队列
257
+ -- 是 core 自己已披露的回滚代价(CHANGELOG:rolling back across the queue event loses steers),
258
+ -- 那半无法在店面消除,但**写侧互相清零**这半可以,且代价只是一个可空列。
259
+ -- 容量:core 的 PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES = 48000 字节是对 {pendingSteer,queue} 整信封的
260
+ -- 硬上界(越界 fail-loud steering.queue_full,绝不驱逐)⇒ 本列恒小于它,MySQL TEXT 的 65535 字节
261
+ -- 够用,**不升 LONGTEXT**(core 常量注里点名「under a MySQL TEXT column with UTF-8 headroom」)。
262
+ pending_steer_queue TEXT NULL,
263
+ -- pending_steer_rev:pending_steer_queue 列**自己**的乐观锁计数(与 resolve/reopen 的 rev 是两个轴,
264
+ -- 刻意不复用)。队列化之后 setPendingSteer 从「一条无条件 UPDATE」变成「读-改-写」,中间那道窗必须
265
+ -- 有 CAS 守着,否则两个并发 steer 各读到同一份队列、后写者把前者的条目整个盖掉。
266
+ -- 🔴 为什么不拿队列列的旧值当守卫:本表没有显式 COLLATE,库默认多为 *_ci —— 字符串 <=> 在大小写
267
+ -- 不敏感的排序规则下会把两份只差字母大小写的 JSON 判等,CAS 假命中 = 丢一条指令。整数列没有这个面。
268
+ -- **绝不**用它做 resolve 的 OCC:bump 它不影响 core 的 rev 语义。
269
+ pending_steer_rev BIGINT NOT NULL DEFAULT 0,
252
270
  -- risk_descriptor (design/80 riskDescriptor inbox): core's INERT CheckpointGate.riskDescriptor ({severity
253
271
  -- 1-5, axes, toolName, redacted summary, touchedPaths}), stamped at put() so listPending can surface it +
254
272
  -- triage-sort the supervisor inbox by severity DESC — WITHOUT parsing the checkpoint blob per row.
@@ -602,6 +620,11 @@ export const SCHEMA_STATEMENTS = [
602
620
  pool VARCHAR(32) NOT NULL,
603
621
  PRIMARY KEY (pool)
604
622
  )`,
623
+ // #151(design/172)流内审批协议持久层的两张表。真源 = approval-ask-store-sql.ts 的
624
+ // `TIDB_APPROVAL_ASK_STATEMENTS`(那里与 PG twin 并排,方言差异一眼可对);展开进这个数组是为了让它们
625
+ // 跟着中央 `ensureSchema` 在 **named-lock 的那条 conn** 上建 —— 把 pool 级 ensure 函数塞进执行循环
626
+ // 会绕开 DDL 串行化。挂进来同时也是「这张表算生产 schema」的声明(基线导出按定义只收录被执行的语句)。
627
+ ...TIDB_APPROVAL_ASK_STATEMENTS,
605
628
  ];
606
629
  /** Named MySQL/TiDB advisory lock that serializes the whole `ensureSchema` DDL run (see below). */
607
630
  export const ENSURE_SCHEMA_LOCK = "sema_ensure_schema";
@@ -11,6 +11,9 @@ export interface QuestionFrame {
11
11
  /** "question_complete" only: whether the human answered (`answered`) or the ask was released unanswered
12
12
  * (`unanswered` — ttl/abort → the model got the headless default). Cosmetic dialog-dismiss. */
13
13
  outcome?: "answered" | "unanswered";
14
+ /** Server-signed emit timestamp (additive, [2805]§五→[2806]). Server-minted side-frame ⇒ no core-stream
15
+ * `eventId` to carry (absence is honest); this is the frame's own time coordinate. */
16
+ serverNowMs?: number;
14
17
  }
15
18
  /** The per-run context `onQuestion` recovers via ALS. `emit` delivers a frame to the run's live stream; `abortSignal`
16
19
  * (the run's own cancel signal) releases a question parked awaiting a human when the run aborts. */
@@ -19,6 +22,14 @@ export interface QuestionRunContext {
19
22
  owner: string | null;
20
23
  emit: (frame: QuestionFrame) => void | Promise<void>;
21
24
  abortSignal?: AbortSignal;
25
+ /** OPTIONAL reachability predicate for this leg's delivery face, consulted by {@link QuestionCoordinator.hasLiveContext}
26
+ * (the park-vs-live adjudication probe), NOT by `question` itself. CONSTRAINT: a leg whose face can go away WHILE the
27
+ * leg keeps running MUST supply it — `/v1/tasks/stream` under `x-detach-on-disconnect: true` keeps executing inside
28
+ * this ALS scope after the client left, and `emit` there writes to a dead socket and is silently dropped (question
29
+ * frames have no durable ledger twin, unlike SendUserFile's ledgerSink). Without the predicate the adjudication would
30
+ * read "live" and hand the leg to a face nobody can reach. ABSENT ⇒ always reachable, which is exactly right for the
31
+ * background leg (emit appends to the durable events tail, readable whenever a client attaches). */
32
+ deliverable?: () => boolean;
22
33
  }
23
34
  export interface QuestionThrottle {
24
35
  /** Max concurrent in-flight questions per run leg (parallel tool calls can each ask). Breach ⇒ headless default. */
@@ -67,10 +78,14 @@ export declare class QuestionCoordinator {
67
78
  };
68
79
  /** Test/observability hook: number of currently-parked questions. */
69
80
  pendingCount(): number;
70
- /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条活流腿的
71
- * per-run 上下文里(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade=false)。
81
+ /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条**投递得到人的**
82
+ * 活流腿上(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade、durable resume 驱动=false)。
72
83
  * resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
73
- * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。 */
84
+ * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。
85
+ * 🔴 判据是「投递面此刻可达」而不只是「ALS 在场」(复审 A1):detach 腿断连后 run 仍在本作用域里跑,
86
+ * 只判 ALS 会把一条谁也收不到的问题判成 allow(挂满 ttl 后以空答喂模型),而 park 才是它该走的腿。
87
+ * 谓词缺席 ⇒ 恒可达(后台腿的 durable events tail 语义)。谓词本身抛错按不可达处理:判决面
88
+ * fail-closed 到 park(park 可由运维补答,空答不可回收)。 */
74
89
  hasLiveContext(): boolean;
75
90
  private countersFor;
76
91
  }
package/dist/question.js CHANGED
@@ -158,7 +158,7 @@ export class QuestionCoordinator {
158
158
  // fire-and-forget append can commit out of seq order and be skipped by the monotonic events-tail cursor). A FAILED
159
159
  // emit headless-defaults (nobody can answer) and must NOT burn the per-run total.
160
160
  try {
161
- await ctx.emit({ type: "question", questionId: id, questions });
161
+ await ctx.emit({ type: "question", questionId: id, questions, serverNowMs: Date.now() });
162
162
  rc.total += 1;
163
163
  }
164
164
  catch {
@@ -168,7 +168,7 @@ export class QuestionCoordinator {
168
168
  // Completion breadcrumb (dialog dismiss) — FIRE-AND-FORGET so a slow durable append can never delay returning the
169
169
  // answer to core. Best-effort + ordering-uncritical (the shell already has the answer via respond).
170
170
  void Promise.resolve()
171
- .then(() => ctx.emit({ type: "question_complete", questionId: id, outcome: lastOutcome }))
171
+ .then(() => ctx.emit({ type: "question_complete", questionId: id, outcome: lastOutcome, serverNowMs: Date.now() }))
172
172
  .catch(() => undefined);
173
173
  return answer;
174
174
  };
@@ -192,12 +192,26 @@ export class QuestionCoordinator {
192
192
  pendingCount() {
193
193
  return this.pending.size;
194
194
  }
195
- /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条活流腿的
196
- * per-run 上下文里(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade=false)。
195
+ /** #152 ([2703] 案二):durable 部署上的 AskUserQuestion 判决探针——本调用点是否处在某条**投递得到人的**
196
+ * 活流腿上(runWithContext 包裹的 bg/SSE 腿=true;sync /v1/tasks、verify/cascade、durable resume 驱动=false)。
197
197
  * resolve-spec 的 durable question policy 用它在**判决时**分腿:有活流 ⇒ allow(问活人),无 ⇒
198
- * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。 */
198
+ * ask(durable park)。ALS 让这个判断天然 per-leg,policy 组装期不必预知腿别。
199
+ * 🔴 判据是「投递面此刻可达」而不只是「ALS 在场」(复审 A1):detach 腿断连后 run 仍在本作用域里跑,
200
+ * 只判 ALS 会把一条谁也收不到的问题判成 allow(挂满 ttl 后以空答喂模型),而 park 才是它该走的腿。
201
+ * 谓词缺席 ⇒ 恒可达(后台腿的 durable events tail 语义)。谓词本身抛错按不可达处理:判决面
202
+ * fail-closed 到 park(park 可由运维补答,空答不可回收)。 */
199
203
  hasLiveContext() {
200
- return this.als.getStore() !== undefined;
204
+ const ctx = this.als.getStore();
205
+ if (ctx === undefined)
206
+ return false;
207
+ if (ctx.deliverable === undefined)
208
+ return true;
209
+ try {
210
+ return ctx.deliverable();
211
+ }
212
+ catch {
213
+ return false;
214
+ }
201
215
  }
202
216
  countersFor(taskId) {
203
217
  let rc = this.counters.get(taskId);
package/dist/runs.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { Metrics } from "./observability/metrics.js";
5
5
  import type { ModelUsageTracker, PromptManifestTracker } from "./budget.js";
6
6
  import type { ElicitationCoordinator } from "./elicitation.js";
7
7
  import type { QuestionCoordinator } from "./question.js";
8
+ import { type ToolApprovalCoordinator } from "./tool-approval.js";
8
9
  import { type FleetRunPublisher } from "./fleet/fleet-bus.js";
9
10
  import { type WorkflowCompletionInbox } from "./orchestration/workflow-completion-inbox.js";
10
11
  import type { VerifyRoundsSpec } from "./http/verify-rounds.js";
@@ -250,7 +251,21 @@ completionDiagLog?: (msg: string, meta?: Record<string, unknown>) => void,
250
251
  sendUserFile?: SendUserFileEmitter,
251
252
  /** [998]② the shared prompt-manifest accumulator (tracer records at prepare; this leg drains the pending
252
253
  * record into a durable `prompt_assembled` event — turns/stream read it). undefined ⇒ not wired. */
253
- promptManifests?: PromptManifestTracker): Promise<void>;
254
+ promptManifests?: PromptManifestTracker,
255
+ /**
256
+ * #151 车3 刀 3b:**bg 腿的流内审批 ctx**(design/172 §4.3(b))。今天为止这条腿连 approval ALS 都没有
257
+ * ——`deps.onAsk` 拿不到 ctx ⇒ 恒 `"unavailable"` ⇒ durable park,一张卡都不产。接上之后,bg run 的
258
+ * 权限 ask 走本腿的 **durable events tail**(`GET /v1/runs/:id/events`,唯一带 SSE `id:` 的面)。
259
+ *
260
+ * `streamApprovalOn` = 协议上场判据(`resolveStreamApprovalGate`,由路由层求值后传进来 —— 本函数是纯
261
+ * 执行腿,不该自己读 backend/config)。为假 ⇒ 只接既有四键 ctx(呈卡口/腿轴都不接),本腿字节逐字不变。
262
+ */
263
+ approval?: {
264
+ coordinator: ToolApprovalCoordinator;
265
+ streamApprovalOn: boolean;
266
+ windowMs: number;
267
+ windowMarginMs: number;
268
+ }): Promise<void>;
254
269
  /** How often a running instance refreshes its run's updated_at (liveness, independent of events).
255
270
  * Must stay strictly below `runStaleSec` (asserted at startup) or the reaper would race live runs. */
256
271
  export declare const HEARTBEAT_MS = 30000;
package/dist/runs.js CHANGED
@@ -3,6 +3,7 @@ import { withPrincipal } from "./observability/principal-context.js";
3
3
  import { redactSecrets } from "./trace/redact.js";
4
4
  import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "./trace/project.js";
5
5
  import { createLedgerSink } from "./trace/ledger-sink.js";
6
+ import { createApprovalCardEmitter, resolveApprovalLeg } from "./tool-approval.js";
6
7
  import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
7
8
  import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
8
9
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "./orchestration/workflow-completion-inbox.js";
@@ -331,7 +332,16 @@ completionDiagLog,
331
332
  sendUserFile,
332
333
  /** [998]② the shared prompt-manifest accumulator (tracer records at prepare; this leg drains the pending
333
334
  * record into a durable `prompt_assembled` event — turns/stream read it). undefined ⇒ not wired. */
334
- promptManifests) {
335
+ promptManifests,
336
+ /**
337
+ * #151 车3 刀 3b:**bg 腿的流内审批 ctx**(design/172 §4.3(b))。今天为止这条腿连 approval ALS 都没有
338
+ * ——`deps.onAsk` 拿不到 ctx ⇒ 恒 `"unavailable"` ⇒ durable park,一张卡都不产。接上之后,bg run 的
339
+ * 权限 ask 走本腿的 **durable events tail**(`GET /v1/runs/:id/events`,唯一带 SSE `id:` 的面)。
340
+ *
341
+ * `streamApprovalOn` = 协议上场判据(`resolveStreamApprovalGate`,由路由层求值后传进来 —— 本函数是纯
342
+ * 执行腿,不该自己读 backend/config)。为假 ⇒ 只接既有四键 ctx(呈卡口/腿轴都不接),本腿字节逐字不变。
343
+ */
344
+ approval) {
335
345
  const startedAt = Date.now();
336
346
  metrics?.addGauge("runs_active", 1);
337
347
  fleetPublisher?.onStart(); // MF-Fleet: the row goes live (running) the moment the background run starts
@@ -372,6 +382,21 @@ promptManifests) {
372
382
  // The owning principal is this run's owner — thread it as the single-DB-fleet null-safe owner guard on every
373
383
  // run-store mutation/poll below (the run row was created with owner = principal ?? null).
374
384
  const owner = principal ?? null;
385
+ // #151 车3 刀 3b(§7.3 + §8.3):本腿的审批装配裁定走**三腿共用**的那一份判据。
386
+ // 🔴 codex 交叉复审 F3(2026-08-06 真 finding)的修:此前本腿**无条件**包 ALS,只把新协议的口与轴挂在
387
+ // `streamApprovalOn` 后面 —— 于是 ①开关关时本腿的行为变了(此前它根本没有 approval ctx,每只 ask 恒
388
+ // `"unavailable"` ⇒ 恒 park;包上之后变成活卡并等满窗,A-1 当场破),②窗=0 在本腿上不生效(照样落行、
389
+ // 注册资源、发帧,再等一个 0ms 的表)。现在两件都由下面这一个裁定统一管。
390
+ // 取值点 = **本腿开始执行的时刻** = `runInBackground` 进入(路由层 `void runInBackground(...)` 之后
391
+ // 立刻),不是请求到达时刻(§3.3:按任务创建至今算会系统性算错)。
392
+ const approvalLeg = resolveApprovalLeg({
393
+ streamApprovalOn: approval?.streamApprovalOn === true,
394
+ windowMs: approval?.windowMs ?? 0,
395
+ windowMarginMs: approval?.windowMarginMs ?? 0,
396
+ ...(typeof spec.limits?.maxWalltimeMs === "number" ? { legWalltimeMs: spec.limits.maxWalltimeMs } : {}),
397
+ nowMonotonicMs: performance.now(),
398
+ });
399
+ const legDeadlineMonotonic = approvalLeg.legDeadlineMonotonic;
375
400
  const heartbeat = setInterval(() => {
376
401
  // 鲁棒性批5 A5(2026-08-05):与下面 cancel/preempt 两条兄弟同族——此前裸吞错,store 持续故障期间本 run
377
402
  // 的心跳每拍静默落空、运维零信号,直到某个副本的 reapStale 把它误判死亡(心跳失败正是那个误判的前兆)。
@@ -476,6 +501,15 @@ promptManifests) {
476
501
  const { type, ...rest } = frame;
477
502
  return append(type, rest);
478
503
  };
504
+ // #151 车3 刀 3b(design/172 §4.3(b)):同款 durable-append emit,服务**审批**帧 —— 既有
505
+ // `tool_approval`/`tool_approval_complete` 走它,新协议的 `approval_request` 也走它(同一条投递面、
506
+ // 两种帧,§4.3(a) 的「不新增出口」在这条腿上的落实)。`type` 是 SSE 事件名,其余是 data。
507
+ // 撤卡帧(`approval_revoke`)**不接**:它是 live-only 语义(不进账本 —— reaper/收敛器那类调用点没有
508
+ // seq 可分配),而本腿的唯一投递面就是账本;丢帧的结构补偿是重连 preamble 的全量对账基准。
509
+ const approvalEmit = (frame) => {
510
+ const { type, ...rest } = frame;
511
+ return append(type, rest);
512
+ };
479
513
  try {
480
514
  // P1 ①② follow-on (core): drain this session's async-workflow completion inbox at the START of the
481
515
  // background leg (before ANY branch — verify/cascade/plain-stream all get it; parity with the sync leg's
@@ -742,10 +776,34 @@ promptManifests) {
742
776
  const withQuestion = () => question
743
777
  ? question.runWithContext({ taskId, owner: elicitOwner ?? owner, emit: questionEmit, abortSignal: cancelCtrl.signal }, withElicit)
744
778
  : withElicit();
779
+ // #151 车3 刀 3b:审批 ALS —— 本腿**首次**拿到卡面(此前 `deps.onAsk` 在这条腿上恒无 ctx ⇒
780
+ // `"unavailable"` ⇒ 恒 park)。嵌套序同样无关(各自独立 ALS)。
781
+ // 🔴 F3 修:包不包 ALS 由 `approvalLeg` 决定,不再是「协调器在场就包」。
782
+ // · `active === false`(协议没上场)⇒ 不包 ⇒ `deps.onAsk` 找不到 ctx ⇒ `"unavailable"` ⇒ core
783
+ // durable park —— 这正是本腿在协议之前的逐字行为(A-1)。
784
+ // · `windowZero`(运维关窗 / 贴 deadline)⇒ 同样不包。本腿没有 per-task `spec.onAsk` 装配点,
785
+ // 「不包 ALS」就是 sync 腿那个 immediate-unavailable 闭包在这条腿上的**等价形**(§8.2 已亲证:
786
+ // `approverUnavailable === true` 与「onAsk 缺席」在 park 结局上逐字等价)。
787
+ const withApproval = () => approval && approvalLeg.active && !approvalLeg.windowZero
788
+ ? approval.coordinator.runWithContext({
789
+ taskId,
790
+ owner: elicitOwner ?? owner,
791
+ emit: approvalEmit,
792
+ abortSignal: cancelCtrl.signal,
793
+ ...(spec.sessionId ? { sessionId: spec.sessionId } : {}),
794
+ // 同一条 durable tail(§4.3(b))。经共用的投递口工厂:账本 append 失败 ⇒ 如实算「这一路没
795
+ // 接下」(本腿只有这一个 sink,于是=整条没送到),协调器据此走 park 而不是挂到窗到期(R2-1)。
796
+ emitCard: createApprovalCardEmitter({ appendDurable: (f) => approvalEmit(f) }),
797
+ // bg run 的这条腿恒是**首腿**(park→resume 走的是 server.ts 的 resume 腿,那条自己带
798
+ // token 摘要);空串是首腿的真值,不是「未知」。
799
+ legKey: "",
800
+ ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
801
+ }, withQuestion)
802
+ : withQuestion();
745
803
  // SendUserFile 帧与 question 帧同投递面(durable events tail,web 渲染文件卡);嵌套序无关(ALS 互不相交)。
746
804
  return sendUserFile
747
- ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withQuestion)
748
- : withQuestion();
805
+ ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withApproval)
806
+ : withApproval();
749
807
  });
750
808
  // Cancelled mid-stream and the generator ended WITHOUT a terminal event (abort can end the stream cleanly
751
809
  // rather than throwing) — finalize cancelled here so the run isn't left "running" for the reaper to catch.
@@ -30,6 +30,9 @@ import type { RuntimeCaps } from "@sema-agent/core";
30
30
  import type { EntitlementRuntimeCaps } from "@sema-agent/registry-core";
31
31
  import { fetchPrincipalCaps, type ExecutionRuling } from "./config-center/facade.js";
32
32
  import type { ScenarioRuling } from "./capabilities/scenarios.js";
33
+ import { type Logger } from "./observability/logger.js";
34
+ /** test-only: reset the once-per-process trace so each test observes a fresh "first hit". */
35
+ export declare function resetExecutionLaneDefaultTraceForTest(): void;
33
36
  export interface EntitlementsResolverOpts {
34
37
  baseUrl: string;
35
38
  token: string;
@@ -99,8 +102,11 @@ export interface PrincipalEntitlementsClient {
99
102
  * allowlist + this worker's lane (machine code on the wire, prose stays with the shell/web).
100
103
  * No ruling / required=false / caps blip ⇒ pass (fail-open + audit). `allowedLanes` is an
101
104
  * OPEN domain — unknown names simply never match this worker's lane.
105
+ *
106
+ * `logger` (optional, defaults to this module's `defaultLogger`) is an injection seam purely for the
107
+ * once-per-process default trace below (test observability) — it does not change enforcement.
102
108
  */
103
- export declare function gateExecutionLane(ruling: ExecutionRuling | undefined, ownLane: string): void;
109
+ export declare function gateExecutionLane(ruling: ExecutionRuling | undefined, ownLane: string, logger?: Logger): void;
104
110
  /**
105
111
  * Build the per-principal caps client: `resolveRuntimeCaps` matches core's seam signature
106
112
  * `(principal) => Promise<RuntimeCaps | undefined>`; `scenarioRuling` rides the SAME fetch/cache/in-flight
@@ -2,6 +2,48 @@ import { fetchPrincipalCaps } from "./config-center/facade.js";
2
2
  import { HttpError } from "./security.js";
3
3
  import { createLogger } from "./observability/logger.js";
4
4
  const defaultLogger = createLogger();
5
+ /** #157 / [2762]§五(sema-comms `audits/failopen-governance-176.md` §7 "已知在办 1 条"):module-scope,
6
+ * once-per-process record of which {@link gateExecutionLane} fail-open DEFAULT form has already been traced.
7
+ * Two forms are counted SEPARATELY (both currently resolve to the same "pass" default, but they are different
8
+ * facts about the world — a caller must be able to tell "we never got a ruling for this principal" apart from
9
+ * "center ruled, and explicitly said not-required"):
10
+ * - `"no_ruling"` — `ruling === undefined` (no principal / center absent-or-blip / older center missing the key).
11
+ * - `"required_false"` — a ruling WAS resolved and it explicitly says `required: false`.
12
+ * Each form logs ONCE per process at first hit (a debug line — this is an expected, non-alarming default, not a
13
+ * warning); every subsequent hit of the SAME form only increments the counter (no repeat log line — this is a
14
+ * per-task gate, so without the throttle a busy deployment would log once per task forever). Pure record-keeping:
15
+ * never changes {@link gateExecutionLane}'s pass/deny outcome. */
16
+ const executionLaneDefaultTrace = new Map();
17
+ /** test-only: reset the once-per-process trace so each test observes a fresh "first hit". */
18
+ export function resetExecutionLaneDefaultTraceForTest() {
19
+ executionLaneDefaultTrace.clear();
20
+ }
21
+ function traceExecutionLaneDefault(form, ownLane, logger) {
22
+ const hitCount = (executionLaneDefaultTrace.get(form) ?? 0) + 1;
23
+ executionLaneDefaultTrace.set(form, hitCount);
24
+ if (hitCount > 1)
25
+ return; // subsequent hits: silently counted only (see map above), no repeat log line
26
+ try {
27
+ // codex 交叉复审第二轮真发现(2026-08-05):a throwing `logger.debug` must NEVER turn this pure
28
+ // observability step into gateExecutionLane itself throwing — that would silently convert a fail-open
29
+ // PASS into an uncaught exception, the exact inversion this module's whole design fights elsewhere
30
+ // (see the FAIL-CLOSED/FAIL-OPEN posture doc above). The hit-count above already advanced, so a retry
31
+ // of the SAME form stays silent rather than storming — the trade-off is "lose one trace line", not
32
+ // "lose one task admission".
33
+ logger.debug("execution_lane_default_applied", {
34
+ dimension: "execution_lane",
35
+ form,
36
+ ownLane,
37
+ default: "pass",
38
+ note: form === "no_ruling"
39
+ ? "no execution-lane ruling was ever resolved for this principal (no principal / center absent-or-blip / older center without the key)"
40
+ : "center resolved a ruling and explicitly set required=false",
41
+ });
42
+ }
43
+ catch {
44
+ // tracing must never affect the fail-open admission outcome — see comment above.
45
+ }
46
+ }
5
47
  /** Map center's `EntitlementRuntimeCaps` → the subset core's engine enforces. `allowUltracode` (shell/UX) is
6
48
  * dropped on purpose (not a core primitive); `allowWorkflows` / `forceDurableGate` / `allowFork` are the three the
7
49
  * engine enforces. Returns undefined when no enforceable cap is set (= no restriction). FAIL-CLOSED defense-in-depth:
@@ -60,10 +102,19 @@ export function scopedTokenNeedsWorker(token, worker) {
60
102
  * allowlist + this worker's lane (machine code on the wire, prose stays with the shell/web).
61
103
  * No ruling / required=false / caps blip ⇒ pass (fail-open + audit). `allowedLanes` is an
62
104
  * OPEN domain — unknown names simply never match this worker's lane.
105
+ *
106
+ * `logger` (optional, defaults to this module's `defaultLogger`) is an injection seam purely for the
107
+ * once-per-process default trace below (test observability) — it does not change enforcement.
63
108
  */
64
- export function gateExecutionLane(ruling, ownLane) {
65
- if (!ruling?.required)
109
+ export function gateExecutionLane(ruling, ownLane, logger = defaultLogger) {
110
+ if (!ruling) {
111
+ traceExecutionLaneDefault("no_ruling", ownLane, logger);
66
112
  return;
113
+ }
114
+ if (!ruling.required) {
115
+ traceExecutionLaneDefault("required_false", ownLane, logger);
116
+ return;
117
+ }
67
118
  if (!ruling.allowedLanes.includes(ownLane)) {
68
119
  throw new HttpError(403, `this worker's execution lane "${ownLane}" is not permitted for this principal (execution policy)`, {
69
120
  code: "execution_lane_not_allowed",
@@ -149,7 +200,18 @@ export function createPrincipalEntitlementsClient(opts) {
149
200
  return entry;
150
201
  }
151
202
  catch (err) {
152
- const e = err instanceof Error ? err : new Error(String(err));
203
+ // codex 交叉复审第三轮真发现(2026-08-05):a poison rejection reason (throwing `Symbol.toPrimitive`/
204
+ // `toString`) must not make THIS normalization line itself throw — this catch block exists to
205
+ // GUARANTEE the documented FAIL-CLOSED degrade below runs; if normalizing `err` throws, that
206
+ // guarantee is exactly what breaks (resolveRuntimeCaps/scenarioRuling/executionRuling would reject
207
+ // instead of resolving to their fail-closed/fail-open defaults on a degraded center response).
208
+ let e;
209
+ try {
210
+ e = err instanceof Error ? err : new Error(String(err));
211
+ }
212
+ catch {
213
+ e = new Error("center fetch failed with an unstringifiable rejection reason");
214
+ }
153
215
  // Caps face FAIL CLOSED: deny the OPTIONAL amplification caps (workflows + fork); never force the durable
154
216
  // gate on error. Scenario face FAIL OPEN: `scenario` stays undefined (no governance — never pin users to
155
217
  // the default scenario on a center blip; deliberate posture split, see PrincipalEntitlementsClient). Cache the
@@ -35,10 +35,17 @@ export type CommandRule = {
35
35
  * fail-open(那个教训的解=引用常量,不是折叠)。 */
36
36
  export declare function isDelegationToolName(name: string): boolean;
37
37
  /** The hands-read-only DELEGATION STRIP (the main.ts governed-spec site, extracted here so the actual filter —
38
- * not just the name predicate — is pinned by tests): core does not propagate `handsReadOnly` to a delegated
39
- * child, so a read-only (plan-mode) run must not mount a delegation tool at all — the child would get WRITABLE
40
- * hands, defeating the read-only contract. Identity (the same array) when nothing matches, so the caller's
41
- * spec-object churn stays minimal. */
38
+ * not just the name predicate — is pinned by tests): a read-only (plan-mode) run does not mount a delegation
39
+ * tool at all.
40
+ *
41
+ * 🔴 原始理由已被上游收编(2026-08-05 亲验安装包 core 5.13.x):子任务 spec 现在**继承** `handsReadOnly`
42
+ * (`dist/agents/subagent.js:1501` 的 `...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {})`,resume
43
+ * 腿 `:405` 同款)与 `shellGate`(`dist/core/runner/prepare-task.js` 的 `inheritedGateForChildren`),所以
44
+ * 「子任务会拿到可写的手」这个当年的缺口在 core 侧已经补上。本 strip 因此是**同向的第二层**(更严,不更松):
45
+ * 它保证 read-only 一档下连委派工具面都不铸,与继承是否存在无关。拆掉它是一次行为变更(read-only 任务将
46
+ * 重新能委派),需要单独决定——不要因为「上游修好了」就顺手删。
47
+ *
48
+ * Identity (the same array) when nothing matches, so the caller's spec-object churn stays minimal. */
42
49
  export declare function stripDelegationTools<T extends {
43
50
  name: string;
44
51
  }>(tools: readonly T[]): T[];
@@ -32,10 +32,17 @@ export function isDelegationToolName(name) {
32
32
  return name === DEFAULT_SUBAGENT_TOOL_NAME;
33
33
  }
34
34
  /** The hands-read-only DELEGATION STRIP (the main.ts governed-spec site, extracted here so the actual filter —
35
- * not just the name predicate — is pinned by tests): core does not propagate `handsReadOnly` to a delegated
36
- * child, so a read-only (plan-mode) run must not mount a delegation tool at all — the child would get WRITABLE
37
- * hands, defeating the read-only contract. Identity (the same array) when nothing matches, so the caller's
38
- * spec-object churn stays minimal. */
35
+ * not just the name predicate — is pinned by tests): a read-only (plan-mode) run does not mount a delegation
36
+ * tool at all.
37
+ *
38
+ * 🔴 原始理由已被上游收编(2026-08-05 亲验安装包 core 5.13.x):子任务 spec 现在**继承** `handsReadOnly`
39
+ * (`dist/agents/subagent.js:1501` 的 `...(ctx.handsReadOnly === true ? { handsReadOnly: true } : {})`,resume
40
+ * 腿 `:405` 同款)与 `shellGate`(`dist/core/runner/prepare-task.js` 的 `inheritedGateForChildren`),所以
41
+ * 「子任务会拿到可写的手」这个当年的缺口在 core 侧已经补上。本 strip 因此是**同向的第二层**(更严,不更松):
42
+ * 它保证 read-only 一档下连委派工具面都不铸,与继承是否存在无关。拆掉它是一次行为变更(read-only 任务将
43
+ * 重新能委派),需要单独决定——不要因为「上游修好了」就顺手删。
44
+ *
45
+ * Identity (the same array) when nothing matches, so the caller's spec-object churn stays minimal. */
39
46
  export function stripDelegationTools(tools) {
40
47
  const kept = tools.filter((t) => !isDelegationToolName(t.name));
41
48
  return kept.length === tools.length ? tools : kept;
@@ -232,7 +239,11 @@ export function applyRuntimeGovernance(base, governance) {
232
239
  const candidate = overrides.shellGate !== undefined && SHELL_GATE_RANK[overrides.shellGate] >= SHELL_GATE_RANK[governance.manualModeShellGate]
233
240
  ? overrides.shellGate
234
241
  : governance.manualModeShellGate;
235
- // base 已更严(如 resume 重放时 base 带 always)⇒ 省略,让 base 原样保留(省略=行为等价,不 throw)
242
+ // base 已更严 ⇒ 省略,让 base 原样保留(省略=行为等价,不 throw)。当前**装配上不可达、纯防御**:
243
+ // resolveSpec 的 spec 字面量从不写 shellGate,governance 是本进程里第一个产它的人(SUP 路由姿态在其**后**
244
+ // 才叠),所以每次真实调用的 base.shellGate 都是缺席。若将来有人给 base 种上更严的值,这里省略而不是让
245
+ // tightenTaskSpec 因「override 更松」throw——candidate 已是 autonomy 派生值与旋钮的较大者,省略它不会
246
+ // 丢掉 autonomy 那一半(autonomy 只产 "always",即最高 rank,永远不会落进这一支)。
236
247
  if (SHELL_GATE_RANK[candidate] >= SHELL_GATE_RANK[base.shellGate ?? "off"])
237
248
  overrides.shellGate = candidate;
238
249
  else
@@ -85,6 +85,10 @@ export declare function toolNameListFromBody(raw: unknown): string[] | undefined
85
85
  * ⇒ 无租户门(deferTools 同姿势);全委托树继承由 core 管。缺省不挂键(引擎缺省 simple,byte-compat)。
86
86
  * DEFENSIVE(resume 重放不过 HTTP 400 门):非法值 ⇒ undefined;提交面另有 fail-loud 400(邻居姿势)。 */
87
87
  export declare function promptProfileFromBody(raw: unknown): "simple" | "classic" | undefined;
88
+ /** [2856]②(core 5.14.0 `TaskSpec.toolMaterializeStrategy`):deferred 工具激活后的 schema 供给策略。
89
+ * 与 {@link promptProfileFromBody} 同姿势的**防御性归一**——提交面的 fail-loud 400 在 HTTP 门,这里
90
+ * 只负责 resume 重放存量 body 时把不认识的值静默 DROP(回落 core 的 env/缺省链),而不是把它带上 spec。 */
91
+ export declare function toolMaterializeStrategyFromBody(raw: unknown): "static" | "swap" | undefined;
88
92
  /** [854]④ — 每键的运营方上限旋钮(env,缺省不设=不封顶)。 */
89
93
  export interface TaskLimitCaps {
90
94
  /** TASK_TIMEOUT_MAX_SEC(env 名不动,仍按**秒**配置;core 5.8.0 起封顶对象是 limits.maxWalltimeMs,
@@ -351,6 +351,12 @@ export function toolNameListFromBody(raw) {
351
351
  export function promptProfileFromBody(raw) {
352
352
  return raw === "simple" || raw === "classic" ? raw : undefined;
353
353
  }
354
+ /** [2856]②(core 5.14.0 `TaskSpec.toolMaterializeStrategy`):deferred 工具激活后的 schema 供给策略。
355
+ * 与 {@link promptProfileFromBody} 同姿势的**防御性归一**——提交面的 fail-loud 400 在 HTTP 门,这里
356
+ * 只负责 resume 重放存量 body 时把不认识的值静默 DROP(回落 core 的 env/缺省链),而不是把它带上 spec。 */
357
+ export function toolMaterializeStrategyFromBody(raw) {
358
+ return raw === "static" || raw === "swap" ? raw : undefined;
359
+ }
354
360
  /** core 5.8.0 时限重构的退役键(fresh-submit 在 HTTP 门 400 点名;resume 重放在 normalize 层 DROP)。 */
355
361
  const RETIRED_LIMIT_KEYS = ["timeoutSec", "deadlineNudge", "callCapByDeadline", "gracefulFinalize"];
356
362
  /** 在场的退役键(normalizeLimits/resolveTaskLimits 共用判据源:留痕事件的 droppedKeys 字段)。 */