@sema-agent/server 7.2.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 (63) hide show
  1. package/README.md +2 -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/reapers.d.ts +34 -0
  17. package/dist/boot/reapers.js +198 -23
  18. package/dist/boot/resolve-spec.js +82 -30
  19. package/dist/config-types.d.ts +61 -1
  20. package/dist/config.d.ts +1 -0
  21. package/dist/config.js +130 -2
  22. package/dist/elicitation.d.ts +4 -0
  23. package/dist/elicitation.js +2 -2
  24. package/dist/http/routes/capabilities.js +14 -0
  25. package/dist/http/routes/runs.d.ts +1 -0
  26. package/dist/http/routes/runs.js +548 -16
  27. package/dist/http/routes/tasks.js +151 -9
  28. package/dist/http/server.d.ts +1 -1
  29. package/dist/http/server.js +114 -4
  30. package/dist/http/sse-log.d.ts +51 -0
  31. package/dist/http/sse-log.js +64 -0
  32. package/dist/http/wire-types.d.ts +20 -5
  33. package/dist/main.js +1 -1
  34. package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
  35. package/dist/plugins/approval-ask-store-memory.js +299 -0
  36. package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
  37. package/dist/plugins/approval-ask-store-sql.js +705 -0
  38. package/dist/plugins/background-agent-store-sql.js +20 -1
  39. package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
  40. package/dist/plugins/checkpoint-store-sql.js +297 -16
  41. package/dist/plugins/local-checkpoint-store.d.ts +6 -5
  42. package/dist/plugins/local-checkpoint-store.js +4 -0
  43. package/dist/plugins/pg-pool.js +11 -0
  44. package/dist/plugins/store-backend.d.ts +18 -0
  45. package/dist/plugins/store-backend.js +10 -0
  46. package/dist/plugins/tidb-pool.js +27 -4
  47. package/dist/question.d.ts +3 -0
  48. package/dist/question.js +2 -2
  49. package/dist/runs.d.ts +16 -1
  50. package/dist/runs.js +61 -3
  51. package/dist/runtime-caps-resolver.d.ts +7 -1
  52. package/dist/runtime-caps-resolver.js +65 -3
  53. package/dist/spec-fields.d.ts +4 -0
  54. package/dist/spec-fields.js +6 -0
  55. package/dist/task-settings.d.ts +35 -15
  56. package/dist/task-settings.js +19 -5
  57. package/dist/tool-approval.d.ts +296 -3
  58. package/dist/tool-approval.js +1066 -50
  59. package/dist/trace/core-keyset-guard.d.ts +2 -2
  60. package/dist/trace/ledger-sink.js +14 -1
  61. package/dist/trace/project.d.ts +55 -0
  62. package/dist/trace/project.js +135 -0
  63. package/package.json +4 -3
@@ -1,4 +1,6 @@
1
1
  import { type AskRequest, type AskOutcome } from "@sema-agent/core";
2
+ import { type ApprovalRequestFrame, type ApprovalRevokeFrame } from "./approval-card.js";
3
+ import type { ApprovalAskStore } from "./plugins/approval-ask-store-sql.js";
2
4
  /** A live approval frame delivered to whoever tails this run's stream. `type` IS the SSE event name (named-event
3
5
  * convention, same as question). The shell renders `tool_approval` as the CC three-choice card and dismisses on
4
6
  * `tool_approval_complete`. */
