@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.
@@ -40,6 +40,16 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
40
40
  let retryTimer;
41
41
  /** [2393] hitl-F5:连续「回调体抛出」的次数(一次跑完就归零)——见 `schedulePoll` 的 catch 臂。 */
42
42
  let consecutivePollEscapes = 0;
43
+ /** [F4957-1] 轮询腿的**代际**:push 腿每重连一次 +1。在途的旧拍完成时凭它认出自己已经过期 ——
44
+ * 它的成败属于上一条腿,不该改现在这条腿的连续计数,也不该由它来续排(见 `schedulePoll`)。 */
45
+ let pollLeg = 0;
46
+ /**
47
+ * [F4957-1 三审] 取件的**单调发号**与「已观察到的最新一号」。乱序回滚是**取件面的通病**,不是
48
+ * 轮询腿的私事:`refresh()`(端上的显式刷新)与 push 腿的重取同样可能后发先至。所以提交面的闸
49
+ * 从「代际」升成请求序号 —— 一次响应只有在「没有更晚发起的请求先落过地」时才允许改视图。
50
+ */
51
+ let takeSeq = 0;
52
+ let lastObservedSeq = 0;
43
53
  const stats = {
44
54
  pushEvents: 0, heartbeats: 0, polls: 0, listErrors: 0, streamFailures: 0, snapshots: 0,
45
55
  };
@@ -73,9 +83,12 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
73
83
  }
74
84
  return verdict;
75
85
  }
