@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
@@ -0,0 +1,237 @@
1
+ /**
2
+ * #151 车3(design/172 流内审批协议)—— **审批卡的 schema 属主 + 重放腿的纯函数**。
3
+ *
4
+ * 本模块只出两样东西,**都不带 IO、不带定时器、不发帧**:
5
+ * 1. `ApprovalCardSchema` / `ApprovalCardEnvelopeSchema` —— 落库 `approval_asks.card_json` 与一切读面
6
+ * (重放腿=刀 3c、回决端点=车4、对账扫描=车5)共用的**同一份** schema。写侧(刀 3b 的 `ensureAsk`)
7
+ * 与读侧走同一个符号,于是「存的形」与「读的形」不可能各自漂。
8
+ * 2. `buildReplayFrame` / `buildApprovalPreamble` —— 行 → 帧的**纯投影**(设计稿 §5.2)。
9
+ *
10
+ * 🔴 为什么读面必须 `safeParse` 而不是裸 as-cast:`card_json` 是 JSON 列,驱动回读的是 `unknown`。
11
+ * 宪法 [2704](边界必 schema / 禁裸 as-cast / schema 单一属主)在这里是硬约束——一条形状漂了的历史行
12
+ * 若被 as-cast 成 `ApprovalCard`,它会带着 `undefined` 字段一路走到 wire 上,消费端拿到的是「结构上
13
+ * 合法、语义上空」的卡。`safeParse` 让这种行**当场落地为「跳过 + 一次 warn」**(§5.3)。
14
+ *
15
+ * schema 属主裁定(设计稿 §12-2,属主 §14 裁 (a) 变体):v1 的 schema 属主 = server 本仓,server 加
16
+ * **zod 直依赖**(此前 zod 只经 `@sema-agent/registry-core` 传递到场)。抽进 `@sema-agent/registry-core`
17
+ * 的时机 = 出现第二个**运行期**消费者(cli 呈卡校验排期时)——届时是「属主迁移」而不是「复制形状」,
18
+ * 不违单一属主宪法;此刻抽包 = 为不存在的消费者发一轮 registry-core 版本 + floor bump,零收益。
19
+ *
20
+ * 命名(CLAUDE.md 工厂命名律):本文件全部是 `build*` —— 返回值是纯数据(帧对象 / 帧数组 / 计数),
21
+ * 没有方法、没有捕获的行为。
22
+ */
23
+ import { z } from "zod";
24
+ /** 模型自由文本(`sourceAgentName` / `delegation.agentName`)的限长(设计稿 §6.2)。设计 §3.1 把
25
+ * 「限长 + 脱敏」写成 **server 新增责任**(引擎无此层):spawning model 挑的名字是自由文本,
26
+ * 不能指望壳去截——一条 100KB 的 agentName 在 server 侧就该被拒,而不是变成一张撑爆呈卡面的卡。 */
27
+ export const MAX_AGENT_NAME = 200;
28
+ /** 中性投影里的自由文本/标识符统一限长(工具名、toolCallId、sourceTaskId)。 */
29
+ const MAX_IDENT = 200;
30
+ /** 已 redact 的 ask 文案上限(`AskRequest.message` 的投影)。 */
31
+ const MAX_MESSAGE = 8192;
32
+ /**
33
+ * design/172 §3.1 的**中性投影**——呈卡面看到的全部内容,与 wire 面的既有 `ToolApprovalFrame` 解耦。
34
+ *
35
+ * `risk` 的三态形(设计稿 §14.1,core [2794] 回帖后定):`AskRequest.riskAxes?.{irreversible,egress}`
36
+ * 是 **additive optional**,**缺席 = 引擎未判,不是「安全」**。所以两轴在这里是 `optional()`:
37
+ * `true` / `false` / **缺席(未标注)** 三态各自可分,投影层**禁把缺席折算成 false** —— 那等于替引擎
38
+ * 打包票。`requiresRealApproval` 是 core 今天唯一在场的粗粒度安全类标记(必填,车2 已有真值来源)。
39
+ */
40
+ export const ApprovalCardSchema = z
41
+ .object({
42
+ toolName: z.string().max(MAX_IDENT),
43
+ /** 已 redact(车2 `redactDeep`)。 */
44
+ message: z.string().max(MAX_MESSAGE),
45
+ /** 已 redactDeep + **字节**帽(设计稿 §6.5:`Buffer.byteLength`,不是 `.length` 的 UTF-16 单元)。 */
46
+ args: z.unknown().optional(),
47
+ argsOmitted: z.literal(true).optional(),
48
+ toolCallId: z.string().max(MAX_IDENT).optional(),
49
+ risk: z
50
+ .object({
51
+ /** 缺席 = 未标注(禁折算 false)。 */
52
+ irreversible: z.boolean().optional(),
53
+ /** 缺席 = 未标注(禁折算 false)。 */
54
+ egress: z.boolean().optional(),
55
+ requiresRealApproval: z.boolean(),
56
+ })
57
+ .strict(),
58
+ /** 委派出处(子代 ask 才在场;判别键 = `fromSubagent`,core RB-39②)。 */
59
+ fromSubagent: z.literal(true).optional(),
60
+ sourceTaskId: z.string().max(MAX_IDENT).optional(),
61
+ sourceAgentName: z.string().max(MAX_AGENT_NAME).optional(),
62
+ delegation: z
63
+ .object({
64
+ parentToolCallId: z.string().max(MAX_IDENT),
65
+ depth: z.number().int().nonnegative(),
66
+ agentName: z.string().max(MAX_AGENT_NAME).optional(),
67
+ })
68
+ .optional(),
69
+ })
70
+ .strict();
71
+ /**
72
+ * `approval_asks.card_json` 里真正存的东西(设计稿 §6.2)。存**信封**而不是裸卡的理由:重放帧要回填
73
+ * 车2 铸的 `approvalId`(wire 面的回决通道桥 + 消费端与并行 `tool_approval` 帧的去重键),而它不属于
74
+ * 「卡的内容」——放进卡里会让 schema 的语义边界糊掉。`schemaVersion` 是形的版本闩(与行上的
75
+ * `schema_version` 列同值,列供 SQL 侧过滤,信封里这份供读面在解出内容**之前**判形)。
76
+ */
77
+ export const ApprovalCardEnvelopeSchema = z
78
+ .object({
79
+ schemaVersion: z.literal(1),
80
+ approvalId: z.string(),
81
+ card: ApprovalCardSchema,
82
+ })
83
+ .strict();
84
+ /** 本车铸的信封形版本(写侧=刀 3b,读侧=本文件)。 */
85
+ export const APPROVAL_CARD_SCHEMA_VERSION = 1;
86
+ /**
87
+ * core 的风险轴(`AskRequest.riskAxes`,[2829]/§14.1)的**边界窄读**。
88
+ *
89
+ * 🔴 为什么是 `safeParse` 而不是读 `req.riskAxes`:树上的 core d.ts(5.13.0)**还没有这个键**,
90
+ * 而字段形已由 core 认领(5.14.0-pre)。窄读让**编译与行为解耦**——今天编译得过、缺席=未标注;
91
+ * 终版到货后同一行代码自然点亮,不需要回来改一个字。裸 `as` 转型会在两个方向上都出错:既绕过了
92
+ * 宪法 [2704] 的「边界必 schema」,也会在 core 真发出一个形状不同的键时静默把垃圾投上卡面。
93
+ *
94
+ * 未知键被 zod 默认 strip(此处**故意不 `.strict()`**:core additive 加轴时不该让整只 ask 的卡面塌掉);
95
+ * 形不合(如 `irreversible: "yes"`)⇒ 整个 safeParse 失败 ⇒ 按**缺席**处置 = 「未标注」,
96
+ * 绝不折算成 `false`(那等于替引擎打包票,§14.1 明令)。
97
+ */
98
+ const RiskAxesEnvelopeSchema = z.object({
99
+ riskAxes: z
100
+ .object({
101
+ irreversible: z.boolean().optional(),
102
+ egress: z.boolean().optional(),
103
+ })
104
+ .optional(),
105
+ });
106
+ /** 自由文本的**截断**(不是拒绝):卡必须还是要送到人手上——`toolName`/`message` 少几个字仍可决策,
107
+ * 而整只 ask 因为一个 200KB 的 agentName 落不了库/上不了 wire 才是真事故(设计稿 §6.2 的 `.max()`
108
+ * 在 server 侧的落实形,E-5 钉)。`undefined` 透传(缺席 ≠ 空串)。 */
109
+ function clip(s, max) {
110
+ if (s === undefined)
111
+ return undefined;
112
+ return s.length > max ? s.slice(0, max) : s;
113
+ }
114
+ /**
115
+ * design/172 §3.1 的**中性投影**(设计稿 §6.2)—— 写侧的唯一铸造点。
116
+ *
117
+ * `risk` 三态(§14.1):两轴 `optional`,`true`/`false`/**缺席(未标注)** 各自可分。`req` 是 core 交来的
118
+ * `AskRequest`(可能带、也可能不带 `riskAxes`),按 `unknown` 窄读;`requiresRealApproval` 是 core 今天
119
+ * 唯一在场的粗粒度安全类标记,缺席 = 这不是一次安全类 ask(core 的铸造点语义,不是我们的折算)。
120
+ */
121
+ export function buildApprovalCard(source, req, requiresRealApproval) {
122
+ const axes = RiskAxesEnvelopeSchema.safeParse(req);
123
+ const riskAxes = axes.success ? axes.data.riskAxes : undefined;
124
+ const toolCallId = clip(source.toolCallId, MAX_IDENT);
125
+ const sourceTaskId = clip(source.sourceTaskId, MAX_IDENT);
126
+ const sourceAgentName = clip(source.sourceAgentName, MAX_AGENT_NAME);
127
+ return {
128
+ toolName: clip(source.toolName, MAX_IDENT) ?? "",
129
+ message: clip(source.message, MAX_MESSAGE) ?? "",
130
+ ...(source.argsOmitted === true ? { argsOmitted: true } : source.args !== undefined ? { args: source.args } : {}),
131
+ ...(toolCallId !== undefined ? { toolCallId } : {}),
132
+ risk: {
133
+ ...(riskAxes?.irreversible !== undefined ? { irreversible: riskAxes.irreversible } : {}),
134
+ ...(riskAxes?.egress !== undefined ? { egress: riskAxes.egress } : {}),
135
+ requiresRealApproval,
136
+ },
137
+ ...(source.fromSubagent === true ? { fromSubagent: true } : {}),
138
+ ...(sourceTaskId !== undefined ? { sourceTaskId } : {}),
139
+ ...(sourceAgentName !== undefined ? { sourceAgentName } : {}),
140
+ ...(source.delegation
141
+ ? {
142
+ delegation: {
143
+ parentToolCallId: clip(source.delegation.parentToolCallId, MAX_IDENT) ?? "",
144
+ depth: source.delegation.depth,
145
+ ...(source.delegation.agentName !== undefined ? { agentName: clip(source.delegation.agentName, MAX_AGENT_NAME) ?? "" } : {}),
146
+ },
147
+ }
148
+ : {}),
149
+ };
150
+ }
151
+ /** 落库信封的纯构造(写侧;读侧 = `ApprovalCardEnvelopeSchema.safeParse`,**同一个 schema**)。 */
152
+ export function buildApprovalCardEnvelope(approvalId, card) {
153
+ return { schemaVersion: APPROVAL_CARD_SCHEMA_VERSION, approvalId, card };
154
+ }
155
+ /**
156
+ * design/172 §3.1 的呈卡帧(设计稿 §4.1)—— **live 铸造**形(重放形见 {@link buildReplayFrame},
157
+ * 两者投的是同一种帧,区别只在 `card` 的来源:这里是刚投影出来的,那里是从行上回读的)。
158
+ *
159
+ * `expiresAtMs` **入参**而不是在这里现算:同一只 ask 的窗**只铸一次**(§3.1「永不赋新 deadline」),
160
+ * 调用点已经算好了它(既是定时器时长的来源,也是落库 `expires_at_ms` 的值)——在这里重算会铸出第二个
161
+ * 真源,而两个真源必然漂。
162
+ */
163
+ export function buildApprovalRequestFrame(input) {
164
+ return {
165
+ type: "approval_request",
166
+ schemaVersion: APPROVAL_CARD_SCHEMA_VERSION,
167
+ askId: input.askId,
168
+ taskId: input.taskId,
169
+ kind: "permission",
170
+ card: input.card,
171
+ expiresAtMs: input.expiresAtMs,
172
+ expiresInMs: Math.max(0, input.expiresAtMs - input.nowMs),
173
+ serverNowMs: input.nowMs,
174
+ approvalId: input.approvalId,
175
+ };
176
+ }
177
+ /** 撤卡帧的纯构造(`build*`:返回纯数据,无方法无捕获行为)。`askIds` 拷贝一份 —— 调用方传进来的多是
178
+ * store 返回的数组,帧不该与它共享可变引用。 */
179
+ export function buildRevokeFrame(batchId, askIds, reason, serverNowMs) {
180
+ return { type: "approval_revoke", schemaVersion: 1, batchId, askIds: [...askIds], reason, serverNowMs };
181
+ }
182
+ /**
183
+ * 持久行 → 重放帧(设计稿 §5.2 逐字)。
184
+ *
185
+ * 三条不变量都在这几行里:
186
+ * - **坏行不炸开流**:`safeParse` 失败 ⇒ 返回 `undefined`(调用方跳过 + 记一次 warn,§5.3)。
187
+ * - **不续窗**:`expiresAtMs` 逐字回读,**永不重铸**;`expiresInMs` 由它与 `nowMs` 现算 ⇒ 同一张卡
188
+ * 连续两次重放必然严格单调减(机器判据 = §10 钉 B-2)。
189
+ * - **零写**:本函数不碰 store —— 重放腿只读,不 `transitionAsk`、不改 `expires_at_ms`、不重挂定时器。
190
+ * 过窗未收敛的行(`expiresInMs === 0`)**仍然重放**,不在这里代打 `expireAsk` CAS(那会制造第五个
191
+ * 竞争者;收敛是车2 的窗到期竞争者与车5 恢复扫描的职责)。
192
+ */
193
+ export function buildReplayFrame(row, nowMs) {
194
+ const parsed = ApprovalCardEnvelopeSchema.safeParse(row.cardJson);
195
+ if (!parsed.success)
196
+ return undefined;
197
+ return {
198
+ type: "approval_request",
199
+ schemaVersion: 1,
200
+ askId: row.askId,
201
+ taskId: row.taskId,
202
+ kind: "permission",
203
+ card: parsed.data.card,
204
+ expiresAtMs: row.expiresAtMs,
205
+ expiresInMs: Math.max(0, row.expiresAtMs - nowMs),
206
+ serverNowMs: nowMs,
207
+ approvalId: parsed.data.approvalId,
208
+ };
209
+ }
210
+ /**
211
+ * 开流 preamble 的行集 → 帧集(设计稿 §5.3 的读面帽 + 坏行跳过)。
212
+ *
213
+ * 帽的方向:超限时**只投最新的 N 张**——未决卡的价值随时间倒序递减(最老的那些多半已经在别处过窗/
214
+ * 被收敛),而壳的呈卡面容量有限。丢弃是**读面**行为,不写库、不改状态;写侧的准入门(设计稿 §0 X-2)
215
+ * 是另一件事,在刀 3b。
216
+ *
217
+ * `replayMax <= 0` 视作「不投」(运维显式关掉重放),不当成「无帽」。
218
+ */
219
+ export function buildApprovalPreamble(rows, nowMs, replayMax) {
220
+ const frames = [];
221
+ let skipped = 0;
222
+ for (const row of rows) {
223
+ const frame = buildReplayFrame(row, nowMs);
224
+ if (frame === undefined)
225
+ skipped += 1;
226
+ else
227
+ frames.push(frame);
228
+ }
229
+ const cap = Math.max(0, replayMax);
230
+ const dropped = Math.max(0, frames.length - cap);
231
+ return { frames: dropped > 0 ? frames.slice(frames.length - cap) : frames, skipped, dropped };
232
+ }
233
+ /** preamble 帧的 SSE 投递形(具名事件,**不带 `id:`** —— 它不是账本行,不参与 Last-Event-ID 游标)。 */
234
+ export function buildApprovalPreambleSseFrames(frames) {
235
+ return frames.map((f) => ({ event: f.type, data: f }));
236
+ }
237
+ //# sourceMappingURL=approval-card.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 流内审批协议(design/172 §3.0 对账三约束)的**终态归因词表** —— #151 车5。
3
+ *
4
+ * 两组常量、**两个不相交的值域**(车5 §8 C-1 裁定,与车4 F34 同向):
5
+ * - {@link DENY_REASONS} —— `DENIED` 终态的归因。v1 是**单员闭集**:`routing_failure_fail_closed`。
6
+ * - {@link VOID_REASONS} —— `VOID` 终态的归因。
7
+ *
8
+ * 🔴 为什么必须分两组、禁混值域:§3.0 的对账约束②是「**超时永不产生 denial**」,而审计面唯一能证明这条
9
+ * 的东西就是终态 + 归因的组合。人拒恒为 `DECIDED(decision=deny)`(车4 §8),路由失败恒为
10
+ * `DENIED(routing_failure_fail_closed)`,撤卡/取消/遗孤恒为 `VOID(<VOID_REASONS 之一>)`——三者在
11
+ * wire 与审计上全程可分。若两组共用一个值域(或某个值同时可落两种终态),「这条 deny 是人做的、还是
12
+ * 路由失败、还是超时兜底」就再也无法从行上读出来,归因诚实性(§6.1)当场失效。
13
+ *
14
+ * 落列面:两组值都写进 `approval_asks.denied_reason`(VARCHAR(64))——列名沿用车1 的 DDL(本车不改列),
15
+ * 但它承载的是**终态归因**而不只是「拒绝理由」。读者靠「值属于哪一组」判别是哪条臂写的,这正是两组
16
+ * 值域不相交这条纪律的用处(`ALL_TERMINAL_REASONS` 的机器判据钉住不相交性)。
17
+ *
18
+ * 写点纪律(grep 钉):全仓 `DENIED` 只有**一个**写点 = 收敛器判据 2 的非取消臂(`approval-reconciler.ts`)。
19
+ */
20
+ /** `DENIED` 终态的归因闭集(v1 单员)。 */
21
+ export declare const DENY_REASONS: {
22
+ /**
23
+ * 收敛器判据 2:出处 run 已到**非 suspended 的终局**(completed / blocked / 非取消的 failed)而始终
24
+ * 没有出现与本 ask 匹配的 checkpoint ⇒ 这只 ask 的投递面永远不会再出现,fail-closed 收 `DENIED`。
25
+ * 🔴 证据是**行的终局态**(journal 的肯定证据),不是「等够久了」——约束②因此成立。
26
+ */
27
+ readonly ROUTING_FAILURE: "routing_failure_fail_closed";
28
+ };
29
+ export type DenyReason = (typeof DENY_REASONS)[keyof typeof DENY_REASONS];
30
+ /** `VOID` 终态的归因闭集。 */
31
+ export declare const VOID_REASONS: {
32
+ /** 判据 2 的取消分臂(§9 C3):run `failed ∧ errorCode==="cancelled"` ⇒ 取消不是路由失败,收 VOID。 */
33
+ readonly RUN_CANCELLED: "run_cancelled";
34
+ /** 判据 2 的取消分臂之二:批行已 `ABORTED`(取消腿打过 `abortBatch`)⇒ 同 abort 语义。 */
35
+ readonly BATCH_ABORTED: "batch_aborted";
36
+ /**
37
+ * bind-once 的**落选者**(codex 交叉复审 round2 R2-3,2026-08-06 验真):批已 `ROUTING_BOUND` 且
38
+ * `bound_ask_id` 是**别人**——本行永远赢不了 `bindBatch`(谓词要求 `state='ROUTING_UNBOUND'`),
39
+ * 却又不是路由失败(投递面好好的,只是这个决策点选了兄弟)。172 §3.0 的 bind-once 字面就是「非中选
40
+ * 兄弟收敛 VOID」;`bindBatch` 的原子事务只连坐它当时看得见的 PARKING 兄弟,**之后**才进入 PARKING 的
41
+ * 迟到行(late `ensureAsk` + `expireAsk` 不查批态,设计如此)由收敛器补判——就是这条臂。
42
+ * 不这么判的后果:该行滞留到 run 终局,然后被判据 2 误报成 `routing_failure_fail_closed`(归因失真)。
43
+ */
44
+ readonly BATCH_BOUND_ELSEWHERE: "batch_bound_elsewhere";
45
+ /** 判据 4:adhoc 腿(`sessionId === taskId` ∧ `getRun` 无行)窗过 + 宽限 —— 该腿形**结构上**没有
46
+ * durable 对账域(一次性 taskId 不进 runStore,park 目的地不可寻址),流死即 abort 语义。
47
+ * 🔴 落 VOID 不落 DENIED:VOID 不是 denial,约束②不被触碰。 */
48
+ readonly ADHOC_LEG_NO_DURABLE_DOMAIN: "adhoc_leg_no_durable_domain";
49
+ /** 判据 5:任何在 `STREAM_APPROVAL_ORPHAN_TTL_MS` 内仍未收敛的 `PARKING` 行(量 immutable 的
50
+ * `createdAtMs`,§9 C5)—— 约束①(遗孤最终可判)的最后兜底,带一条 warn。 */
51
+ readonly ORPHAN_TTL_EXCEEDED: "orphan_ttl_exceeded";
52
+ };
53
+ export type VoidReason = (typeof VOID_REASONS)[keyof typeof VOID_REASONS];
54
+ /** 两组的并集(机器判据用:值域不相交 + 列宽 ≤ 64 的钉子在 test/approval-reconciler.test.ts)。 */
55
+ export declare const ALL_TERMINAL_REASONS: readonly string[];
56
+ //# sourceMappingURL=approval-deny-reasons.d.ts.map
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 流内审批协议(design/172 §3.0 对账三约束)的**终态归因词表** —— #151 车5。
3
+ *
4
+ * 两组常量、**两个不相交的值域**(车5 §8 C-1 裁定,与车4 F34 同向):
5
+ * - {@link DENY_REASONS} —— `DENIED` 终态的归因。v1 是**单员闭集**:`routing_failure_fail_closed`。
6
+ * - {@link VOID_REASONS} —— `VOID` 终态的归因。
7
+ *
8
+ * 🔴 为什么必须分两组、禁混值域:§3.0 的对账约束②是「**超时永不产生 denial**」,而审计面唯一能证明这条
9
+ * 的东西就是终态 + 归因的组合。人拒恒为 `DECIDED(decision=deny)`(车4 §8),路由失败恒为
10
+ * `DENIED(routing_failure_fail_closed)`,撤卡/取消/遗孤恒为 `VOID(<VOID_REASONS 之一>)`——三者在
11
+ * wire 与审计上全程可分。若两组共用一个值域(或某个值同时可落两种终态),「这条 deny 是人做的、还是
12
+ * 路由失败、还是超时兜底」就再也无法从行上读出来,归因诚实性(§6.1)当场失效。
13
+ *
14
+ * 落列面:两组值都写进 `approval_asks.denied_reason`(VARCHAR(64))——列名沿用车1 的 DDL(本车不改列),
15
+ * 但它承载的是**终态归因**而不只是「拒绝理由」。读者靠「值属于哪一组」判别是哪条臂写的,这正是两组
16
+ * 值域不相交这条纪律的用处(`ALL_TERMINAL_REASONS` 的机器判据钉住不相交性)。
17
+ *
18
+ * 写点纪律(grep 钉):全仓 `DENIED` 只有**一个**写点 = 收敛器判据 2 的非取消臂(`approval-reconciler.ts`)。
19
+ */
20
+ /** `DENIED` 终态的归因闭集(v1 单员)。 */
21
+ export const DENY_REASONS = {
22
+ /**
23
+ * 收敛器判据 2:出处 run 已到**非 suspended 的终局**(completed / blocked / 非取消的 failed)而始终
24
+ * 没有出现与本 ask 匹配的 checkpoint ⇒ 这只 ask 的投递面永远不会再出现,fail-closed 收 `DENIED`。
25
+ * 🔴 证据是**行的终局态**(journal 的肯定证据),不是「等够久了」——约束②因此成立。
26
+ */
27
+ ROUTING_FAILURE: "routing_failure_fail_closed",
28
+ };
29
+ /** `VOID` 终态的归因闭集。 */
30
+ export const VOID_REASONS = {
31
+ /** 判据 2 的取消分臂(§9 C3):run `failed ∧ errorCode==="cancelled"` ⇒ 取消不是路由失败,收 VOID。 */
32
+ RUN_CANCELLED: "run_cancelled",
33
+ /** 判据 2 的取消分臂之二:批行已 `ABORTED`(取消腿打过 `abortBatch`)⇒ 同 abort 语义。 */
34
+ BATCH_ABORTED: "batch_aborted",
35
+ /**
36
+ * bind-once 的**落选者**(codex 交叉复审 round2 R2-3,2026-08-06 验真):批已 `ROUTING_BOUND` 且
37
+ * `bound_ask_id` 是**别人**——本行永远赢不了 `bindBatch`(谓词要求 `state='ROUTING_UNBOUND'`),
38
+ * 却又不是路由失败(投递面好好的,只是这个决策点选了兄弟)。172 §3.0 的 bind-once 字面就是「非中选
39
+ * 兄弟收敛 VOID」;`bindBatch` 的原子事务只连坐它当时看得见的 PARKING 兄弟,**之后**才进入 PARKING 的
40
+ * 迟到行(late `ensureAsk` + `expireAsk` 不查批态,设计如此)由收敛器补判——就是这条臂。
41
+ * 不这么判的后果:该行滞留到 run 终局,然后被判据 2 误报成 `routing_failure_fail_closed`(归因失真)。
42
+ */
43
+ BATCH_BOUND_ELSEWHERE: "batch_bound_elsewhere",
44
+ /** 判据 4:adhoc 腿(`sessionId === taskId` ∧ `getRun` 无行)窗过 + 宽限 —— 该腿形**结构上**没有
45
+ * durable 对账域(一次性 taskId 不进 runStore,park 目的地不可寻址),流死即 abort 语义。
46
+ * 🔴 落 VOID 不落 DENIED:VOID 不是 denial,约束②不被触碰。 */
47
+ ADHOC_LEG_NO_DURABLE_DOMAIN: "adhoc_leg_no_durable_domain",
48
+ /** 判据 5:任何在 `STREAM_APPROVAL_ORPHAN_TTL_MS` 内仍未收敛的 `PARKING` 行(量 immutable 的
49
+ * `createdAtMs`,§9 C5)—— 约束①(遗孤最终可判)的最后兜底,带一条 warn。 */
50
+ ORPHAN_TTL_EXCEEDED: "orphan_ttl_exceeded",
51
+ };
52
+ /** 两组的并集(机器判据用:值域不相交 + 列宽 ≤ 64 的钉子在 test/approval-reconciler.test.ts)。 */
53
+ export const ALL_TERMINAL_REASONS = [...Object.values(DENY_REASONS), ...Object.values(VOID_REASONS)];
54
+ //# sourceMappingURL=approval-deny-reasons.js.map
@@ -0,0 +1,167 @@
1
+ /**
2
+ * 流内审批协议(design/172 §3.0)的**对账收敛器** —— #151 车5。
3
+ *
4
+ * 职责一句话:把 `PARKING`(窗到期中选、正在转投递面)这个**唯一的非终态中间态**收敛成
5
+ * `PARKED | DENIED | VOID`,并在崩溃后补位那些没人打 expire 的孤儿 `STREAM_PENDING` 行。
6
+ *
7
+ * ── 判据表 v2(设计稿 §9 尾的五臂汇总,逐字落地;每臂注读口)────────────────────────────────────────
8
+ * ① **identity ∧ hash 双等** ⇒ `bindBatch`(判别式返回;`ok:false` ⇒ 降级续判)
9
+ * identity = (scope=owner, sessionId, toolCallId, 因果下界 `cp.createdAtMs ≥ ask.createdAtMs`)
10
+ * ∧ `cp.boundInputHash === ask.boundInputHash`,**任一侧 hash 缺席 = 不命中**(§9 C2:同 session 内
11
+ * `toolCallId` 会被网关重用,只靠 identity 会把旧 ask PARK 到别人的 resume 坐标上,而 `PARKED` 是
12
+ * 不可回滚的终态)。读口 = `findCheckpointCandidatesForAsk`(§9 C4 窄谓词精确查,无分页假阴性);
13
+ * `unparseable` 候选**视同不匹配**(单行读不出不许打断整段扫描,§8 C-6)。
14
+ * ② run 终局分臂(读口 `runStore.getRun`):`status ∈ {completed, failed, blocked}`(§8 A-1 词表修正 ——
15
+ * `cancelled` 不是 run 状态,取消 = `failed` + `errorCode`)——
16
+ * - `failed ∧ errorCode === "cancelled"`,或批行已 `ABORTED` ⇒ `VOID`(取消不是路由失败,§9 C3);
17
+ * - 其余终局 ⇒ `DENIED(routing_failure_fail_closed)`。**全仓唯一的 DENIED 写点**(grep 钉)。
18
+ * ②′ **bind-once 落选者**(codex round2 R2-3 增补,排在 ② 之前判):批已 `ROUTING_BOUND` 且中选者是
19
+ * 兄弟 ⇒ `VOID(batch_bound_elsewhere)` —— 迟到进 PARKING 的行(`bindBatch` 的原子事务连坐不到它)
20
+ * 结构上再也赢不了 bind,当下即可判;不判会让它滞留到 run 终局再被 ② 误报成路由失败。
21
+ * ③ else(run 还在跑 / suspended / needs_review / 无行且不满足 ④)⇒ **保持 PARKING** + `deferReconcile`
22
+ * touch(推 `updated_at_ms` 排到队尾,配合 `listByState` 的 `ORDER BY updated_at_ms ASC` 解队头堵塞,
23
+ * §8 D-4 / §9 C5)。**超时永不产生终态 denial**(约束②)。
24
+ * ④ adhoc 双谓词(`sessionId === taskId` ∧ `getRun` 无行,§8 C-2)∧ 窗过 + `ADHOC_GRACE` ⇒ `VOID`
25
+ * (归因 `adhoc_leg_no_durable_domain`)。
26
+ * ⑤ `createdAtMs` 量的 `ORPHAN_TTL` ⇒ `VOID(orphan_ttl_exceeded)` + warn ——「任何未在 TTL 内收敛的
27
+ * PARKING」的最后兜底(§8 A-1 放宽形,不限「getRun 无行」),约束①(遗孤最终可判)。
28
+ * 🔴 TTL 一律量 immutable 的 `createdAtMs`/`expiresAtMs`,**绝不量 `updatedAtMs`**(它被 ③ 的队列
29
+ * 轮转每轮刷新,量它的 TTL 永不到期,§9 C5)。
30
+ *
31
+ * ── 三条铁则 ────────────────────────────────────────────────────────────────────────────────────
32
+ * 1. **一经发布的终态不改义**:本模块只从 `PARKING`/`STREAM_PENDING` 出发,`PARKED/DECIDED/DENIED/VOID`
33
+ * 的行永不再被碰(CAS 的 `WHERE state=?` 谓词是机器保证,不靠调用序自觉)。
34
+ * 2. **幂等可重放**:两副本同扫无害——每条转移都是带 `from` 态的 CAS,单赢者;输者本轮什么都不做。
35
+ * 3. **宁可不命中,绝不错配**:判别不出(hash 缺席 / 候选 `unparseable` / 列与 blob 矛盾)一律落 ②③⑤,
36
+ * 绝不发一张别人的 resume 凭据。
37
+ *
38
+ * 命名(CLAUDE.md 工厂命名律):`decideReconcileAction`/`selectGateCandidate` 是纯判定函数;
39
+ * `createApprovalReconciler` 返回带方法的活对象 ⇒ `create*`。
40
+ */
41
+ import type { AskRow, ApprovalAskStore } from "./plugins/approval-ask-store-sql.js";
42
+ import type { BatchState } from "./approval-ask-machine.js";
43
+ import type { CheckpointAskCandidate } from "./plugins/checkpoint-store-sql.js";
44
+ import type { RunRecord } from "./plugins/store-contracts.js";
45
+ import type { Logger } from "./observability/logger.js";
46
+ import type { Metrics } from "./observability/metrics.js";
47
+ import { type DenyReason, type VoidReason } from "./approval-deny-reasons.js";
48
+ import { type ApprovalRevokeFrame } from "./approval-card.js";
49
+ /** 判据表 v2 的一次判定结果(纯数据;执行器按 kind 分派到店面的一次 CAS)。 */
50
+ export type ReconcileAction = {
51
+ kind: "bind";
52
+ gate: {
53
+ gateToken: string;
54
+ gateBoundCallId: string | null;
55
+ gateBoundInputHash: string | null;
56
+ };
57
+ } | {
58
+ kind: "deny";
59
+ reason: DenyReason;
60
+ } | {
61
+ kind: "void";
62
+ reason: VoidReason;
63
+ } | {
64
+ kind: "hold";
65
+ };
66
+ /** {@link decideReconcileAction} 的入参(全部是**已经读好的事实**——纯函数不碰 IO)。 */
67
+ export interface ReconcileInput {
68
+ ask: AskRow;
69
+ /** 判据 1 的候选集(读口已按 scope+session+toolCallId+sinceMs 精确查);无 checkpoint 面 ⇒ 空数组。 */
70
+ candidates: readonly CheckpointAskCandidate[];
71
+ /** 出处 run 行;`null` = 无行(被 reap / 从未建 / adhoc 腿)。 */
72
+ run: RunRecord | null;
73
+ /** 批行当前态;`null` = 本轮未探(还没走到需要它的臂)。`"MISSING"` = 批行不存在。 */
74
+ batchState: BatchState | "MISSING" | null;
75
+ /** 批的中选者(`bound_ask_id`);`ROUTING_BOUND` 臂靠它分「中选的是我」与「中选的是兄弟」两种处置。
76
+ * 缺席/未知 = `null`(与 `batchState: null` 同栏:未探到就不据它下判)。 */
77
+ batchBoundAskId?: string | null;
78
+ nowMs: number;
79
+ adhocGraceMs: number;
80
+ orphanTtlMs: number;
81
+ /** `false` ⇒ 跳过判据 1(本轮已经试过 `bindBatch` 且失败,降级续判 ②③④⑤)。缺省 `true`。 */
82
+ allowBind?: boolean;
83
+ }
84
+ /**
85
+ * 判据 1 的**硬谓词**(纯函数,§9 C2 + §8 D-2)。
86
+ *
87
+ * 返回选中的候选,或 `undefined` = 不命中。逐条:
88
+ * - `unparseable` 候选直接出局(读不出 ⇒ 不确定 ⇒ 不命中);
89
+ * - `boundCallId` 必须逐字等于 `ask.toolCallId`(读口已按它查,这里是纵深防御);
90
+ * - **因果下界**:`cp.createdAtMs >= ask.createdAtMs`(park 不可能早于它要 park 的那次 ask);
91
+ * - **hash 双等**:两侧都必须在场且相等 —— 任一侧缺席即不命中(禁「能取到时才比」的可选谓词)。
92
+ *
93
+ * 多候选时的取舍:优先 `status === "pending"`(活着的那张 gate),否则取最早的一条(读口按
94
+ * `created_at ASC` 返回)。两者都满足全部硬谓词,选谁都不会错配;取 pending 只是让 `PARKED` 行落到
95
+ * 一个还能被 resume 的坐标上,对壳更有用。
96
+ */
97
+ export declare function selectGateCandidate(ask: AskRow, candidates: readonly CheckpointAskCandidate[]): CheckpointAskCandidate | undefined;
98
+ /** 判据表 v2 的判定(纯函数;顺序 = 设计稿 §9 尾的五臂汇总,注见文件头)。 */
99
+ export declare function decideReconcileAction(input: ReconcileInput): ReconcileAction;
100
+ /** 收敛器一次 tick 的产出(可观测 + 测试断言面)。 */
101
+ export interface ReconcileStats {
102
+ /** 段一扫到的 `PARKING` 行数。 */
103
+ scanned: number;
104
+ /**
105
+ * 🔴 判据 1 **结构上无法命中**的行数(`bound_input_hash` 列为 NULL ⇒ 双等硬谓词永远不成立)。
106
+ *
107
+ * 存在理由(codex 交叉复审 C1,2026-08-06 验真):设计稿 §9 C2 的前提是「`AskRequest.boundInputHash`
108
+ * core 侧已在场」——**对树上的 core 5.13.0 不成立**:`AskRequest`(core/tool-policy.d.ts:78-90)没有这个
109
+ * 字段,铸行侧也就无值可存;唯一能算出同一摘要的 `boundInputHashOf`(core/canonical-json)既不在
110
+ * `index` 导出面也没有 subpath 出口。⇒ 在 core 供值之前,判据 1 恒不命中,PARKING 行只会落 ②③④⑤。
111
+ * 这个计数把「盲区」变成**可观测量**而不是静默行为(下面每 tick 一条 warn),也是协议开关翻真的前置
112
+ * 判据之一。
113
+ */
114
+ unmatchableNoHash: number;
115
+ parked: number;
116
+ denied: number;
117
+ voided: number;
118
+ /** 本轮没有产生终态的行:判据 3 的保持,**以及**判据 2/4/5 判出了终态但那条 CAS 干净地输的行
119
+ * (别的副本先收走了它)——两者对本轮的意义相同:行没被本实例改动,下轮按新态重判。 */
120
+ held: number;
121
+ /** 单行判定/写入抛错被隔离掉的行数(一条坏行不许打断整段扫描)。 */
122
+ failed: number;
123
+ /** 段一/段二因**墙钟预算**用尽而提前收工的段数(0/1/2)。>0 ⇒ 依赖在退化,剩余行留给下一轮
124
+ * (见 {@link RECONCILE_SEGMENT_BUDGET_MS})。 */
125
+ budgetExhausted: number;
126
+ /** 段二:被代打 `expireAsk` 的孤儿 `STREAM_PENDING` 行数(赢 CAS 的)。 */
127
+ orphansExpired: number;
128
+ }
129
+ /** 撤卡帧的投递面(live only,见 `ApprovalRevokeFrame` 顶注)。收敛器不认识 SSE,只交给这个口。 */
130
+ export type ApprovalRevokeEmitter = (frame: ApprovalRevokeFrame, target: {
131
+ owner: string | null;
132
+ sessionId: string;
133
+ taskId: string;
134
+ }) => void;
135
+ /** 判据 1 的读口(窄接口,不绑具体 checkpoint 店实现——local 车道的店没有这个面,装配点传 undefined)。 */
136
+ export interface ReconcileCheckpointPort {
137
+ findCheckpointCandidatesForAsk(scope: string, sessionId: string, toolCallId: string, sinceMs: number): Promise<CheckpointAskCandidate[]>;
138
+ }
139
+ /** 判据 2/4 的读口(窄接口,同上)。 */
140
+ export interface ReconcileRunPort {
141
+ getRun(taskId: string): Promise<RunRecord | undefined>;
142
+ }
143
+ export interface ApprovalReconcilerDeps {
144
+ askStore: ApprovalAskStore;
145
+ /** 缺席 ⇒ 判据 1 恒不命中(候选集恒空),行落 ②③④⑤ —— 诚实降级,不是「假装没 checkpoint 就该 DENY」。 */
146
+ checkpoints?: ReconcileCheckpointPort | undefined;
147
+ /** 缺席 ⇒ `run` 恒 `null`,判据 2 不成立;durable 腿因此只可能被 ⑤ 兜底(不会被误判 DENIED)。 */
148
+ runs?: ReconcileRunPort | undefined;
149
+ logger: Logger;
150
+ metrics?: Metrics | undefined;
151
+ /** 每段每 tick 的行数上限(`STREAM_APPROVAL_RECONCILE_BATCH`)。 */
152
+ batchLimit: number;
153
+ /** 孤儿代打的宽限(`STREAM_APPROVAL_PENDING_GRACE_MS`)——活属主的窗到期竞争者常态必胜,
154
+ * reaper 只在超过它之后才补位(§8 D-1.1:否则 reaper 会跟活属主抢,制造挂死面)。 */
155
+ pendingGraceMs: number;
156
+ adhocGraceMs: number;
157
+ orphanTtlMs: number;
158
+ emitRevoke?: ApprovalRevokeEmitter | undefined;
159
+ }
160
+ /** {@link createApprovalReconciler} 返回的活对象。 */
161
+ export interface ApprovalReconciler {
162
+ /** 跑一轮(两段:PARKING 判据表 + 孤儿 STREAM_PENDING 代打)。**永不抛**——单行错误隔离在行内,
163
+ * 段级错误由调用方(reaper 腿的 throttled catch)兜。 */
164
+ runOnce(nowMs: number): Promise<ReconcileStats>;
165
+ }
166
+ export declare function createApprovalReconciler(deps: ApprovalReconcilerDeps): ApprovalReconciler;
167
+ //# sourceMappingURL=approval-reconciler.d.ts.map