@sema-agent/server 7.2.0 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/README.zh-CN.md +1 -1
- package/USAGE.md +26 -1
- package/dist/approval-ask-machine.d.ts +39 -0
- package/dist/approval-ask-machine.js +101 -0
- package/dist/approval-card.d.ts +244 -0
- package/dist/approval-card.js +237 -0
- package/dist/approval-deny-reasons.d.ts +56 -0
- package/dist/approval-deny-reasons.js +54 -0
- package/dist/approval-reconciler.d.ts +174 -0
- package/dist/approval-reconciler.js +307 -0
- package/dist/boot/coordinators.d.ts +1 -0
- package/dist/boot/coordinators.js +39 -4
- package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
- package/dist/boot/deferred-sandbox-path-env.js +279 -0
- package/dist/boot/execution-env.js +11 -1
- package/dist/boot/lexical-path-env.d.ts +10 -0
- package/dist/boot/lexical-path-env.js +88 -0
- package/dist/boot/reapers.d.ts +34 -0
- package/dist/boot/reapers.js +198 -23
- package/dist/boot/resolve-spec.js +97 -33
- package/dist/capabilities/center-prompts.js +4 -1
- package/dist/capabilities/oa-tools.d.ts +15 -0
- package/dist/capabilities/oa-tools.js +54 -0
- package/dist/config-types.d.ts +68 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +138 -2
- package/dist/elicitation.d.ts +4 -0
- package/dist/elicitation.js +7 -3
- package/dist/finance/cost-taxonomy.d.ts +34 -0
- package/dist/finance/cost-taxonomy.js +26 -0
- package/dist/hooks/hook-runner.js +32 -0
- package/dist/http/routes/capabilities.js +14 -0
- package/dist/http/routes/diagnostics.d.ts +84 -0
- package/dist/http/routes/diagnostics.js +140 -0
- package/dist/http/routes/runs.d.ts +1 -0
- package/dist/http/routes/runs.js +548 -16
- package/dist/http/routes/tasks.js +175 -12
- package/dist/http/server.d.ts +6 -1
- package/dist/http/server.js +120 -4
- package/dist/http/sse-log.d.ts +51 -0
- package/dist/http/sse-log.js +64 -0
- package/dist/http/wire-types.d.ts +20 -5
- package/dist/main.js +35 -4
- package/dist/observability/fail-open.d.ts +98 -0
- package/dist/observability/fail-open.js +216 -0
- package/dist/observability/prompt-manifest.d.ts +13 -0
- package/dist/observability/prompt-manifest.js +8 -0
- package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
- package/dist/plugins/approval-ask-store-memory.js +299 -0
- package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
- package/dist/plugins/approval-ask-store-sql.js +705 -0
- package/dist/plugins/approval-store-sql.d.ts +116 -0
- package/dist/plugins/approval-store-sql.js +151 -0
- package/dist/plugins/background-agent-store-sql.js +20 -1
- package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
- package/dist/plugins/checkpoint-store-sql.js +297 -16
- package/dist/plugins/file-workflow-journal-store.d.ts +12 -0
- package/dist/plugins/file-workflow-journal-store.js +12 -0
- package/dist/plugins/local-checkpoint-store.d.ts +6 -5
- package/dist/plugins/local-checkpoint-store.js +4 -0
- package/dist/plugins/pg-approval-store.d.ts +9 -0
- package/dist/plugins/pg-approval-store.js +9 -0
- package/dist/plugins/pg-breaker-state.d.ts +8 -0
- package/dist/plugins/pg-breaker-state.js +8 -0
- package/dist/plugins/pg-checkpoint-store.d.ts +10 -0
- package/dist/plugins/pg-checkpoint-store.js +10 -0
- package/dist/plugins/pg-file-snapshot-store.d.ts +8 -0
- package/dist/plugins/pg-file-snapshot-store.js +8 -0
- package/dist/plugins/pg-image-bake.d.ts +12 -0
- package/dist/plugins/pg-image-bake.js +11 -0
- package/dist/plugins/pg-image-index.d.ts +12 -0
- package/dist/plugins/pg-image-index.js +11 -0
- package/dist/plugins/pg-outcome-ledger.d.ts +12 -0
- package/dist/plugins/pg-outcome-ledger.js +11 -0
- package/dist/plugins/pg-pool.js +11 -0
- package/dist/plugins/pg-resume-anchor-store.d.ts +7 -0
- package/dist/plugins/pg-resume-anchor-store.js +7 -0
- package/dist/plugins/pg-run-store.d.ts +9 -0
- package/dist/plugins/pg-run-store.js +9 -0
- package/dist/plugins/pg-session-policy-store.d.ts +7 -0
- package/dist/plugins/pg-session-policy-store.js +7 -0
- package/dist/plugins/pg-session-store.d.ts +12 -0
- package/dist/plugins/pg-session-store.js +12 -0
- package/dist/plugins/pg-tool-result-store.d.ts +9 -0
- package/dist/plugins/pg-tool-result-store.js +9 -0
- package/dist/plugins/pg-workflow-journal-store.d.ts +9 -0
- package/dist/plugins/pg-workflow-journal-store.js +9 -0
- package/dist/plugins/pg-workflow-run-store.d.ts +9 -0
- package/dist/plugins/pg-workflow-run-store.js +9 -0
- package/dist/plugins/store-backend.d.ts +18 -0
- package/dist/plugins/store-backend.js +10 -0
- package/dist/plugins/tidb-approval-store.d.ts +8 -0
- package/dist/plugins/tidb-approval-store.js +8 -0
- package/dist/plugins/tidb-breaker-state.d.ts +7 -0
- package/dist/plugins/tidb-breaker-state.js +7 -0
- package/dist/plugins/tidb-checkpoint-store.d.ts +9 -0
- package/dist/plugins/tidb-checkpoint-store.js +9 -0
- package/dist/plugins/tidb-file-snapshot-store.d.ts +8 -0
- package/dist/plugins/tidb-file-snapshot-store.js +8 -0
- package/dist/plugins/tidb-image-bake.d.ts +12 -0
- package/dist/plugins/tidb-image-bake.js +11 -0
- package/dist/plugins/tidb-image-index.d.ts +12 -0
- package/dist/plugins/tidb-image-index.js +11 -0
- package/dist/plugins/tidb-outcome-ledger.d.ts +12 -0
- package/dist/plugins/tidb-outcome-ledger.js +12 -0
- package/dist/plugins/tidb-pool.js +27 -4
- package/dist/plugins/tidb-resume-anchor-store.d.ts +7 -0
- package/dist/plugins/tidb-resume-anchor-store.js +7 -0
- package/dist/plugins/tidb-run-store.d.ts +10 -0
- package/dist/plugins/tidb-run-store.js +9 -0
- package/dist/plugins/tidb-session-policy-store.d.ts +7 -0
- package/dist/plugins/tidb-session-policy-store.js +7 -0
- package/dist/plugins/tidb-tool-result-store.d.ts +8 -0
- package/dist/plugins/tidb-tool-result-store.js +10 -0
- package/dist/plugins/tidb-workflow-journal-store.d.ts +9 -0
- package/dist/plugins/tidb-workflow-journal-store.js +9 -0
- package/dist/plugins/tidb-workflow-run-store.d.ts +10 -0
- package/dist/plugins/tidb-workflow-run-store.js +10 -0
- package/dist/plugins/workflow-journal-limits.d.ts +12 -0
- package/dist/plugins/workflow-journal-limits.js +12 -0
- package/dist/question.d.ts +21 -14
- package/dist/question.js +83 -34
- package/dist/runs.d.ts +16 -1
- package/dist/runs.js +61 -3
- package/dist/runtime-caps-resolver.d.ts +7 -1
- package/dist/runtime-caps-resolver.js +65 -3
- package/dist/sema-registry.d.ts +41 -0
- package/dist/sema-registry.js +40 -0
- package/dist/spec-fields.d.ts +4 -0
- package/dist/spec-fields.js +6 -0
- package/dist/task-settings.d.ts +36 -15
- package/dist/task-settings.js +19 -5
- package/dist/tool-approval.d.ts +296 -3
- package/dist/tool-approval.js +1074 -50
- package/dist/trace/core-keyset-guard.d.ts +2 -2
- package/dist/trace/ledger-sink.js +14 -1
- package/dist/trace/project.d.ts +90 -0
- package/dist/trace/project.js +188 -0
- package/package.json +5 -4
package/dist/tool-approval.js
CHANGED
|
@@ -43,6 +43,13 @@
|
|
|
43
43
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
44
44
|
import { uuidv7 } from "@sema-agent/core";
|
|
45
45
|
import { redactDeep, redactSecrets } from "./trace/redact.js";
|
|
46
|
+
import { createLogger } from "./observability/logger.js";
|
|
47
|
+
import { deriveAskId, deriveBatchId } from "./approval-ask-machine.js";
|
|
48
|
+
import { ApprovalCardEnvelopeSchema, buildApprovalCard, buildApprovalCardEnvelope, buildApprovalRequestFrame, buildRevokeFrame, } from "./approval-card.js";
|
|
49
|
+
/** #151 车2:本模块自有的日志出口——同 config-provider.ts/runtime-caps-resolver.ts 先例(协调器不走
|
|
50
|
+
* DI logger,构造签名是设计定稿钉死的三键 options bag,加第四个 logger 键属于重议已裁事项)。仅用于
|
|
51
|
+
* D5 的一次性 store-故障 warn。 */
|
|
52
|
+
const defaultLogger = createLogger();
|
|
46
53
|
/** Size bound on the redacted args payload in a `tool_approval` frame (parity with question's MAX_QUESTIONS_BYTES).
|
|
47
54
|
* Over the cap ⇒ the frame still goes out WITHOUT args (`argsOmitted: true`) — unlike a question (undisplayable ⇒
|
|
48
55
|
* headless default is safe), an approval must still reach the human: toolName+message suffice to decide, and the
|
|
@@ -50,8 +57,45 @@ import { redactDeep, redactSecrets } from "./trace/redact.js";
|
|
|
50
57
|
const MAX_APPROVAL_ARGS_BYTES = 16384;
|
|
51
58
|
/** How long an unanswered approval waits before it DENIES (fail-closed — the human walked away; don't hold core). */
|
|
52
59
|
const DEFAULT_APPROVAL_TTL_MS = 5 * 60_000;
|
|
60
|
+
/** #151 车2(design/172 §3.3 D3):窗长三元公式里的安全余量默认值——`STREAM_ASK_WINDOW_MARGIN_MS` 缺省时
|
|
61
|
+
* 用这个(config.ts 的 numEnv fallback 字符串与本常量保持同值,便于对表)。 */
|
|
62
|
+
const DEFAULT_WINDOW_MARGIN_MS = 10_000;
|
|
53
63
|
/** Bound on the remembered allow-all grants (insertion-order evict — a long-lived worker must not grow unbounded). */
|
|
54
64
|
const MAX_ALLOW_SESSIONS = 4096;
|
|
65
|
+
/** #151 车3 刀 3b(设计稿 §0 X-2):写侧准入门两帽的**代码默认**——与 `config.ts` 的
|
|
66
|
+
* `STREAM_APPROVAL_ADMIT_MAX_PER_TASK` / `_PER_OWNER` fallback 同值(便于对表)。装配点恒显式传,
|
|
67
|
+
* 这两个常量只服务「手搓协调器」的测试与防御性缺省。 */
|
|
68
|
+
const DEFAULT_ADMIT_MAX_PER_TASK = 32;
|
|
69
|
+
const DEFAULT_ADMIT_MAX_PER_OWNER = 256;
|
|
70
|
+
/** #151 车5(X-1 状态感知分派):CAS 干净地输之后重读持久态的**有界**退避表(ms,一次退避一格)。
|
|
71
|
+
* 长度 = 重试次数上限(6 次,累计 ≈ 3.1s)。为什么必须有界:重读是为了「不挂死」,一个无界重试循环
|
|
72
|
+
* 只是把挂死从 promise 挪到 store —— 用尽仍读不出 ⇒ D5 fail-open 退回纯进程内语义。期间任何一步发现
|
|
73
|
+
* `done`(真赢家已在本进程落定)立即退出,退避 timer 一律 `unref`(绝不持住进程)。 */
|
|
74
|
+
const DURABLE_CONVERGE_BACKOFF_MS = [50, 100, 200, 400, 800, 1600];
|
|
75
|
+
/** 单次重读的**墙钟上限**(ms)。🔴 codex 交叉复审 C5(2026-08-06 真缺陷):只数重试次数是**不够**的 ——
|
|
76
|
+
* 次数只约束「已经返回或已经 reject 的调用」,一次**挂住不返回**的 `getAsk`(DB 连接黑洞、池饿死)会让
|
|
77
|
+
* 整个退避循环连一格都走不完,`onAsk` 的 promise 于是永久悬挂 —— 正是这段代码要消灭的那个形。每次读都
|
|
78
|
+
* 与这个 deadline 显式 race,超时算一次失败继续退避;全部用尽走 D5 fail-open。 */
|
|
79
|
+
const DURABLE_CONVERGE_READ_TIMEOUT_MS = 2_000;
|
|
80
|
+
/** #151 车5(codex round2 R2-5):**决定 onAsk 能否落定**的那些 store 调用(`ensureAsk` 及其复核读、
|
|
81
|
+
* 窗到期/取消/emit-全灭三臂的 CAS)的墙钟上限。
|
|
82
|
+
*
|
|
83
|
+
* 🔴 为什么必须有:R2-5 抓的是「重读有 deadline、但**它之前**的调用没有」——`ensureAsk` 在窗定时器与
|
|
84
|
+
* abort 监听器**装上之前**被 await,一次黑洞化的 `ensureAsk` 会让整只 ask 连一个兜底都没有地永久悬挂。
|
|
85
|
+
* 比 converge 的 2s 宽(这些是真正在做事的调用,不是轮询复核),但必须是**有限**的:超时后走的正是各自
|
|
86
|
+
* 既有的「store 抛错」路径(D5 fail-open / 防御性复核),语义不新增,只是把触发条件从「抛了」放宽到
|
|
87
|
+
* 「抛了**或**久到不可能再有意义」。 */
|
|
88
|
+
const DURABLE_CALL_TIMEOUT_MS = 5_000;
|
|
89
|
+
/** {@link ToolApprovalCoordinator.withStoreDeadline} 超时时抛的**具名**错误。
|
|
90
|
+
* 🔴 具名类而不是「按 message 文本判别」:错误文案是给人看的,拿它当控制流 = 上游改一句人话下游静默
|
|
91
|
+
* 失效(#90 工程规范 v3 门②,机械门在 test/engineering-code-gates.test.ts 盯着)。调用点用
|
|
92
|
+
* `instanceof` 分「超时(不知道做没做成)」与「店真报错(没做成)」两条**处置方向不同**的臂。 */
|
|
93
|
+
class ApprovalStoreDeadlineError extends Error {
|
|
94
|
+
constructor(label, timeoutMs) {
|
|
95
|
+
super(`approval store call ${label} timed out after ${timeoutMs}ms`);
|
|
96
|
+
this.name = "ApprovalStoreDeadlineError";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
55
99
|
/** The fs-write tool family the session allow-all collapses into ONE capability bucket (canonical space — a
|
|
56
100
|
* 5.0.0 RB-476:折叠面退役,三名即 CC canonical(旧拼法在 core roster 层响亮 miss)。Mirrors core's
|
|
57
101
|
* PATH_WRITE_TOOLS + NotebookEdit (the same set `createFsWriteGatePolicy` gates); NOT exported by core's
|
|
@@ -96,7 +140,13 @@ function isChildAsk(req) {
|
|
|
96
140
|
function boundArgs(args) {
|
|
97
141
|
const redacted = redactDeep(args);
|
|
98
142
|
try {
|
|
99
|
-
|
|
143
|
+
// 🔴 #151 车3 刀 3b(设计稿 §6.5,codex 复审抓获的真缺陷):`.length` 数的是 **UTF-16 编码单元**,
|
|
144
|
+
// 而常量名写的是 `_BYTES`——含中文/emoji 的 args 实际 UTF-8 字节数可达该值的约 3 倍(CJK 每字符
|
|
145
|
+
// 3 字节)⇒ 帽名不副实,而设计 §3.1 把「全量 args 的**字节**帽与洗涤」明写成 server 的新增责任
|
|
146
|
+
// (引擎无此层)。改按真字节数判,常量语义与名字对齐。
|
|
147
|
+
// 同一判据**同时**服务 wire 帧与 `card_json` 落库:两侧共用本函数的这一份 `bounded` 结果
|
|
148
|
+
// (见 askBroadcast 里 frame/card 的构造),不各算各的。钉:E-3(纯 CJK 压线的变异钉)。
|
|
149
|
+
if (Buffer.byteLength(JSON.stringify(redacted), "utf8") > MAX_APPROVAL_ARGS_BYTES)
|
|
100
150
|
return { omitted: true };
|
|
101
151
|
}
|
|
102
152
|
catch {
|
|
@@ -104,6 +154,115 @@ function boundArgs(args) {
|
|
|
104
154
|
}
|
|
105
155
|
return { args: redacted, omitted: false };
|
|
106
156
|
}
|
|
157
|
+
/** #151 车3 刀 3b:`AskRequest.boundInputHash` 的**边界窄读**(车5 §9 C2 的对账 join 键)。
|
|
158
|
+
*
|
|
159
|
+
* ✅ **已点亮**(#164 翻真验证车,2026-08-07 亲验 core 5.15.0):`AskRequest.boundInputHash` 现在既在类型面
|
|
160
|
+
* (`core/tool-policy.d.ts:83`,`readonly boundInputHash?: string`)也在真码面(`core/tool-policy.js:610`:
|
|
161
|
+
* `onAsk({ ...req, boundInputHash: boundInputHashOf(presented.value), args: approverView.value })`)——
|
|
162
|
+
* 每一只经 `resolveAsk` 的 ask 都带值。窄读当初就是为这一刻写的:**同一行代码**不改而自然点亮,收敛器
|
|
163
|
+
* 判据 1 从「结构上恒不命中」变成会命中,`unmatchableNoHash` 从「响亮化的盲区」退回它该有的边缘含义。
|
|
164
|
+
* (旧注写的「树上 core 5.13.0 没有这个键 ⇒ 今天恒回 null」自 core 5.15.0 提货起即过期,勿据以判断。)
|
|
165
|
+
*
|
|
166
|
+
* 🔴 保留窄读而不改 import 的理由不变:`boundInputHashOf` 至今**不在 core 的公开导出面**上
|
|
167
|
+
* (package `exports` 只有 `.` / `./bench` / `./fixtures`),server 因此既算不出也不该算这个摘要 ——
|
|
168
|
+
* 它的职责恒是「原样透传落列」。非字符串/空串一律按缺席处置:一个形不对的 hash 比没有 hash 更危险
|
|
169
|
+
* (它会让「硬相等」这道门在一个垃圾值上偶然成立)。钉:test/stream-approval-on-e2e.test.ts 件4
|
|
170
|
+
* (正控 / 缺席 / 空串三臂 + 收敛器 `unmatchableNoHash` 归零的非零对照)。 */
|
|
171
|
+
function readBoundInputHash(req) {
|
|
172
|
+
if (req === null || typeof req !== "object")
|
|
173
|
+
return null;
|
|
174
|
+
const v = Reflect.get(req, "boundInputHash");
|
|
175
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
176
|
+
}
|
|
177
|
+
export function resolveStreamApprovalGate(input) {
|
|
178
|
+
if (!input.toolApprovalEnabled)
|
|
179
|
+
return { active: false, reason: "no_tool_approval" };
|
|
180
|
+
if (!input.streamApprovalEnabled)
|
|
181
|
+
return { active: false, reason: "protocol_disabled" };
|
|
182
|
+
if (!input.backend)
|
|
183
|
+
return { active: false, reason: "no_backend" };
|
|
184
|
+
if (input.backend.kind === "local")
|
|
185
|
+
return { active: false, reason: "volatile_ask_ledger" };
|
|
186
|
+
if (!input.parkFacility)
|
|
187
|
+
return { active: false, reason: "no_park_facility" };
|
|
188
|
+
return { active: true, askStore: input.backend.approvalAsk() };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* #151 车3 刀 3b(codex 交叉复审 round2 R2-1,2026-08-06 真 finding)—— 呈卡帧的**双写投递口**。
|
|
192
|
+
*
|
|
193
|
+
* 🔴 为什么必须收成一个有属主的工厂:round1 之后「新帧送达」开始**参与**「卡到底有没有送到人手上」的
|
|
194
|
+
* 判定(见 `emitOne` 顶注)。而 sync 腿原来那个内联闭包把两个 sink 的失败都吞掉、然后**正常返回** ——
|
|
195
|
+
* 于是「账本写失败 ∧ socket 已死」这种**真的一路都没送到**的情形被上报成成功,`anySucceeded` 因此压住了
|
|
196
|
+
* park 路由,ask 会一直挂到窗到期而**没有任何人可能回答它**。诚实的形只有一个:**至少一个 sink 真的接下
|
|
197
|
+
* 了才算送达**,一个都没接下就抛 —— 调用点(`emitOne`)的 catch 会把它如实记成 `card: false`。
|
|
198
|
+
*
|
|
199
|
+
* 顺序 = **先账本后 live**(与 file_link 的先例相反,理由是本帧的用武之地恰好在 live 面已死的时候):
|
|
200
|
+
* detach 断连后 live 写被丢弃,而壳换道 events tail 必须还能看到这张未决卡(§4.3(c) 的案A)。
|
|
201
|
+
*
|
|
202
|
+
* 命名(CLAUDE.md 工厂命名律):`create*` —— 返回的是**捕获了两个 sink 的闭包**,不是纯数据。
|
|
203
|
+
*/
|
|
204
|
+
export function createApprovalCardEmitter(sinks) {
|
|
205
|
+
return async (frame) => {
|
|
206
|
+
let accepted = false;
|
|
207
|
+
if (sinks.appendDurable) {
|
|
208
|
+
try {
|
|
209
|
+
await sinks.appendDurable(frame);
|
|
210
|
+
accepted = true;
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
/* 账本瞬断不 fault 卡面 —— 但也**不算**送达(下面还有一路,两路都没接下才抛) */
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (sinks.writeLive) {
|
|
217
|
+
try {
|
|
218
|
+
await sinks.writeLive(frame);
|
|
219
|
+
accepted = true;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
/* 死流/已 destroy 的 socket —— 同上 */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!accepted)
|
|
226
|
+
throw new Error("approval card undeliverable — no sink accepted the frame (live stream closed and/or the durable append failed)");
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export function resolveApprovalLeg(input) {
|
|
230
|
+
if (!input.streamApprovalOn)
|
|
231
|
+
return { active: false, windowZero: false };
|
|
232
|
+
const tightDeadline = input.legWalltimeMs !== undefined && input.legWalltimeMs - input.windowMarginMs <= 0;
|
|
233
|
+
if (input.windowMs === 0 || tightDeadline)
|
|
234
|
+
return { active: true, windowZero: true };
|
|
235
|
+
return {
|
|
236
|
+
active: true,
|
|
237
|
+
windowZero: false,
|
|
238
|
+
...(input.legWalltimeMs !== undefined ? { legDeadlineMonotonic: input.nowMonotonicMs + input.legWalltimeMs } : {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** #151 车2(design/172 §3.3 D3,窗长三元,逐字):有效窗 = `min(ttlMs 配置, legRemainingMs − windowMarginMs)`。
|
|
242
|
+
* `legDeadlineMonotonic` 缺席(现行为绝大多数腿——装配点车3 才真传)⇒ 有效窗退化成 `ttlMs`,逐字不变
|
|
243
|
+
* (D1 零行为变化)。在场且算出的窗 ≤ 0(本 leg 剩余 walltime 撑不住安全余量)⇒ 折 0——调用方按 0ms
|
|
244
|
+
* 定时器处理,等同「不开窗,立即走窗到期同路」(§3.3 不变量:窗不得把一个可 park 的 ask 拖成
|
|
245
|
+
* abort-deny)。纯函数(禁 if 链风格的最小实现,`nowMonotonicMs` 显式入参便于测试注入,不裸读全局时钟)。 */
|
|
246
|
+
function effectiveAskWindowMs(ttlMs, windowMarginMs, legDeadlineMonotonic, nowMonotonicMs) {
|
|
247
|
+
if (legDeadlineMonotonic === undefined)
|
|
248
|
+
return ttlMs;
|
|
249
|
+
const legRemainingMs = legDeadlineMonotonic - nowMonotonicMs;
|
|
250
|
+
return Math.min(ttlMs, Math.max(0, legRemainingMs - windowMarginMs));
|
|
251
|
+
}
|
|
252
|
+
/** #151 车2(codex 交叉复审 round4 抓获,真 finding):`ensureAsk` 幂等 upsert 撞见一条**已经终结**(或
|
|
253
|
+
* 正在 PARKING/PARKED)的既有行时,把它翻译成一个直接可回放的 `AskOutcome`——不重新挂一条注定赢不了
|
|
254
|
+
* CAS 的本地待决。三态映射:`DECIDED` 按记录的决定回放(仅布尔臂,持久层不存 `updatedInput`,ctrl+g
|
|
255
|
+
* 编辑重放的字面丢失属已知、可接受的降级——`updatedInput` 全链条本就是纯 wire 层语义,未进车1 的行
|
|
256
|
+
* schema);`PARKING`/`PARKED` 统一按「已转投递面」回放 `"unavailable"`(park 路由,与 D2 窗到期赢的
|
|
257
|
+
* 语义一致);`DENIED`/`VOID` 回放 `false`(routing_failure 与人拒/取消的两分归因是车4 的活,本车只
|
|
258
|
+
* 保证不挂死、不用一个捏造的 true 掩盖真实终局)。`STREAM_PENDING` 不会走到这里(调用点已经判过)。 */
|
|
259
|
+
function replayTerminalAskRow(row) {
|
|
260
|
+
if (row.state === "DECIDED")
|
|
261
|
+
return row.decision === "approve";
|
|
262
|
+
if (row.state === "PARKING" || row.state === "PARKED")
|
|
263
|
+
return "unavailable";
|
|
264
|
+
return false; // DENIED | VOID(STREAM_PENDING 由调用点排除在外)
|
|
265
|
+
}
|
|
107
266
|
/**
|
|
108
267
|
* Coordinates the live tool-approval HITL for the singleton runner. Process-local + same-replica (the pending map is
|
|
109
268
|
* in memory, like QuestionCoordinator): a respond that lands on another replica finds nothing → 404. Present (passed
|
|
@@ -119,6 +278,22 @@ function boundArgs(args) {
|
|
|
119
278
|
export class ToolApprovalCoordinator {
|
|
120
279
|
als = new AsyncLocalStorage();
|
|
121
280
|
pending = new Map();
|
|
281
|
+
/** #151 车2(codex 交叉复审 round3 抓获真 finding,round5 精化成 Set):次级索引,键 = 持久层 askId
|
|
282
|
+
* (与 {@link pending} 的 wire-面 uuidv7 `id` 是两条独立的身份轴,顶注同精神)——只在 askStore 在场且
|
|
283
|
+
* 这只 ask 真有 askId 时才登记。**同一 askId 下可能同时挂着不止一条本地条目**(round5 抓获:
|
|
284
|
+
* `ensureAsk` 是幂等 upsert,若 core 对同一 (sourceTaskId,runId,toolCallId,legKey) 真发起过两次并发调用——如
|
|
285
|
+
* 重试/failover 场景,round4 finding2 的顶注同源——两次 `askBroadcast` 各自的调用栈都会在行仍是
|
|
286
|
+
* STREAM_PENDING 时各自注册一条独立的本地 pending 条目,值形若是单值 Map 会被后到者覆盖前者的登记,
|
|
287
|
+
* 前者从此再也没有任何本地事件会驱动它 settle),故值形是 `Set`,不是单个 `PendingApproval`。
|
|
288
|
+
* 存在理由(两类真实成因共用同一套修复):①同一批(相同 sourceTaskId+leg)下若有多只**不同** ask 并发
|
|
289
|
+
* 在飞(不同 toolCallId,同一 batchId),`expireAsk` 赢家的同一次持久事务会把批内其余 STREAM_PENDING
|
|
290
|
+
* 兄弟原子撤成 VOID 并把它们的 askId 列在 `voidedSiblings` 里;②同一 askId 的**重复本地注册**(见上)。
|
|
291
|
+
* 两类情形下,那些"没赢"的本地条目各自独立的 timer/settle 闭包都对"这只 askId 其实已经有了终局"一无
|
|
292
|
+
* 所知:它们自己的窗到期/取消若稍后也去打 CAS,只会干净地输(D2 round2「输⇒什么都不做」),从此再没有
|
|
293
|
+
* 任何本地事件会驱动它们 settle——TTL 形同虚设,ask 会挂到进程重启。这个索引让赢家能反过来找到同一
|
|
294
|
+
* askId 下全部本地条目(自己 + 兄弟 + 重复注册),直接调用它们各自的 `settle`,而不是被动等一个永远
|
|
295
|
+
* 不会来的本地信号。 */
|
|
296
|
+
pendingByAskId = new Map();
|
|
122
297
|
/** Per-capability session grants — keys = sessionAllowKey(owner, sessionId, category) (修2; bounded). */
|
|
123
298
|
allowAllSessions = new Set();
|
|
124
299
|
/** [1546] HIGH-1(broker)→[1559]四 core 产品裁定「多活集合+广播+首决胜出」:per-(owner, host-session)
|
|
@@ -131,8 +306,234 @@ export class ToolApprovalCoordinator {
|
|
|
131
306
|
* 收 dismiss 通知(见 askBroadcast)。 */
|
|
132
307
|
streams = new Map();
|
|
133
308
|
ttlMs;
|
|
134
|
-
|
|
135
|
-
|
|
309
|
+
/** #151 车2(design/172 §7 协调器半场,D1):可选持久层——缺席 ⇒ 每一条现行为逐字不变(in-memory
|
|
310
|
+
* promise 机械即契约);在场 ⇒ 四竞争者(回决/窗到期/取消/emit 全灭)的终局多一道持久 CAS 记账。
|
|
311
|
+
* additive 改造,不是替换——settle 闭包/pending map/TTL/broker 全保留,CAS 只决定「谁有权 settle
|
|
312
|
+
* 成什么终局」。 */
|
|
313
|
+
askStore;
|
|
314
|
+
/** #151 车2(design/172 §3.3 D3):窗长三元公式的安全余量,构造期定,详见 {@link effectiveAskWindowMs}。 */
|
|
315
|
+
windowMarginMs;
|
|
316
|
+
/** #151 车3 刀 3b(设计稿 §0 X-2 写侧准入门):per-task / per-owner 的未决 ask 上限。 */
|
|
317
|
+
admitMaxPerTask;
|
|
318
|
+
admitMaxPerOwner;
|
|
319
|
+
/** X-2 的两把**在飞计数**。键 = 出处 taskId / owner(`null` 折一个不可能与真 principal 相撞的哨兵)。
|
|
320
|
+
* 值在**准入那一刻**加、在 `settle`(或准入后的任何早退路径)减 —— 计的是「已经分配了资源的未决 ask」,
|
|
321
|
+
* 不是「注册进 pending map 的条目」:两者之间隔着 `ensureAsk` 的一次 await,只在注册时计数会让并发的
|
|
322
|
+
* 一群 ask 全部越过门。 */
|
|
323
|
+
admitByTask = new Map();
|
|
324
|
+
admitByOwner = new Map();
|
|
325
|
+
/** #151 车2(D5 一次性 warn 节流):同实例只报第一次,后续只计数(避免 store 抖动期间刷屏)。 */
|
|
326
|
+
storeErrorWarned = false;
|
|
327
|
+
storeErrorTally = 0;
|
|
328
|
+
constructor(opts) {
|
|
329
|
+
this.ttlMs = opts?.ttlMs ?? DEFAULT_APPROVAL_TTL_MS;
|
|
330
|
+
this.askStore = opts?.askStore;
|
|
331
|
+
this.windowMarginMs = opts?.windowMarginMs ?? DEFAULT_WINDOW_MARGIN_MS;
|
|
332
|
+
this.admitMaxPerTask = opts?.admitMaxPerTask ?? DEFAULT_ADMIT_MAX_PER_TASK;
|
|
333
|
+
this.admitMaxPerOwner = opts?.admitMaxPerOwner ?? DEFAULT_ADMIT_MAX_PER_OWNER;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* #151 车3 刀 3b —— design/172 §3.3 / 设计稿 §0 X-2 的**写侧准入门**。
|
|
337
|
+
*
|
|
338
|
+
* 位置(硬条款):在落 `pending`、建 timer、`ensureAsk` 落行、发帧**之前**。资源分配发生在**创建侧**
|
|
339
|
+
* (每只未决 ask 各带一条持久行 + 一只 timer + 一个 promise + 一份 SSE 载荷;`pending` map 本身无容量
|
|
340
|
+
* 上限,`MAX_ALLOW_SESSIONS` 只管 grant 集合),所以回决腿与恢复扫描都只能在**已经分配之后**动手 ——
|
|
341
|
+
* 门必须长在这里。
|
|
342
|
+
*
|
|
343
|
+
* 超限的处置是 `"unavailable"`(park 路由),**永不 deny**:过载是**我方**的容量事实,不是人对这次
|
|
344
|
+
* 操作的判断;用 deny 表达过载会把一次「本可以 park 后由人补批」的操作变成任务失败(§3.3 硬条款)。
|
|
345
|
+
*
|
|
346
|
+
* 只在 `askStore` 在场(= 协议开着)时把关:开关关闭时本方法恒放行,现行为逐字不变(D1/A-1)。
|
|
347
|
+
*
|
|
348
|
+
* ⚠️ **已认领的偏离**(设计 X-2 字面要求「计数在持久层做」):v1 是**进程内**计数 —— 它对单副本完整
|
|
349
|
+
* 有效,多副本下每个副本各自把关(真实上限 = N × 帽)。理由=持久层计数要么在每只 ask 的热路径上多一次
|
|
350
|
+
* 往返(与「门必须在分配之前」叠加成两跳),要么引入一张新的计数表与它自己的收敛问题;而本门的目的是
|
|
351
|
+
* **防单腿失控**(一个疯狂 ask 的 run 打爆本副本的内存/连接),那正是进程内计数能完整覆盖的形。
|
|
352
|
+
* 多副本级的总量控制登记为后续件(汇报存疑单)。
|
|
353
|
+
*/
|
|
354
|
+
admit(taskId, owner) {
|
|
355
|
+
if (!this.askStore)
|
|
356
|
+
return () => undefined; // 协议未上场:门不参与(D1 零行为变化)
|
|
357
|
+
// JSON 元组编码(同 sessionAllowKey 先例):`null` 与字面量 "null" 的 principal 不可能算出同一把键。
|
|
358
|
+
const ownerKey = JSON.stringify(["owner", owner]);
|
|
359
|
+
const taskCount = this.admitByTask.get(taskId) ?? 0;
|
|
360
|
+
const ownerCount = this.admitByOwner.get(ownerKey) ?? 0;
|
|
361
|
+
if (taskCount >= this.admitMaxPerTask || ownerCount >= this.admitMaxPerOwner)
|
|
362
|
+
return undefined;
|
|
363
|
+
this.admitByTask.set(taskId, taskCount + 1);
|
|
364
|
+
this.admitByOwner.set(ownerKey, ownerCount + 1);
|
|
365
|
+
let released = false;
|
|
366
|
+
return () => {
|
|
367
|
+
if (released)
|
|
368
|
+
return; // 幂等:settle 与早退路径可能都调到它
|
|
369
|
+
released = true;
|
|
370
|
+
const t = (this.admitByTask.get(taskId) ?? 1) - 1;
|
|
371
|
+
if (t <= 0)
|
|
372
|
+
this.admitByTask.delete(taskId);
|
|
373
|
+
else
|
|
374
|
+
this.admitByTask.set(taskId, t);
|
|
375
|
+
const o = (this.admitByOwner.get(ownerKey) ?? 1) - 1;
|
|
376
|
+
if (o <= 0)
|
|
377
|
+
this.admitByOwner.delete(ownerKey);
|
|
378
|
+
else
|
|
379
|
+
this.admitByOwner.set(ownerKey, o);
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/** 测试/可观测性钩子(X-2):某 (taskId) 维当前占用的准入名额数。 */
|
|
383
|
+
admittedCount(taskId) {
|
|
384
|
+
return this.admitByTask.get(taskId) ?? 0;
|
|
385
|
+
}
|
|
386
|
+
/** #151 车2(D5 store 故障姿势):任何 store 调用 throw ⇒ fail-open 到进程内机械照旧(store 是记账/
|
|
387
|
+
* 收敛层,不是投递面——裁决可用性不因它抖动而降级),一次性 `logger.warn`(同实例只报第一次,后续
|
|
388
|
+
* 只计数)。**区分**「store threw」(本方法专管)与「CAS 输」(如实拒绝,D2 必须服从,绝不算故障、
|
|
389
|
+
* 绝不走本方法)。 */
|
|
390
|
+
noteStoreError(err, where) {
|
|
391
|
+
this.storeErrorTally++;
|
|
392
|
+
if (!this.storeErrorWarned) {
|
|
393
|
+
this.storeErrorWarned = true;
|
|
394
|
+
defaultLogger.warn("approval-ask store call failed — falling back to in-process semantics", {
|
|
395
|
+
where,
|
|
396
|
+
err: err instanceof Error ? err.message : String(err),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** #151 车5(R2-5):给一次 store 调用套墙钟上限。超时 = 以 `Error` 拒绝 ⇒ 调用点既有的 catch(D5
|
|
401
|
+
* fail-open / 防御性复核)原样接住,不需要为超时新增一条语义。定时器一律 `unref`(绝不持住进程),
|
|
402
|
+
* 竞速输的那一路由 `Promise.race` 自己的 rejection handler 接住(不会变成 unhandled rejection)。 */
|
|
403
|
+
withStoreDeadline(op, label, timeoutMs = DURABLE_CALL_TIMEOUT_MS, onLateSuccess) {
|
|
404
|
+
let timedOut = false;
|
|
405
|
+
// 🔴 codex 交叉复审 round3 R3-1(2026-08-06 真缺陷):deadline **只能取消等待,取消不了副作用** ——
|
|
406
|
+
// store 没有 abort 面,一条超时的 `expireAsk` 事务完全可能**稍后真的提交**(行进 PARKING、兄弟被原子
|
|
407
|
+
// 撤成 VOID)。旧形把那次结果整个丢掉,于是:兄弟的本地条目永远等不到 settle(挂死),撤卡帧一张不发
|
|
408
|
+
// (壳上留着已作废的卡)。判据不能是「我等到了吗」,得是「它到底做成了吗」。
|
|
409
|
+
// 修法:保留原 promise 的续接 —— 迟到的成功仍然把该做的收尾做完(`onLateSuccess`)。这不是「取消副作用」
|
|
410
|
+
// (那要 store 层给可中止事务,超本车范围),而是**不丢失**副作用;本地终局那一半由 `settle` 的幂等性
|
|
411
|
+
// 保证不被二次改写。回调自身抛错一律吞掉(收尾的收尾不许再变成故障源)。
|
|
412
|
+
// ⚠️ 迟到成功的观测器必须**旁挂**,不能挂进返回链(`op.then(...)` 会多插一个 microtask 跳,而本方法
|
|
413
|
+
// 包着的第一个调用点是 `ensureAsk` —— 它在开卡帧发射**之前**,多一跳就把「卡什么时候出现」整体推后
|
|
414
|
+
// 一格;存量用例用 `await Promise.resolve()` 计跳等这张卡,会静默拿到 undefined 的 approvalId)。
|
|
415
|
+
// 旁挂 = 观测得到、时序零影响(codex round4 的同款建议)。两个 handler 都给:`op` 的 rejection 因此
|
|
416
|
+
// 恒有处理者,不会变成 unhandled rejection。
|
|
417
|
+
if (onLateSuccess) {
|
|
418
|
+
void op.then((value) => {
|
|
419
|
+
if (!timedOut)
|
|
420
|
+
return;
|
|
421
|
+
try {
|
|
422
|
+
onLateSuccess(value);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
/* 收尾的收尾不许成为故障源 */
|
|
426
|
+
}
|
|
427
|
+
}, () => undefined);
|
|
428
|
+
}
|
|
429
|
+
// R4-3:正常落定时**清掉**定时器 —— `unref` 只让它不持住进程,不释放定时器本身与它闭包里捕获的
|
|
430
|
+
// (coordinator / ctx / 回调)引用。审批流量持续时,这就是一堆本可立刻回收的定时器与堆。清定时器
|
|
431
|
+
// **不影响**迟到成功的续接:那一半挂在 `tracked` 上,与本定时器无关。
|
|
432
|
+
let timer;
|
|
433
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
434
|
+
timer = setTimeout(() => {
|
|
435
|
+
timedOut = true;
|
|
436
|
+
reject(new ApprovalStoreDeadlineError(label, timeoutMs));
|
|
437
|
+
}, timeoutMs);
|
|
438
|
+
timer.unref?.();
|
|
439
|
+
});
|
|
440
|
+
const raced = Promise.race([op, deadline]);
|
|
441
|
+
// 清定时器同样**旁挂**(`.finally()` 会给返回链再插一跳,理由同上面的观测器):调用方拿到的就是
|
|
442
|
+
// `raced` 本身,时序零影响;清理晚一个 microtask 发生,对定时器毫无影响。
|
|
443
|
+
const clear = () => {
|
|
444
|
+
if (timer !== undefined)
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
};
|
|
447
|
+
void raced.then(clear, clear);
|
|
448
|
+
return raced;
|
|
449
|
+
}
|
|
450
|
+
/** 测试/可观测性钩子(D5):store 调用失败的累计次数(第一次触发 warn,其余只计数——本方法让「只计数」
|
|
451
|
+
* 那部分可断言)。 */
|
|
452
|
+
storeErrorCount() {
|
|
453
|
+
return this.storeErrorTally;
|
|
454
|
+
}
|
|
455
|
+
/** #151 车2(codex 交叉复审 round3 抓获、round5 精化,真 finding,{@link pendingByAskId} 顶注有完整
|
|
456
|
+
* 背景):某个 ask 赢下持久 CAS 时,同一 askId 下**全部**其余本地条目(批内被原子撤卡的兄弟、或同一
|
|
457
|
+
* askId 的重复本地注册——两类成因,{@link pendingByAskId} 顶注)都对"这只 askId 其实已经有了终局"一无
|
|
458
|
+
* 所知,任其自生自灭会让它们的 TTL 形同虚设(round2 之后,它们自己的窗到期/取消一旦发现 CAS 已经干净
|
|
459
|
+
* 地输,只会「什么都不做」,永远等不到本地事件驱动 settle)。这里直接反查这些 askId 各自的本地 pending
|
|
460
|
+
* 条目集合并逐个调用它们自己的 `settle` 闭包(复用它们自己的 timer 清理/abort 监听器摘除/pending
|
|
461
|
+
* 删除),把远程/其他调用栈发生的终局如实同步回本地——查无对应条目(不在这个副本、或从未真正注册过)
|
|
462
|
+
* 是正常情况,静默跳过。**调用方职责**:传入的 `askIds` 应当包含调用方自己的 askId(捕获"同一 askId
|
|
463
|
+
* 的重复本地注册"这一支)以及(如适用)`expireAsk` 返回的 `voidedSiblings`(捕获"批内兄弟被撤卡"那一
|
|
464
|
+
* 支)——赢家自己的条目此刻已经 settle 过、已经从集合里摘除(见 settle() 内的摘除逻辑),故这里对它
|
|
465
|
+
* 自己重复调用 `settle` 天然是无操作,不会有副作用。 */
|
|
466
|
+
settleVoidedSiblings(askIds) {
|
|
467
|
+
for (const askId of askIds) {
|
|
468
|
+
const entries = this.pendingByAskId.get(askId);
|
|
469
|
+
if (!entries)
|
|
470
|
+
continue;
|
|
471
|
+
for (const entry of [...entries])
|
|
472
|
+
entry.settle(false, "expired"); // 拷贝快照:settle 会修改原集合
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* #151 车4 §12-E(F29/F30 裁定形):外部回决(车4 端点或本类 respond 腿)**赢下持久 CAS 之后**,把同
|
|
477
|
+
* askId 下全部本地悬挂条目按**真实决议**结算——端点已是唯一权威(CAS 已落),本地只是同步终局;查无
|
|
478
|
+
* 条目(跨副本/无流内窗)= 正常,返回 `{ settled: 0 }`,调用方据此诚实回显 `updatedInputForwarded`。
|
|
479
|
+
*
|
|
480
|
+
* 🔴 与 {@link settleVoidedSiblings} 是**两个语义,禁合并**(F29):那边是撤卡收尾,结算值恒
|
|
481
|
+
* `(false, "expired")`——批内兄弟被原子撤卡,它们的终局就是「没了」;这边是「同一只 askId 已经有了
|
|
482
|
+
* 真决议」,重复注册必须拿到**同一个**决议(approve 就是 approve)——合并会把外部批准的重复条目错
|
|
483
|
+
* 结算成拒绝。已被结算过的条目再调 settle 天然无操作(幂等),所以本口对「赢家自己已 settle」安全。
|
|
484
|
+
*/
|
|
485
|
+
notifyExternalDecision(askId, allowed, updatedInput) {
|
|
486
|
+
const entries = this.pendingByAskId.get(askId);
|
|
487
|
+
if (!entries)
|
|
488
|
+
return { settled: 0 };
|
|
489
|
+
let settled = 0;
|
|
490
|
+
for (const entry of [...entries]) {
|
|
491
|
+
entry.settle(allowed, allowed ? "allowed" : "denied", updatedInput);
|
|
492
|
+
settled += 1;
|
|
493
|
+
}
|
|
494
|
+
return { settled };
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* #151 车6:把一张**批级撤卡帧**投给给定的一组连接(live only —— 见 `ApprovalRevokeFrame` 顶注:
|
|
498
|
+
* 撤卡帧不进 durable tail,丢帧的结构补偿是重连 preamble 的全量对账基准)。
|
|
499
|
+
*
|
|
500
|
+
* 空名单不发(零信息的帧只会让壳多一次无意义的对账)。每路独立 catch:一路 emit 失败(连接刚死、
|
|
501
|
+
* durable-append 目标抖动)**绝不回滚已经落定的持久 CAS** —— 帧是通知,行才是真源。
|
|
502
|
+
*/
|
|
503
|
+
emitRevokeTo(ctxs, frame) {
|
|
504
|
+
if (frame.askIds.length === 0)
|
|
505
|
+
return;
|
|
506
|
+
for (const c of ctxs) {
|
|
507
|
+
if (!c.emitRevoke)
|
|
508
|
+
continue; // 本连接没接撤卡口(见 ToolApprovalRunContext.emitRevoke 顶注)
|
|
509
|
+
try {
|
|
510
|
+
void Promise.resolve(c.emitRevoke(frame)).catch(() => undefined);
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// 同步 throw 的 emit 目标(流已结束的那种)——吞掉:卡的作废已经是持久事实,通知投不出去
|
|
514
|
+
// 不改变它,更不回滚已经赢下的 CAS。
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* #151 车6 发射点③④:**进程外**收敛器(reaper 腿的 `approval-reconciler.ts`)产出的撤卡帧的投递口。
|
|
520
|
+
* 收敛器没有 ctx —— 它只有行上的 (owner, sessionId, taskId),经 broker 反查该身份下**当前**的活跃
|
|
521
|
+
* 连接集合。查无活连接 = 正常(壳不在线;重连 preamble 会把这张卡对账掉),返回 0。
|
|
522
|
+
*
|
|
523
|
+
* 两把 key 都试(session 形 + adhoc 形)并去重:行上的 `sessionId` 对 adhoc 腿折的是 taskId
|
|
524
|
+
* (车2 落行时的折叠),而 broker 的 adhoc 分桶键用的正是 taskId —— 两形各试一次比在这里猜哪种腿
|
|
525
|
+
* 更诚实,代价是一次 Map 查找。
|
|
526
|
+
*/
|
|
527
|
+
emitRevoke(frame, target) {
|
|
528
|
+
const targets = new Set();
|
|
529
|
+
for (const key of [this.streamKey(target.owner, target.sessionId, target.taskId), this.streamKey(target.owner, undefined, target.taskId)]) {
|
|
530
|
+
for (const c of this.streams.get(key) ?? [])
|
|
531
|
+
targets.add(c);
|
|
532
|
+
}
|
|
533
|
+
if (targets.size === 0)
|
|
534
|
+
return 0;
|
|
535
|
+
this.emitRevokeTo([...targets], frame);
|
|
536
|
+
return targets.size;
|
|
136
537
|
}
|
|
137
538
|
/** Broker key:宿主 session 优先(retained/续聊子代跨 SSE 找得到新流),sessionless adhoc 退 taskId。
|
|
138
539
|
* 并发审查加固:显式维度前缀(`"session"`/`"adhoc"`)分隔两个取值域,不再靠 `task:` 字符串前缀"祈祷"
|
|
@@ -197,7 +598,8 @@ export class ToolApprovalCoordinator {
|
|
|
197
598
|
// "unavailable"(交 durable park),不再把「无人可问」翻译成 deny(1.295 前无三值只能 fail-closed)。
|
|
198
599
|
if (!ctx)
|
|
199
600
|
return "unavailable";
|
|
200
|
-
|
|
601
|
+
// 出处 = 本 ctx 自身(ALS 腿的投递集合恒是它一个),显式传,不靠 askBroadcast 去猜。
|
|
602
|
+
return this.askBroadcast({ taskId: ctx.taskId, legKey: ctx.legKey ?? "", ...(ctx.legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic: ctx.legDeadlineMonotonic } : {}) }, [ctx], req, signal);
|
|
201
603
|
};
|
|
202
604
|
/** [1535] server 半场(审批链战役,[1539] 定谳断点①的修;[1546] HIGH-1 broker 形):宿主 sync
|
|
203
605
|
* streaming 腿在装配点以本闭包挂 `spec.onAsk`;core 继承链(parentConstraints 冻结
|
|
@@ -212,7 +614,12 @@ export class ToolApprovalCoordinator {
|
|
|
212
614
|
const live = set && set.size > 0 ? [...set] : [];
|
|
213
615
|
if (live.length === 0)
|
|
214
616
|
return Promise.resolve("unavailable");
|
|
215
|
-
|
|
617
|
+
// 🔴 codex 交叉复审(刀 3a,high)修:身份轴取**捕获的出处身份**,不取投递集合的第一条流。
|
|
618
|
+
// `streamKey` 的 session 形不含 taskId ⇒ 同一集合里可以躺着不同 run 的 ctx,`aliveCtxs[0]` 只是
|
|
619
|
+
// 「当下第一条活着的投递连接」。用它当 runId 会双向出错:同一只出处 ask 在集合组成变化后重入拿到
|
|
620
|
+
// 不同 askId(幂等 upsert 失效 ⇒ 重入挂到 TTL),不同出处 run 经同一条流下发拿到同一 askId
|
|
621
|
+
// (正是换轴要封死的复用面)。`legKey` 同理随出处走(装配点 3b 供真值,缺席折空串)。
|
|
622
|
+
return this.askBroadcast({ taskId: identity.taskId, legKey: identity.legKey ?? "", ...(identity.legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic: identity.legDeadlineMonotonic } : {}) }, live, req, signal);
|
|
216
623
|
};
|
|
217
624
|
};
|
|
218
625
|
/** 广播版裁决路径——`ask()`(ALS,恒单元素数组)与 `boundAsk`([1559]四多活集合,可能多元素)共用。
|
|
@@ -226,7 +633,7 @@ export class ToolApprovalCoordinator {
|
|
|
226
633
|
* "unavailable"。人类 respond() 落地的裁决永远照旧信任、不受本判据影响:它只可能在 approvalId 已经
|
|
227
634
|
* 送达某个客户端之后发生(respond 需要客户端手上有这个 id),这是 outcome==="allowed"/"denied" 与
|
|
228
635
|
* "expired"(TTL/abort/run-exit-cleanup/emit 广播全灭四类共用)的天然分野。 */
|
|
229
|
-
async askBroadcast(ctxs, req, signal) {
|
|
636
|
+
async askBroadcast(origin, ctxs, req, signal) {
|
|
230
637
|
if (signal?.aborted)
|
|
231
638
|
return false;
|
|
232
639
|
// 连接级存活过滤:单连接(ask() 的 ALS 语义,今日 100% 生产流量)沿用「这条连接已经死了 ⇒ false」的
|
|
@@ -243,15 +650,264 @@ export class ToolApprovalCoordinator {
|
|
|
243
650
|
if (primary.sessionId && this.allowAllSessions.has(sessionAllowKey(primary.owner, primary.sessionId, sessionAllowCategory(req.toolName)))) {
|
|
244
651
|
return true;
|
|
245
652
|
}
|
|
653
|
+
// #151 车3 刀 3b(设计稿 §0 X-2):**写侧准入门** —— 落 pending / 建 timer / 落行 / 发帧之前。
|
|
654
|
+
// 超限 ⇒ `"unavailable"`(park 路由,永不 deny)。协议未上场时恒放行(见 {@link admit})。
|
|
655
|
+
const releaseAdmission = this.admit(origin.taskId, primary.owner);
|
|
656
|
+
if (!releaseAdmission) {
|
|
657
|
+
defaultLogger.warn("approval admission gate: too many pending asks — routing to park", {
|
|
658
|
+
taskId: origin.taskId,
|
|
659
|
+
perTask: this.admitByTask.get(origin.taskId) ?? 0,
|
|
660
|
+
admitMaxPerTask: this.admitMaxPerTask,
|
|
661
|
+
admitMaxPerOwner: this.admitMaxPerOwner,
|
|
662
|
+
});
|
|
663
|
+
return "unavailable";
|
|
664
|
+
}
|
|
665
|
+
// `id` = **本地** pending 条目的键(每次注册各一个,绝不共享 —— {@link pendingByAskId} 顶注记的
|
|
666
|
+
// 「同 askId 重复本地注册」两类成因下,两条条目必须各占一格,否则后到者会覆盖先到者的登记)。
|
|
246
667
|
const id = uuidv7();
|
|
668
|
+
const child = isChildAsk(req);
|
|
669
|
+
const bounded = boundArgs(req.args);
|
|
670
|
+
const frame = {
|
|
671
|
+
type: "tool_approval",
|
|
672
|
+
approvalId: id,
|
|
673
|
+
toolName: req.toolName,
|
|
674
|
+
// [1939]:core 的 tool-call id 逐字透传(AskRequest.toolCallId 是必填);理由见字段顶注。
|
|
675
|
+
...(typeof req.toolCallId === "string" && req.toolCallId !== "" ? { toolCallId: req.toolCallId } : {}),
|
|
676
|
+
// 帧上 sourceTaskId/fromSubagent/sourceAgentName 只出**子代** ask(server 侧判别收口,现判别
|
|
677
|
+
// 键 = req.fromSubagent[core RB-39② 显式受信键]——sourceTaskId 仍旁随传但不再是判别式,复审
|
|
678
|
+
// F2 案:core 对宿主 ask 也恒填 sourceTaskId=宿主 sessionId,裸透传会让每张宿主卡被误判子代)。
|
|
679
|
+
...(child
|
|
680
|
+
? {
|
|
681
|
+
sourceTaskId: req.sourceTaskId,
|
|
682
|
+
fromSubagent: true,
|
|
683
|
+
...(typeof req.sourceAgentName === "string" ? { sourceAgentName: redactSecrets(req.sourceAgentName) } : {}),
|
|
684
|
+
// core 5.9.0 W1:出处链透传(agentName 同 sourceAgentName 待遇 redact;其余为 core-mint 标识非内容)。
|
|
685
|
+
...(req.delegation
|
|
686
|
+
? {
|
|
687
|
+
delegation: {
|
|
688
|
+
parentToolCallId: req.delegation.parentToolCallId,
|
|
689
|
+
depth: req.delegation.depth,
|
|
690
|
+
...(typeof req.delegation.agentName === "string" ? { agentName: redactSecrets(req.delegation.agentName) } : {}),
|
|
691
|
+
},
|
|
692
|
+
}
|
|
693
|
+
: {}),
|
|
694
|
+
}
|
|
695
|
+
: {}),
|
|
696
|
+
message: typeof req.message === "string" ? redactDeep(req.message) : "",
|
|
697
|
+
...(bounded.omitted ? { argsOmitted: true } : { args: bounded.args }),
|
|
698
|
+
};
|
|
699
|
+
// #151 车2(design/172 §3.3 D3 窗长三元):有效窗——legDeadlineMonotonic 缺席时退化成 ttlMs(D1 零
|
|
700
|
+
// 行为变化)。用它既定时器时长,也是 store 在场时 ensureAsk 的 expiresAtMs。
|
|
701
|
+
// 🔴 车3 刀 3b 收口(3a 在此留的坐标):`legDeadlineMonotonic` 现取自 **`origin`**(出处腿),
|
|
702
|
+
// 不再取投递连接 `primary` —— 理由见 {@link AskOriginIdentity.legDeadlineMonotonic}(session 形的
|
|
703
|
+
// 投递集合可以躺着别的 run 的 ctx,读 primary 会拿到另一条腿的 deadline)。缺席仍退化成 ttlMs。
|
|
704
|
+
const effectiveWindowMs = effectiveAskWindowMs(this.ttlMs, this.windowMarginMs, origin.legDeadlineMonotonic, performance.now());
|
|
705
|
+
// 🔴 窗的**绝对** deadline 一次铸定(§3.1「永不赋新 deadline、永不重启窗」):落库的 `expires_at_ms`、
|
|
706
|
+
// 呈卡帧的 `expiresAtMs`、以及重放腿回读的那一份**必须**是同一个数。此前它内联在 ensureAsk 的入参里,
|
|
707
|
+
// 呈卡帧接上来之后若各算各的 `Date.now() + effectiveWindowMs`,两处就会差出一个 await 的时间。
|
|
708
|
+
const expiresAtMs = Date.now() + effectiveWindowMs;
|
|
709
|
+
// #151 车2(D4 askId/batchId 铸造 + register-before-emit):只在 askStore 在场时计算/落盘——askId 派生
|
|
710
|
+
// 与 pending map 的 uuidv7 `id` 是两条独立的身份轴(顶注)。ensureAsk 在此处调用(register-before-emit
|
|
711
|
+
// 之前),cardJson 存 frame 的已洗投影(boundArgs 之后的 frame 对象,D4)。
|
|
712
|
+
let askId;
|
|
713
|
+
let batchId;
|
|
714
|
+
// #151 车3 刀 3b:design/172 §3.1 的**中性投影**。素材 = 上面那个已洗、已按**字节**帽处理过的
|
|
715
|
+
// `frame`(结构上满足 `ApprovalCardSource`)—— 于是「wire 帧与 card_json 同一份判据」是结构事实,
|
|
716
|
+
// 不是两处各算一遍的巧合(§6.5)。`risk` 三态由 `buildApprovalCard` 从 `req` 窄读(缺席=未标注)。
|
|
717
|
+
// 只在协议上场(askStore 在场)时铸:关着时这几行连算都不算,现行为逐字不变(A-1)。
|
|
718
|
+
const card = this.askStore ? buildApprovalCard(frame, req, req.requiresRealApproval === true) : undefined;
|
|
719
|
+
/**
|
|
720
|
+
* 发帧时真正用的**卡 / deadline / wire 身份** —— 默认是本地刚铸的这一份;`ensureAsk` 返回一条
|
|
721
|
+
* **早已存在**的 STREAM_PENDING 行时改采信行(codex 交叉复审 F2,2026-08-06 真 finding)。
|
|
722
|
+
*
|
|
723
|
+
* 🔴 与上面的 `id` 是**两条轴,不可合并**:`id` 是本地 pending 的键(重复注册各占一格),
|
|
724
|
+
* `wireApprovalId` 是这只 ask 在 **wire 上的唯一身份**(消费端按它去重、`respond` 按它寻的那把)。
|
|
725
|
+
* 合并会二选一地出错:共用一把 ⇒ 重复注册互相覆盖本地登记(先到者从此无人驱动);各铸各的 ⇒
|
|
726
|
+
* 同一个 askId 带着两个 approvalId 上 wire(壳把一张卡渲两次)、两个 deadline(破 §3.1
|
|
727
|
+
* 「窗一次铸定、永不重算」)。分成两条轴,两件事各自成立。
|
|
728
|
+
*/
|
|
729
|
+
let wireApprovalId = id;
|
|
730
|
+
let cardForFrame = card;
|
|
731
|
+
let persistedExpiresAtMs = expiresAtMs;
|
|
732
|
+
// R4-1 的三件套(见下方 ensureAsk 调用点注):迟到落盘的行 id / 本地是否已放弃 / 放弃时的收尾动作。
|
|
733
|
+
let lateEnsuredAskId;
|
|
734
|
+
let abandonedEnsure = false;
|
|
735
|
+
/** 本次 `ensureAsk` 传给店里的 `createdAtMs` —— 兼作**这次插入的归属凭据**(R2-2,见 onLateSuccess)。 */
|
|
736
|
+
const attemptCreatedAtMs = Date.now();
|
|
737
|
+
const voidAbandonedRow = (orphanAskId) => {
|
|
738
|
+
const store = this.askStore;
|
|
739
|
+
if (!store)
|
|
740
|
+
return;
|
|
741
|
+
void store
|
|
742
|
+
.transitionAsk(orphanAskId, "STREAM_PENDING", "VOID", { updatedAtMs: Date.now() })
|
|
743
|
+
.catch((err) => this.noteStoreError(err, "transitionAsk(abandon-late-ensure)"));
|
|
744
|
+
};
|
|
745
|
+
// `card` 与 `askStore` 一律同在同不在(上面同一个三元铸的)——把两者一起narrow,下面落行时就不必
|
|
746
|
+
// 为「理论上可能缺席」的卡再写一条不可达的兜底分支。
|
|
747
|
+
if (this.askStore && card) {
|
|
748
|
+
const sourceTaskId = req.sourceTaskId ?? origin.taskId;
|
|
749
|
+
// runId 轴 = 本 run 的 wire id,取自**出处**(`origin`)而不是投递连接:core 给
|
|
750
|
+
// `AskRequest.sourceTaskId` 填的是 sessionId(prepare-task.js:2595),两者在同一会话的多次提交
|
|
751
|
+
// 之间是「不变」与「必变」的关系——正是这条轴封死了跨 run 复用旧同意(§10 钉 C-1)。
|
|
752
|
+
// 为什么不是 `primary.taskId`:见 boundAsk 里那条 codex 复审注(投递集合可含别的 run 的流)。
|
|
753
|
+
const runId = origin.taskId;
|
|
754
|
+
const legKey = origin.legKey;
|
|
755
|
+
askId = deriveAskId(sourceTaskId, runId, req.toolCallId, legKey, req.delegation?.parentToolCallId);
|
|
756
|
+
batchId = deriveBatchId(sourceTaskId, runId, legKey);
|
|
757
|
+
try {
|
|
758
|
+
// R2-5:`ensureAsk` 在窗定时器/abort 监听器装上**之前**被 await ⇒ 必须有界(见 DURABLE_CALL_TIMEOUT_MS)。
|
|
759
|
+
// R4-1(codex round4):**迟到成功**要观测 —— 超时后若复核读也没看见行,下面会把 askId/batchId 清空
|
|
760
|
+
// 降级成「store 缺席」;而那笔事务完全可能**稍后才提交**,留下一条**没有任何本地属主**的
|
|
761
|
+
// STREAM_PENDING 行。它随后会被本车的孤儿腿代打 expire → PARKING → 甚至被对账收敛器 PARK 出一张
|
|
762
|
+
// **幻影 gate**(工具其实早已按本地终局执行完了)。所以:一旦确认本地放弃了这条行,就把它收成
|
|
763
|
+
// VOID(「这行从来没有过属主」)。best-effort:失败也无妨,收敛器仍是最后兜底。
|
|
764
|
+
const existingOrCreated = await this.withStoreDeadline(this.askStore.ensureAsk({
|
|
765
|
+
askId,
|
|
766
|
+
// 行记在**出处** run 名下(listPendingByTask 与恢复扫描按它找人),不是投递流的 run。
|
|
767
|
+
taskId: origin.taskId,
|
|
768
|
+
sourceTaskId,
|
|
769
|
+
// sessionId 是店内必填列;session-less(adhoc)ask 折 taskId——同 streamKey 的 "adhoc" 分桶
|
|
770
|
+
// 精神一致(taskId 替身份充当会话锚),非 design/172 字面钉死,是本车工程判断(见汇报存疑单)。
|
|
771
|
+
sessionId: primary.sessionId ?? origin.taskId,
|
|
772
|
+
owner: primary.owner,
|
|
773
|
+
batchId,
|
|
774
|
+
toolCallId: req.toolCallId,
|
|
775
|
+
legKey,
|
|
776
|
+
parentToolCallId: req.delegation?.parentToolCallId ?? null,
|
|
777
|
+
// 🔴 车3 刀 3b(设计稿 §6.2,车5 移交②):`card_json` 存的是**信封**(`{schemaVersion, approvalId,
|
|
778
|
+
// card}`),不再是整帧。写侧(这里)与读侧(重放腿/车4/车5 的 `ApprovalCardEnvelopeSchema.safeParse`)
|
|
779
|
+
// 因此是同一个符号,存的形与读的形不可能各自漂。**直接迁移、不做兼容读**:表与代码都还没出厂
|
|
780
|
+
// (未打 tag),按 SCHEMA POLICY drop-and-recreate,不给一个从未出厂的形状留兼容层(§6.2 裁定 +
|
|
781
|
+
// 本仓「不做临时方案」纪律)。
|
|
782
|
+
cardJson: buildApprovalCardEnvelope(id, card),
|
|
783
|
+
// 车5 §9 C2 的对账 join 键。**窄读**(顶注:core 5.15.0 起真有值,窄读的理由改为「摘要函数
|
|
784
|
+
// 不在 core 公开导出面,server 只透传不重算」)——有则存、无则 null;缺席 ⇒ 收敛器判据 1
|
|
785
|
+
// 结构上不命中(`unmatchableNoHash` 计数),这是**设计要的** fail-safe,不是降级(禁「能取到时才比」)。
|
|
786
|
+
boundInputHash: readBoundInputHash(req),
|
|
787
|
+
schemaVersion: 1,
|
|
788
|
+
expiresAtMs,
|
|
789
|
+
createdAtMs: attemptCreatedAtMs,
|
|
790
|
+
}), "ensureAsk", DURABLE_CALL_TIMEOUT_MS, (late) => {
|
|
791
|
+
// 🔴 codex 交叉复审 round2 R2-2(2026-08-06 真 finding):`ensureAsk` 是**幂等 upsert**,它迟到
|
|
792
|
+
// 返回的那一行可能根本不是本次插的 —— 而是一条**早已存在、且正被另一次调用持有**的行(askId 是
|
|
793
|
+
// 确定性派生,两次并发调用天然指向同一行)。旧形把任何 STREAM_PENDING 结果都当成「本次留下的
|
|
794
|
+
// 孤儿」去收 VOID,于是本次超时放弃时会把**别人正在等的那张活卡**作废掉:真属主的 decideAsk
|
|
795
|
+
// 随后干净地输,人的批准被吞成拒绝/延迟收敛。
|
|
796
|
+
// 归属判据用现成数据:`createdAtMs` 是**插入者**写下的值,幂等命中时返回的是**先到者**的值。
|
|
797
|
+
// 相等 ⇒ 这行是本次插的(才轮得到本次收尾);不等 ⇒ 幂等命中别人的行,一个字都不许动。
|
|
798
|
+
// (残留:同一毫秒内的两次并发插入无法区分 —— ms 粒度的窄面,记在汇报存疑单。)
|
|
799
|
+
lateEnsuredAskId = late.state === "STREAM_PENDING" && late.createdAtMs === attemptCreatedAtMs ? late.askId : undefined;
|
|
800
|
+
if (abandonedEnsure && lateEnsuredAskId !== undefined)
|
|
801
|
+
voidAbandonedRow(lateEnsuredAskId);
|
|
802
|
+
});
|
|
803
|
+
// 🔴 codex 交叉复审 round4 抓获(真 finding):`ensureAsk` 是幂等 upsert(approval-ask-machine.ts
|
|
804
|
+
// 顶注:「同参数元组——含重试/failover/闭包再入——永远派生同一个 id」)——同一确定性 askId 的重入
|
|
805
|
+
// 拿到的可能不是一条新鲜的 STREAM_PENDING 行,而是一条**早已终结**(或正在 PARKING/PARKED)的既有
|
|
806
|
+
// 行。裸忽略返回值、继续往下注册一条全新的本地待决,会让这只 ask 陷入一个永远赢不了 CAS 的死局:
|
|
807
|
+
// round2/round3 之后,三条竞争者对「干净地输」的响应统一是「什么都不做、信任真赢家」——但真赢家
|
|
808
|
+
// 是**过去**某次早已返回的 askBroadcast 调用,进程里已经没有任何存活的闭包会再驱动它 settle,
|
|
809
|
+
// TTL 到点时 expireAsk 对一条非 STREAM_PENDING 行同样只会干净地输,一样什么都不做——挂死到进程
|
|
810
|
+
// 重启。修法:行不是 STREAM_PENDING ⇒ 不重新挂卡,直接回放既有终局(不落 pending、不 emit)。
|
|
811
|
+
if (existingOrCreated.state !== "STREAM_PENDING") {
|
|
812
|
+
releaseAdmission(); // X-2:回放既有终局 = 这只 ask 从未占用一条未决腿,名额立刻还回去
|
|
813
|
+
return replayTerminalAskRow(existingOrCreated);
|
|
814
|
+
}
|
|
815
|
+
// 🔴 F2 修:行 = 真源。刚插的行上这三样与本地一致(采信是恒等操作);**重入**拿到既有行时,
|
|
816
|
+
// 采信才是唯一正确的做法 —— 帧、定时器、落库三处从此只有一个 deadline、一个 approvalId、一份卡。
|
|
817
|
+
// 🔴 codex 交叉复审 round2 R2-3(2026-08-06 真 finding):**信封与 deadline 是一个校验单元**。
|
|
818
|
+
// 旧形对 `safeParse` 失败「宽容降级」:留着本地新铸的 approvalId/card、却照样采信行上的
|
|
819
|
+
// `expires_at_ms` —— 于是同一条持久行会以**编造的** wire 身份上 wire(重放腿反而因为形不合把它
|
|
820
|
+
// 跳过),而车4 的回决端点按 askId 仍决得动它:人有可能照着一份**不是行上那份**的卡做授权。
|
|
821
|
+
// 采信行上一个**无界/非有限**的 deadline 同样危险:它会把 pending 条目与准入名额钉在那里远超配置窗。
|
|
822
|
+
// 裁:两样任一不合 ⇒ 不编造替身、不发帧,**fail-safe 走 park**(行本身交给对账收敛器处理)。
|
|
823
|
+
const persistedEnvelope = ApprovalCardEnvelopeSchema.safeParse(existingOrCreated.cardJson);
|
|
824
|
+
const sanePersistedDeadline = Number.isFinite(existingOrCreated.expiresAtMs) && existingOrCreated.expiresAtMs <= Date.now() + effectiveWindowMs + DURABLE_CALL_TIMEOUT_MS;
|
|
825
|
+
if (!persistedEnvelope.success || !sanePersistedDeadline) {
|
|
826
|
+
this.noteStoreError(new Error(`persisted approval row is unusable (envelopeOk=${persistedEnvelope.success}, deadlineOk=${sanePersistedDeadline})`), "ensureAsk(persisted-row-unusable)");
|
|
827
|
+
releaseAdmission();
|
|
828
|
+
return "unavailable"; // park 路由;绝不用一个编造的身份把这张卡推上 wire
|
|
829
|
+
}
|
|
830
|
+
persistedExpiresAtMs = existingOrCreated.expiresAtMs;
|
|
831
|
+
wireApprovalId = persistedEnvelope.data.approvalId;
|
|
832
|
+
frame.approvalId = wireApprovalId; // 旧帧的去重键必须与新帧同值(两帧同带 approvalId 是消费端的对账口)
|
|
833
|
+
cardForFrame = persistedEnvelope.data.card;
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
this.noteStoreError(err, "ensureAsk");
|
|
837
|
+
// 🔴 codex 交叉复审 round1 抓获(真 finding):ensureAsk 失败若放任 askId/batchId 留在场,下游
|
|
838
|
+
// decideAsk/expireAsk/transitionAsk 会对一个(疑似)不存在的行打 CAS——那不是「CAS 输」(某个真
|
|
839
|
+
// 竞争者已经决定了)而是「压根没有行」,但 round2 的「只在自己赢时才 settle」修正后,三条竞争者
|
|
840
|
+
// 现在对「干净地输」的响应统一是"什么都不做、信任真赢家"——若压根没有真赢家(行真的没落盘),
|
|
841
|
+
// 这只 ask 会被三方一致「谦让」到永远,直接违反 D5「裁决可用性不因店抖动而降级」。
|
|
842
|
+
// 🔴 codex 交叉复审 round2 追加抓获(真 finding,精化 round1 的修法):但反过来裸清空也不对——
|
|
843
|
+
// `SqlApprovalAskStore.ensureAsk` 与 `decideAsk` 同款「提交后另起一次独立 getAsk 读」形态
|
|
844
|
+
// (approval-ask-store-sql.ts:382-434),commit 可能已经真的成功、只是这次确认读断了——那种情形下
|
|
845
|
+
// 行其实已经落盘,裸清空会把一条真实存在的 STREAM_PENDING 行永久架空(未来任何 CAS 都不会再碰
|
|
846
|
+
// 它,只能靠车5 的恢复扫描兜底)。折中:失败后打一次防御性复核读——行确实存在(不论其当前状态)
|
|
847
|
+
// 就保留 askId/batchId(后续 CAS 仍有机会打中真行);复核读本身也失败、或行确实不存在,才降级成
|
|
848
|
+
// 「视同这只 ask 的 store 缺席」(D1 路径)。
|
|
849
|
+
// 🔴 车3 刀 3b(设计稿 §2.2(b)-3,改车2 的 D5「继续呈卡」臂):复核读要分**两种失败**。
|
|
850
|
+
// ①「读成功、行不在」= **确定**的事实 ⇒ 降级成「视同 store 缺席」,活卡腿逐字照旧(D1 路径)——
|
|
851
|
+
// 这条腿早于本协议、不受「无持久身份不发新帧」约束,只是不发新帧、不落行。
|
|
852
|
+
// ②「读本身也失败/超时」= **不确定** ⇒ 有界重试;耗尽仍不确定 ⇒ 返回 `"unavailable"` 走 park,
|
|
853
|
+
// **不继续呈卡**。理由(§2.2(b)):在身份是否落盘都不知道的情况下继续收人决,与设计 §3.1
|
|
854
|
+
// 「身份**先持久化后**发帧」直接相悖 —— 人批了、行却可能不存在(或存在而无人对得上),
|
|
855
|
+
// 那是一次会凭空消失的批准。park 是这条路上唯一诚实的出路(人仍可经 durable gate 补批)。
|
|
856
|
+
let confirmedRowExists = false;
|
|
857
|
+
let indeterminate = true;
|
|
858
|
+
for (let attempt = 0; attempt <= DURABLE_CONVERGE_BACKOFF_MS.length; attempt++) {
|
|
859
|
+
try {
|
|
860
|
+
confirmedRowExists = (await this.withStoreDeadline(this.askStore.getAsk(askId), "getAsk(ensureAsk-confirm)")) !== null;
|
|
861
|
+
indeterminate = false; // 读到了确定答案(在 / 不在),不必再试
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
catch (readErr) {
|
|
865
|
+
this.noteStoreError(readErr, "getAsk(ensureAsk-confirm)");
|
|
866
|
+
if (attempt < DURABLE_CONVERGE_BACKOFF_MS.length)
|
|
867
|
+
await new Promise((r) => setTimeout(r, DURABLE_CONVERGE_BACKOFF_MS[attempt]).unref?.());
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (indeterminate) {
|
|
871
|
+
// 🔴 codex 交叉复审 F6(2026-08-06 真 finding):**必须**先登记「本地已放弃」再返回。
|
|
872
|
+
// 这一臂是超时 —— 那笔 `ensureAsk` 完全可能**稍后才提交**,留下一条没有任何本地属主的
|
|
873
|
+
// STREAM_PENDING 行(没有 pending 条目、没有定时器)。不登记的话 `onLateSuccess` 观测器看到
|
|
874
|
+
// `abandonedEnsure === false` 就什么都不做,那条行会被孤儿腿代打 expire → PARKING → 被对账
|
|
875
|
+
// 收敛器 PARK 出一张**幻影 gate**(工具其实早就按本地终局 park 掉了)。两个方向都覆盖:
|
|
876
|
+
// 观测器已经先跑过 ⇒ 这里补收;还没跑 ⇒ 它稍后自己看到这个标志再收(与下面 D1 降级臂同形)。
|
|
877
|
+
abandonedEnsure = true;
|
|
878
|
+
if (lateEnsuredAskId !== undefined)
|
|
879
|
+
voidAbandonedRow(lateEnsuredAskId);
|
|
880
|
+
releaseAdmission(); // 名额还回去:这只 ask 从未真正进场(既没落 pending 也没建 timer)
|
|
881
|
+
return "unavailable"; // park 路由(§2.2(b)-3);绝不在身份未定的情况下继续呈卡收人决
|
|
882
|
+
}
|
|
883
|
+
if (!confirmedRowExists) {
|
|
884
|
+
askId = undefined;
|
|
885
|
+
batchId = undefined;
|
|
886
|
+
// R4-1:登记「本地已放弃」。迟到的 ensureAsk 若已经落盘(观测器先跑),这里补收;若还没落盘,
|
|
887
|
+
// 观测器稍后自己看到这个标志再收。两个方向都覆盖,不依赖谁先谁后。
|
|
888
|
+
abandonedEnsure = true;
|
|
889
|
+
if (lateEnsuredAskId !== undefined)
|
|
890
|
+
voidAbandonedRow(lateEnsuredAskId);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
247
894
|
let done = false;
|
|
248
895
|
let emitFailed = false;
|
|
249
896
|
let emitSettled = false;
|
|
897
|
+
// #151 车2(D2 窗到期竞争者的返回值覆盖):askStore 在场且 expireAsk 赢 CAS ⇒ 强制返回 "unavailable"
|
|
898
|
+
// (park 路由,G1 回路)——与旧形的 emit-race 覆盖判据(!emitSettled && lastOutcome==="expired")并存,
|
|
899
|
+
// 互不排斥(见方法尾的返回值分派)。askStore 缺席时恒 false,不影响 D1 零行为变化。
|
|
900
|
+
let windowRouteUnavailable = false;
|
|
250
901
|
let timer;
|
|
251
902
|
let resolveAllowed;
|
|
252
903
|
const allowedP = new Promise((resolve) => { resolveAllowed = resolve; });
|
|
253
904
|
let lastOutcome = "expired";
|
|
254
905
|
const abortListeners = [];
|
|
906
|
+
// #151 车2(round5 精化):`pendingEntry`(下方构造,携带 `settle` 本身)构造之前,`settle` 闭包已经
|
|
907
|
+
// 需要引用它自己在 {@link pendingByAskId} 那个 Set 里的身份,用来在结算时把自己摘出去——先立一个
|
|
908
|
+
// 稍后才赋值的引用格(鸡生蛋:settle 是 pendingEntry 的一个字段,pendingEntry 建好之后才能把它自己
|
|
909
|
+
// 塞进这个引用里)。
|
|
910
|
+
let selfEntry;
|
|
255
911
|
const settle = (allowed, outcome, updatedInput) => {
|
|
256
912
|
if (done)
|
|
257
913
|
return;
|
|
@@ -261,13 +917,188 @@ export class ToolApprovalCoordinator {
|
|
|
261
917
|
for (const [sig, fn] of abortListeners)
|
|
262
918
|
sig.removeEventListener("abort", fn);
|
|
263
919
|
this.pending.delete(id);
|
|
920
|
+
// #151 车2(round3 兄弟撤卡索引 + round5 精化成 Set,顶注):把自己从同 askId 的集合里摘除——集合
|
|
921
|
+
// 可能还有其他条目(批内兄弟、或同 askId 的重复本地注册),它们的存亡与自己的结算无关。
|
|
922
|
+
if (askId !== undefined) {
|
|
923
|
+
const siblings = this.pendingByAskId.get(askId);
|
|
924
|
+
if (siblings && selfEntry) {
|
|
925
|
+
siblings.delete(selfEntry);
|
|
926
|
+
if (siblings.size === 0)
|
|
927
|
+
this.pendingByAskId.delete(askId);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
264
930
|
lastOutcome = outcome;
|
|
931
|
+
releaseAdmission(); // X-2:落定即释放名额(幂等;settle 本身已由 `done` 去重)
|
|
932
|
+
if (selfEntry)
|
|
933
|
+
selfEntry.settledOutcome = outcome; // 车5:供 respondWithCas 判「有效终局是不是这次回决」
|
|
265
934
|
resolveAllowed({ allowed, ...(allowed && updatedInput !== undefined ? { updatedInput } : {}) });
|
|
266
935
|
};
|
|
267
|
-
|
|
936
|
+
// #151 车2(D2 窗到期竞争者):askStore 缺席 ⇒ settle(false,"expired") 逐字不变(D1)。在场 ⇒ settle
|
|
937
|
+
// 前先赢一把 expireAsk CAS——赢 ⇒ settle 之余把终局强制改判 "unavailable"(windowRouteUnavailable);
|
|
938
|
+
// 输 ⇒ 人已决(或即将由回决腿落定),这里什么都不做,交 done 标志兜底。⚠️ store CAS 是 async 而 settle
|
|
939
|
+
// 是 sync,TTL 定时器回调因此变 async(void IIFE 包裹,内部 catch 全部错误按 D5 fail-open 处置)。
|
|
940
|
+
/**
|
|
941
|
+
* #151 车5(design/172 §4 状态感知分派表 = 车3 §14 X-1 裁定②的实施件;车5 稿 §8 D-1.2):
|
|
942
|
+
* **CAS 干净地输之后,重读持久态并按真实终局收敛**——取代此前的「什么都不做、信任真赢家」。
|
|
943
|
+
*
|
|
944
|
+
* 🔴 为什么必须改:「信任真赢家」在**单进程**里成立(赢家是本进程另一条闭包,它会 settle 我),在
|
|
945
|
+
* 跨副本/收敛器上线后**不成立**——赢家可能是另一个副本的回决端点,或本车的 reaper 收敛器。那条
|
|
946
|
+
* 路径压根不认识本进程的 pending 条目,本地闭包于是永远等不到任何事件:HTTP 200 而持卡腿永挂
|
|
947
|
+
* (X-1 契约明令排除的那个形)。收敛器上线前必须先落这一段,否则本车自己制造挂死面。
|
|
948
|
+
*
|
|
949
|
+
* 映射复用 {@link replayTerminalAskRow}(**同一套 settle 语义,不造第二套**):
|
|
950
|
+
* `DECIDED` ⇒ 真决议(approve ⇒ allowed / deny ⇒ denied);
|
|
951
|
+
* `PARKING|PARKED` ⇒ park 路由(`"unavailable"`,与窗到期赢家的终局同义,幂等);
|
|
952
|
+
* `DENIED|VOID` ⇒ deny。
|
|
953
|
+
* 行读不出(店抖动)⇒ **有界重试**(指数退避,期间随时被真赢家 settle 就退出),用尽仍失败 ⇒
|
|
954
|
+
* D5 fail-open 退回纯进程内语义(`settle(false,"expired")`)——绝不无限等。
|
|
955
|
+
*/
|
|
956
|
+
const convergeFromDurableState = async (where) => {
|
|
957
|
+
if (!this.askStore || askId === undefined)
|
|
958
|
+
return;
|
|
959
|
+
const store = this.askStore;
|
|
960
|
+
const pinnedAskId = askId;
|
|
961
|
+
for (let attempt = 0; attempt < DURABLE_CONVERGE_BACKOFF_MS.length; attempt++) {
|
|
962
|
+
if (done)
|
|
963
|
+
return; // 真赢家已经在本进程里落定了终局——无事可做(幂等)
|
|
964
|
+
try {
|
|
965
|
+
// 每次读都带墙钟 deadline(见 DURABLE_CONVERGE_READ_TIMEOUT_MS 顶注):挂住的读不许吃掉整个
|
|
966
|
+
// 退避预算。超时抛进下面的 catch,与「读失败」同一条路(退避 → 用尽 → fail-open)。
|
|
967
|
+
const row = await this.withStoreDeadline(store.getAsk(pinnedAskId), `getAsk(${where})`, DURABLE_CONVERGE_READ_TIMEOUT_MS);
|
|
968
|
+
if (done)
|
|
969
|
+
return;
|
|
970
|
+
if (row === null) {
|
|
971
|
+
// 行不在(被 deleteByTask 清过 / 从未落盘)——没有可信终局可读,退回进程内语义。
|
|
972
|
+
settle(false, "expired");
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (row.state === "STREAM_PENDING") {
|
|
976
|
+
// 竞争者 CAS 输、行却还是 STREAM_PENDING:只可能是刚才那一瞬别人先赢又被回滚一类的窄窗
|
|
977
|
+
// (或 batchId 错配导致的 expireAsk 不赢)。退避后再读一次,不在这里替它编造终局。
|
|
978
|
+
await new Promise((r) => setTimeout(r, DURABLE_CONVERGE_BACKOFF_MS[attempt]).unref?.());
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
const outcome = replayTerminalAskRow(row);
|
|
982
|
+
if (outcome === "unavailable") {
|
|
983
|
+
windowRouteUnavailable = true; // park 语义 ⇒ onAsk 交 "unavailable"(G1 回路)
|
|
984
|
+
settle(false, "expired");
|
|
985
|
+
}
|
|
986
|
+
else if (outcome === true) {
|
|
987
|
+
settle(true, "allowed");
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
settle(false, "denied");
|
|
991
|
+
}
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
catch (err) {
|
|
995
|
+
this.noteStoreError(err, `getAsk(${where})`);
|
|
996
|
+
await new Promise((r) => setTimeout(r, DURABLE_CONVERGE_BACKOFF_MS[attempt]).unref?.());
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (!done)
|
|
1000
|
+
settle(false, "expired"); // 重试用尽:D5 fail-open,绝不挂死
|
|
1001
|
+
};
|
|
1002
|
+
const windowExpired = () => {
|
|
1003
|
+
void (async () => {
|
|
1004
|
+
if (!this.askStore || askId === undefined || batchId === undefined) {
|
|
1005
|
+
settle(false, "expired");
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
try {
|
|
1009
|
+
const res = await this.withStoreDeadline(this.askStore.expireAsk(askId, batchId), "expireAsk(window)", DURABLE_CALL_TIMEOUT_MS, (late) => {
|
|
1010
|
+
// R3-1 迟到成功:本地终局已由下面的 catch 落定(幂等,不改写),但**兄弟的结算与撤卡帧**
|
|
1011
|
+
// 是这次事务真做出来的事实,必须补做 —— 否则兄弟闭包挂死、壳上留一张已作废的卡。
|
|
1012
|
+
if (!late.won)
|
|
1013
|
+
return;
|
|
1014
|
+
this.settleVoidedSiblings([askId, ...late.voidedSiblings]);
|
|
1015
|
+
this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, late.voidedSiblings, "superseded_by_park", Date.now()));
|
|
1016
|
+
});
|
|
1017
|
+
if (res.won) {
|
|
1018
|
+
windowRouteUnavailable = true;
|
|
1019
|
+
settle(false, "expired");
|
|
1020
|
+
// round3:同批兄弟被远程撤卡,本地同步收尾;round5 追加自己的 askId——覆盖「同 askId 重复本地
|
|
1021
|
+
// 注册」那一支(顶注,settleVoidedSiblings 对已经结算过的自己天然是无操作)。
|
|
1022
|
+
this.settleVoidedSiblings([askId, ...res.voidedSiblings]);
|
|
1023
|
+
// #151 车6 发射点①:降级连坐的兄弟被原子撤卡 ⇒ 壳上那些卡必须消失(live only)。
|
|
1024
|
+
this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, res.voidedSiblings, "superseded_by_park", Date.now()));
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
// 干净地输 ⇒ 重读持久态收敛(X-1;见 convergeFromDurableState 顶注)。
|
|
1028
|
+
await convergeFromDurableState("windowExpired");
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
catch (err) {
|
|
1032
|
+
this.noteStoreError(err, "expireAsk");
|
|
1033
|
+
// R3-1:**超时**与「店报错」的诚实处置方向不同。超时 = 不知道那次 `expireAsk` 做没做成 ——
|
|
1034
|
+
// 若它稍后提交,行就是 PARKING(已转投递面);此时把 onAsk 判成 deny 会与持久真源矛盾,而且
|
|
1035
|
+
// 违反 §3.3「窗不得把一个可 park 的 ask 拖成 abort-deny」。交 `"unavailable"`(park 路由)对
|
|
1036
|
+
// 两种结局都成立:真提交了 ⇒ 与行一致;没提交 ⇒ core 自己走 durable park,不劣于 deny。
|
|
1037
|
+
if (err instanceof ApprovalStoreDeadlineError)
|
|
1038
|
+
windowRouteUnavailable = true;
|
|
1039
|
+
settle(false, "expired"); // fail-open(D5):店抖动不挡窗到期的进程内机械照旧完成
|
|
1040
|
+
}
|
|
1041
|
+
})();
|
|
1042
|
+
};
|
|
1043
|
+
// F2 修:定时器量的是**行上那个绝对 deadline** 的剩余时长(重入拿到既有行时,剩余时长比一整个窗短
|
|
1044
|
+
// —— 旧形会给一条早就该到期的行重新挂一个满窗的表)。store 缺席时逐字沿用 `effectiveWindowMs`(D1)。
|
|
1045
|
+
timer = setTimeout(windowExpired, this.askStore ? Math.max(0, persistedExpiresAtMs - Date.now()) : effectiveWindowMs);
|
|
268
1046
|
timer.unref?.();
|
|
1047
|
+
// #151 车2(D2 取消竞争者:task signal abort / 连接全灭同路):askStore 缺席 ⇒ settle(false,
|
|
1048
|
+
// "expired") 逐字不变(D1)。在场 ⇒ settle 前先打一把 transitionAsk(STREAM_PENDING→VOID)CAS。
|
|
1049
|
+
// 🔴 codex 交叉复审 round2 抓获(真 finding,与 D2 原文「赢输都照旧 settle」的字面不同——属主已认领
|
|
1050
|
+
// 的、以「验真优先于不重议」为准的偏离,理由如下):store 各方法的网络往返跳数并不对称——SQL 双方言
|
|
1051
|
+
// 实现里 `decideAsk` 在提交事务后**另起一次独立的 `getAsk` 读**才返回(`approval-ask-store-sql.ts`
|
|
1052
|
+
// :382-434/456-499),而 `transitionAsk` 直接把 UPDATE 的 affected-rows 当结果返回,少一整跳。若取消
|
|
1053
|
+
// 臂真照 D2 字面「赢输都 settle」,在人类回决(`respond`)已经**赢下持久 CAS**、但它自己较慢的第二跳
|
|
1054
|
+
// 还没落地时,取消臂会用一个在 CAS 层面已经**输了**的 transitionAsk 结果抢先把本地终局锁成 false 并
|
|
1055
|
+
// 删掉 pending 条目——回决那边随后真正拿到 `ok:true` 时,`respondWithCas` 的 entry 存活性复核(下方)
|
|
1056
|
+
// 只能如实 404:durable 行写着人已批准,活的终局却是拒绝,自相矛盾。改判:**只在自己真赢时才
|
|
1057
|
+
// settle**;干净地输(transitionAsk 返回 false,不是 throw)⇒ 什么都不做,信任真赢家走它自己的路径
|
|
1058
|
+
// 完成 settle(与 windowExpired 的「输 ⇒ 什么都不做」同精神,两条路径现在对称)。throw(店真的挂了,
|
|
1059
|
+
// 不是「CAS 输」而是「不知道」)仍走 D5 fail-open:退化回 D1 的纯进程内语义,无条件 settle。
|
|
1060
|
+
// #151 车2(codex round4 抓获,真 finding,精化):`runCancel` 改成**可 await 的**async 函数(不再是
|
|
1061
|
+
// void-返回的 fire-and-forget 包装)——事件监听器(下方 `onTaskAbort`/`onTargetGone`)仍然只是
|
|
1062
|
+
// `void runCancel()` 触发它、不等结果(它们是真正的"未来事件"回调,没有调用方在等);但下面的
|
|
1063
|
+
// 「ensureAsk 挂起期间错过的 abort」复核**需要真等它跑完**才能知道是否该在注册/emit 之前就短路收尾
|
|
1064
|
+
// (见复核处的顶注)——这是 round4 finding 1 的直接修复点,round3 的 fire-and-forget 形只触发了取消,
|
|
1065
|
+
// 却让函数继续往下注册/广播一张其实已经作废的卡。
|
|
1066
|
+
const runCancel = async () => {
|
|
1067
|
+
if (!this.askStore || askId === undefined || batchId === undefined) {
|
|
1068
|
+
settle(false, "expired"); // D1:现行为逐字不变
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
try {
|
|
1072
|
+
const won = await this.withStoreDeadline(this.askStore.transitionAsk(askId, "STREAM_PENDING", "VOID", { updatedAtMs: Date.now() }), "transitionAsk(cancel)", DURABLE_CALL_TIMEOUT_MS, (lateWon) => {
|
|
1073
|
+
if (!lateWon)
|
|
1074
|
+
return; // R3-1 迟到成功:补做撤卡收尾(本地终局由 catch 落定,幂等)
|
|
1075
|
+
this.settleVoidedSiblings([askId]);
|
|
1076
|
+
this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, [askId], "aborted", Date.now()));
|
|
1077
|
+
});
|
|
1078
|
+
if (won) {
|
|
1079
|
+
settle(false, "expired");
|
|
1080
|
+
// round5:transitionAsk 不携带 voidedSiblings(批级撤卡只是 expireAsk 的副作用),但「同 askId
|
|
1081
|
+
// 重复本地注册」那一支(pendingByAskId 顶注)与竞争者种类无关,取消赢下 CAS 时同样要收尾。
|
|
1082
|
+
this.settleVoidedSiblings([askId]);
|
|
1083
|
+
// #151 车6 发射点②:取消 ⇒ 本行已 VOID,壳上这张卡必须消失(reason="aborted")。
|
|
1084
|
+
// 🔴 名单是**这一行自己**,不是「本批全体」:`abortBatch`(整批 STREAM_PENDING→VOID)在本仓
|
|
1085
|
+
// 今天没有任何调用点(grep 实证,车2/车4 域),取消腿走的是单行 `transitionAsk`。兄弟各自的
|
|
1086
|
+
// 闭包会走它们自己的这条路径、各发各的撤卡帧;真正的整批名单形要等 `abortBatch` 接线后才成立
|
|
1087
|
+
// (登记在本车汇报的偏离单里,不在这里凭空造一个不存在的名单)。
|
|
1088
|
+
this.emitRevokeTo(aliveCtxs, buildRevokeFrame(batchId, [askId], "aborted", Date.now()));
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
// 干净地输 ⇒ 重读持久态收敛(X-1;与窗到期臂同一条路径,见 convergeFromDurableState 顶注)。
|
|
1092
|
+
await convergeFromDurableState("runCancel");
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
catch (err) {
|
|
1096
|
+
this.noteStoreError(err, "transitionAsk(cancel)");
|
|
1097
|
+
settle(false, "expired"); // fail-open(D5)
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
269
1100
|
if (signal) {
|
|
270
|
-
const onTaskAbort = () =>
|
|
1101
|
+
const onTaskAbort = () => void runCancel();
|
|
271
1102
|
signal.addEventListener("abort", onTaskAbort, { once: true });
|
|
272
1103
|
abortListeners.push([signal, onTaskAbort]);
|
|
273
1104
|
}
|
|
@@ -282,7 +1113,7 @@ export class ToolApprovalCoordinator {
|
|
|
282
1113
|
// 必然关系——一条完全不相关、只是恰好也在同一 (owner,session) 广播集合里的连接,处理完它自己另一个
|
|
283
1114
|
// 无关的请求正常退出,就会单独触发把整条 ask 误杀,即便真正在等它的那条连接分毫未动、仍然存活。
|
|
284
1115
|
// `goneCtxs` 去重:同一个 ctx 理论上可能同时/先后触发 (a)(b) 两条路径(abort 通常伴随其作用域跟着
|
|
285
|
-
// 退出)
|
|
1116
|
+
// 退出),避免重复扣减。#151 车2:归零判死改走 runCancel(D2 取消竞争者,同 task-signal abort 同路)。
|
|
286
1117
|
let liveConnections = aliveCtxs.length;
|
|
287
1118
|
const goneCtxs = new Set();
|
|
288
1119
|
const onTargetGone = (c) => {
|
|
@@ -291,7 +1122,7 @@ export class ToolApprovalCoordinator {
|
|
|
291
1122
|
goneCtxs.add(c);
|
|
292
1123
|
liveConnections--;
|
|
293
1124
|
if (liveConnections <= 0)
|
|
294
|
-
|
|
1125
|
+
void runCancel();
|
|
295
1126
|
};
|
|
296
1127
|
for (const c of aliveCtxs) {
|
|
297
1128
|
if (!c.abortSignal)
|
|
@@ -300,12 +1131,38 @@ export class ToolApprovalCoordinator {
|
|
|
300
1131
|
c.abortSignal.addEventListener("abort", onCtxAbort, { once: true });
|
|
301
1132
|
abortListeners.push([c.abortSignal, onCtxAbort]);
|
|
302
1133
|
}
|
|
1134
|
+
// 🔴 codex 交叉复审 round3 抓获、round4 精化(真 finding):上面两组监听器都装在 ensureAsk 的 await
|
|
1135
|
+
// 之后(askStore 在场时那是一个真实的挂起点)——`AbortSignal` 不会给「事件发生之后才装上」的监听器
|
|
1136
|
+
// 补放已经错过的那次 abort。若取消恰好发生在 ensureAsk 挂起期间(店慢),这次取消会被彻底吞掉,直到
|
|
1137
|
+
// 自己的 TTL 才收尾。监听器装完后立即补一次「是不是已经在等待期间触发过」的复核——**必须真等它跑完**
|
|
1138
|
+
// (round3 的 fire-and-forget 形只触发了取消,却让函数继续往下注册/广播一张其实已经作废的卡:respond()
|
|
1139
|
+
// 稍后如果撞上这条已经 settle 过的僵尸 entry,会经同步 `finishRespond` 分支应答成功并可能误记
|
|
1140
|
+
// `allowAllSessions`——`finishRespond` 对"是否已经 settle 过"没有重新核验,round1 那道存活性复核只
|
|
1141
|
+
// 长在 `respondWithCas` 的异步分支里)。等它跑完后若 `done` 已经为真,直接短路返回,绝不再往下注册/
|
|
1142
|
+
// emit——askStore 缺席时整段函数体到这里为止零 await,`earlyAbortDuringEnsure` 恒假,对 D1 零行为
|
|
1143
|
+
// 变化。
|
|
1144
|
+
const earlyAbortDuringEnsure = signal?.aborted === true || aliveCtxs.some((c) => c.abortSignal?.aborted === true);
|
|
1145
|
+
if (earlyAbortDuringEnsure) {
|
|
1146
|
+
await runCancel();
|
|
1147
|
+
// 🔴 #151 车5 自查(属主亲读 diff 抓获,2026-08-06):这里**不能**再写死 `return false`。
|
|
1148
|
+
// 旧形那句注释「取消恒 settle(false,"expired")」在 X-1 状态感知分派之前成立;现在 `runCancel` 的
|
|
1149
|
+
// CAS 输臂会重读持久态并按**真决议**结算 —— 一条在 ensureAsk 悬挂期间被别的副本 approve 掉的 ask,
|
|
1150
|
+
// `done` 为真而落定值是 `true`,写死 false 会把一次真实的人类批准悄悄吞成拒绝(且与 durable 行矛盾)。
|
|
1151
|
+
// 改成把**已经落定的那个值**如实交回去(与方法尾部的返回值分派同一套判据)。
|
|
1152
|
+
if (done) {
|
|
1153
|
+
const early = await allowedP;
|
|
1154
|
+
if (windowRouteUnavailable)
|
|
1155
|
+
return "unavailable"; // 持久终局是 PARKING/PARKED ⇒ park 路由
|
|
1156
|
+
if (early.allowed && early.updatedInput !== undefined)
|
|
1157
|
+
return { allow: true, updatedInput: early.updatedInput };
|
|
1158
|
+
return early.allowed;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
303
1161
|
// Register BEFORE emitting so a (fast) respond can never miss the entry. 子代 ask 的生存期挂子代
|
|
304
1162
|
// (originTaskId 在场 = 不被宿主 finally 清扫烧;[1539] 注意事项,判别式 = isChildAsk 顶注)——同理,
|
|
305
1163
|
// 子代 ask 从不设 targetCtxs/onTargetGone(任何宿主 ctx 的 runWithContext 退出都不该碰它,只有它
|
|
306
1164
|
// 自己的 TTL/abortSignal 才作数;上面的 aliveCtxs abort 倒计时仍照常适用于它自己的连接级 abort)。
|
|
307
|
-
const
|
|
308
|
-
this.pending.set(id, {
|
|
1165
|
+
const pendingEntry = {
|
|
309
1166
|
settle,
|
|
310
1167
|
owner: primary.owner,
|
|
311
1168
|
ctxTaskId: primary.taskId,
|
|
@@ -313,49 +1170,70 @@ export class ToolApprovalCoordinator {
|
|
|
313
1170
|
...(child ? { originTaskId: req.sourceTaskId } : { targetCtxs: new Set(aliveCtxs), onTargetGone }),
|
|
314
1171
|
...(primary.sessionId ? { sessionId: primary.sessionId } : {}),
|
|
315
1172
|
toolName: req.toolName,
|
|
316
|
-
|
|
317
|
-
const bounded = boundArgs(req.args);
|
|
318
|
-
const frame = {
|
|
319
|
-
type: "tool_approval",
|
|
320
|
-
approvalId: id,
|
|
321
|
-
toolName: req.toolName,
|
|
322
|
-
// [1939]:core 的 tool-call id 逐字透传(AskRequest.toolCallId 是必填);理由见字段顶注。
|
|
323
|
-
...(typeof req.toolCallId === "string" && req.toolCallId !== "" ? { toolCallId: req.toolCallId } : {}),
|
|
324
|
-
// 帧上 sourceTaskId/fromSubagent/sourceAgentName 只出**子代** ask(server 侧判别收口,现判别
|
|
325
|
-
// 键 = req.fromSubagent[core RB-39② 显式受信键]——sourceTaskId 仍旁随传但不再是判别式,复审
|
|
326
|
-
// F2 案:core 对宿主 ask 也恒填 sourceTaskId=宿主 sessionId,裸透传会让每张宿主卡被误判子代)。
|
|
327
|
-
...(child
|
|
328
|
-
? {
|
|
329
|
-
sourceTaskId: req.sourceTaskId,
|
|
330
|
-
fromSubagent: true,
|
|
331
|
-
...(typeof req.sourceAgentName === "string" ? { sourceAgentName: redactSecrets(req.sourceAgentName) } : {}),
|
|
332
|
-
// core 5.9.0 W1:出处链透传(agentName 同 sourceAgentName 待遇 redact;其余为 core-mint 标识非内容)。
|
|
333
|
-
...(req.delegation
|
|
334
|
-
? {
|
|
335
|
-
delegation: {
|
|
336
|
-
parentToolCallId: req.delegation.parentToolCallId,
|
|
337
|
-
depth: req.delegation.depth,
|
|
338
|
-
...(typeof req.delegation.agentName === "string" ? { agentName: redactSecrets(req.delegation.agentName) } : {}),
|
|
339
|
-
},
|
|
340
|
-
}
|
|
341
|
-
: {}),
|
|
342
|
-
}
|
|
343
|
-
: {}),
|
|
344
|
-
message: typeof req.message === "string" ? redactDeep(req.message) : "",
|
|
345
|
-
...(bounded.omitted ? { argsOmitted: true } : { args: bounded.args }),
|
|
1173
|
+
...(askId !== undefined && batchId !== undefined ? { askId, batchId } : {}),
|
|
346
1174
|
};
|
|
1175
|
+
selfEntry = pendingEntry; // 供 settle() 结算时把自己从 pendingByAskId 的 Set 里摘除(上方顶注)
|
|
1176
|
+
this.pending.set(id, pendingEntry);
|
|
1177
|
+
// #151 车2(round3 兄弟撤卡索引 + round5 精化成 Set,顶注):同一 askId 下可能已经有其他条目在场
|
|
1178
|
+
// (批内兄弟、或同 askId 的重复本地注册)——这里是 add 进集合,不是覆盖单值。
|
|
1179
|
+
if (askId !== undefined) {
|
|
1180
|
+
let siblings = this.pendingByAskId.get(askId);
|
|
1181
|
+
if (!siblings) {
|
|
1182
|
+
siblings = new Set();
|
|
1183
|
+
this.pendingByAskId.set(askId, siblings);
|
|
1184
|
+
}
|
|
1185
|
+
siblings.add(pendingEntry);
|
|
1186
|
+
}
|
|
347
1187
|
// 每路 emit 独立兜底(catch 而非任其抛出/reject 冒泡)——一路失败不拖垮整个广播的判定;`emitOne`
|
|
348
1188
|
// 因此**永不** reject(同时吸收同步 throw 与异步 rejection),`Promise.all` 也就永不 reject,
|
|
349
1189
|
// 下面的 `Promise.race` 才能安全地把它当"正常完成"的一端,不会把某一路的失败错误地冒泡成
|
|
350
1190
|
// askBroadcast 自身的未捕获异常。
|
|
1191
|
+
// #151 车3 刀 3b(设计稿 §4.1/§4.2):呈卡帧 —— **并行加帧,不替换**。
|
|
1192
|
+
// · 只有拿到**确认落盘**的 `askId` 才铸(§2.2(b) 硬条款一:无持久身份不发新帧)⇒ 开关关 / store 缺席 /
|
|
1193
|
+
// ensureAsk 降级三种情形下恒 `undefined`,wire 字节逐字不变(A-1)。
|
|
1194
|
+
// · `expiresAtMs` 用上面**一次铸定**的那个数(与落库同值),`expiresInMs` 由它现算。
|
|
1195
|
+
const cardFrame = askId !== undefined && cardForFrame !== undefined
|
|
1196
|
+
? buildApprovalRequestFrame({ askId, taskId: origin.taskId, approvalId: wireApprovalId, card: cardForFrame, expiresAtMs: persistedExpiresAtMs, nowMs: Date.now() })
|
|
1197
|
+
: undefined;
|
|
1198
|
+
/**
|
|
1199
|
+
* 一条连接的**两种**投递结果。分开记是 codex 交叉复审 F1(2026-08-06 真 finding)的修:
|
|
1200
|
+
* · `legacy` —— 旧 `tool_approval` 帧送达没有。它单独决定**闭合帧**发不发(没见过开卡帧的连接
|
|
1201
|
+
* 收到一个 `tool_approval_complete` 就是孤儿 close),也是存量壳能否 `respond` 的前提。
|
|
1202
|
+
* · `card` —— 新 `approval_request` 帧送达没有。它同样算「卡送到人手上了」:detach 车道正是
|
|
1203
|
+
* **旧帧必失败、新帧走 durable 账本必成功**的形,旧形只数旧帧 ⇒ `anySucceeded=false` ⇒
|
|
1204
|
+
* `expireAsk` 赢 ⇒ 行被改判 PARKING、`onAsk` 交 `"unavailable"` —— 而消费端手上明明已经拿到
|
|
1205
|
+
* 一张说自己未决的卡,且它带着可回决的 `askId`(车4 端点)。
|
|
1206
|
+
* 「emit 全灭」因此是**两条都没送到**,不是「旧帧没送到」。
|
|
1207
|
+
*/
|
|
351
1208
|
const emitOne = async (c) => {
|
|
1209
|
+
let delivered = true;
|
|
352
1210
|
try {
|
|
353
1211
|
await c.emit(frame);
|
|
354
|
-
return true;
|
|
355
1212
|
}
|
|
356
1213
|
catch {
|
|
357
|
-
|
|
1214
|
+
delivered = false;
|
|
1215
|
+
}
|
|
1216
|
+
// 🔴 发射序钉死:旧帧在前、新帧在后(§4.2)—— 旧壳的字节流与今天逐字一致,新壳按 `approvalId` 去重
|
|
1217
|
+
// 后优先渲染新帧。**per-连接**串行(排在这条连接自己的旧帧之后),与下面 complete 帧的 per-ctx 排序
|
|
1218
|
+
// 同一条不变式。
|
|
1219
|
+
//
|
|
1220
|
+
// 两条判据都是有意的:
|
|
1221
|
+
// · 旧帧**失败也照样试新帧** —— 新帧的投递口在 detach 车道上是「live + durable 账本双写」
|
|
1222
|
+
// (routes/tasks.ts),而那条车道的 live 面**恰恰是死的**;若这里跟着旧帧一起早退,§4.3(c) 要修的
|
|
1223
|
+
// 「断连 ⇒ 卡永久不可见」正好一次都不会被修到。
|
|
1224
|
+
// · 新帧的成败**不参与** `delivered` —— 「卡有没有送达一个人」这条判断归旧帧(它是存量壳唯一认得的
|
|
1225
|
+
// 那一帧,也是 `respond` 的入口)。把新帧的失败算进去会误触 emit-全灭那条 `"unavailable"` 臂。
|
|
1226
|
+
let cardDelivered = false;
|
|
1227
|
+
if (cardFrame && c.emitCard) {
|
|
1228
|
+
try {
|
|
1229
|
+
await c.emitCard(cardFrame);
|
|
1230
|
+
cardDelivered = true;
|
|
1231
|
+
}
|
|
1232
|
+
catch {
|
|
1233
|
+
/* 投不出去就是没送到;它不影响旧帧那半边的判定 */
|
|
1234
|
+
}
|
|
358
1235
|
}
|
|
1236
|
+
return { legacy: delivered, card: cardDelivered };
|
|
359
1237
|
};
|
|
360
1238
|
// 🔴 [1575] F2 修(cli 复查,红先行确认真红):旧形完成帧广播是"调用序"跟着 `await allowedP`
|
|
361
1239
|
// 走,不是"落盘序"——respond() 可能在某条连接自己的 OPEN emit 尚未落盘完成时就已经 settle(TTL/
|
|
@@ -369,13 +1247,45 @@ export class ToolApprovalCoordinator {
|
|
|
369
1247
|
const openByCtx = new Map(aliveCtxs.map((c) => [c, emitOne(c)]));
|
|
370
1248
|
const emitAllP = (async () => {
|
|
371
1249
|
const results = await Promise.all(openByCtx.values());
|
|
372
|
-
|
|
1250
|
+
// F1 修:**两种帧任一送达**都算「卡送到人手上了」(见 emitOne 顶注)。
|
|
1251
|
+
const anySucceeded = results.some((r) => r.legacy || r.card);
|
|
373
1252
|
// ⚠️ 竞态门(cross-review 抓获,原单连接形已有的不变式,广播形延续):respond/abort 可能在 emit
|
|
374
1253
|
// 广播完成前已经 settle(entry 先于 emit 注册)。emit 随后才判定全灭时,已有的人类 boolean 裁决/
|
|
375
1254
|
// abort 终局必须保持——只有本判定是第一个 settle 者(done 仍 false)才算「卡从未送达任何人」。
|
|
376
1255
|
if (!anySucceeded && !done) {
|
|
377
|
-
|
|
378
|
-
|
|
1256
|
+
// 🔴 codex 交叉复审两轮抓获(真 finding,红先复现 [tool-approval-machine.test.ts §3 4/8 emit-race
|
|
1257
|
+
// 用例];round2 精化):`emitFailed` 不能在 store 调用的 await 之前置位(round1 已修),但仅补一个
|
|
1258
|
+
// `!done` 复核仍不够严——`expireAsk` 与 `decideAsk` 的网络往返跳数不对称(`decideAsk` 提交后另起
|
|
1259
|
+
// 一次独立 `getAsk` 读,`expireAsk` 没有,approval-ask-store-sql.ts:382-434/456-499),respond() 可能
|
|
1260
|
+
// 已经**赢下**持久 CAS 但它自己较慢的第二跳尚未落地、本地 `done` 仍是 false——此时 `expireAsk`
|
|
1261
|
+
// 干净地**输**(`res.won===false`,不是 throw),若仍然只看 `!done` 就置位 `emitFailed`,会重演
|
|
1262
|
+
// round2 finding A 同款矛盾(durable 行写着已批准,活的终局却被 emit-全灭强改成
|
|
1263
|
+
// "unavailable")。改判:**只在 `res.won===true` 时才置位 settle**;干净地输 ⇒ 什么都不做,信任
|
|
1264
|
+
// 真赢家走它自己的路径完成 settle(与 windowExpired/runCancel 现在同一形状)。throw(店真的挂了)
|
|
1265
|
+
// 仍走 D5 fail-open,保留 `!done` 保险(避免明知已经有人赢了还去无条件覆盖)。
|
|
1266
|
+
if (this.askStore && askId !== undefined && batchId !== undefined) {
|
|
1267
|
+
try {
|
|
1268
|
+
const res = await this.withStoreDeadline(this.askStore.expireAsk(askId, batchId), "expireAsk(emit-failed)");
|
|
1269
|
+
if (res.won && !done) {
|
|
1270
|
+
emitFailed = true;
|
|
1271
|
+
settle(false, "expired");
|
|
1272
|
+
// round3:同批兄弟被远程撤卡,本地同步收尾;round5 追加自己的 askId(同精神,windowExpired 侧注)。
|
|
1273
|
+
this.settleVoidedSiblings([askId, ...res.voidedSiblings]);
|
|
1274
|
+
}
|
|
1275
|
+
// 干净地输:什么都不做(见上方顶注)。
|
|
1276
|
+
}
|
|
1277
|
+
catch (err) {
|
|
1278
|
+
this.noteStoreError(err, "expireAsk(emit-failed)");
|
|
1279
|
+
if (!done) {
|
|
1280
|
+
emitFailed = true;
|
|
1281
|
+
settle(false, "expired"); // fail-open(D5)
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
else if (!done) {
|
|
1286
|
+
emitFailed = true;
|
|
1287
|
+
settle(false, "expired"); // D1:现行为逐字不变(store 缺席)
|
|
1288
|
+
}
|
|
379
1289
|
}
|
|
380
1290
|
emitSettled = true;
|
|
381
1291
|
})();
|
|
@@ -394,13 +1304,20 @@ export class ToolApprovalCoordinator {
|
|
|
394
1304
|
for (const c of aliveCtxs) {
|
|
395
1305
|
void openByCtx
|
|
396
1306
|
.get(c)
|
|
397
|
-
|
|
1307
|
+
// F1 修:闭合帧的门仍**只看旧帧**(`legacy`)—— 它按 `approvalId` 消卡,只有见过开卡帧的连接才有
|
|
1308
|
+
// 卡可消;拿新帧的成功去放行闭合帧会给只收到 `approval_request` 的连接发一个孤儿 close。
|
|
1309
|
+
.then((delivered) => (delivered.legacy ? c.emit({ type: "tool_approval_complete", approvalId: wireApprovalId, outcome: lastOutcome }) : undefined))
|
|
398
1310
|
.catch(() => undefined);
|
|
399
1311
|
}
|
|
400
1312
|
// emit 全灭 = 卡从未送达任何人(≠人拒绝/TTL 走人)——同属「无活人可达」类 ⇒ "unavailable" 交 durable
|
|
401
1313
|
// park。
|
|
402
1314
|
if (emitFailed)
|
|
403
1315
|
return "unavailable";
|
|
1316
|
+
// #151 车2(D2/D3):窗到期赢了持久 CAS ⇒ 强制 park 路由,不看 emit 是否已经确认送达——D2 原话
|
|
1317
|
+
// 「赢 ⇒ settle + onAsk 返回 "unavailable"」是无条件的,与下面 emit-race 覆盖判据(供 store 缺席/
|
|
1318
|
+
// emit 尚未确认两种场景)并存、互不排斥。askStore 缺席时本判据恒 false,不影响 D1 零行为变化。
|
|
1319
|
+
if (windowRouteUnavailable)
|
|
1320
|
+
return "unavailable";
|
|
404
1321
|
// emit 广播尚未有任何一路确认完成(既非成功也非已知失败)就被 TTL/abort 抢先判定 ⇒ 无法确认卡是否
|
|
405
1322
|
// 曾送达任何人,不可扣一个武断的「deny」终局——按 G1 回路交 "unavailable"(同 emit 失败同一处置,
|
|
406
1323
|
// core [1559]三 确认方向)。respond() 落地的人类裁决(outcome allowed/denied)不受影响,原因见本
|
|
@@ -417,7 +1334,12 @@ export class ToolApprovalCoordinator {
|
|
|
417
1334
|
}
|
|
418
1335
|
/** `POST /v1/tool-approvals/:id/respond` — settle a parked approval with the shell's decision. Owner-gated with a
|
|
419
1336
|
* 404 (no existence oracle), body validated first (400 is existence-independent) — question/steer parity. The HTTP
|
|
420
|
-
* layer owns auth (gatedPrincipal + REQUIRE_PRINCIPAL) before calling.
|
|
1337
|
+
* layer owns auth (gatedPrincipal + REQUIRE_PRINCIPAL) before calling.
|
|
1338
|
+
*
|
|
1339
|
+
* #151 车2(D1/D2):return type 是 UNION,不是把整个方法标 `async`——askStore 缺席时这个方法逐字同步
|
|
1340
|
+
* 完成(D1 现行为不变;现存量测试对它的同步断言/`void` 调用零改动)。在场时才真的变成 Promise(D2 的
|
|
1341
|
+
* 「settle 前先赢一把 decideAsk CAS」离不开 await,同步函数做不到)。唯一生产调用点(`routes/runs.ts`)
|
|
1342
|
+
* 统一 `await`——`await` 对非 Promise 值是恒等操作,两条路径对它透明。 */
|
|
421
1343
|
respond(id, principal, body) {
|
|
422
1344
|
const parsed = parseToolApprovalResponse(body);
|
|
423
1345
|
if (!parsed.ok)
|
|
@@ -426,6 +1348,93 @@ export class ToolApprovalCoordinator {
|
|
|
426
1348
|
if (!entry || (entry.owner !== null && entry.owner !== principal)) {
|
|
427
1349
|
return { status: 404, body: { error: "no pending tool approval for this id (settled, expired, or not on this replica)", errorCode: "tool_approval.not_pending" } };
|
|
428
1350
|
}
|
|
1351
|
+
// #151 车2 D2(回决竞争者):askStore 缺席 ⇒ finishRespond 同步收尾,逐字不变(D1)。在场 ⇒ 先打一把
|
|
1352
|
+
// decideAsk CAS,赢了才 finishRespond;输(某个其他竞争者已经决定了这只 ask 的终局)⇒ 不 settle,照旧
|
|
1353
|
+
// 回现有 404 形(响应体的 409/410 分化是车4 的活,本车只保证「输者绝不覆盖赢者」)。
|
|
1354
|
+
if (this.askStore && entry.askId !== undefined && entry.batchId !== undefined) {
|
|
1355
|
+
return this.respondWithCas(this.askStore, entry.askId, entry.batchId, id, entry, parsed);
|
|
1356
|
+
}
|
|
1357
|
+
return this.finishRespond(id, entry, parsed);
|
|
1358
|
+
}
|
|
1359
|
+
/** store 在场时的回决 CAS 门(D2)——`respond()` 的异步分支,拆出以保持 `respond()` 本身在 store 缺席
|
|
1360
|
+
* 时仍是纯同步函数(D1)。`askStore`/`askId`/`batchId` 由调用方在已窄化的分支里传入(避免非空断言)。 */
|
|
1361
|
+
async respondWithCas(askStore, askId, batchId, id, entry, parsed) {
|
|
1362
|
+
let decidedWon = false;
|
|
1363
|
+
try {
|
|
1364
|
+
const decided = await this.withStoreDeadline(askStore.decideAsk(askId, batchId, { decision: parsed.value === "deny" ? "deny" : "approve" }), "decideAsk", DURABLE_CALL_TIMEOUT_MS, (late) => {
|
|
1365
|
+
// R3-1 迟到成功:HTTP 响应早已发出(这次调用报的是超时),但**决议真落了盘** —— 同 askId 下
|
|
1366
|
+
// 还在悬挂的本地条目必须按真决议结清,否则它们只能等自己的窗/取消再绕一圈。
|
|
1367
|
+
if (late.ok)
|
|
1368
|
+
this.notifyExternalDecision(askId, parsed.value !== "deny", parsed.updatedInput);
|
|
1369
|
+
});
|
|
1370
|
+
decidedWon = decided.ok;
|
|
1371
|
+
if (!decided.ok) {
|
|
1372
|
+
// CAS 输——respond 绝不覆盖赢家(D2)。逐字复用现行 404 形(409/410 分化留给车4)。
|
|
1373
|
+
return { status: 404, body: { error: "no pending tool approval for this id (settled, expired, or not on this replica)", errorCode: "tool_approval.not_pending" } };
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
catch (err) {
|
|
1377
|
+
this.noteStoreError(err, "decideAsk"); // D5 fail-open:店抖动不挡真实回决
|
|
1378
|
+
// 🔴 codex 交叉复审 C6(2026-08-06 真缺陷):`decideAsk` 的 SQL twin 是「commit 之后**另起**一次
|
|
1379
|
+
// `getAsk` 读」的形状 —— 那一跳抛错时,事务**可能已经提交**(人的决定真落了盘),而这里的
|
|
1380
|
+
// `decidedWon` 却还是 false。这种「不知道」如果直接当成「没赢」,下面的守卫会对一个**已经生效**的
|
|
1381
|
+
// 决定回 404。补一次**有界**的复核读:行已是 `DECIDED` 且决议与本次请求逐字一致 ⇒ 就是我们赢的
|
|
1382
|
+
// (`decideAsk` 的 CAS 是单赢者,别人不可能写出同一条决议再让我们看见——除非是同一个请求的重试,
|
|
1383
|
+
// 那本就该回放成功)。复核读本身失败/超时 ⇒ 维持「不知道」,守卫照旧 404(不许把未知说成成功)。
|
|
1384
|
+
try {
|
|
1385
|
+
const row = await this.withStoreDeadline(askStore.getAsk(askId), "getAsk(decideAsk-confirm)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
|
|
1386
|
+
if (row?.state === "DECIDED" && row.decision === (parsed.value === "deny" ? "deny" : "approve"))
|
|
1387
|
+
decidedWon = true;
|
|
1388
|
+
}
|
|
1389
|
+
catch (confirmErr) {
|
|
1390
|
+
this.noteStoreError(confirmErr, "getAsk(decideAsk-confirm)");
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
// 🔴 codex 交叉复审抓获(真 finding):`decideAsk` throw(店抖动、非 CAS 输)时上面完全没有 return——
|
|
1394
|
+
// 两个真正并发的 respond() 调用若都撞上这次抖动,会都揣着同一个(尚未被任何一方 settle 过的)`entry`
|
|
1395
|
+
// 走到这里;`finishRespond` 本身不重新核验 `entry` 是否还活着,会让**两个**调用都拿到 200 且都可能
|
|
1396
|
+
// 修改 `allowAllSessions`(先落地的赢家 settle 生效,晚到的那个理应 404,却会在 store 抖动窗口内被
|
|
1397
|
+
// 误当成「也成功了」——包括它自己的 `allow_session` 语义,即便真正的终局是另一路的 deny)。这里用
|
|
1398
|
+
// `this.pending` 本身当权威:是否还是**这一个** entry 引用(而非仅仅「id 还在」——万一同一 id 已被
|
|
1399
|
+
// 别的机制以别的对象重铸,值比较比键存在性更严——现网这只会是防御性写法,和 [1550] 的 originCtx
|
|
1400
|
+
// identity-compare 同精神)。已被任何竞争者(另一 respond / 取消 / 窗到期)结算过 ⇒ 如实 404,不
|
|
1401
|
+
// 覆盖、不重复应用 session 记忆。
|
|
1402
|
+
const wantAllowed = parsed.value !== "deny";
|
|
1403
|
+
if (this.pending.get(id) !== entry) {
|
|
1404
|
+
// 🔴 #151 车5(X-1 状态感知分派的连带收口,2026-08-06):判据从「本地条目还在不在」精化成
|
|
1405
|
+
// 「**这次回决是不是有效终局**」。两支分开:
|
|
1406
|
+
// (a) `decideAsk` **抛错**(`decidedWon=false`,不知道谁赢)⇒ 逐字保留 round1 的 404(否则两路
|
|
1407
|
+
// 并发 respond 在店抖动窗内会都自称成功、都改 `allowAllSessions`);
|
|
1408
|
+
// (b) CAS **真赢**了 ⇒ 再看本地那次抢先结算与本次决议**是否一致**:
|
|
1409
|
+
// · 一致(取消/窗到期臂经 `convergeFromDurableState` 重读到的正是这次刚写下的 `DECIDED`)
|
|
1410
|
+
// ⇒ 行是 approve、闭包也交了 approve,回 404「no pending approval」等于对壳撒谎,必须 200;
|
|
1411
|
+
// · 不一致(D5 fail-open 的无条件 `settle(false)` —— 店真挂了那一支)⇒ 引擎实际收到的是 deny,
|
|
1412
|
+
// 如实 404,绝不把一次没有生效的批准回显成成功(round6 钉住的正是这一支)。
|
|
1413
|
+
// 无论哪一支,只要 CAS 真赢就先把同 askId 下**其余**尚未闻讯的本地条目按真决议清算(F30;round6
|
|
1414
|
+
// 修的「因为提前 404 而跳过收尾」缺口在这里保住)。
|
|
1415
|
+
if (decidedWon)
|
|
1416
|
+
this.notifyExternalDecision(askId, wantAllowed, parsed.updatedInput);
|
|
1417
|
+
const converged = decidedWon && entry.settledOutcome === (wantAllowed ? "allowed" : "denied");
|
|
1418
|
+
if (!converged) {
|
|
1419
|
+
return { status: 404, body: { error: "no pending tool approval for this id (settled, expired, or not on this replica)", errorCode: "tool_approval.not_pending" } };
|
|
1420
|
+
}
|
|
1421
|
+
// 一致 ⇒ 走与正常路径**同一段**收尾:`finishRespond` 对已结算 entry 调 `settle` 天然无操作(幂等),
|
|
1422
|
+
// session 记忆照记(grant 谈的是**将来**的 ask,与这次投递是否由我完成无关)。
|
|
1423
|
+
// 但 `updatedInput` **没有**随那次结算送达闭包(R2-4)⇒ 显式声明未投递,回显里不许出现
|
|
1424
|
+
// `updatedInputForwarded`。
|
|
1425
|
+
return this.finishRespond(id, entry, parsed, { updatedInputDelivered: false });
|
|
1426
|
+
}
|
|
1427
|
+
const result = this.finishRespond(id, entry, parsed);
|
|
1428
|
+
// round5(pendingByAskId 顶注):回决赢下 CAS 时同样要收尾「同 askId 重复本地注册」那一支——与
|
|
1429
|
+
// windowExpired/runCancel/emitAllP 三条竞争者的赢家路径同精神(那三处已经这么做)。此处 entry 已经
|
|
1430
|
+
// 经 `finishRespond` 自行 settle 并从 pendingByAskId 的 Set 里摘除自己,故这里天然只会清算真正
|
|
1431
|
+
// 「其余」的重复注册,不会覆盖自己刚落定的裁决。(F30 收口:按真决议,同上分支的理由。)
|
|
1432
|
+
this.notifyExternalDecision(askId, wantAllowed, parsed.updatedInput);
|
|
1433
|
+
return result;
|
|
1434
|
+
}
|
|
1435
|
+
/** 回决收尾(D1 现行为逐字不变的那一半)——session allow-all 记账 + settle + 200 响应体。原 `respond()`
|
|
1436
|
+
* 方法体的逐字搬运(#151 车2 拆分,行为零改动)。 */
|
|
1437
|
+
finishRespond(id, entry, parsed, opts) {
|
|
429
1438
|
// PAIR-REVIEW [1392]②:ack 带 rememberApplied 诚实回显——allow_session 且**真记了**(sessionId 在,
|
|
430
1439
|
// grant 落桥内店)= true;allow_session 但无 sessionId(adhoc 无会话,记不了)= false(壳不得渲染
|
|
431
1440
|
// 「本 session 全放行」);普通 allow/deny 不涉 remember = 字段省略。durable decide 腿的同名回显
|
|
@@ -449,7 +1458,22 @@ export class ToolApprovalCoordinator {
|
|
|
449
1458
|
entry.settle(allowed, allowed ? "allowed" : "denied", parsed.updatedInput);
|
|
450
1459
|
// updatedInputForwarded=server 已透传(是否被引擎消费取决于 core OnAsk 对象臂是否在——诚实措辞,
|
|
451
1460
|
// 不称 applied;cli 版本门+core 单落地后全链生效)。
|
|
452
|
-
|
|
1461
|
+
// 🔴 codex 交叉复审 round2 R2-4(2026-08-06 真缺陷):这个回显必须以「**这次调用真的把 updatedInput
|
|
1462
|
+
// 交给了等待中的闭包**」为准,不能只看请求里带没带。已被别的臂(状态感知分派/另一路 respond)结算过的
|
|
1463
|
+
// entry,上面这行 `settle` 是幂等无操作 —— 引擎拿到的是**原始 args**,而编辑是安全相关的(ctrl+g 改
|
|
1464
|
+
// 命令行);此时还回 `updatedInputForwarded: true` 等于告诉壳「你的编辑生效了」,是最不该撒的那种谎。
|
|
1465
|
+
// 调用方在 stale-entry 分支传 `updatedInputDelivered: false`,回显里这个键就整个缺席(壳按未透传处理)。
|
|
1466
|
+
const updatedInputDelivered = opts?.updatedInputDelivered ?? true;
|
|
1467
|
+
return {
|
|
1468
|
+
status: 200,
|
|
1469
|
+
body: {
|
|
1470
|
+
approvalId: id,
|
|
1471
|
+
delivery: "applied",
|
|
1472
|
+
decision: parsed.value,
|
|
1473
|
+
...(rememberApplied !== undefined ? { rememberApplied } : {}),
|
|
1474
|
+
...(parsed.updatedInput !== undefined && allowed && updatedInputDelivered ? { updatedInputForwarded: true } : {}),
|
|
1475
|
+
},
|
|
1476
|
+
};
|
|
453
1477
|
}
|
|
454
1478
|
/** Test/observability hooks. */
|
|
455
1479
|
pendingCount() {
|