76
- async function take(via) {
86
+ async function take(via,
87
+ /** [F4957-1 二审] 发起这次取件时的轮询代际(只有轮询腿传;push/refresh 不受代际管)。 */
88
+ leg) {
77
89
  if (stopped)
78
90
  return;
91
+ const seq = ++takeSeq;
79
92
  let rows;
80
93
  try {
81
94
  rows = (await client.approvals.list({ signal: ac.signal })).pending;
@@ -88,6 +101,23 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
88
101
  if (stopped)
89
102
  return;
90
103
  const d = digestOf(rows);
104
+ // 🔴 [F4957-1 二审 finding②] 代际闸必须排在**提交之前**:下面四行(lastDigest / revision /
105
+ // last / onSnapshot)一落,视图就已经被改了 —— 守卫排在 take() 之外只挡得住计数与续排,挡不住
106
+ // 回滚。乱序是真的会发生的:旧 poll 在途 → push 重连并发布新快照 → 旧 poll 才带着**更老**的
107
+ // 结果返回 ⇒ 端拿到一张 revision 更高、内容更旧的 `mode:'poll'` 快照(刚出现的待审批被抹掉,
108
+ // 或刚解决的又冒回来)。过期的结论一个字节都不提交。
109
+ if (leg !== undefined && leg !== pollLeg) {
110
+ hostLog('debug', `approvalsFeed: dropping stale poll snapshot from leg ${leg} (current ${pollLeg})`);
111
+ return;
112
+ }
113
+ // 🔴 [F4957-1 三审 finding②] 乱序闸(三条取件路径共用):比「已观察到的最新一号」更早发起的
114
+ // 响应,其结论已经被后发者的结论取代 —— 一个字节都不提交。`refresh()` 不带代际,单靠上面那道
115
+ // 闸盖不住它(端上「用户点了刷新」正是最容易与 push 重取撞车的那一发)。
116
+ if (seq < lastObservedSeq) {
117
+ hostLog('debug', `approvalsFeed: dropping out-of-order take #${seq} (newest observed #${lastObservedSeq})`);
118
+ return;
119
+ }
120
+ lastObservedSeq = seq;
91
121
  if (d === lastDigest)
92
122
  return; // 内容没变 ⇒ 不打扰订阅者
93
123
  lastDigest = d;
@@ -118,6 +148,11 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
118
148
  return;
119
149
  pollTimer = arm(setTimeout(() => {
120
150
  pollTimer = undefined;
151
+ // 🔴 [F4957-1 二审] 这一拍属于**哪一代**轮询腿。`take('poll')` 是可以挂很久的(慢 list /
152
+ // 挂死读面),期间 push 腿完全可能重连成功并把连续计数清零 —— 那一拍的结论此时已经是
153
+ // **上一条腿**的历史,回写到新计数里就等于把「被健康 push 窗隔开的失败」又算回连续
154
+ // (回落之后只需 4 拍就转 idle),而这正是本批要修掉的那条语义。代际在重连处 +1。
155
+ const leg = pollLeg;
121
156
  // [C77]② 跳拍:消费方说这一拍不用查 ⇒ 不取件、不计 `stats.polls`(没取件却计数 = 谎报流量)、
122
157
  // **不动** `consecutivePollEscapes`(跳拍不是失败,连着跳一万拍也不该被 F5 判成腿挂了),
123
158
  // 但下一拍照排 —— 这是「腿活着但闲着」,不是停摆。
@@ -128,7 +163,11 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
128
163
  }
129
164
  void (async () => {
130
165
  stats.polls++;
131
- await take('poll');
166
+ await take('poll', leg); // 代际随取件一路带进去(提交面的闸在 take 内,见其头注)
167
+ // 代际已翻篇 ⇒ 这一拍的成败都不属于现在这条腿:既不清零(会掩盖新腿真正的连续失败),
168
+ // 也不续排(当代那条链由 degradeToPoll 自己排,这里再排一次纯属两条腿并行的种子)。
169
+ if (leg !== pollLeg)
170
+ return;
132
171
  consecutivePollEscapes = 0; // 跑完一整拍 ⇒ 之前的逃逸不再连续
133
172
  if (mode === 'poll')
134
173
  schedulePoll();
@@ -149,6 +188,11 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
149
188
  // ② **连续失败上限转 idle** —— 一直抛就是真的挂了,那时 `mode` 必须说真话,让端的自检
150
189
  // 看得见([honest-absence-not-fabricated-zero]:诚实缺席优先于一个还在报活的假象)。
151
190
  hostLog('debug', `approvalsFeed: poll leg threw unexpectedly (take() should have self-caught): ${String(e)}`);
191
+ // 🔴 [F4957-1 二审] 同上:上一代的逃逸不喂现在这条腿的连续计数,也不由它来续排。
192
+ if (leg !== pollLeg) {
193
+ hostLog('debug', `approvalsFeed: stale poll completion from leg ${leg} (current ${pollLeg}) — not counted as a consecutive escape`);
194
+ return;
195
+ }
152
196
  consecutivePollEscapes++;
153
197
  if (consecutivePollEscapes >= MAX_CONSECUTIVE_POLL_ESCAPES) {
154
198
  mode = 'idle';
@@ -216,6 +260,17 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
216
260
  clearTimeout(pollTimer);
217
261
  pollTimer = undefined;
218
262
  }
263
+ // 🔴 [F4957-1](0.41.0):**同批清零 F5 的连续逃逸计数**。这一拍与 `schedulePoll` 里
264
+ // 「跑完一整拍 ⇒ 之前的逃逸不再连续」是同等强度的健康证据 —— 端真的从服务端收到了一个
265
+ // 事件。不清零的后果不是多算几次:被一整段健康 push 窗**隔开**的两批逃逸会被当成连续
266
+ // 累加(3 次 → 重连 → 再 2 次 = 5),于是刚从 push 回落的轮询腿才逃逸两拍就撞上限,
267
+ // `mode()` 谎报 idle —— 而文件头 38-42 卖的正是「mode 不是 idle」这条端自检,
268
+ // 它会指着一条刚活过来的腿说它停摆了([honest-absence-not-fabricated-zero] 的反面:
269
+ // 把「活着」渲成「死了」同样是撒谎)。计数的语义是**连续**,那就只能被真正连续的失败喂大。
270
+ consecutivePollEscapes = 0;
271
+ // 与清零**同一拍**翻代际:清零只管已经落地的计数,代际管的是那些还挂在 `take()` 里、
272
+ // 稍后才回写的在途拍(二审 finding③ —— 少了这一半,清零会被一个旧拍当场推回 1)。
273
+ pollLeg++;
219
274
  hostLog('debug', 'approvalsFeed: push leg re-established — polling leg stopped');
220
275
  }
221
276
  if (ev?.type === 'heartbeat') {
@@ -73,6 +73,7 @@ import { type AskGateWireDeps } from './frameRouter.js';
73
73
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
74
74
  export type { AskGateWireDeps } from './frameRouter.js';
75
75
  export type { AskAnsweredOutput } from './gateLedger.js';
76
+ export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
76
77
  export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
77
78
  export type { GateOutcome, GateFailureCode } from './parkResolver.js';
78
79
  /**
@@ -19,6 +19,12 @@ import { resolvePark } from './parkResolver.js';
19
19
  // `toAnsweredOutput` / 下方 `bridgeAskUserQuestionGates`)、类型四个。定义搬去了实现所在的
20
20
  // 那一刀,本文件是它们对外的**唯一门牌**——下游 import 路径不变,public-export 基线不变。
21
21
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
22
+ // #324 / [4907]:连坐 abort 机读位的**单一真源**导出([C93] `toolEndOutputText` 同款理由 ——
23
+ // 三端各自手抄键名 = 漂移温床)。壳/web/desktop 认这个常量,不认文案。
24
+ export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
25
+ // 🔴 `HoldOrigin` 随 #324 换锚删除(2026-08-22):出身不再在入表当拍冻结成两值枚举,改在
26
+ // `flushHeld` 当拍按已登记的 gate 主角身份求值 —— 那个枚举没有对外语义了。干净切:它只在
27
+ // 未发布的 0.40.0 窗里存在过,零已发布消费者([clean-cut-no-legacy-compat])。
22
28
  export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
23
29
  /**
24
30
  * 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
@@ -70,6 +76,8 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
70
76
  park = {
71
77
  gate: action.gate,
72
78
  ...(action.pendingDone !== undefined ? { pendingDone: action.pendingDone } : {}),
79
+ // [4914] 请托①到货件:park 的待批 call 身份(server ≥7.41.0)——缺席是常态,见 GatePark 头注。
80
+ ...(action.gatedCallId !== undefined ? { gatedCallId: action.gatedCallId } : {}),
73
81
  };
74
82
  break;
75
83
  }
@@ -24,8 +24,16 @@ import type { GateLedger } from './gateLedger.js';
24
24
  * 🔴 pure 门 B7 段:包内冻结字面量(无条件)+ 壳树全树扫描「每一处声明都逐字节相同」(壳树缺席=跳过)。
25
25
  */
26
26
  export declare const HITL_REJECT_MESSAGE = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
27
- /** core 对被 gate/连坐 abort 的 call 铸的 tool_end 载体(逐字;desktop session-host 真引擎实测
28
- * 同款)——HOLD 谓词锚它做**精确等值**,普通工具错的输出是各自错误文案,永不进 HOLD。 */
27
+ /**
28
+ * core 对**被 gate park 的那个 call**(gate 主角)铸的 tool_end 载体(逐字;desktop session-host
29
+ * 真引擎实测同款)。HOLD 谓词锚它做**精确等值** —— 普通工具错的输出是各自的错误文案,永不进 HOLD。
30
+ *
31
+ * 🔴 **勘误(件⑤,2026-08-22,[4970] 真机取证)**:此处原话是「core 对被 gate/**连坐** abort 的
32
+ * call **恒**铸这一串」——对连坐的旁观者**不成立**。core 5.52.0 dist 直证两个短路点两种文案:
33
+ * gate 主角走 abort 早退 = 本串;**执行前被连坐**的旁观者 = `"operation aborted before execution"`
34
+ * (小写 + 后缀)。所以本串只是判据的**兜底臂**(旧 core 唯一的信号),主锚是机读码
35
+ * `gate.parked`——见 {@link isEngineAbortToolEnd}。
36
+ */
29
37
  export declare const ENGINE_ABORT_TOOL_RESULT = "Operation aborted";
30
38
  /**
31
39
  * tool_end.output 的文本归一(P0 案B,2026-08-03,server 6.0.0 wire 实测):sync leg 的毒化帧
@@ -63,6 +71,16 @@ export interface GatePark {
63
71
  gate: GateKind;
64
72
  /** sync leg 的 `done{suspended}` 终帧 —— fail-soft 回退时原样吐回去(durable leg 的 park 无此帧)。 */
65
73
  pendingDone?: AgentEvent | undefined;
74
+ /**
75
+ * 这张 park 的**待批 call 身份**(server ≥7.41.0,[4913]/[4914] 请托① 到货件)。
76
+ * 供给见 {@link parkGatedCallId};消费者今天只有一个:`parkResolver` 决断成功臂的
77
+ * `dropHeldForDecidedPark` —— 它此前只能拿 `findPendingForTask` 那个**卡面渲染宽口**当身份,
78
+ * 同族多行排队时会摘错 call 的扣留帧([4933] 立案的存量)。
79
+ * 🔴 缺席是**常态**(旧引擎 / tool-less park / 键畸形),消费方必须自带无身份也能走的臂。
80
+ * 🔴 刻意**不**喂给 `#324` 的连坐判别子:那条判据的换锚是独立一件事(S3/S33 的「已知局限锁」
81
+ * 断言到时会翻红 = 点亮信号),两件事混在一起改就分不清谁引入了假阳。
82
+ */
83
+ gatedCallId?: string | undefined;
66
84
  }
67
85
  /** 帧路由的产物:驱动只认这四种,自己不再判帧型。 */
68
86
  export type FrameAction = {
@@ -74,6 +92,7 @@ export type FrameAction = {
74
92
  kind: 'park';
75
93
  gate: GateKind;
76
94
  pendingDone?: AgentEvent | undefined;
95
+ gatedCallId?: string | undefined;
77
96
  } | {
78
97
  kind: 'end';
79
98
  events: readonly AgentEvent[];
@@ -11,8 +11,16 @@ export const HITL_REJECT_MESSAGE = "The user doesn't want to proceed with this t
11
11
  function rejectMessageForRender() {
12
12
  return HITL_REJECT_MESSAGE;
13
13
  }
14
- /** core 对被 gate/连坐 abort 的 call 铸的 tool_end 载体(逐字;desktop session-host 真引擎实测
15
- * 同款)——HOLD 谓词锚它做**精确等值**,普通工具错的输出是各自错误文案,永不进 HOLD。 */
14
+ /**
15
+ * core 对**被 gate park 的那个 call**(gate 主角)铸的 tool_end 载体(逐字;desktop session-host
16
+ * 真引擎实测同款)。HOLD 谓词锚它做**精确等值** —— 普通工具错的输出是各自的错误文案,永不进 HOLD。
17
+ *
18
+ * 🔴 **勘误(件⑤,2026-08-22,[4970] 真机取证)**:此处原话是「core 对被 gate/**连坐** abort 的
19
+ * call **恒**铸这一串」——对连坐的旁观者**不成立**。core 5.52.0 dist 直证两个短路点两种文案:
20
+ * gate 主角走 abort 早退 = 本串;**执行前被连坐**的旁观者 = `"operation aborted before execution"`
21
+ * (小写 + 后缀)。所以本串只是判据的**兜底臂**(旧 core 唯一的信号),主锚是机读码
22
+ * `gate.parked`——见 {@link isEngineAbortToolEnd}。
23
+ */
16
24
  export const ENGINE_ABORT_TOOL_RESULT = 'Operation aborted';
17
25
  /**
18
26
  * tool_end.output 的文本归一(P0 案B,2026-08-03,server 6.0.0 wire 实测):sync leg 的毒化帧
@@ -76,6 +84,62 @@ function isGatedToolName(name) {
76
84
  return false;
77
85
  return toolNameIsFsWrite(name) || toolNameIsShellExec(name);
78
86
  }
87
+ /** 这个 gate 的**主角属 fs 写 / shell 执行族**吗(`gateLedger.noteParkGate` 的准入判据)。
88
+ * 🔴 主语是 **gate 本体**的 toolName(park 的 `checkpointGate`/`gate`),不是某张 tool_end
89
+ * 帧的 —— 拿「本 turn 有哪些 gate-capable 工具」当准入证正是 v1 名字锚那条病的同形残留。 */
90
+ function isFsOrShellToolName(name) {
91
+ return typeof name === 'string' && (toolNameIsFsWrite(name) || toolNameIsShellExec(name));
92
+ }
93
+ /**
94
+ * core 对**因 gate park 而中止**的 call 铸的机读短码(`tool_end.errorCode`,core ≥5.9.0)。
95
+ *
96
+ * 🔴 **契约,不是巧合**(core 官方答复 [4973]):两个 abort 短路点(gate 主角 / 执行前被连坐的
97
+ * 旁观者)**消费同一个分类席**,而 park 提交**先于** abort 发射 —— 铸点读那一席时无竞态,
98
+ * 于是主角与旁观者**恒**同席同码。所以本码可以当**主锚**用,而不是「我们在消费侧观察到它总在」
99
+ * 那种推断。SDK 的 `Event_tool_end.errorCode` 声明处也逐字点名这个码 =「门把这次调用 park 了」;
100
+ * 供给点见 core 5.52.0 dist `prepare-task` 的 `abortResultDetails: () => ({ code: "gate.parked" })`。
101
+ *
102
+ * 🔴 开集里的**具名一员**,不是前缀族:判据只认这一个码。放宽成「有 errorCode 就算」会把
103
+ * `path_not_in_root` 这类普通工具错拖进 HOLD —— 那正是 [2084]①-b 收窄掉的病(真错迟到上屏)。
104
+ * 常驻反证:hitl 门 F11-d(表外码的普通工具错必须**零滞后**上屏,判据锚在帧的**位置**上)。
105
+ *
106
+ * 🔴 **在场 ≠ 这次 park 有 call 身份**([4973]③):plan-review / resource-slice 这类不持有 call
107
+ * 的 park 照样带本码,但诚实缺席 call id。所以本判据只锚 errorCode,**不**顺手去锚任何 id —— 锚了
108
+ * 就等于把那几族 park 的连坐帧判成「认不出」。(core 5.55 的 `tool_end.gatedCallId` **顶层键**
109
+ * 到货后可用于「折叠归组」那一面,与本判据是两件事,跟车另批。)
110
+ */
111
+ const ENGINE_GATE_PARKED_ERROR_CODE = 'gate.parked';
112
+ /**
113
+ * 「这一帧是**引擎因 gate 而中止**的毒化帧」——双判据 OR(件⑤,2026-08-22,[4970] G1 端到端 RED)。
114
+ *
115
+ * 🔴 病形(test 线真机取证:真 server 7.41.0 + 真 Postgres,1 Bash 待审批 + 2 并行 WebFetch):
116
+ * 判据原来只有下面第②条(精确串),而 core 的**两个短路点产两种串**——
117
+ * · gate 主角(`agent-loop` abort 早退,dist 两处)= `"Operation aborted"`(精确);
118
+ * · **同批被连坐的旁观者**(执行前短路)= `"operation aborted before execution"`(小写 + 后缀)。
119
+ * ⇒ 判据对旁观者**恰好全不命中**:它们的毒化帧根本没进 HOLD(当场上屏 + 被 generic-close 记
120
+ * `markEnded`,resume 后重放的真结果被静默去重),而 #324 的连坐机读位在真实现网**一次都没出现过**。
121
+ * 本文件旧注写的「core 对被 gate/连坐 abort 的 call **恒**铸这一串」对旁观者不成立,已按实证撤回。
122
+ *
123
+ * 两臂各自的适用域(都**不**放宽成模糊匹配):
124
+ * ① **主锚** `errorCode === "gate.parked"`([4973] 契约:双短路点同席恒铸)—— 机读位,
125
+ * 两种文案都覆盖,core ≥5.9.0 起在场;
126
+ * ② **兜底** 输出等于 {@link ENGINE_ABORT_TOOL_RESULT} —— 覆盖两类:老 core(还没有 errorCode
127
+ * 这个位)**以及** core 现役唯一**不铸**本码的那一条臂 —— **post-hook signal re-check**
128
+ * ([4973]②:工具**已经执行完**、hook 之后签核那一拍才发现 abort,历来铸的 abort 结果
129
+ * 不带 details ⇒ 无 errorCode)。这条臂正是双判据设计成立的理由:少了兜底臂,它整条落空。
130
+ * 🔴 缺席不反推:两臂都不命中 = 这一帧没有「因 gate 中止」的任何证据,照旧走普通收口
131
+ * (老 core + 变体串就落在这一格 —— 诚实缺席,不靠猜文案家族把普通工具错也收进来)。
132
+ *
133
+ * 🔴 **两串族不会被上游合并**([4973],core 显式拒绝改串向,**不是**「core 待修」):两条串各自
134
+ * 承的是真语义 —— `"Operation aborted"` = **执行中被中止**,`"operation aborted before execution"`
135
+ * = **从未执行**。消费端要的是「归不归 gate 管」这个判定,那件事归机读码;文案面归 core 的语义面,
136
+ * 两者不该互相迁就。
137
+ */
138
+ function isEngineAbortToolEnd(ev) {
139
+ if (ev.errorCode === ENGINE_GATE_PARKED_ERROR_CODE)
140
+ return true;
141
+ return toolEndOutputText(ev.output) === ENGINE_ABORT_TOOL_RESULT;
142
+ }
79
143
  /**
80
144
  * 「这一帧 tool_end 归 gate 管」的判据 —— 上面那条**名字腿**加一条 kind 腿的替身([2393] hitl-F1,
81
145
  * 2026-08-02)。
@@ -90,9 +154,10 @@ function isGatedToolName(name) {
90
154
  * 🔴 为什么不能在名字腿里补 kind:`tool_end` 帧上**根本没有 kind 位** —— gate 的 kind 只在
91
155
  * `done{suspended}.checkpointGate` / `suspended.gate` 上,而那两帧**晚于**毒化 tool_end 到达
92
156
  * (park 帧序:tool_start → tool_end(毒化)→ done{suspended})。「park 时按 callId 打标、
93
- * tool_end 时读台账」这条路对**这一帧**来得太晚。帧面上唯一可读的信号是引擎铸的**确切 abort
94
- * 标记** `ENGINE_ABORT_TOOL_RESULT`(见下方 臂头注:core 对被 gate/连坐 abort call 恒铸
95
- * 这一串),所以本判据 = 名字腿 ∪ abort 标记腿。
157
+ * tool_end 时读台账」这条路对**这一帧**来得太晚。帧面上唯一可读的信号是引擎铸的 abort 标记
158
+ * ({@link isEngineAbortToolEnd} 的**双判据**:机读码 `gate.parked` 那条确切串 —— 件⑤ 之前
159
+ * 这里只有后者,而 core 对**旁观者**铸的是另一种文案,见该函数头注),所以本判据 =
160
+ * 名字腿 ∪ abort 标记腿。
96
161
  *
97
162
  * 🔴 集合关系(说清楚,别让下一棒再以为两边逐字相等):本判据是 park 判定的**超集**(它还会盖到
98
163
  * 被同 turn 连坐 abort 的非 gate call)。方向是有意的,且不对称成立 —— 多盖一帧的代价是那一帧
@@ -102,6 +167,26 @@ function isGatedToolName(name) {
102
167
  * ⚠️ 普通工具错(非 abort 标记)照旧**不进** HOLD —— [2084]①-b 的收窄没有被放宽,负控 F1-c 钉着。
103
168
  */
104
169
  function isGatedToolEnd(ev) {
170
+ if (isGatedToolName(ev.toolName))
171
+ return true;
172
+ return isEngineAbortToolEnd(ev); // 件⑤:abort 标记腿单源(机读码 ∪ 确切串)
173
+ }
174
+ /**
175
+ * 上者的**窄口**,只给 ②`deny-stamp-next` 那条**位置性**判据用(件⑤ 的臂间掩蔽面,异源复审四审
176
+ * finding①)。
177
+ *
178
+ * 🔴 为什么这一条不能用超集谓词:deny stamp 是**一次性的全局记号**(同步审批帧腿 deny 且 callId
179
+ * 关联不上时才武装),语义是「**下一张** gated 报错帧就是那张门的收口帧」——**谁先到谁消费**。
180
+ * 件⑤ 把 abort 标记腿放宽到认 `gate.parked` 之后,同批被连坐的**旁观者**(WebFetch 之流)也成了
181
+ * 「gated 报错帧」:它先到就把记号吃掉、被改写成「用户拒绝了」并记 ended(它 resume 后的真结果
182
+ * 还会被去重),而真正被拒的那个 call 拿到的是引擎原文 —— 正是 [2393] hitl-F1 要修的那个用户面,
183
+ * 方向还反了。连坐帧带的是「我被 gate 连累中止」,**不是**「我就是那张被拒的门」,所以它没资格
184
+ * 认领一个**位置性**的身份记号。
185
+ * ⇒ 本判据 = 件⑤ **之前**的那一版(名字腿 ∪ 确切串),逐字保留;新加的连坐码臂**刻意不进来**。
186
+ * (确切串腿理论上也可能是一条正在执行中被连坐的帧 —— 那是本记号自带的既有近似,与件⑤ 无关,
187
+ * 不在本批放宽也不在本批收紧。)
188
+ */
189
+ function isDenyStampCandidate(ev) {
105
190
  if (isGatedToolName(ev.toolName))
106
191
  return true;
107
192
  return toolEndOutputText(ev.output) === ENGINE_ABORT_TOOL_RESULT;
@@ -127,6 +212,19 @@ function isFsApprovalPark(result) {
127
212
  !isAskToolLoose(r.checkpointGate?.toolName) &&
128
213
  isToolApprovalGate(r.checkpointGate));
129
214
  }
215
+ /**
216
+ * park 帧上的**待批 call 身份**(server ≥7.41.0;[4914] 施工帖逐字:数据源 = checkpoint 本体的
217
+ * `pendingAction.toolCallId`,投影到 **`done{suspended}` 的 result 顶层**与 durable **`suspended`
218
+ * 事件**的 `gate` **旁**,两处同键同义)。两个 park 腿各读各的载体,判据是同一只。
219
+ *
220
+ * 🔴 UNTRUSTED wire 值:只认**非空串**,其余(缺席 / 非串 / 空串)一律诚实缺席 —— tool-less park
221
+ * (resource_limit / plan_review / task_done)按契约本来就没有这个键,而一个坏值被当成身份用
222
+ * 会去摘一条不存在的扣留帧(等于什么都没摘,但把「判不出」的保守臂骗过去了)。
223
+ */
224
+ function parkGatedCallId(src) {
225
+ const v = src?.toolCallId;
226
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
227
+ }
130
228
  /** 台账里那份 gated `tool_start` 当拍的 args 快照(UNTRUSTED,只搬运不解释)。 */
131
229
  function argsOfGatedStart(led, callId) {
132
230
  if (callId === undefined)
@@ -191,7 +289,9 @@ const TOOL_END_ARMS = [
191
289
  // 收口帧名字腿认不出,不盖它 = 用户按了 No 却看到引擎原文。
192
290
  id: 'deny-stamp-next',
193
291
  run(ev, callId, led) {
194
- if (ev.isError !== true || !isGatedToolEnd(ev) || led.isDenied(callId))
292
+ // 🔴 窄口(件⑤/四审 finding①):这条记号是**一次性 + 位置性**的,超集谓词会让连坐旁观者
293
+ // 抢走它 —— 理由见 `isDenyStampCandidate` 头注。
294
+ if (ev.isError !== true || !isDenyStampCandidate(ev) || led.isDenied(callId))
195
295
  return undefined;
196
296
  if (!led.takeDenyStamp())
197
297
  return undefined;
@@ -223,11 +323,19 @@ const TOOL_END_ARMS = [
223
323
  run(ev, callId, led) {
224
324
  if (ev.isError !== true || !isGatedToolEnd(ev))
225
325
  return undefined;
226
- // 6.0.0 wire:output 可为 content-block 数组形(见 toolEndOutputText 头注)—— 归一后仍做精确等值。
227
- if (toolEndOutputText(ev.output) !== ENGINE_ABORT_TOOL_RESULT)
326
+ // 件⑤(2026-08-22,[4970] 真机 RED):判据 = `isEngineAbortToolEnd` 的**双判据**(机读码
327
+ // `gate.parked` ∪ 那条确切串)。此前只有后者,而 core 对**被连坐的旁观者**铸的是
328
+ // `"operation aborted before execution"` —— 于是连坐帧恰好全不进 HOLD(当场上屏 + 被 ⑤ 臂
329
+ // 记 ended 吃掉重放的真结果),#324 的机读位在现网一次都没出现过。适用域与不放宽的理由
330
+ // 见该函数头注;6.0.0 wire 的 content-block 数组形由 `toolEndOutputText` 归一后仍做精确等值。
331
+ if (!isEngineAbortToolEnd(ev))
228
332
  return undefined;
229
- led.hold(callId, ev); // park 毒化帧,先 HOLD(gate 定性前不渲 Error 卡)
230
- return { kind: 'skip' };
333
+ // #324 换锚(2026-08-22):这一拍**不判出身**。durable park 腿的帧序是
334
+ // `tool_start → tool_end(毒化) → done{suspended}` —— gate 身份晚于本帧到达,此刻任何判词
335
+ // 都是猜的(名字腿猜法被双向证伪:一等 kind gate 的主角判成连坐、同族兄弟两个都判成主角)。
336
+ // 出身改在 `flushHeld` 当拍按**已登记的主角身份**求值,判据供给见 `gateLedger.noteGateSubject`。
337
+ led.hold(callId, ev);
338
+ return { kind: 'skip' }; // park 毒化帧,先 HOLD(gate 定性前不渲 Error 卡)
231
339
  },
232
340
  },
233
341
  {
@@ -266,6 +374,12 @@ async function routeToolApprovalFrame(ev, ctx) {
266
374
  const { led, deps } = ctx;
267
375
  if (ev.type === 'tool_approval_complete')
268
376
  return { kind: 'skip' }; // 收口帧:卡已由决断路径撤下,吞掉
377
+ // 🔴 #324:本臂**刻意不给连坐判别子供证**(异源对抗复审四轮的收口,2026-08-22)。
378
+ // 帧只证明「本 turn 出现过一张 gate」,证明不了「**这一批** abort 帧属于它」—— 那张 gate
379
+ // 可能早已正常收口(零毒化帧 ⇒ 零扣留表 ⇒ 一次 flush 都不跑),证却会活到下一批派错判词。
380
+ // 三条实测假阳(旧 server 双主角 / 零 HOLD 审批证跨批 / 历史身份跨批)都出自帧腿取证。
381
+ // 判别子只认 park(归属可结构性证明,见 `gateLedger.noteParkGate` 头注);同步帧腿一律
382
+ // 不标 —— 诚实缺席,登记在 §3z 已知局限。
269
383
  if (!deps.respondToolApproval) {
270
384
  hostLog('debug', `liveHitlAskWire: tool_approval frame ${ev.approvalId} but no respond wire — engine self-settles (TTL deny)`);
271
385
  return { kind: 'skip' };
@@ -345,12 +459,21 @@ function routeToolEnd(ev, callId, led) {
345
459
  function routeSuspended(ev, ctx) {
346
460
  // durable leg 的 park arm(续流中模型再次提问/再次撞写权限)。gate 只带 kind/toolName,
347
461
  // payload 走 approvals。
348
- if (ev.gate && isAskToolLoose(ev.gate.toolName) && ctx.taskId.current)
349
- return { kind: 'park', gate: 'ask' };
462
+ // 🔴 两条 park 臂都要登记(判别子的归属证据只认 park)—— ask park 自己用不到兜底腿,
463
+ // 但它必须把**上一张** gate 的准入证清掉,否则旧证会让兜底把一个 Bash 连坐帧反认成主角、
464
+ // 再把真正的 ask 主角 stamp 掉(对抗复审实测 `ask-main=STAMP`)。
465
+ // [4914]:durable `suspended` 事件的待批 call 身份就挂在 `gate` **旁**(不是 gate 里)。
466
+ const gatedCallId = parkGatedCallId(ev);
467
+ if (ev.gate && isAskToolLoose(ev.gate.toolName) && ctx.taskId.current) {
468
+ ctx.led.noteParkGate({ fsOrShellFamily: false });
469
+ return { kind: 'park', gate: 'ask', ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
470
+ }
350
471
  if (ev.gate &&
351
472
  isToolApprovalGate(ev.gate) &&
352
473
  ctx.taskId.current) {
353
- return { kind: 'park', gate: 'fs' };
474
+ // 准入证:park gate 本体属 fs/shell 族(kind-only gate 的 toolName 在族外 ⇒ 不发证)。
475
+ ctx.led.noteParkGate({ fsOrShellFamily: isFsOrShellToolName(ev.gate.toolName) });
476
+ return { kind: 'park', gate: 'fs', ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
354
477
  }
355
478
  // 其余 gate:透传。⚠️ ADAPTER-F8 注纠(2026-08-02):原文写「eventToSdkMessage 出 null」——
356
479
  // 那个返回形已随 REF-CC-058 退役,今天它出的是 `EventProjection` 三态,永不是 null;本臂的
@@ -359,13 +482,19 @@ function routeSuspended(ev, ctx) {
359
482
  }
360
483
  function routeDone(ev, ctx) {
361
484
  const { led } = ctx;
485
+ // [4914]:sync 腿的待批 call 身份在 `done{suspended}` 的 **result 顶层**(与 checkpointId 同位)。
486
+ const gatedCallId = parkGatedCallId(ev.result);
487
+ // 两条 park 臂都登记(理由同 routeSuspended)。
362
488
  if (isAskGatePark(ev.result)) {
363
489
  ctx.taskId.current = ctx.taskId.current ?? ev.result.taskId;
364
- return { kind: 'park', gate: 'ask', pendingDone: ev };
490
+ led.noteParkGate({ fsOrShellFamily: false });
491
+ return { kind: 'park', gate: 'ask', pendingDone: ev, ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
365
492
  }
366
493
  if (isFsApprovalPark(ev.result)) {
367
494
  ctx.taskId.current = ctx.taskId.current ?? ev.result.taskId;
368
- return { kind: 'park', gate: 'fs', pendingDone: ev };
495
+ // 准入证:主语是 checkpointGate **本体**的 toolName,不是任何 tool_end 帧的。
496
+ led.noteParkGate({ fsOrShellFamily: isFsOrShellToolName(ev.result.checkpointGate?.toolName) });
497
+ return { kind: 'park', gate: 'fs', pendingDone: ev, ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
369
498
  }
370
499
  const events = [...led.flushHeld()]; // 真错(非 gate)的 Ask tool_end 此刻诚实渲染
371
500
  // durable 终帧的 result.taskId === sessionId(引擎 durable quirk)会让 captureSessionId 跳过
@@ -19,6 +19,17 @@
19
19
  * 台账要能自描述,值类型必须与台账同层或更低。`askGateWire.ts` 原样 re-export 它,对外类型面不变。
20
20
  */
21
21
  import type { AgentEvent } from '@sema-agent/sdk';
22
+ /**
23
+ * 连坐 abort 帧的 additive 超集机读位(#324 / [4907] 裁定;登记见 `docs/type-superset.json`)。
24
+ *
25
+ * `flushHeld` 对**已知不是 gate 主角**的帧 stamp `true`,别的帧一个字节不改。消费方(壳 / web /
26
+ * desktop)据此把连坐簇按 repeated-error 族软化折叠,**不猜文案**。
27
+ *
28
+ * 🔴 刻意**不动** `isError` 与 `output`:core 5.51 的 `[DEFERRED]` / `gate.batch_halted` 词汇
29
+ * ([4826]③)尚未到货,那一面的定形归 core;本键只是机读位,到货后与官方形对表,
30
+ * additive 键保留兼容(裁定原文)。
31
+ */
32
+ export declare const SEMA_COLLATERAL_ABORT_KEY = "_sema_collateral_abort";
22
33
  /** CC AskUserQuestion outputSchema 形状(答过的问题卡):toolResult 的 `ask-user-question` arm
23
34
  * 消费它渲真实答案卡(否则「诚实缺席」路径会把这张卡渲成结果不可用)。 */
24
35
  export interface AskAnsweredOutput {
@@ -52,12 +63,71 @@ export interface GateLedger {
52
63
  gatedStartArgs(): ReadonlyMap<string, unknown>;
53
64
  isEnded(callId: string): boolean;
54
65
  markEnded(callId: string): void;
55
- /** park 毒化的 gated tool_end(isError:true "Operation aborted")——HOLD 到 gate 定性
56
- * (AskUserQuestion + [816] 放宽后的 fs 写工具 + [2150] Bash)。 */
66
+ /**
67
+ * park 毒化的 gated tool_end(isError:true "Operation aborted")——HOLD gate 定性
68
+ * (AskUserQuestion + [816] 放宽后的 fs 写工具 + [2150] Bash)。
69
+ *
70
+ * 🔴 **入表当拍不判出身**(#324 换锚,2026-08-22):durable park 腿的帧序是
71
+ * `tool_start → tool_end(毒化) → done{suspended}` —— gate 身份**晚于**毒化帧到达,入表那一拍
72
+ * 包层根本还不知道谁是主角。出身改在 {@link flushHeld} 当拍求值(五个 flush 出口全部晚于 park
73
+ * 被看见),判据见 {@link noteParkGate}。
74
+ */
57
75
  hold(callId: string, ev: AgentEvent): void;
58
76
  dropHeld(callId: string): void;
77
+ /**
78
+ * 件④([4933] 立案的存量根修,0.41.0):**决断成功**之后摘掉这张 park 主角的扣留帧。
79
+ *
80
+ * 🔴 与裸 {@link dropHeld} 的差别只有一个:**入参可信度**。内环那四个调用点摘的是**帧自己的**
81
+ * `toolCallId`(帧在手,身份无疑);而决断成功那一下,外环手上只有 `findPendingForTask` 给的
82
+ * callId —— 那是给**卡面渲染**用的宽口(同 taskId + 族谓词命中的**第一行**,谓词不命中还退
83
+ * 「任意行」),不是身份。同族多行排队时它可能指向旁观者,于是被摘掉的是**另一条** call 的
84
+ * 扣留帧,而那一帧从此不再吐:{@link flushHeld} 的「一帧不丢」在这一格上不成立
85
+ * (用户面 = dim 圆点永远等不到结果行)。
86
+ *
87
+ * 🔴 摘的**永远是真被决断的那一行**(`candidateCallId`);`trustedCallId` 的角色是**确证**,
88
+ * 不是第二个摘取目标 —— 两边指向不同 call 时摘谁都是 split-brain(决断绑一行、摘另一行的帧)。
89
+ *
90
+ * 两条腿:
91
+ * ① `trustedCallId`(server ≥7.41.0 的 park 身份:`done{suspended}.result.toolCallId` /
92
+ * durable `suspended` 事件 `gate` 旁同键,[4914])与 `candidateCallId` **相等** ⇒ 身份确证,
93
+ * 点名摘。取件腿今天已优先按同一个身份选行,所以正常路径上两者恒等;
94
+ * ② 身份缺席(旧引擎 / tool-less park / 键畸形)或两者不等(身份在场但队列没有那一行 ⇒ 决断
95
+ * 落在别的行上)⇒ 只在 `candidateCallId` 于扣留表里**同族唯一**
96
+ * 时才摘;同族多条 = 判不出谁是主角 ⇒ **一条都不摘**,帧照走 flush 的连坐判别
97
+ * (与 {@link flushHeld}「判不出整表不标」同轴的 fail-safe:宁可多渲一行 abort,
98
+ * 也不许让一帧永久消失)。
99
+ *
100
+ * 🔴 两条腿都要走「摘完看表空不空」那一步(见 {@link dropHeld} 尾注的批级退休不变量),
101
+ * 包括**什么都没摘**那条路 —— 零 HOLD 批正是每次 delete 都落空的形状。
102
+ */
103
+ dropHeldForDecidedPark(candidateCallId: string | undefined, trustedCallId?: string): void;
59
104
  heldCount(): number;
60
- /** drain:逐帧吐出并**同批记 ended**(fail-soft 回退路径与终帧路径共用的唯一出口)。 */
105
+ /**
106
+ * drain:逐帧吐出并**同批记 ended**(fail-soft 回退路径与终帧路径共用的唯一出口)。
107
+ *
108
+ * 🔴 **一帧不丢**(frameRouter:185 的不变量,#324 车实测确证它是刻意的):HOLD 过的帧在这里
109
+ * 全部吐出去 —— 丢帧的后果是永久假排队(dim 圆点永远等不到结果行),比多渲一行红字更坏。
110
+ * 本出口做的唯一一件事是给**已知不是 gate 主角**的帧 stamp {@link SEMA_COLLATERAL_ABORT_KEY}
111
+ * 机读位(additive 超集键;`isError` / `output` / 帧序一律不动),让消费端能把连坐簇与主角帧
112
+ * 分开呈现。
113
+ *
114
+ * 🔴 判词方向(fail-safe):**判不出本批主角 ⇒ 一个都不 stamp**(诚实缺席,与本键出现之前
115
+ * 逐字节相同);判得出 ⇒ 主角走原路零改动、其余一律 stamp。绝不按到达顺序编判词。
116
+ *
117
+ * 主角怎么判(实现见 `resolveGateSubjects`,判据全部**批级**、一批一套):
118
+ * ① **硬门:本批恰好一张 park**({@link noteParkGate})。零张 = 归属无从证明(同步帧腿 /
119
+ * 纯取消批都落这里);两张以上 = 这批扣留帧混了两次 gate 的 abort,谁属谁无解。一律不标。
120
+ * ② **主角 = 本批(扣留帧 ∪ 本批已出批的帧)里唯一属该 park 族的 call**。并上「已出批的帧」
121
+ * 是因为决断成功那一下会把主角的毒化帧 `dropHeld` 掉,只看扣留帧的话主角就「不在这一批」,
122
+ * 整批判据当场失效。族外 park(kind-only / ask)不发准入证 ⇒ 整批不标。
123
+ * ③ 前置:**每一条扣留帧的 `tool_start` 本连接都得见过** —— 族成员判据取自 tool_start 当拍
124
+ * 记的集合,没见过 start 的 call 在「唯一」这件事上是隐形的;续流只消费 `runs.events` 的
125
+ * 一段,主角的 start 完全可能落在这次连接之外,那时表里剩下的唯一族成员是**旁观者**。
126
+ *
127
+ * 🔴 **pending 队列一概不参与判别**:`durable checkpointGate` 不带 callId,`findPendingForTask`
128
+ * 取的是「同 taskId + 谓词命中的第一行」(不命中时退「任意行」)—— 那是给**卡面渲染**用的宽口,
129
+ * **不是身份**。同族两行排队时它会给出旁观者,拿它当主角就把真主角挤成「非主角」而 stamp。
130
+ */
61
131
  flushHeld(): Generator<AgentEvent>;
62
132
  /** 三选卡 deny 过的 call:重放 tool_end 的 render 面 stamp REJECT_MESSAGE(vendored
63
133
  * renderToolUseRejectedMessage 渲 `User rejected <op> to <path>`,CC 逐字)。 */
@@ -81,6 +151,28 @@ export interface GateLedger {
81
151
  * 时前进,天然把「这次 park 到底是哪个 call」的身份带过 decide→重放两轮。 */
82
152
  noteFsOrShellGate(callId: string): void;
83
153
  lastFsOrShellGatedCallId(): string | undefined;
154
+ /**
155
+ * 登记「**这一批**的 park」——判别子唯一认的归属证据。写者 = 四条 park 臂:`routeDone` 的
156
+ * ask/fs 两条、`routeSuspended` 的 ask/fs 两条。`fsOrShellFamily` = 这张 park 的 gate **本体**
157
+ * `toolName` 属 fs 写 / shell 执行族吗(唯一性兜底腿的准入证)。
158
+ *
159
+ * 🔴 **为什么只认 park、不认 `tool_approval` 帧**(异源对抗复审四轮的收口,2026-08-22):
160
+ * 判别子要回答的是「**这一批** abort 帧属于哪张 gate」,而 wire 上**没有**任何位做这个关联 ——
161
+ * 包层能算的都是推断。park 是唯一**归属可结构性证明**的证据:`flushHeld` 的出口就长在那张 park
162
+ * 自己的决断/回退路径上(`parkResolver` fail-soft / decide 成功后的续流放行 / 终帧),
163
+ * 「这一批是这张 park 的 abort 批」是结构事实,不是猜的。
164
+ * 审批帧腿不具备这个性质:帧只证明「本 turn 出现过一张 gate」,那张 gate 可能**早已正常收口**
165
+ * (零毒化帧 ⇒ 零扣留表 ⇒ 一次 flush 都不跑),它的证却会活到下一批去派判词。四轮实测里
166
+ * 三条「假阳打回主角」都出自帧腿证据(旧 server 双主角 / 零 HOLD 审批证跨批 / 历史身份跨批),
167
+ * 故本版**刻意**不从帧腿取证 —— 代价是同步帧腿整条不标(已知局限,见 §3z)。
168
+ *
169
+ * 🔴 **重放同一张 park 不算新的一张**:#110 成文「durable re-attach 必然把已决断的 park 再送
170
+ * 一遍」。判据 = 自上一次 park 登记以来**有没有新的帧进过扣留表**;没有就是同一张 park 被重放,
171
+ * 计数不动(否则本批会被自己的重放判成「多 park」而整批失效,真连坐帧拿不到位)。
172
+ */
173
+ noteParkGate(evidence: {
174
+ fsOrShellFamily: boolean;
175
+ }): void;
84
176
  /** 续流重放的解答 tool_end 无 output,不补就会渲成结果不可用;这里记下真实答案供该帧 stamp
85
177
  * `structured` 让卡片渲真实选择。 */
86
178
  rememberAnswer(callId: string, answered: AskAnsweredOutput): void;