@@ -49,7 +51,47 @@ export interface ToolApprovalRunContext {
49
51
  sessionId?: string;
50
52
  owner: string | null;
51
53
  emit: (frame: ToolApprovalFrame) => void | Promise<void>;
54
+ /**
55
+ * #151 车6:**批级撤卡帧**(`approval_revoke`,live only —— 语义见 approval-card.ts 的
56
+ * `ApprovalRevokeFrame` 顶注)的投递口。
57
+ *
58
+ * 🔴 为什么是**独立的可选钩子**而不是给 `emit` 的入参加宽:`emit` 是既有 `tool_approval` 活卡腿的
59
+ * 通道,它的消费方(装配点 + 全部存量帧断言面)今天只认那两种帧;把入参改成联合会强迫每一个消费点
60
+ * 立刻处理一个它还不认识的帧形(存量测试的 `ToolApprovalFrame[]` 收集器首当其冲)。撤卡帧属于**新
61
+ * 协议**(`approval_request` 族)的一员,消费方是新壳 —— 分口投递是诚实的分层,装配点(车3 3b /
62
+ * 车4 域)可以与 `approval_request` 的发射点**同批**接上。
63
+ *
64
+ * 缺席 ⇒ 本连接不收撤卡帧(与 `approval_request` 今天尚未接上发射点同栏);壳侧的结构补偿恒是重连
65
+ * preamble 的全量对账基准。
66
+ */
67
+ emitRevoke?: (frame: ApprovalRevokeFrame) => void | Promise<void>;
68
+ /**
69
+ * #151 车3 刀 3b:**呈卡帧**(`approval_request`,design/172 §3.1)的投递口。
70
+ *
71
+ * 🔴 与 {@link ToolApprovalRunContext.emitRevoke} 同一条理由(见其顶注):新协议族的帧走**独立的可选
72
+ * 钩子**,不把 `emit` 的入参加宽成联合 —— `emit` 是既有 `tool_approval` 活卡腿的通道,它的全部消费点
73
+ * (三条装配腿 + 每一个存量 `ToolApprovalFrame[]` 收集器)今天只认那两种帧,加宽会强迫它们立刻处理一个
74
+ * 还不认识的帧形。设计稿 §4.3(a)「新帧同走 `emitApproval`、不新增出口」讲的是**投递面**(同一条 SSE /
75
+ * 同一条 durable tail),不是同一个 TS 字段:三条装配腿都把本钩子接到与 `emit` **同一个**写出口上,
76
+ * 于是 wire 上确实是一条面、两种帧。
77
+ *
78
+ * 缺席 ⇒ 本连接不收呈卡帧(旧壳照常靠 `tool_approval` 工作);`askId` 未确认落盘时**也不发**
79
+ * (设计稿 §2.2(b) 硬条款一:无持久身份不发新帧)。发射序钉死在 `tool_approval` **之后**(§4.2)。
80
+ */
81
+ emitCard?: (frame: ApprovalRequestFrame) => void | Promise<void>;
52
82
  abortSignal?: AbortSignal;
83
+ /** #151(design/172 §3.1 askId 派生的腿轴;车3 刀 3a 换轴:原 `leg?: number` → `legKey?: string`)。
84
+ * = `sha256(resume checkpoint token)` 的 hex,**首腿缺席折空串**(空串是首腿的真值,不是「未知」)。
85
+ * 与 `ToolApprovalRunContext.taskId`(= 派生里的 `runId` 轴)一起,使同一 `(sourceTaskId, toolCallId)`
86
+ * 在不同 run / 不同 resume 腿下铸出不同持久行(不会被上一腿或上一 run 已终结的行绊住)。
87
+ * 换轴动机与残留边界见 `approval-ask-machine.ts` 的 `deriveAskId` 头注。
88
+ * 真值由装配点(刀 3b)供给,本刀只立形 + 折空串兜底。 */
89
+ legKey?: string;
90
+ /** #151 车2(design/172 §3.3 窗长三元 D3):本 leg 的 walltime deadline(`performance.now()` 单调基,
91
+ * NOT `Date.now()`)。缺席 ⇒ 有效窗退化成构造时的 `ttlMs`(现行为逐字不变)。在场且
92
+ * `legRemainingMs − windowMarginMs ≤ 0` ⇒ 不开窗,直接走窗到期同路——§3.3 不变量:窗不得把一个
93
+ * 可 park 的 ask 拖成 abort-deny。真值由车3 的装配点供给,本车只立形+消费。 */
94
+ legDeadlineMonotonic?: number;
53
95
  }
54
96
  export type ToolApprovalDecision = "allow" | "allow_session" | "deny";
55
97
  /** Validate the respond body — the closed three-choice enum (rationale in the module header). */
@@ -61,6 +103,117 @@ export declare function parseToolApprovalResponse(body: unknown): {
61
103
  ok: false;
62
104
  error: string;
63
105
  };
