@sema-agent/client-core 0.39.0 → 0.41.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/CHANGELOG.md +299 -0
- package/README.md +1 -1
- package/dist/adapter/downstream/eventToSdkMessage.js +16 -0
- package/dist/classifierVerdictWire.d.ts +0 -25
- package/dist/classifierVerdictWire.js +27 -7
- package/dist/engineAgentPanelStore.js +102 -13
- package/dist/hitl/approvalsFeed.d.ts +2 -2
- package/dist/hitl/approvalsFeed.js +57 -2
- package/dist/hitl/askGateWire.d.ts +1 -0
- package/dist/hitl/askGateWire.js +8 -0
- package/dist/hitl/frameRouter.d.ts +21 -2
- package/dist/hitl/frameRouter.js +144 -15
- package/dist/hitl/gateLedger.d.ts +95 -3
- package/dist/hitl/gateLedger.js +157 -3
- package/dist/hitl/hitlBridge.d.ts +53 -11
- package/dist/hitl/hitlBridge.js +97 -30
- package/dist/hitl/parkResolver.js +30 -5
- package/dist/hitl/toolApprovalWire.d.ts +15 -1
- package/dist/hitl/toolApprovalWire.js +20 -2
- package/dist/subagent/engineDelegatedPrompt.js +10 -3
- package/dist/systemReminderTag.d.ts +52 -0
- package/dist/systemReminderTag.js +73 -0
- package/docs/INTEGRATION-CLIENTS.md +100 -6
- package/package.json +1 -1
package/dist/hitl/gateLedger.js
CHANGED
|
@@ -1,14 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 连坐 abort 帧的 additive 超集机读位(#324 / [4907] 裁定;登记见 `docs/type-superset.json`)。
|
|
3
|
+
*
|
|
4
|
+
* `flushHeld` 对**已知不是 gate 主角**的帧 stamp `true`,别的帧一个字节不改。消费方(壳 / web /
|
|
5
|
+
* desktop)据此把连坐簇按 repeated-error 族软化折叠,**不猜文案**。
|
|
6
|
+
*
|
|
7
|
+
* 🔴 刻意**不动** `isError` 与 `output`:core 5.51 的 `[DEFERRED]` / `gate.batch_halted` 词汇
|
|
8
|
+
* ([4826]③)尚未到货,那一面的定形归 core;本键只是机读位,到货后与官方形对表,
|
|
9
|
+
* additive 键保留兼容(裁定原文)。
|
|
10
|
+
*/
|
|
11
|
+
export const SEMA_COLLATERAL_ABORT_KEY = '_sema_collateral_abort';
|
|
1
12
|
/** 造一份 turn 级 gate 台账(**不是单例**,见文件头注)。 */
|
|
2
13
|
export function createGateLedger() {
|
|
3
14
|
const startedCalls = new Set();
|
|
4
15
|
const endedCalls = new Set();
|
|
16
|
+
/** 只存帧:出身不在入表当拍冻结(那一拍 gate 身份还没到),改在 `flushHeld` 当拍求值。 */
|
|
5
17
|
const heldAskEnds = new Map();
|
|
6
18
|
const gatedStartArgsByCall = new Map();
|
|
7
19
|
const deniedCalls = new Set();
|
|
8
20
|
const pendingFsCalls = [];
|
|
9
21
|
const resolvedAnswers = new Map();
|
|
10
22
|
const decidedGates = new Set();
|
|
23
|
+
// ── 批级判据(四样同生共死,退休点见 `retireBatchState` 的两处调用)────────────────────────
|
|
24
|
+
/** 本批 park 属 fs/shell 族的准入证(唯一性兜底的前置)。 */
|
|
25
|
+
let fsOrShellFamilyGate = false;
|
|
26
|
+
/** 本批里被 `dropHeld` 摘出扣留表的 call(decide 成功会把主角摘走,判据还得认它)。 */
|
|
27
|
+
const droppedFromBatch = new Set();
|
|
28
|
+
/** 本批登记过几张 park。≠1 ⇒ 归属无从证明 ⇒ 整表不标(硬门)。 */
|
|
29
|
+
let batchParkCount = 0;
|
|
30
|
+
/** 上一次 park 登记之后有没有新帧进过扣留表 —— 用来把「同一张 park 被 durable 重放」认出来。 */
|
|
31
|
+
let heldSinceLastPark = false;
|
|
32
|
+
/** 本 turn 见过的 fs 写 / shell 执行族 gated tool_start —— 只做**族成员**判据,不做主角判据。 */
|
|
33
|
+
const fsOrShellGatedCalls = new Set();
|
|
11
34
|
let lastFsOrShellGatedCall;
|
|
35
|
+
/**
|
|
36
|
+
* flush 当拍解析「谁是主角」:登记在案的优先;否则唯一性兜底(要准入证 + 扣留表内族成员唯一);
|
|
37
|
+
* 都拿不到 ⇒ `undefined` = 不知道,调用方一个都不 stamp。
|
|
38
|
+
*
|
|
39
|
+
* 🔴 返回 `undefined` 与返回空集合是**两件事**,别合并([honest-absence-not-fabricated-zero]):
|
|
40
|
+
* 空集合意味着「知道谁是主角,且一个都不是」。
|
|
41
|
+
* 🔴 兜底在**扣留表内**求唯一 —— 主角必在这一批被同一次 abort 连坐的 call 里;拿整个 turn 的
|
|
42
|
+
* 历史集求唯一会把「已经收口过的旧 call」算进来,又是一次「拿旁证冒充身份」。
|
|
43
|
+
*/
|
|
44
|
+
const resolveGateSubjects = () => {
|
|
45
|
+
// 🔴 硬门:归属可结构性证明 = 本批恰好一张 park。零张(同步帧腿 / 纯取消批)与多张(两次
|
|
46
|
+
// gate 的 abort 混进同一张扣留表)都无解 —— 诚实不标,绝不猜。
|
|
47
|
+
if (batchParkCount !== 1)
|
|
48
|
+
return undefined;
|
|
49
|
+
// 准入证:这张 park 的 gate **本体**属 fs 写 / shell 执行族 —— 主角必是该族的某个 call。
|
|
50
|
+
if (!fsOrShellFamilyGate)
|
|
51
|
+
return undefined;
|
|
52
|
+
// 前置:每一条扣留帧的 `tool_start` 都得是本连接亲眼见过的。族成员判据取自 tool_start 当拍
|
|
53
|
+
// 记的集合,没见过 start 的 call 在「唯一」这件事上是隐形的;而续流只消费 `runs.events` 的一段
|
|
54
|
+
// (re-attach 从 `lastEventId` 起),主角的 start 完全可能落在这次连接之外 —— 那时表里剩下的
|
|
55
|
+
// 唯一族成员是**旁观者**,会被当成主角、反手给真主角打上连坐位。信息不全就不许声称「唯一」。
|
|
56
|
+
for (const id of heldAskEnds.keys()) {
|
|
57
|
+
if (!startedCalls.has(id))
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
// 主角 = 本批(扣留帧 ∪ 已出批帧)里**唯一**属该族的 call。并上出批帧是因为决断成功那一下会
|
|
61
|
+
// 把主角的毒化帧 `dropHeld` 掉;不并上就等于主角「不在这一批」,整批判据当场失效。
|
|
62
|
+
const family = [...new Set([...heldAskEnds.keys(), ...droppedFromBatch])].filter((id) => fsOrShellGatedCalls.has(id));
|
|
63
|
+
return family.length === 1 ? new Set(family) : undefined;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* `dropHeld` / `dropHeldForDecidedPark` 的**唯一**实现(两个动词共用一份,批级退休那条不变量
|
|
67
|
+
* 只写一处 —— 两份写法各自漂正是本模块存在的理由)。
|
|
68
|
+
* `callId === undefined` = 「这次什么都不摘」:退休检查照跑,别的一律不动。
|
|
69
|
+
*/
|
|
70
|
+
const dropHeldImpl = (callId) => {
|
|
71
|
+
if (callId !== undefined && heldAskEnds.delete(callId))
|
|
72
|
+
droppedFromBatch.add(callId);
|
|
73
|
+
// 🔴 扣留表空 = 这一批没有(或不再有)任何待判的帧,批级判据当场退休 —— 不能等下一次
|
|
74
|
+
// flush:「决断成功但本批压根没有毒化帧」的路径上 `flushHeld` 一次都不会跑,判据会就这么
|
|
75
|
+
// 活到下一批去把那一批的**真主角**判成连坐(实测 `new-main=STAMP`)。
|
|
76
|
+
// 🔴 「摘的那个不在表里」也要走这一步(不能早退):零 HOLD 批正是**每次** delete 都落空的
|
|
77
|
+
// 形状,早退就等于那种批永远不退休 —— 上一版的漏洞就在这里。
|
|
78
|
+
if (heldAskEnds.size === 0)
|
|
79
|
+
retireBatchState();
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* 件④ 保守臂的判据:候选在扣留表里**同族唯一**吗。
|
|
83
|
+
*
|
|
84
|
+
* 「族」的口径与**取件谓词**同轴(那才是可能撞车的那一层 —— `findPendingForTask` 是按族选行的):
|
|
85
|
+
* · 候选属 fs 写 / shell 执行族(`fsOrShellGatedCalls`,tool_start 当拍记)⇒ 数扣留表里同属
|
|
86
|
+
* 该族的条数(两个 Bash / 一个 Write 一个 Edit 都算同族,因为取件谓词对它们不可分);
|
|
87
|
+
* · 否则(ask / kind-only 等)⇒ 数扣留表里 `toolName` 逐字相同的条数。
|
|
88
|
+
* 不在扣留表里 ⇒ `false`(没有可摘的帧,也就谈不上「唯一」)。
|
|
89
|
+
*/
|
|
90
|
+
const soleOfItsFamilyInHold = (callId) => {
|
|
91
|
+
const ev = heldAskEnds.get(callId);
|
|
92
|
+
if (ev === undefined)
|
|
93
|
+
return false;
|
|
94
|
+
let n = 0;
|
|
95
|
+
if (fsOrShellGatedCalls.has(callId)) {
|
|
96
|
+
for (const id of heldAskEnds.keys())
|
|
97
|
+
if (fsOrShellGatedCalls.has(id))
|
|
98
|
+
n++;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const name = ev.toolName;
|
|
102
|
+
for (const held of heldAskEnds.values())
|
|
103
|
+
if (held.toolName === name)
|
|
104
|
+
n++;
|
|
105
|
+
}
|
|
106
|
+
return n === 1;
|
|
107
|
+
};
|
|
108
|
+
/** 一批判完(或整批被摘空)⇒ 四样批级判据同批退休:下一批从零证起。 */
|
|
109
|
+
const retireBatchState = () => {
|
|
110
|
+
fsOrShellFamilyGate = false;
|
|
111
|
+
droppedFromBatch.clear();
|
|
112
|
+
batchParkCount = 0;
|
|
113
|
+
heldSinceLastPark = false;
|
|
114
|
+
};
|
|
12
115
|
let denyStampNextFsEnd = false;
|
|
13
116
|
let seq;
|
|
14
117
|
return {
|
|
@@ -41,20 +144,55 @@ export function createGateLedger() {
|
|
|
41
144
|
endedCalls.add(callId);
|
|
42
145
|
},
|
|
43
146
|
hold(callId, ev) {
|
|
147
|
+
// 🔴 只有**新** call 进批才算「批里来了新东西」:durable re-attach 会把仍被扣留的毒化帧
|
|
148
|
+
// 连同 park 一起重放,那一下若也置位,同一张 park 就会被当成第二张而触发硬门弃判
|
|
149
|
+
// (实测:重放后本该带位的兄弟帧变成 ABSENT)。覆盖同一 callId = 重放,不置位。
|
|
150
|
+
if (!heldAskEnds.has(callId))
|
|
151
|
+
heldSinceLastPark = true;
|
|
44
152
|
heldAskEnds.set(callId, ev);
|
|
45
153
|
},
|
|
46
154
|
dropHeld(callId) {
|
|
47
|
-
|
|
155
|
+
dropHeldImpl(callId);
|
|
156
|
+
},
|
|
157
|
+
dropHeldForDecidedPark(candidateCallId, trustedCallId) {
|
|
158
|
+
// ① wire 身份**对上了这次真被决断的那一行** ⇒ 身份确证,点名摘。
|
|
159
|
+
// 🔴 摘的永远是「真的被决断的那一行」(`candidateCallId`),wire 身份在这里的角色是**确证**
|
|
160
|
+
// 而不是另一个独立的摘取目标 —— 异源复审 finding①:决断腿按队列首行选行、这里却按 wire
|
|
161
|
+
// 身份摘帧,两边指向不同 call 时就是 split-brain(用户批的是旁观者,被摘的是主角帧)。
|
|
162
|
+
// 取件腿今天已经优先按同一个身份选行(见 `surfaceFsApprovalAndDecide` 的 parkGatedCallId),
|
|
163
|
+
// 所以正常路径上两者恒相等;不相等只发生在「身份在场但队列里没有那一行」的回落形 ——
|
|
164
|
+
// 那时决断落在别的行上,任何一帧都证明不了自己已过时,走②/③ 的保守判。
|
|
165
|
+
if (trustedCallId !== undefined && trustedCallId === candidateCallId) {
|
|
166
|
+
dropHeldImpl(trustedCallId);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// ② 无可信身份:候选同族唯一才摘 —— 同族多条时任何选择都是猜,而猜错的代价是一帧永久消失。
|
|
170
|
+
if (candidateCallId !== undefined && soleOfItsFamilyInHold(candidateCallId)) {
|
|
171
|
+
dropHeldImpl(candidateCallId);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// ③ 判不出 ⇒ 一条都不摘(退休检查仍照跑,见 dropHeldImpl 头注)。
|
|
175
|
+
dropHeldImpl(undefined);
|
|
48
176
|
},
|
|
49
177
|
heldCount() {
|
|
50
178
|
return heldAskEnds.size;
|
|
51
179
|
},
|
|
52
180
|
*flushHeld() {
|
|
53
|
-
|
|
181
|
+
// 🔴 出身在**这一拍**求值(不是入表当拍):五个 flush 出口全部晚于 park 被看见,
|
|
182
|
+
// gate 身份此时已在手。`undefined` = 不知道谁是主角 ⇒ 整表一个字节不改。
|
|
183
|
+
const subjects = resolveGateSubjects();
|
|
184
|
+
for (const [callId, ev] of heldAskEnds) {
|
|
54
185
|
endedCalls.add(callId);
|
|
55
|
-
|
|
186
|
+
// 已知不是主角 ⇒ additive 机读位(帧的其余部分逐字节原样);主角与「不知道」走原路。
|
|
187
|
+
yield subjects !== undefined && !subjects.has(callId)
|
|
188
|
+
? { ...ev, [SEMA_COLLATERAL_ABORT_KEY]: true }
|
|
189
|
+
: ev;
|
|
56
190
|
}
|
|
57
191
|
heldAskEnds.clear();
|
|
192
|
+
// 🔴 判完即弃:这一批帧就是这张 gate 的 abort 批。不退休 ⇒ 判完之后又来一批**背后没有
|
|
193
|
+
// 任何 gate**的 abort 帧(例如整轮被取消)时,上一批的证会给它们派判词。
|
|
194
|
+
// 身份不在退休之列(turn 级事实),它靠上面的「∩ 本批」求交集来限定作用域。
|
|
195
|
+
retireBatchState();
|
|
58
196
|
},
|
|
59
197
|
markDenied(callId) {
|
|
60
198
|
deniedCalls.add(callId);
|
|
@@ -87,10 +225,26 @@ export function createGateLedger() {
|
|
|
87
225
|
},
|
|
88
226
|
noteFsOrShellGate(callId) {
|
|
89
227
|
lastFsOrShellGatedCall = callId;
|
|
228
|
+
fsOrShellGatedCalls.add(callId);
|
|
90
229
|
},
|
|
91
230
|
lastFsOrShellGatedCallId() {
|
|
92
231
|
return lastFsOrShellGatedCall;
|
|
93
232
|
},
|
|
233
|
+
noteParkGate(evidence) {
|
|
234
|
+
// 自上次登记以来没有**新 call** 进过扣留表 ⇒ 按 durable 重放同一张 park 处置:不计新的一张。
|
|
235
|
+
// 🔴 而且**一个字节都不写**。「没有新帧进批」证明得了「这一批的组成没变」,证明不了
|
|
236
|
+
// 「这张 park 就是上一张」—— 一张**真的新 park** 同样可以零毒化帧(例如上一张 park 走
|
|
237
|
+
// already-resolved 重放救回、续流紧接着来一张别的 gate 的 park)。此时若顺手刷新族属,
|
|
238
|
+
// 本批的判据就被一张与这批帧无关的 gate 改写:实测 `WebFetch` 主角 + `Bash` 旁观者的批
|
|
239
|
+
// 被一张零毒化 `Bash` park 改成 fs/shell 族,兜底选中旁观者、反手给真主角打位
|
|
240
|
+
// (`web-main=STAMP`)。真是重放时族属本就相同,不写零损失;是新 park 时不写才安全。
|
|
241
|
+
// (本批的帧全部早于第一张 park,归属本来就该按第一张算。)
|
|
242
|
+
if (batchParkCount > 0 && !heldSinceLastPark)
|
|
243
|
+
return;
|
|
244
|
+
batchParkCount += 1;
|
|
245
|
+
fsOrShellFamilyGate = evidence.fsOrShellFamily;
|
|
246
|
+
heldSinceLastPark = false;
|
|
247
|
+
},
|
|
94
248
|
rememberAnswer(callId, answered) {
|
|
95
249
|
resolvedAnswers.set(callId, answered);
|
|
96
250
|
},
|
|
@@ -213,6 +213,14 @@ export type FindPendingOutcome = {
|
|
|
213
213
|
* whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
|
|
214
214
|
* — a typed failure when nothing for this taskId is queued.
|
|
215
215
|
*
|
|
216
|
+
* F4947-1(2026-08-22,0.41.0):`callId` 是**更强的身份腿**,在场时先于上面两条跑。它来自
|
|
217
|
+
* `HitlBridge.decideTool`/`answerQuestion` 的 `toolUseID`(CC 的 tool_use id)—— 那两处此前走的是
|
|
218
|
+
* 本类里一个**同名私有** `findPending`,REF-CC-029 的收编没覆盖到它、P-30 的门种闸也没加到它,
|
|
219
|
+
* 于是真实生产入口(`makeHitlCanUseTool` → `decideTool`,从不传 `preResolvedPending`)一直在一条
|
|
220
|
+
* **没有闸**的复刻上跑(test 线 P0-KPI 复现)。收口方式是把私有件删掉改调本函数,而不是给它补第二
|
|
221
|
+
* 份过滤 —— 复刻正是这条缺陷的成因。身份腿同样只在**过完门种闸的行**里找:一个 `plan_review` 行
|
|
222
|
+
* 哪怕 callId 逐字命中也不该被工具审批 decide 掉(server 那边照样 409 `gate_not_tool_approval`)。
|
|
223
|
+
*
|
|
216
224
|
* P-30(2026-08-14):both legs only consider rows whose `gateKind` belongs to the ask/approval family
|
|
217
225
|
* ({@link askParkForeignGateKind} — the same single-source guard `classifyAskParkRows` uses). Without it
|
|
218
226
|
* the ANY-row fallback would hand a `plan_review`/`resource_limit` row to the tool-approval wire — an
|
|
@@ -222,7 +230,34 @@ export type FindPendingOutcome = {
|
|
|
222
230
|
*/
|
|
223
231
|
export declare function findPendingForTask(client: HitlClientLike, taskId: string, matches: (toolName: string | undefined) => boolean, opts?: {
|
|
224
232
|
signal?: AbortSignal;
|
|
225
|
-
}
|
|
233
|
+
},
|
|
234
|
+
/**
|
|
235
|
+
* F4947-1:调用方手上的**更强身份** —— CC 的 `toolUseID`(`HitlBridge`),或这张 park 的
|
|
236
|
+
* wire 身份(server ≥7.41.0,两条决断腿)。在场 ⇒ 先在**本 taskId** 的行里按
|
|
237
|
+
* `boundCallId`/`toolCallId` 逐字命中(仍在门种闸之内);不命中再走下面两条 taskId 腿。
|
|
238
|
+
*
|
|
239
|
+
* 🔴 **刻意不 fail-closed**(异源复审二审 finding① 的部分驳回,理由成文):身份在场而队列里
|
|
240
|
+
* 没有那一行时**照旧回落**,不铸「身份不匹配」的硬拒。两条理由:
|
|
241
|
+
* · 回落的失效面 = 与本参数出现之前**逐字节相同**(那时压根没有身份腿);fail-closed 的失效面
|
|
242
|
+
* = **每一次审批都决断不了**(用户点了 Yes 也过不去)。代价严重不对称;
|
|
243
|
+
* · 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 富行的
|
|
244
|
+
* `toolCallId`/`boundCallId`)本仓**没有对真 server 的实证**,只有上游帖面的口径。拿一个
|
|
245
|
+
* 没实证的键去关整条 HITL 的门,是把「可能对不上」赌成「一定对得上」。
|
|
246
|
+
* 跨 run 那一半的风险由上面的 taskId 约束关掉;「决断落在同 task 的另一行」这一格与本参数出现
|
|
247
|
+
* 之前同形,且下游 `dropHeldForDecidedPark` 对这一格自带保守判(身份对不上 ⇒ 一帧都不摘)。
|
|
248
|
+
*/
|
|
249
|
+
callId?: string,
|
|
250
|
+
/**
|
|
251
|
+
* 身份腿的**族闸**(整行可读)。缺省 = 退回 `matches(toolName)`。
|
|
252
|
+
*
|
|
253
|
+
* 🔴 为什么必须能读**整行**而不是只读 toolName(五审 finding①):工具审批族有**两条腿** ——
|
|
254
|
+
* 一等 `kind === 'tool_approval'`(放行**任意** toolName)与 fs 写 / shell 名字腿。而 wire 模块
|
|
255
|
+
* 传下来的 `matches` 只有名字腿那半(它服务的是「同族第一行」那条 leg 2)。拿它当身份闸,一个
|
|
256
|
+
* **合法的 kind-only 行**即使 taskId+callId 逐字命中也会被判出局 ⇒ 回落挑了同 task 的另一行,
|
|
257
|
+
* 卡面与决断全落在别的审批上、真 gate 继续 suspended。⇒ 调用方按自己那条腿的**完整**族判据
|
|
258
|
+
* 供给本闸(fs 腿 = `isToolApprovalGate({kind: row.gateKind, toolName})`,ask 腿 = `isAskTool`)。
|
|
259
|
+
*/
|
|
260
|
+
identityAccepts?: (row: PendingCheckpoint) => boolean): Promise<FindPendingOutcome>;
|
|
226
261
|
/**
|
|
227
262
|
* The bridge state. One per run/session. The shell feeds it `suspended` events (observe) and the human's
|
|
228
263
|
* outcome (decide / answer / planReview); it owns the `approvals.list()` join + the verbatim binding echo.
|
|
@@ -240,17 +275,24 @@ export declare class HitlBridge {
|
|
|
240
275
|
/** The gate currently awaiting a human, if any. The shell branches on `gate.kind` to pick the chrome. */
|
|
241
276
|
currentGate(): CheckpointGate | null;
|
|
242
277
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
278
|
+
* Fetch the `PendingCheckpoint` this decide/answer is about to resolve, for the callers that do NOT
|
|
279
|
+
* already have one in hand (see `decideTool`/`answerQuestion`'s `preResolvedPending` param — the wire
|
|
280
|
+
* modules that surface a card/overlay BEFORE deciding fetch the row themselves and pass it straight
|
|
281
|
+
* through, closing the TOCTOU window between "the row the human saw" and "the row decide resolves",
|
|
282
|
+
* REF-CC-029/hitl2-08).
|
|
283
|
+
*
|
|
284
|
+
* 🔴 F4947-1(2026-08-22,0.41.0):本方法**没有自己的查找逻辑** —— 它是 {@link findPendingForTask}
|
|
285
|
+
* 的一层出参映射,一个字节的行过滤都不在这里。上一版是一个与那个单源导出**撞名**的私有复刻
|
|
286
|
+
* (`findPending`),REF-CC-029 的三合一没覆盖到它、P-30 的门种闸也只加在导出那一份上,于是
|
|
287
|
+
* `makeHitlCanUseTool` → `decideTool`(真实用户点 Yes/No 那条路,从不传 `preResolvedPending`)
|
|
288
|
+
* 一直在没有闸的老复刻上跑:同 task 停着 `plan_review` 行时,它被当成「这个 run 的 pending」
|
|
289
|
+
* 交给工具审批 decide(空 toolName 卡 / 409 `gate_not_tool_approval`)。
|
|
290
|
+
* 名字也一并换掉:撞名正是「看起来只有一份实现」的成因。
|
|
246
291
|
*
|
|
247
|
-
*
|
|
248
|
-
* `
|
|
249
|
-
* overlay BEFORE calling decide/answer fetch the row themselves via `findPendingForTask` (below) and
|
|
250
|
-
* pass it straight through, closing the TOCTOU window between "the row the human saw" and "the row
|
|
251
|
-
* decide resolves" (REF-CC-029/hitl2-08).
|
|
292
|
+
* 谓词传 {@link ANY_TOOL_FAMILY}:桥这一层对工具族**没有意见**(人刚刚决断的是哪张门就是哪张),
|
|
293
|
+
* 与旧私有件的 taskId 兜底逐字同语义;身份腿(`toolUseID`)由单源函数的 `callId` 参数承接。
|
|
252
294
|
*/
|
|
253
|
-
private
|
|
295
|
+
private pendingForDecide;
|
|
254
296
|
/**
|
|
255
297
|
* Build the D-1 binding off the pending row the human SAW. Echo VERBATIM — NEVER recompute the hash
|
|
256
298
|
* (contract/04 §2.2). `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
|
|
@@ -288,7 +330,7 @@ export declare class HitlBridge {
|
|
|
288
330
|
* its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
|
|
289
331
|
* row decide resolves" (two independent fetches could otherwise land on different rows if the
|
|
290
332
|
* first one got resolved/expired in between, hitl2-01/hitl2-08). Omit only when the caller has no
|
|
291
|
-
* such row in hand
|
|
333
|
+
* such row in hand(缺省回落 `pendingForDecide` — 见其头注,那是单源 `findPendingForTask` 的一层出参映射). */
|
|
292
334
|
preResolvedPending?: PendingCheckpoint): Promise<unknown>;
|
|
293
335
|
/**
|
|
294
336
|
* Answer an `AskUserQuestion` gate (contract/04 §4; 08 CS-16). Same suspended → decide loop as a
|
package/dist/hitl/hitlBridge.js
CHANGED
|
@@ -213,6 +213,12 @@ export class DecideTransportRetryExhaustedError extends Error {
|
|
|
213
213
|
this.name = 'DecideTransportRetryExhaustedError';
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* 「本层对工具族没有意见」的谓词(`HitlBridge.pendingForDecide` 用)。桥不是 wire 模块:它决断的
|
|
218
|
+
* 就是人刚刚看过的那张门,族属由那两条 wire 在**取件时**说了算。写成具名常量而不是就地 `() => true`
|
|
219
|
+
* —— 一个匿名恒真谓词读起来像「忘了填」,而这里的恒真是**有理由**的(理由即本注)。
|
|
220
|
+
*/
|
|
221
|
+
const ANY_TOOL_FAMILY = () => true;
|
|
216
222
|
/**
|
|
217
223
|
* The single source for "which `PendingCheckpoint` row is the human about to decide on", used by the two
|
|
218
224
|
* decision wires (`toolApprovalWire.surfaceFsApprovalAndDecide` / `askGateWire.surfaceGateAndDecide`)
|
|
@@ -233,6 +239,14 @@ export class DecideTransportRetryExhaustedError extends Error {
|
|
|
233
239
|
* whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
|
|
234
240
|
* — a typed failure when nothing for this taskId is queued.
|
|
235
241
|
*
|
|
242
|
+
* F4947-1(2026-08-22,0.41.0):`callId` 是**更强的身份腿**,在场时先于上面两条跑。它来自
|
|
243
|
+
* `HitlBridge.decideTool`/`answerQuestion` 的 `toolUseID`(CC 的 tool_use id)—— 那两处此前走的是
|
|
244
|
+
* 本类里一个**同名私有** `findPending`,REF-CC-029 的收编没覆盖到它、P-30 的门种闸也没加到它,
|
|
245
|
+
* 于是真实生产入口(`makeHitlCanUseTool` → `decideTool`,从不传 `preResolvedPending`)一直在一条
|
|
246
|
+
* **没有闸**的复刻上跑(test 线 P0-KPI 复现)。收口方式是把私有件删掉改调本函数,而不是给它补第二
|
|
247
|
+
* 份过滤 —— 复刻正是这条缺陷的成因。身份腿同样只在**过完门种闸的行**里找:一个 `plan_review` 行
|
|
248
|
+
* 哪怕 callId 逐字命中也不该被工具审批 decide 掉(server 那边照样 409 `gate_not_tool_approval`)。
|
|
249
|
+
*
|
|
236
250
|
* P-30(2026-08-14):both legs only consider rows whose `gateKind` belongs to the ask/approval family
|
|
237
251
|
* ({@link askParkForeignGateKind} — the same single-source guard `classifyAskParkRows` uses). Without it
|
|
238
252
|
* the ANY-row fallback would hand a `plan_review`/`resource_limit` row to the tool-approval wire — an
|
|
@@ -240,7 +254,34 @@ export class DecideTransportRetryExhaustedError extends Error {
|
|
|
240
254
|
* out, not a rejection of the whole queue: a genuine approval row for the same task still resolves.
|
|
241
255
|
* Rows with no `gateKind` (pre-`gate_kind` history) keep the old behaviour — no stronger signal exists.
|
|
242
256
|
*/
|
|
243
|
-
export async function findPendingForTask(client, taskId, matches, opts
|
|
257
|
+
export async function findPendingForTask(client, taskId, matches, opts,
|
|
258
|
+
/**
|
|
259
|
+
* F4947-1:调用方手上的**更强身份** —— CC 的 `toolUseID`(`HitlBridge`),或这张 park 的
|
|
260
|
+
* wire 身份(server ≥7.41.0,两条决断腿)。在场 ⇒ 先在**本 taskId** 的行里按
|
|
261
|
+
* `boundCallId`/`toolCallId` 逐字命中(仍在门种闸之内);不命中再走下面两条 taskId 腿。
|
|
262
|
+
*
|
|
263
|
+
* 🔴 **刻意不 fail-closed**(异源复审二审 finding① 的部分驳回,理由成文):身份在场而队列里
|
|
264
|
+
* 没有那一行时**照旧回落**,不铸「身份不匹配」的硬拒。两条理由:
|
|
265
|
+
* · 回落的失效面 = 与本参数出现之前**逐字节相同**(那时压根没有身份腿);fail-closed 的失效面
|
|
266
|
+
* = **每一次审批都决断不了**(用户点了 Yes 也过不去)。代价严重不对称;
|
|
267
|
+
* · 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 富行的
|
|
268
|
+
* `toolCallId`/`boundCallId`)本仓**没有对真 server 的实证**,只有上游帖面的口径。拿一个
|
|
269
|
+
* 没实证的键去关整条 HITL 的门,是把「可能对不上」赌成「一定对得上」。
|
|
270
|
+
* 跨 run 那一半的风险由上面的 taskId 约束关掉;「决断落在同 task 的另一行」这一格与本参数出现
|
|
271
|
+
* 之前同形,且下游 `dropHeldForDecidedPark` 对这一格自带保守判(身份对不上 ⇒ 一帧都不摘)。
|
|
272
|
+
*/
|
|
273
|
+
callId,
|
|
274
|
+
/**
|
|
275
|
+
* 身份腿的**族闸**(整行可读)。缺省 = 退回 `matches(toolName)`。
|
|
276
|
+
*
|
|
277
|
+
* 🔴 为什么必须能读**整行**而不是只读 toolName(五审 finding①):工具审批族有**两条腿** ——
|
|
278
|
+
* 一等 `kind === 'tool_approval'`(放行**任意** toolName)与 fs 写 / shell 名字腿。而 wire 模块
|
|
279
|
+
* 传下来的 `matches` 只有名字腿那半(它服务的是「同族第一行」那条 leg 2)。拿它当身份闸,一个
|
|
280
|
+
* **合法的 kind-only 行**即使 taskId+callId 逐字命中也会被判出局 ⇒ 回落挑了同 task 的另一行,
|
|
281
|
+
* 卡面与决断全落在别的审批上、真 gate 继续 suspended。⇒ 调用方按自己那条腿的**完整**族判据
|
|
282
|
+
* 供给本闸(fs 腿 = `isToolApprovalGate({kind: row.gateKind, toolName})`,ask 腿 = `isAskTool`)。
|
|
283
|
+
*/
|
|
284
|
+
identityAccepts = (r) => matches(typeof r.toolName === 'string' ? r.toolName : undefined)) {
|
|
244
285
|
let rows;
|
|
245
286
|
try {
|
|
246
287
|
rows = (await client.approvals.list(opts)).pending;
|
|
@@ -249,8 +290,33 @@ export async function findPendingForTask(client, taskId, matches, opts) {
|
|
|
249
290
|
return { ok: false, reason: `approvals.list failed: ${String(e)}` };
|
|
250
291
|
}
|
|
251
292
|
const decidable = rows.filter((r) => askParkForeignGateKind(r) === null);
|
|
252
|
-
|
|
253
|
-
|
|
293
|
+
// 🔴 身份腿**约束在本 taskId 之内**(异源复审二审 finding①):`/v1/approvals` 是 scope 级单队列,
|
|
294
|
+
// 别的 run / 后台子代 lane 的行同在表里 —— 一个撞名或被复用的 callId 就能把**别人会话**的 pending
|
|
295
|
+
// 行交给这次 decide。REF-CC-022 拆掉 `?? pending[0]` 防的正是这件事;身份腿是「更强的身份」,
|
|
296
|
+
// 不是「可以跨 run 的身份」,不能自带一个新的绕行口。
|
|
297
|
+
const identityRow = callId !== undefined
|
|
298
|
+
? decidable.find((r) => r.taskId === taskId && (r.boundCallId === callId || r.toolCallId === callId))
|
|
299
|
+
: undefined;
|
|
300
|
+
// 🔴 身份命中还要过**族闸**(四审 finding② / 五审 finding①,闸读整行见 `identityAccepts`):
|
|
301
|
+
// 门种闸只挡 plan_review/resource_limit 这类外族,AskUserQuestion 与工具审批**同在** `decidable` 里。
|
|
302
|
+
// 一个陈旧/错配/被复用的 park 身份否则就能让 fs 腿选中一行 ask(反之亦然),而那一行随后原样当
|
|
303
|
+
// `preResolvedPending` 去呈卡 + decide —— 卡面全错、决断打在另一个 checkpoint 上。
|
|
304
|
+
const identityConflicts = identityRow !== undefined && !identityAccepts(identityRow);
|
|
305
|
+
// 🔴 身份证据与本腿族属**冲突**时,这一轮的候选池整体收紧到族闸放行的行(六审 finding① +
|
|
306
|
+
// 七审 finding①)。两步演进的理由要写清楚:
|
|
307
|
+
// · 只拦身份腿是自相矛盾的 —— 最后那条「同 taskId 任意行」兜底会把**同一行**重新捞出来
|
|
308
|
+
// (队列里只剩它时必然发生),刚判定「这一行不是本腿的」,转头又拿它去呈卡 + 决断;
|
|
309
|
+
// · 只剔除命中的**那一个对象**也不够 —— 同 task 排着两条反族行时,删掉第一条,兜底照样捡起
|
|
310
|
+
// 第二条(七审实撞)。
|
|
311
|
+
// 冲突 = wire 数据自相矛盾(park 说 gate 是 A 族、身份却指着 B 族的行),这一轮**整体**保守:
|
|
312
|
+
// 只在族闸放行的行里选;一条都不剩 ⇒ 诚实 `no_pending`(调用方走既有 fail-soft / 重呈路)。
|
|
313
|
+
// 🔴 收紧**只发生在冲突路径上**:身份缺席(绝大多数路径)时候选池一个字节不动 —— 那条
|
|
314
|
+
// 「任意行」兜底本来就是给 kind-only / 名字腿认不得的合法行留的(见上方 leg 说明),不能被
|
|
315
|
+
// 一条与它无关的规则顺手削掉。
|
|
316
|
+
const pool = identityConflicts ? decidable.filter((r) => identityAccepts(r)) : decidable;
|
|
317
|
+
const pending = (identityConflicts ? undefined : identityRow) ??
|
|
318
|
+
pool.find((r) => r.taskId === taskId && matches(typeof r.toolName === 'string' ? r.toolName : undefined)) ??
|
|
319
|
+
pool.find((r) => r.taskId === taskId);
|
|
254
320
|
if (!pending)
|
|
255
321
|
return { ok: false, reason: 'no pending checkpoint for this run (resolved/expired?)', code: 'no_pending' };
|
|
256
322
|
return { ok: true, pending, gatedCallId: pending.toolCallId ?? pending.boundCallId ?? undefined };
|
|
@@ -298,32 +364,33 @@ export class HitlBridge {
|
|
|
298
364
|
return this.active?.gate ?? null;
|
|
299
365
|
}
|
|
300
366
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
367
|
+
* Fetch the `PendingCheckpoint` this decide/answer is about to resolve, for the callers that do NOT
|
|
368
|
+
* already have one in hand (see `decideTool`/`answerQuestion`'s `preResolvedPending` param — the wire
|
|
369
|
+
* modules that surface a card/overlay BEFORE deciding fetch the row themselves and pass it straight
|
|
370
|
+
* through, closing the TOCTOU window between "the row the human saw" and "the row decide resolves",
|
|
371
|
+
* REF-CC-029/hitl2-08).
|
|
304
372
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
373
|
+
* 🔴 F4947-1(2026-08-22,0.41.0):本方法**没有自己的查找逻辑** —— 它是 {@link findPendingForTask}
|
|
374
|
+
* 的一层出参映射,一个字节的行过滤都不在这里。上一版是一个与那个单源导出**撞名**的私有复刻
|
|
375
|
+
* (`findPending`),REF-CC-029 的三合一没覆盖到它、P-30 的门种闸也只加在导出那一份上,于是
|
|
376
|
+
* `makeHitlCanUseTool` → `decideTool`(真实用户点 Yes/No 那条路,从不传 `preResolvedPending`)
|
|
377
|
+
* 一直在没有闸的老复刻上跑:同 task 停着 `plan_review` 行时,它被当成「这个 run 的 pending」
|
|
378
|
+
* 交给工具审批 decide(空 toolName 卡 / 409 `gate_not_tool_approval`)。
|
|
379
|
+
* 名字也一并换掉:撞名正是「看起来只有一份实现」的成因。
|
|
380
|
+
*
|
|
381
|
+
* 谓词传 {@link ANY_TOOL_FAMILY}:桥这一层对工具族**没有意见**(人刚刚决断的是哪张门就是哪张),
|
|
382
|
+
* 与旧私有件的 taskId 兜底逐字同语义;身份腿(`toolUseID`)由单源函数的 `callId` 参数承接。
|
|
310
383
|
*/
|
|
311
|
-
async
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
384
|
+
async pendingForDecide(toolUseID, opts) {
|
|
385
|
+
const found = await findPendingForTask(this.client, this.taskId, ANY_TOOL_FAMILY, opts, toolUseID);
|
|
386
|
+
if (found.ok)
|
|
387
|
+
return found.pending;
|
|
388
|
+
// 🔴 `no_pending`(良性缺席)才是「没有行可决」;`approvals.list` 自身失败必须照旧**抛**出去。
|
|
389
|
+
// 旧私有件让 list 的错原样逃逸,而下游 `parkResolver` 对 `no_pending` 的处置是「这张 gate
|
|
390
|
+
// 早就被解决了 ⇒ 继续读流」—— 把一次网络失败降级成那个码 = 谎报已解决(#110 烧会话的路径)。
|
|
391
|
+
if (found.code === 'no_pending')
|
|
314
392
|
return null;
|
|
315
|
-
|
|
316
|
-
const byCall = pending.find((p) => p.boundCallId === toolUseID || p.toolCallId === toolUseID);
|
|
317
|
-
if (byCall)
|
|
318
|
-
return byCall;
|
|
319
|
-
}
|
|
320
|
-
// Fall back to this run's pending row (the suspended run holding the session claim).
|
|
321
|
-
// REF-CC-022(2026-08-02): NEVER `?? pending[0]` — once this taskId's own rows are exhausted, the
|
|
322
|
-
// first queue row can belong to a DIFFERENT session or a background subagent lane (they share the
|
|
323
|
-
// same `/v1/approvals` queue); deciding on it would resolve someone else's gate. A miss here is a
|
|
324
|
-
// real `no_pending` — the caller's existing typed-fail path (and exactly the signal #110's
|
|
325
|
-
// cancel-by-deny-recovery arm expects), not "decide on whatever happens to be first".
|
|
326
|
-
return pending.find((p) => p.taskId === this.taskId) ?? null;
|
|
393
|
+
throw new Error(found.reason);
|
|
327
394
|
}
|
|
328
395
|
/**
|
|
329
396
|
* Build the D-1 binding off the pending row the human SAW. Echo VERBATIM — NEVER recompute the hash
|
|
@@ -361,9 +428,9 @@ export class HitlBridge {
|
|
|
361
428
|
* its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
|
|
362
429
|
* row decide resolves" (two independent fetches could otherwise land on different rows if the
|
|
363
430
|
* first one got resolved/expired in between, hitl2-01/hitl2-08). Omit only when the caller has no
|
|
364
|
-
* such row in hand
|
|
431
|
+
* such row in hand(缺省回落 `pendingForDecide` — 见其头注,那是单源 `findPendingForTask` 的一层出参映射). */
|
|
365
432
|
preResolvedPending) {
|
|
366
|
-
const pending = preResolvedPending ?? (await this.
|
|
433
|
+
const pending = preResolvedPending ?? (await this.pendingForDecide(toolUseID, opts));
|
|
367
434
|
if (!pending) {
|
|
368
435
|
throw new HitlSafetyError('no pending checkpoint to decide (resolved/expired under the human) — refetch + re-present', 'no_pending');
|
|
369
436
|
}
|
|
@@ -408,10 +475,10 @@ export class HitlBridge {
|
|
|
408
475
|
/** REF-CC-029(2026-08-02):see `decideTool`'s same-named param — the pending row the caller already
|
|
409
476
|
* fetched via `findPendingForTask`. */
|
|
410
477
|
preResolvedPending) {
|
|
411
|
-
// 🔴
|
|
478
|
+
// 🔴 校验在取件**之前**:入参坏了跟 pending 行在不在场无关,而先 list 再拒会把
|
|
412
479
|
// 「你的作答是空的」这条真判词换成「no_pending」那条假判词(行恰好过期时)。
|
|
413
480
|
assertAnswersSendable(answers);
|
|
414
|
-
const pending = preResolvedPending ?? (await this.
|
|
481
|
+
const pending = preResolvedPending ?? (await this.pendingForDecide(toolUseID, opts));
|
|
415
482
|
if (!pending) {
|
|
416
483
|
throw new HitlSafetyError('no pending AskUserQuestion checkpoint to answer — refetch + re-present', 'no_pending');
|
|
417
484
|
}
|
|
@@ -90,7 +90,10 @@ export function toAnsweredOutput(questions, answer) {
|
|
|
90
90
|
* 弹既有 AskUserQuestion overlay → 等作答 → 经 HitlBridge decide(答案/拒答)。
|
|
91
91
|
* decide 成功即引擎已把 run 驱到下一状态(sync);调用方随后 attach runs.events 续流。
|
|
92
92
|
*/
|
|
93
|
-
async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal
|
|
93
|
+
async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal,
|
|
94
|
+
/** 这张 park 的待批 call 身份(server ≥7.41.0;缺席是常态)。语义与不 fail-closed 的理由
|
|
95
|
+
* 与 fs 腿同一条,见 `toolApprovalWire.surfaceFsApprovalAndDecide` 的同名参数头注。 */
|
|
96
|
+
parkGatedCallId) {
|
|
94
97
|
if (!hasQuestionOverlay()) {
|
|
95
98
|
return { kind: 'failed', reason: 'no question overlay mounted (print/non-REPL mode)' };
|
|
96
99
|
}
|
|
@@ -98,7 +101,9 @@ async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
|
|
|
98
101
|
// REF-CC-029(单源取件)+ REF-CC-033(no_pending 判别码):findPendingForTask 是唯一取件口
|
|
99
102
|
// (三份近似复刻已收敛),其失败形带 code 判别位,本处原样透传给 GateOutcome ——
|
|
100
103
|
// 「没有待决 gate」(良性,park 重放常见)与「approvals.list 真失败」自此结构可分。
|
|
101
|
-
const found = await findPendingForTask(deps.client, taskId, isAskTool, signal ? { signal } : undefined)
|
|
104
|
+
const found = await findPendingForTask(deps.client, taskId, isAskTool, signal ? { signal } : undefined, parkGatedCallId, // 身份在场 ⇒ 逐字命中优先;缺席/不命中 ⇒ 原两腿(不 fail-closed,理由见参数头注)
|
|
105
|
+
// 本腿的族就是 ask 一族(整行可读闸的 ask 半场,与 fs 腿的 `isToolApprovalGate` 对称)。
|
|
106
|
+
(row) => isAskTool(typeof row.toolName === 'string' ? row.toolName : undefined));
|
|
102
107
|
if (!found.ok)
|
|
103
108
|
return { kind: 'failed', reason: found.reason, ...(found.code !== undefined ? { code: found.code } : {}) };
|
|
104
109
|
const { pending, gatedCallId } = found;
|
|
@@ -208,7 +213,7 @@ export async function resolvePark(park, ctx) {
|
|
|
208
213
|
else if (park.gate === 'fs') {
|
|
209
214
|
candidateGatedCallId = led.lastFsOrShellGatedCallId();
|
|
210
215
|
// [816] 放宽腿:fs 写权限 gate → CC 三选卡(vendored PermissionRequest)→ decide。
|
|
211
|
-
outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal);
|
|
216
|
+
outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal, park.gatedCallId);
|
|
212
217
|
// #51: `outcome`'s declared type is the wider `GateOutcome |
|
|
213
218
|
// FsApprovalOutcome`; both unions share a 'decided' kind with
|
|
214
219
|
// different optional fields (`answered` vs `denied`), so a plain
|
|
@@ -221,7 +226,7 @@ export async function resolvePark(park, ctx) {
|
|
|
221
226
|
}
|
|
222
227
|
}
|
|
223
228
|
else {
|
|
224
|
-
outcome = await surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal);
|
|
229
|
+
outcome = await surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal, park.gatedCallId);
|
|
225
230
|
}
|
|
226
231
|
// #110 缺陷② b/c —— **重放的、早已决断过的 park 不是失败**。
|
|
227
232
|
// durable re-attach 必然会把 park 帧再送一遍(`lastEventId` 再准也只能精确到帧,park 就在
|
|
@@ -288,8 +293,28 @@ export async function resolvePark(park, ctx) {
|
|
|
288
293
|
}
|
|
289
294
|
// decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
|
|
290
295
|
// 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
|
|
296
|
+
//
|
|
297
|
+
// 🔴 件④([4933] 立案的存量,0.41.0 根修):摘扣留帧走 `dropHeldForDecidedPark` 而不是裸
|
|
298
|
+
// `dropHeld(outcome.gatedCallId)` —— 后者的入参是 `findPendingForTask` 的**卡面渲染宽口**
|
|
299
|
+
// (同 taskId + 谓词命中的第一行),同族多行排队时它可能是旁观者,于是摘掉的是**另一条**
|
|
300
|
+
// call 的扣留帧、那一帧再也不吐(「一帧不丢」在这一格上不成立)。新动词的两条腿见其头注:
|
|
301
|
+
// `park.gatedCallId`(server ≥7.41.0 的 wire 身份)在场就点名摘,缺席则只在同族唯一时摘。
|
|
302
|
+
// 这一行**刻意排在 `if (outcome.gatedCallId)` 之外**:身份在 wire 上时,摘不摘与 pending
|
|
303
|
+
// 行给没给出 callId 无关(park 有主角,decide 也成功了)。
|
|
304
|
+
led.dropHeldForDecidedPark(outcome.gatedCallId, park.gatedCallId);
|
|
291
305
|
if (outcome.gatedCallId) {
|
|
292
|
-
|
|
306
|
+
// 🔴 #324:**刻意不把 pending 行的 callId 当成连坐判别的主角身份**。durable `checkpointGate`
|
|
307
|
+
// 不带 callId,`findPendingForTask` 取的是「同 taskId + 谓词命中的**第一行**」(谓词不命中
|
|
308
|
+
// 时还退「任意行」)—— 那是给**卡面渲染**用的宽口,证明不了这一行就是这张 park 的主角:
|
|
309
|
+
// 同族两行排队时它可能给出旁观者,于是真主角被挤成「非主角」而 stamp(异源复审实测)。
|
|
310
|
+
// 连坐判别改由台账按「本批唯一的该族 call」自证(`gateLedger.flushHeld` 头注),
|
|
311
|
+
// 分不出来就整批不标。下面两行是 gate 生命周期(#110 / REF-CC-034),与判别无关,照旧。
|
|
312
|
+
// ✅ **记案已销**(0.41.0 件④):此处原先那句「`dropHeld` 用的也是这个不可信 callId」的
|
|
313
|
+
// 存量记案,已由上方 `dropHeldForDecidedPark` 收口(wire 身份点名 / 判不出就不摘)。
|
|
314
|
+
// 🔴 `markDecided` **刻意仍用** `outcome.gatedCallId`:它喂的是 #110 那条「重放的 park 不是
|
|
315
|
+
// 失败」的承重判据,而那条判据的候选取自 `lastFsOrShellGatedCallId()`(同一条 pending 宽口
|
|
316
|
+
// 时代的口径),两边必须同源才对得上;换成 wire 身份是**另一件**改动(要连着上方
|
|
317
|
+
// `candidateGatedCallId` 一起换,且会改动烧会话那条路径的行为),不搭本批的车。
|
|
293
318
|
led.markDecided(outcome.gatedCallId); // #110 缺陷② b / REF-CC-034:身份留痕,上方候选匹配消费
|
|
294
319
|
if ('answered' in outcome && outcome.answered)
|
|
295
320
|
led.rememberAnswer(outcome.gatedCallId, outcome.answered);
|
|
@@ -344,7 +344,21 @@ export declare function surfaceApprovalCard(req: ApprovalCardRequest): Promise<A
|
|
|
344
344
|
*
|
|
345
345
|
* @param argsByCall tool_start.args(toolCallId → args)——题干/diff 的一手源;缺则 pending.input。
|
|
346
346
|
*/
|
|
347
|
-
export declare function surfaceFsApprovalAndDecide(deps: FsApprovalWireDeps, taskId: string, argsByCall: Map<string, unknown>, signal?: AbortSignal
|
|
347
|
+
export declare function surfaceFsApprovalAndDecide(deps: FsApprovalWireDeps, taskId: string, argsByCall: Map<string, unknown>, signal?: AbortSignal,
|
|
348
|
+
/**
|
|
349
|
+
* 这张 park 的**待批 call 身份**(server ≥7.41.0 的 `done{suspended}.result.toolCallId` /
|
|
350
|
+
* durable `suspended` 事件同键;供给见 `frameRouter.parkGatedCallId`,缺席是常态)。
|
|
351
|
+
*
|
|
352
|
+
* 🔴 它必须从**取件**这一步就起作用,不能只拿去删扣留帧(0.41.0 件④ 的异源复审 finding①):
|
|
353
|
+
* 取件按 taskId + 族谓词选**第一行**,同 task 同族多行排队时那可能是旁观者 —— 只用身份删帧就成了
|
|
354
|
+
* split-brain(决断绑的是旁观者行、删掉的是主角帧):用户其实在批准/拒绝**另一个**工具调用,
|
|
355
|
+
* 而真 park 原地不动、反复重挂。
|
|
356
|
+
* 🔴 **不 fail-closed**:身份在场但队列里没有对应行时**照旧回落**既有两条腿(taskId + 族谓词)。
|
|
357
|
+
* 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 行的 `toolCallId`/
|
|
358
|
+
* `boundCallId`)本仓没有对真 server 的实证,而 fail-closed 的失效面是**每一次审批都决断不了**;
|
|
359
|
+
* 回落的失效面则与本键出现之前逐字节相同。方向按代价不对称取:宁可退回原状,不赌一个没实证的键。
|
|
360
|
+
*/
|
|
361
|
+
parkGatedCallId?: string): Promise<FsApprovalOutcome>;
|
|
348
362
|
/** server 包 src/tool-approval.ts 头注的帧契约(redactDeep + 16KiB 帽;超帽 argsOmitted 仍出帧)。 */
|
|
349
363
|
export interface ToolApprovalFrame {
|
|
350
364
|
type: 'tool_approval' | 'tool_approval_complete';
|
|
@@ -221,7 +221,21 @@ function cardPortMissReason() {
|
|
|
221
221
|
*
|
|
222
222
|
* @param argsByCall tool_start.args(toolCallId → args)——题干/diff 的一手源;缺则 pending.input。
|
|
223
223
|
*/
|
|
224
|
-
export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signal
|
|
224
|
+
export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signal,
|
|
225
|
+
/**
|
|
226
|
+
* 这张 park 的**待批 call 身份**(server ≥7.41.0 的 `done{suspended}.result.toolCallId` /
|
|
227
|
+
* durable `suspended` 事件同键;供给见 `frameRouter.parkGatedCallId`,缺席是常态)。
|
|
228
|
+
*
|
|
229
|
+
* 🔴 它必须从**取件**这一步就起作用,不能只拿去删扣留帧(0.41.0 件④ 的异源复审 finding①):
|
|
230
|
+
* 取件按 taskId + 族谓词选**第一行**,同 task 同族多行排队时那可能是旁观者 —— 只用身份删帧就成了
|
|
231
|
+
* split-brain(决断绑的是旁观者行、删掉的是主角帧):用户其实在批准/拒绝**另一个**工具调用,
|
|
232
|
+
* 而真 park 原地不动、反复重挂。
|
|
233
|
+
* 🔴 **不 fail-closed**:身份在场但队列里没有对应行时**照旧回落**既有两条腿(taskId + 族谓词)。
|
|
234
|
+
* 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 行的 `toolCallId`/
|
|
235
|
+
* `boundCallId`)本仓没有对真 server 的实证,而 fail-closed 的失效面是**每一次审批都决断不了**;
|
|
236
|
+
* 回落的失效面则与本键出现之前逐字节相同。方向按代价不对称取:宁可退回原状,不赌一个没实证的键。
|
|
237
|
+
*/
|
|
238
|
+
parkGatedCallId) {
|
|
225
239
|
// REF-CC-031(2026-08-02):卡口缺席判据不在此前置重复一遍 —— `surfaceApprovalCard`(下方调用)
|
|
226
240
|
// 已经计 miss + 出 typed `{kind:'failed', reason:...}`,card.kind==='failed' 走既有汇流分支
|
|
227
241
|
// (下面 switch 的 'failed' 臂);两处各写一份文案会在 SEMA_DEBUG 里出现两种措辞,取决于走的是
|
|
@@ -232,7 +246,11 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
|
|
|
232
246
|
// 二次 list(),消掉 hitl2-01/hitl2-08 的 TOCTOU 窗。
|
|
233
247
|
// 🔴 常驻判据不是「看得见 pending 这个实参」,是 pure 门那四条「approvals.list 全程恰好 1 次」——
|
|
234
248
|
// 漏传一条腿,形状断言全绿而计数当场翻红(回炉车前的实况:只有 aborted 臂传了)。
|
|
235
|
-
const found = await findPendingForTask(deps.client, taskId, (toolName) => toolName !== undefined && (toolNameIsFsWrite(toolName) || toolNameIsShellExec(toolName)), signal ? { signal } : undefined
|
|
249
|
+
const found = await findPendingForTask(deps.client, taskId, (toolName) => toolName !== undefined && (toolNameIsFsWrite(toolName) || toolNameIsShellExec(toolName)), signal ? { signal } : undefined, parkGatedCallId, // 见本参数头注:身份在场 ⇒ 逐字命中优先于「同族第一行」;缺席/不命中 ⇒ 原两腿
|
|
250
|
+
// 身份腿的族闸走**整行**判据:本腿的族是 `isToolApprovalGate` 的**两条腿**(一等 kind 放行任意
|
|
251
|
+
// toolName + fs/shell 名字腿),而上面那个 `matches` 只有名字腿那半 —— 拿它当身份闸会把合法的
|
|
252
|
+
// kind-only 行判出局(五审 finding①)。gateKind = 行上的扁平投影,与 gate.kind 同一权威。
|
|
253
|
+
(row) => isToolApprovalGate({ kind: row.gateKind, toolName: row.toolName }));
|
|
236
254
|
if (!found.ok)
|
|
237
255
|
return { kind: 'failed', reason: found.reason };
|
|
238
256
|
const { pending, gatedCallId } = found;
|