106
+ /**
107
+ * #151 车3 刀 3b —— 「流内审批协议到底上不上场」的**单一谓词**(设计稿 §2.3 注入 / §2.4 能力面 /
108
+ * §8.4 park 设施自检,三处同一份判据)。
109
+ *
110
+ * 🔴 为什么必须是一个函数而不是三处各写一遍的合取式:车4 落地时曾在回决端点里放了一份局部的
111
+ * `backend.kind !== "local"` 临时判据,并在注释里写死「等能力面谓词落地就换掉本段 —— 两份判据长期并存
112
+ * 必然漂」。这就是那一车。三个消费点(协调器注入 / `/v1/capabilities` / 回决端点 501)现在读同一个符号,
113
+ * 于是「能力面说 true」⟺「协调器真拿到了 askStore」⟺「回决端点真有账可 CAS」是**结构成立**的,
114
+ * 不再靠三处注释互相提醒。
115
+ *
116
+ * 五个合取项,缺一即不上场(每一项的缺席都有它自己的 `reason`,供启动期 info 与诊断分辨):
117
+ * 1. `toolApprovalEnabled` —— 连活卡腿都没有,谈不上流内协议;
118
+ * 2. `streamApprovalEnabled` —— 协议总开关(默认 OFF);
119
+ * 3. `backend` 在场 —— env-only worker 没有 `StoreBackend` 本体;
120
+ * 4. **账必须是持久的**(`kind !== "local"`)—— InMemory 形重启即丢**已接受的决议**,那与「对账便利
121
+ * 丢失」不是一个量级(§2.2(b));File 形 ask 店落地后这一项自然翻真;
122
+ * 5. **park 设施在场**(§8.4)—— 缺席时「窗到期 ⇒ unavailable」在 core 侧没有降级目的地,结局是
123
+ * fail-closed deny,**比现状(5min 活卡、人能批)更差**。所以自检不满足 ⇒ **协议不上场**、现行
124
+ * `tool_approval` 活卡腿逐字保留,而不是「把 ask 推向一个不存在的目的地」(§14 属主照准,core [2794]
125
+ * 回帖确认即原意)。
126
+ */
127
+ export type StreamApprovalGate = {
128
+ active: true;
129
+ askStore: ApprovalAskStore;
130
+ } | {
131
+ active: false;
132
+ reason: "no_tool_approval" | "protocol_disabled" | "no_backend" | "volatile_ask_ledger" | "no_park_facility";
133
+ };
134
+ /** {@link resolveStreamApprovalGate} 的入参。`backend` 用**结构形**(不 import `StoreBackend`):本模块是
135
+ * 协调器的家,不该为一个布尔判据把整棵 store 依赖树拖进类型面。 */
136
+ export interface StreamApprovalGateInput {
137
+ toolApprovalEnabled: boolean;
138
+ /** 协议总开关。⚠️ 调用点一律写 `config.streamApproval?.enabled === true` —— **可选链是 fail-closed 的**:
139
+ * 配置段缺席只可能意味着「这个部署没有配它」,而协议的默认就是 OFF。真实 boot 恒有此段
140
+ * (`config.ts` 无条件铸 + 白名单门盯着),`?.` 服务的是那些只填被测路由用得到的键的 stub-harness。 */
141
+ streamApprovalEnabled: boolean;
142
+ backend: {
143
+ readonly kind: "mysql" | "pg" | "local";
144
+ approvalAsk(): ApprovalAskStore;
145
+ } | undefined;
146
+ /** park 设施在场性。本仓真码里 `checkpointStore` 的构造条件本身就是
147
+ * `backend?.checkpoint !== undefined && config.durableApproval`(main.ts),所以设计 §8.4 那条析取式的
148
+ * 「safety 词表非空」一支在**装配时刻**结构上不可达(core 的 irreversible/egress 集合来自 **per-task**
149
+ * 的 `spec.tools[].irreversibility/egress`,composition root 看不到)——两条缺失形因此在本仓收敛成
150
+ * 同一个 `no_park_facility`(D-5/D-6 两钉打的是两种**输入**形,不是两个 reason)。 */
151
+ parkFacility: boolean;
152
+ }
153
+ export declare function resolveStreamApprovalGate(input: StreamApprovalGateInput): StreamApprovalGate;
154
+ /**
155
+ * #151 车3 刀 3b —— **一条执行腿的审批装配裁定**(sync / bg / resume **三腿共用**)。
156
+ *
157
+ * 🔴 为什么必须是一个函数(codex 交叉复审 F3/F4,2026-08-06 真 finding):第一版把这套判断**只**写在
158
+ * `routes/tasks.ts` 的 sync 腿上,bg(`runs.ts`)与 resume(`server.ts`)两腿只把「新协议的口与轴」挂在
159
+ * `streamApprovalOn` 后面,**却无条件包了 ALS**。后果有两条,都是真的:
160
+ * ① **开关关时行为变了** —— 这两条腿此前根本没有 approval ctx(`deps.onAsk` 拿不到 ctx ⇒ 恒
161
+ * `"unavailable"` ⇒ 恒 park),包上之后每只 ask 都会变成一张活卡并等满窗。A-1「开关关=逐字零变化」
162
+ * 当场破。
163
+ * ② **窗=0 在这两条腿上不生效** —— 运维显式关窗 / 贴 deadline 时,它们照样落行、注册资源、发帧,
164
+ * 然后等一个 0ms 的定时器,而不是设计要求的「直接走 park,连卡都不发」。
165
+ * 一份判据、三处消费,这两类漂就结构性地不可能再发生。
166
+ *
167
+ * 三源(§8.3)里本仓可执行的是后两源;第一源 `forceDurableGate` 在 HTTP 装配时刻不可解析(§14 §12-5
168
+ * 亲裁:不可读则该源不做,core `prepare-task.js:3330` 结构性兜底),故不在本函数内。
169
+ *
170
+ * **`windowZero` 的两件事必须同时做**(调用方契约):不包 ALS **且**(sync 腿)注入 immediate-unavailable
171
+ * 闭包。只做一件另一条路仍会呈卡 —— bg/resume 两腿没有 per-task `spec.onAsk` 装配点,「不包 ALS」本身
172
+ * 就是它们的等价形(`deps.onAsk` 找不到 ctx ⇒ `"unavailable"` ⇒ core 走 durable park)。
173
+ */
174
+ export interface ApprovalLegAssembly {
175
+ /** 协议在本腿上是否上场。false ⇒ **既不包 ALS、也不接** `emitCard`/`emitRevoke`/`legKey`/
176
+ * `legDeadlineMonotonic` —— 本腿逐字保持协议之前的行为。 */
177
+ active: boolean;
178
+ /** §8.3 窗=0 恒 park。`active` 为假时恒 false(协议都没上场,谈不上关窗)。 */
179
+ windowZero: boolean;
180
+ /** §7.3 本 leg 的 walltime deadline(`performance.now()` 单调基)。只在 `active ∧ ¬windowZero ∧
181
+ * 有 walltime 墙` 时在场 —— 关着时供值会把既有 5min 活卡窗按三元公式缩短(§7.2 真行为变更)。 */
182
+ legDeadlineMonotonic?: number;
183
+ }
184
+ /**
185
+ * #151 车3 刀 3b(codex 交叉复审 round2 R2-1,2026-08-06 真 finding)—— 呈卡帧的**双写投递口**。
186
+ *
187
+ * 🔴 为什么必须收成一个有属主的工厂:round1 之后「新帧送达」开始**参与**「卡到底有没有送到人手上」的
188
+ * 判定(见 `emitOne` 顶注)。而 sync 腿原来那个内联闭包把两个 sink 的失败都吞掉、然后**正常返回** ——
189
+ * 于是「账本写失败 ∧ socket 已死」这种**真的一路都没送到**的情形被上报成成功,`anySucceeded` 因此压住了
190
+ * park 路由,ask 会一直挂到窗到期而**没有任何人可能回答它**。诚实的形只有一个:**至少一个 sink 真的接下
191
+ * 了才算送达**,一个都没接下就抛 —— 调用点(`emitOne`)的 catch 会把它如实记成 `card: false`。
192
+ *
193
+ * 顺序 = **先账本后 live**(与 file_link 的先例相反,理由是本帧的用武之地恰好在 live 面已死的时候):
194
+ * detach 断连后 live 写被丢弃,而壳换道 events tail 必须还能看到这张未决卡(§4.3(c) 的案A)。
195
+ *
196
+ * 命名(CLAUDE.md 工厂命名律):`create*` —— 返回的是**捕获了两个 sink 的闭包**,不是纯数据。
197
+ */
198
+ export declare function createApprovalCardEmitter(sinks: {
199
+ /** durable 账本口(bg/resume 腿恒有;sync 腿只有 detach 车道有)。抛 = 这一路没接下。 */
200
+ appendDurable?: (frame: ApprovalRequestFrame) => Promise<void>;
201
+ /** live SSE 口。**已死的流应当由调用方判成缺席或让它抛**,不要写一个静默 no-op —— 那正是本 finding。 */
202
+ writeLive?: (frame: ApprovalRequestFrame) => void | Promise<void>;
203
+ }): (frame: ApprovalRequestFrame) => Promise<void>;
204
+ export declare function resolveApprovalLeg(input: {
205
+ /** = `resolveStreamApprovalGate(...).active`(协议在本 worker 上场没有)。 */
206
+ streamApprovalOn: boolean;
207
+ /** `config.streamApproval.windowMs`(`STREAM_ASK_WINDOW_MS`)。 */
208
+ windowMs: number;
209
+ /** `config.streamAskWindowMarginMs`(`STREAM_ASK_WINDOW_MARGIN_MS`)。 */
210
+ windowMarginMs: number;
211
+ /** `spec.limits.maxWalltimeMs`。缺席 = 没有 walltime 墙 ⇒ 第三源不参与(**缺席 ≠ 0**)。 */
212
+ legWalltimeMs?: number;
213
+ /** 本腿**开始执行的时刻**的 `performance.now()`。取值点绝不能挪到腿启动之后:误差方向必须是
214
+ * 「窗偏短」(安全侧,§7.3 的误差方向论证)。 */
215
+ nowMonotonicMs: number;
216
+ }): ApprovalLegAssembly;
64
217
  /**
65
218
  * Coordinates the live tool-approval HITL for the singleton runner. Process-local + same-replica (the pending map is
66
219
  * in memory, like QuestionCoordinator): a respond that lands on another replica finds nothing → 404. Present (passed
@@ -76,6 +229,22 @@ export declare function parseToolApprovalResponse(body: unknown): {
76
229
  export declare class ToolApprovalCoordinator {
77
230
  private readonly als;
78
231
  private readonly pending;
232
+ /** #151 车2(codex 交叉复审 round3 抓获真 finding,round5 精化成 Set):次级索引,键 = 持久层 askId
233
+ * (与 {@link pending} 的 wire-面 uuidv7 `id` 是两条独立的身份轴,顶注同精神)——只在 askStore 在场且
234
+ * 这只 ask 真有 askId 时才登记。**同一 askId 下可能同时挂着不止一条本地条目**(round5 抓获:
235
+ * `ensureAsk` 是幂等 upsert,若 core 对同一 (sourceTaskId,runId,toolCallId,legKey) 真发起过两次并发调用——如
236
+ * 重试/failover 场景,round4 finding2 的顶注同源——两次 `askBroadcast` 各自的调用栈都会在行仍是
237
+ * STREAM_PENDING 时各自注册一条独立的本地 pending 条目,值形若是单值 Map 会被后到者覆盖前者的登记,
238
+ * 前者从此再也没有任何本地事件会驱动它 settle),故值形是 `Set`,不是单个 `PendingApproval`。
239
+ * 存在理由(两类真实成因共用同一套修复):①同一批(相同 sourceTaskId+leg)下若有多只**不同** ask 并发
240
+ * 在飞(不同 toolCallId,同一 batchId),`expireAsk` 赢家的同一次持久事务会把批内其余 STREAM_PENDING
241
+ * 兄弟原子撤成 VOID 并把它们的 askId 列在 `voidedSiblings` 里;②同一 askId 的**重复本地注册**(见上)。
242
+ * 两类情形下,那些"没赢"的本地条目各自独立的 timer/settle 闭包都对"这只 askId 其实已经有了终局"一无
243
+ * 所知:它们自己的窗到期/取消若稍后也去打 CAS,只会干净地输(D2 round2「输⇒什么都不做」),从此再没有
244
+ * 任何本地事件会驱动它们 settle——TTL 形同虚设,ask 会挂到进程重启。这个索引让赢家能反过来找到同一
245
+ * askId 下全部本地条目(自己 + 兄弟 + 重复注册),直接调用它们各自的 `settle`,而不是被动等一个永远
246
+ * 不会来的本地信号。 */
247
+ private readonly pendingByAskId;
79
248
  /** Per-capability session grants — keys = sessionAllowKey(owner, sessionId, category) (修2; bounded). */
80
249
  private readonly allowAllSessions;
81
250
  /** [1546] HIGH-1(broker)→[1559]四 core 产品裁定「多活集合+广播+首决胜出」:per-(owner, host-session)
@@ -88,7 +257,113 @@ export declare class ToolApprovalCoordinator {
88
257
  * 收 dismiss 通知(见 askBroadcast)。 */
89
258
  private readonly streams;
90
259
  private readonly ttlMs;
91
- constructor(ttlMs?: number);
260
+ /** #151 车2(design/172 §7 协调器半场,D1):可选持久层——缺席 ⇒ 每一条现行为逐字不变(in-memory
261
+ * promise 机械即契约);在场 ⇒ 四竞争者(回决/窗到期/取消/emit 全灭)的终局多一道持久 CAS 记账。
262
+ * additive 改造,不是替换——settle 闭包/pending map/TTL/broker 全保留,CAS 只决定「谁有权 settle
263
+ * 成什么终局」。 */
264
+ private readonly askStore?;
265
+ /** #151 车2(design/172 §3.3 D3):窗长三元公式的安全余量,构造期定,详见 {@link effectiveAskWindowMs}。 */
266
+ private readonly windowMarginMs;
267
+ /** #151 车3 刀 3b(设计稿 §0 X-2 写侧准入门):per-task / per-owner 的未决 ask 上限。 */
268
+ private readonly admitMaxPerTask;
269
+ private readonly admitMaxPerOwner;
270
+ /** X-2 的两把**在飞计数**。键 = 出处 taskId / owner(`null` 折一个不可能与真 principal 相撞的哨兵)。
271
+ * 值在**准入那一刻**加、在 `settle`(或准入后的任何早退路径)减 —— 计的是「已经分配了资源的未决 ask」,
272
+ * 不是「注册进 pending map 的条目」:两者之间隔着 `ensureAsk` 的一次 await,只在注册时计数会让并发的
273
+ * 一群 ask 全部越过门。 */
274
+ private readonly admitByTask;
275
+ private readonly admitByOwner;
276
+ /** #151 车2(D5 一次性 warn 节流):同实例只报第一次,后续只计数(避免 store 抖动期间刷屏)。 */
277
+ private storeErrorWarned;
278
+ private storeErrorTally;
279
+ constructor(opts?: {
280
+ ttlMs?: number;
281
+ askStore?: ApprovalAskStore;
282
+ windowMarginMs?: number;
283
+ admitMaxPerTask?: number;
284
+ admitMaxPerOwner?: number;
285
+ });
286
+ /**
287
+ * #151 车3 刀 3b —— design/172 §3.3 / 设计稿 §0 X-2 的**写侧准入门**。
288
+ *
289
+ * 位置(硬条款):在落 `pending`、建 timer、`ensureAsk` 落行、发帧**之前**。资源分配发生在**创建侧**
290
+ * (每只未决 ask 各带一条持久行 + 一只 timer + 一个 promise + 一份 SSE 载荷;`pending` map 本身无容量
291
+ * 上限,`MAX_ALLOW_SESSIONS` 只管 grant 集合),所以回决腿与恢复扫描都只能在**已经分配之后**动手 ——
292
+ * 门必须长在这里。
293
+ *
294
+ * 超限的处置是 `"unavailable"`(park 路由),**永不 deny**:过载是**我方**的容量事实,不是人对这次
295
+ * 操作的判断;用 deny 表达过载会把一次「本可以 park 后由人补批」的操作变成任务失败(§3.3 硬条款)。
296
+ *
297
+ * 只在 `askStore` 在场(= 协议开着)时把关:开关关闭时本方法恒放行,现行为逐字不变(D1/A-1)。
298
+ *
299
+ * ⚠️ **已认领的偏离**(设计 X-2 字面要求「计数在持久层做」):v1 是**进程内**计数 —— 它对单副本完整
300
+ * 有效,多副本下每个副本各自把关(真实上限 = N × 帽)。理由=持久层计数要么在每只 ask 的热路径上多一次
301
+ * 往返(与「门必须在分配之前」叠加成两跳),要么引入一张新的计数表与它自己的收敛问题;而本门的目的是
302
+ * **防单腿失控**(一个疯狂 ask 的 run 打爆本副本的内存/连接),那正是进程内计数能完整覆盖的形。
303
+ * 多副本级的总量控制登记为后续件(汇报存疑单)。
304
+ */
305
+ private admit;
306
+ /** 测试/可观测性钩子(X-2):某 (taskId) 维当前占用的准入名额数。 */
307
+ admittedCount(taskId: string): number;
308
+ /** #151 车2(D5 store 故障姿势):任何 store 调用 throw ⇒ fail-open 到进程内机械照旧(store 是记账/
309
+ * 收敛层,不是投递面——裁决可用性不因它抖动而降级),一次性 `logger.warn`(同实例只报第一次,后续
310
+ * 只计数)。**区分**「store threw」(本方法专管)与「CAS 输」(如实拒绝,D2 必须服从,绝不算故障、
311
+ * 绝不走本方法)。 */
312
+ private noteStoreError;
313
+ /** #151 车5(R2-5):给一次 store 调用套墙钟上限。超时 = 以 `Error` 拒绝 ⇒ 调用点既有的 catch(D5
314
+ * fail-open / 防御性复核)原样接住,不需要为超时新增一条语义。定时器一律 `unref`(绝不持住进程),
315
+ * 竞速输的那一路由 `Promise.race` 自己的 rejection handler 接住(不会变成 unhandled rejection)。 */
316
+ private withStoreDeadline;
317
+ /** 测试/可观测性钩子(D5):store 调用失败的累计次数(第一次触发 warn,其余只计数——本方法让「只计数」
318
+ * 那部分可断言)。 */
319
+ storeErrorCount(): number;
320
+ /** #151 车2(codex 交叉复审 round3 抓获、round5 精化,真 finding,{@link pendingByAskId} 顶注有完整
321
+ * 背景):某个 ask 赢下持久 CAS 时,同一 askId 下**全部**其余本地条目(批内被原子撤卡的兄弟、或同一
322
+ * askId 的重复本地注册——两类成因,{@link pendingByAskId} 顶注)都对"这只 askId 其实已经有了终局"一无
323
+ * 所知,任其自生自灭会让它们的 TTL 形同虚设(round2 之后,它们自己的窗到期/取消一旦发现 CAS 已经干净
324
+ * 地输,只会「什么都不做」,永远等不到本地事件驱动 settle)。这里直接反查这些 askId 各自的本地 pending
325
+ * 条目集合并逐个调用它们自己的 `settle` 闭包(复用它们自己的 timer 清理/abort 监听器摘除/pending
326
+ * 删除),把远程/其他调用栈发生的终局如实同步回本地——查无对应条目(不在这个副本、或从未真正注册过)
327
+ * 是正常情况,静默跳过。**调用方职责**:传入的 `askIds` 应当包含调用方自己的 askId(捕获"同一 askId
328
+ * 的重复本地注册"这一支)以及(如适用)`expireAsk` 返回的 `voidedSiblings`(捕获"批内兄弟被撤卡"那一
329
+ * 支)——赢家自己的条目此刻已经 settle 过、已经从集合里摘除(见 settle() 内的摘除逻辑),故这里对它
330
+ * 自己重复调用 `settle` 天然是无操作,不会有副作用。 */
331
+ private settleVoidedSiblings;
332
+ /**
333
+ * #151 车4 §12-E(F29/F30 裁定形):外部回决(车4 端点或本类 respond 腿)**赢下持久 CAS 之后**,把同
334
+ * askId 下全部本地悬挂条目按**真实决议**结算——端点已是唯一权威(CAS 已落),本地只是同步终局;查无
335
+ * 条目(跨副本/无流内窗)= 正常,返回 `{ settled: 0 }`,调用方据此诚实回显 `updatedInputForwarded`。
336
+ *
337
+ * 🔴 与 {@link settleVoidedSiblings} 是**两个语义,禁合并**(F29):那边是撤卡收尾,结算值恒
338
+ * `(false, "expired")`——批内兄弟被原子撤卡,它们的终局就是「没了」;这边是「同一只 askId 已经有了
339
+ * 真决议」,重复注册必须拿到**同一个**决议(approve 就是 approve)——合并会把外部批准的重复条目错
340
+ * 结算成拒绝。已被结算过的条目再调 settle 天然无操作(幂等),所以本口对「赢家自己已 settle」安全。
341
+ */
342
+ notifyExternalDecision(askId: string, allowed: boolean, updatedInput?: unknown): {
343
+ settled: number;
344
+ };
345
+ /**
346
+ * #151 车6:把一张**批级撤卡帧**投给给定的一组连接(live only —— 见 `ApprovalRevokeFrame` 顶注:
347
+ * 撤卡帧不进 durable tail,丢帧的结构补偿是重连 preamble 的全量对账基准)。
348
+ *
349
+ * 空名单不发(零信息的帧只会让壳多一次无意义的对账)。每路独立 catch:一路 emit 失败(连接刚死、
350
+ * durable-append 目标抖动)**绝不回滚已经落定的持久 CAS** —— 帧是通知,行才是真源。
351
+ */
352
+ private emitRevokeTo;
353
+ /**
354
+ * #151 车6 发射点③④:**进程外**收敛器(reaper 腿的 `approval-reconciler.ts`)产出的撤卡帧的投递口。
355
+ * 收敛器没有 ctx —— 它只有行上的 (owner, sessionId, taskId),经 broker 反查该身份下**当前**的活跃
356
+ * 连接集合。查无活连接 = 正常(壳不在线;重连 preamble 会把这张卡对账掉),返回 0。
357
+ *
358
+ * 两把 key 都试(session 形 + adhoc 形)并去重:行上的 `sessionId` 对 adhoc 腿折的是 taskId
359
+ * (车2 落行时的折叠),而 broker 的 adhoc 分桶键用的正是 taskId —— 两形各试一次比在这里猜哪种腿
360
+ * 更诚实,代价是一次 Map 查找。
361
+ */
362
+ emitRevoke(frame: ApprovalRevokeFrame, target: {
363
+ owner: string | null;
364
+ sessionId: string;
365
+ taskId: string;
366
+ }): number;
92
367
  /** Broker key:宿主 session 优先(retained/续聊子代跨 SSE 找得到新流),sessionless adhoc 退 taskId。
93
368
  * 并发审查加固:显式维度前缀(`"session"`/`"adhoc"`)分隔两个取值域,不再靠 `task:` 字符串前缀"祈祷"
94
369
  * 不会跟一个真实 sessionId 字面重合——此前形式 `sessionId ?? "task:"+taskId` 里,若某会话的
@@ -123,6 +398,10 @@ export declare class ToolApprovalCoordinator {
123
398
  owner: string | null;
124
399
  taskId: string;
125
400
  sessionId?: string;
401
+ legKey?: string;
402
+ /** #151 车3 刀 3b(§7.3):本腿的 walltime deadline —— 与 `legKey` 同源同理由,随**出处**走
403
+ * (子代经继承链拿到的是宿主腿的闭包,窗必须按宿主腿的剩余 walltime 算)。 */
404
+ legDeadlineMonotonic?: number;
126
405
  }) => ((req: AskRequest, signal?: AbortSignal) => Promise<AskOutcome>);
127
406
  /** 广播版裁决路径——`ask()`(ALS,恒单元素数组)与 `boundAsk`([1559]四多活集合,可能多元素)共用。
128
407
  * 全体 `ctxs` 保证同一 (owner, sessionId)(streamKey 分组不变式;单元素数组平凡成立)。
@@ -138,11 +417,25 @@ export declare class ToolApprovalCoordinator {
138
417
  private askBroadcast;
139
418
  /** `POST /v1/tool-approvals/:id/respond` — settle a parked approval with the shell's decision. Owner-gated with a
140
419
  * 404 (no existence oracle), body validated first (400 is existence-independent) — question/steer parity. The HTTP
141
- * layer owns auth (gatedPrincipal + REQUIRE_PRINCIPAL) before calling. */
420
+ * layer owns auth (gatedPrincipal + REQUIRE_PRINCIPAL) before calling.
421
+ *
422
+ * #151 车2(D1/D2):return type 是 UNION,不是把整个方法标 `async`——askStore 缺席时这个方法逐字同步
423
+ * 完成(D1 现行为不变;现存量测试对它的同步断言/`void` 调用零改动)。在场时才真的变成 Promise(D2 的
424
+ * 「settle 前先赢一把 decideAsk CAS」离不开 await,同步函数做不到)。唯一生产调用点(`routes/runs.ts`)
425
+ * 统一 `await`——`await` 对非 Promise 值是恒等操作,两条路径对它透明。 */
142
426
  respond(id: string, principal: string | undefined, body: unknown): {
143
427
  status: number;
144
428
  body: unknown;
145
- };
429
+ } | Promise<{
430
+ status: number;
431
+ body: unknown;
432
+ }>;
433
+ /** store 在场时的回决 CAS 门(D2)——`respond()` 的异步分支,拆出以保持 `respond()` 本身在 store 缺席
434
+ * 时仍是纯同步函数(D1)。`askStore`/`askId`/`batchId` 由调用方在已窄化的分支里传入(避免非空断言)。 */
435
+ private respondWithCas;
436
+ /** 回决收尾(D1 现行为逐字不变的那一半)——session allow-all 记账 + settle + 200 响应体。原 `respond()`
437
+ * 方法体的逐字搬运(#151 车2 拆分,行为零改动)。 */
438
+ private finishRespond;
146
439
  /** Test/observability hooks. */
147
440
  pendingCount(): number;
148
441
  sessionAllowedCount(): number;