@sema-agent/client-core 0.36.0 → 0.37.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.
@@ -12,8 +12,18 @@
12
12
  * · kick 幂等(in-flight 去重),绝不 throw;
13
13
  * · 判定固化;探测失败不缓存(引擎未起/瞬断 → 下次 kick 再判);
14
14
  * · 同步读口 boolean(未判 = false = 调用方回落,version-safe)。
15
- * 与单键探测不同处:缓存整个 caps 对象(一次探测服务后续所有键),false 不设 TTL——本缓存随
16
- * 每次 createLiveConversationClient 构造重 kick(引擎温切重启后新构造自然重探)。
15
+ * 与单键探测不同处:缓存整个 caps 对象(一次探测服务后续所有键),false 不设 TTL
16
+ *
17
+ * 🔴 **失效口的由来**(#307 双扫 S25 勘误,2026-08-19)。本段此前自述「随每次
18
+ * `createLiveConversationClient` 构造重 kick(引擎温切重启后新构造自然重探)」——**那句话不成立**:
19
+ * {@link kickEngineCapsProbe} 首行就是 `capsByBase.has(baseUrl) ⇒ return`,而引擎温切
20
+ * (respawn / restartEngine)重启后 baseUrl 常与重启前**一模一样**,于是「新构造」被这条幂等闸
21
+ * 原样挡住,缓存里留的永远是**旧引擎**那一版的 caps。后果不是报错,是安静地按旧能力位走:
22
+ * 新引擎新增的车道被判成「没有」(藏功能),旧引擎有而新引擎撤掉的车道被判成「有」(走死路)。
23
+ * 此前除测试钩 {@link __resetEngineCapsCacheForTests} 外**没有任何生产失效路径**。
24
+ * 修 = 显式失效口 {@link invalidateEngineCaps},由知道「引擎换人了」的那一层(壳的 respawn /
25
+ * restartEngine)在重启后调用 —— 缓存自己无从分辨「同一个 baseUrl 后面还是不是同一个引擎」,
26
+ * 猜(TTL / 每次构造清)只会把一个确定事实换成一个定时器。
17
27
  */
18
28
  /** 构造期 kick(async 幂等);probe = client.capabilities 薄闭包。 */
19
29
  export declare function kickEngineCapsProbe(baseUrl: string, probe: () => Promise<unknown>): void;
@@ -35,5 +45,41 @@ export declare function engineCapTrue(baseUrl: string | undefined, key: string):
35
45
  * 引擎能力位缺席时任何「假定它有」的分支都是对用户/模型的谎报。
36
46
  */
37
47
  export declare function engineCapString(baseUrl: string | undefined, key: string): string | undefined;
48
+ /**
49
+ * **生产失效口**(#307 双扫 S25,client-core 0.37.0):把该 base 的探测结果作废,
50
+ * 使**下一次** {@link kickEngineCapsProbe} 真正重探(而不是被幂等闸原样挡回)。
51
+ *
52
+ * 消费方 = **知道「这个 baseUrl 后面换了一个引擎进程」的那一层**,目前唯一一处是壳的引擎温切:
53
+ * cli `respawn` / `restartEngine` 成功后、重新构造 `createLiveConversationClient` **之前**调用。
54
+ * 库这一层看到的只有一个字符串 base,分辨不出对面是不是同一个进程,所以失效必须由上面显式下达 ——
55
+ * 见模块头注:靠 TTL 或「每次构造清」去猜,是把一个确定事实换成一个定时器。
56
+ *
57
+ * 语义与边界:
58
+ * · **推进代际**({@link genByBase}) + 清 `capsByBase`(已判结果) + 清 `inFlight`(在途去重位)。
59
+ * · **不 abort** 在途探测(没有可 abort 的把手,probe 是调用方给的薄闭包),改用代际让它
60
+ * **安静退场**:被顶掉的旧 run 落地后既不写缓存、也不归还任何位。所以本口在**探测在途时
61
+ * 调用是安全的** —— 旧引擎那次响应绝不会覆盖新引擎的能力位(2026-08-19 对抗复审 [high])。
62
+ * · `settleByBase` **不清**:{@link engineCapsSettled} 的语义是「等**当前这一次**探测落地」,
63
+ * 把在途 promise 抽走会让正在 await 的调用方立即拿到 resolve(假「已落地」)。旧 run 的
64
+ * settle 位在被新 run 顶掉后由代际认领保护,旧 run 的 `finally` 不会误删。
65
+ * · 空串 ⇒ no-op。**从没探过也从没失效过的 base ⇒ 真 no-op**(不留代际条目):那种 base 上
66
+ * 既无判定也无在途 run,没有任何陈旧写入可挡,留条目只会让任意串撑大表。
67
+ * · 绝不 throw(与本模块其余口同款:失效口在错误路径上响 = 把一个清理动作变成新的故障源)。
68
+ *
69
+ * 🔴 **推荐用两参形 `invalidateEngineCaps(baseUrl, probe)`**(对抗复审第三轮 [medium] 采纳,
70
+ * 2026-08-19)。单参形与「下一次 kick」之间有一个**真窗**:失效之后、新探测注册之前,如果旧代际
71
+ * 探测正好在这一拍落地,`settleByBase` 里已经没有更新代际的条目 ⇒ {@link engineCapsSettled} 把
72
+ * 等待者放走,而缓存刚被清空 ⇒ 等待者把**当代引擎的能力位读成缺席**。窗口只在调用方于失效与 kick
73
+ * 之间 `await` 了什么时才张开(同步块里 JS 单线程,旧探测的续体根本插不进来),但引擎温切本身就是
74
+ * 异步流程,所以它是可达的。
75
+ * 两参形把「推进代际」与「注册替代探测」放进**同一个同步块** ⇒ 窗口按构造不存在,等待者会被接力
76
+ * 到新探测上(见 {@link engineCapsSettled} 的跨代际接力)。壳的 respawn/restartEngine 应当用它。
77
+ * 单参形保留给「只想丢掉缓存、这一刻没有替代探测」的调用方 —— 那种情形下等待者读到**未判**
78
+ * 是诚实结局(判据永远是缓存位),不是缺陷:硬等一个可能永远不会来的 kick 才是。
79
+ *
80
+ * @param probe 可选的**替代探测**(与新引擎同一拍注册)。给了就等价于「失效 + 立刻 kick」,
81
+ * 且中间没有任何可插入点。
82
+ */
83
+ export declare function invalidateEngineCaps(baseUrl: string | undefined, probe?: () => Promise<unknown>): void;
38
84
  /** 测试钩子。 */
39
85
  export declare function __resetEngineCapsCacheForTests(): void;
@@ -12,21 +12,77 @@
12
12
  * · kick 幂等(in-flight 去重),绝不 throw;
13
13
  * · 判定固化;探测失败不缓存(引擎未起/瞬断 → 下次 kick 再判);
14
14
  * · 同步读口 boolean(未判 = false = 调用方回落,version-safe)。
15
- * 与单键探测不同处:缓存整个 caps 对象(一次探测服务后续所有键),false 不设 TTL——本缓存随
16
- * 每次 createLiveConversationClient 构造重 kick(引擎温切重启后新构造自然重探)。
15
+ * 与单键探测不同处:缓存整个 caps 对象(一次探测服务后续所有键),false 不设 TTL
16
+ *
17
+ * 🔴 **失效口的由来**(#307 双扫 S25 勘误,2026-08-19)。本段此前自述「随每次
18
+ * `createLiveConversationClient` 构造重 kick(引擎温切重启后新构造自然重探)」——**那句话不成立**:
19
+ * {@link kickEngineCapsProbe} 首行就是 `capsByBase.has(baseUrl) ⇒ return`,而引擎温切
20
+ * (respawn / restartEngine)重启后 baseUrl 常与重启前**一模一样**,于是「新构造」被这条幂等闸
21
+ * 原样挡住,缓存里留的永远是**旧引擎**那一版的 caps。后果不是报错,是安静地按旧能力位走:
22
+ * 新引擎新增的车道被判成「没有」(藏功能),旧引擎有而新引擎撤掉的车道被判成「有」(走死路)。
23
+ * 此前除测试钩 {@link __resetEngineCapsCacheForTests} 外**没有任何生产失效路径**。
24
+ * 修 = 显式失效口 {@link invalidateEngineCaps},由知道「引擎换人了」的那一层(壳的 respawn /
25
+ * restartEngine)在重启后调用 —— 缓存自己无从分辨「同一个 baseUrl 后面还是不是同一个引擎」,
26
+ * 猜(TTL / 每次构造清)只会把一个确定事实换成一个定时器。
17
27
  */
18
28
  const capsByBase = new Map();
19
29
  const inFlight = new Set();
20
- /** in-flight 探测的 settle 载体(finally 清;engineCapsSettled 消费)。 */
30
+ /**
31
+ * in-flight 探测的 settle 载体(**带代际**;finally 只删自己那一条,engineCapsSettled 消费)。
32
+ *
33
+ * `superseded` = **代际变更信号**(对抗复审第四轮 [high] 采纳,2026-08-19):
34
+ * {@link invalidateEngineCaps} 推进代际时 resolve 它,把等在**旧代际**上的调用方叫醒去重新求值,
35
+ * 而不是让它们被吊在一个可能永不落地的旧探测上。叫醒**不等于放行** —— 醒来后仍走
36
+ * {@link engineCapsSettled} 的循环:表里换上了新代际就接着等新探测,没换就按诚实缺席返回。
37
+ */
21
38
  const settleByBase = new Map();
39
+ /**
40
+ * per-base **代际计数**(#307 S25 对抗复审 [high] 采纳,2026-08-19)。
41
+ *
42
+ * 为什么必须有:{@link invalidateEngineCaps} 清 `inFlight` 之后,同 base 立刻可以再 kick,于是
43
+ * **两次探测并发跑在同一份无版本共享态上**。没有代际时的两个真后果(都发生在「重启撞上一次慢
44
+ * capabilities 请求」这个恰恰最该正确的时刻):
45
+ * · 旧引擎那次探测**后**落地 ⇒ 它把 `capsByBase` 覆盖回**旧引擎**的能力位,失效等于没做;
46
+ * · 旧探测的 `finally` 删掉的是**新 run** 的 `inFlight`/`settleByBase` 条目 ⇒
47
+ * {@link engineCapsSettled} 提前 resolve(假「已落地」)+ 幂等闸被打开(重复探测)。
48
+ * 代际 = 每个 run 出生时抓一份号,写缓存/清位之前核对「我这一号还是不是当代」——不是当代的 run
49
+ * 只许**安静退场**,绝不许写、也绝不许清别人的位。号只增不减,`invalidate` 是唯一的推进者。
50
+ */
51
+ const genByBase = new Map();
52
+ /** 该 base 的当代号(从没失效过 = 0)。 */
53
+ function capsGeneration(baseUrl) {
54
+ return genByBase.get(baseUrl) ?? 0;
55
+ }
22
56
  /** 构造期 kick(async 幂等);probe = client.capabilities 薄闭包。 */
23
57
  export function kickEngineCapsProbe(baseUrl, probe) {
24
58
  if (!baseUrl || capsByBase.has(baseUrl) || inFlight.has(baseUrl))
25
59
  return;
60
+ // 出生代际:落地时拿它与当代号核对(见 genByBase 头注)。
61
+ const gen = capsGeneration(baseUrl);
26
62
  inFlight.add(baseUrl);
27
- const run = (async () => {
63
+ let wake = () => { };
64
+ const superseded = new Promise((resolve) => {
65
+ wake = () => resolve();
66
+ });
67
+ // settle 位用**手工兑现**的 promise,而不是 IIFE 的返回值 —— 这样它能在**调用方代码跑起来之前**
68
+ // 就落位。
69
+ // 🔴 顺序是判据的一部分(对抗复审第五轮 [medium] 采纳,2026-08-19):`probe` 是**调用方给的
70
+ // 闭包**,它同步段里完全可以回头调 `invalidateEngineCaps(baseUrl, 替代探测)`(两参原子形正是
71
+ // 为温切设计的,而温切逻辑就住在这种回调里)。旧写法先跑 IIFE(= 先跑调用方代码)、后
72
+ // `settleByBase.set` ⇒ 嵌套 kick 装好的**新代际**条目当场被外层这一行覆盖回旧条目,随后旧 run 的
73
+ // finally 又把它删掉 ⇒ `engineCapsSettled` 在替代探测仍在途时返回,能力读口报 false/undefined。
74
+ // 先落位再调用方代码 = 重入时外层没有任何机会回头覆盖别人。
75
+ let markSettled = () => { };
76
+ const settled = new Promise((resolve) => {
77
+ markSettled = () => resolve();
78
+ });
79
+ settleByBase.set(baseUrl, { gen, promise: settled, superseded, wake });
80
+ void (async () => {
28
81
  try {
29
82
  const caps = await probe();
83
+ // 🔴 被 invalidate 顶掉的旧 run 绝不写缓存 —— 它拿到的是**上一个引擎**的能力位。
84
+ if (gen !== capsGeneration(baseUrl))
85
+ return;
30
86
  if (caps && typeof caps === 'object') {
31
87
  capsByBase.set(baseUrl, caps);
32
88
  }
@@ -35,11 +91,17 @@ export function kickEngineCapsProbe(baseUrl, probe) {
35
91
  // 失败不缓存:下次 kick 再判
36
92
  }
37
93
  finally {
38
- inFlight.delete(baseUrl);
39
- settleByBase.delete(baseUrl);
94
+ // 幂等闸位只由**当代** run 归还;旧 run 归还会把新 run 的在途位抹掉(重复探测)
95
+ if (gen === capsGeneration(baseUrl)) {
96
+ inFlight.delete(baseUrl);
97
+ }
98
+ // settle 位按代际认领:表里那条不是我这一代的(= 已被新 run 顶掉)就别动它,
99
+ // 否则 engineCapsSettled 会对着仍在跑的新探测提前 resolve。
100
+ if (settleByBase.get(baseUrl)?.gen === gen)
101
+ settleByBase.delete(baseUrl);
102
+ markSettled();
40
103
  }
41
104
  })();
42
- settleByBase.set(baseUrl, run);
43
105
  }
44
106
  /**
45
107
  * 0.26.0(#225 件1 配套,消费请托自领):等**当前这一次**探测落地(成功或失败都算落地)。
@@ -47,11 +109,39 @@ export function kickEngineCapsProbe(baseUrl, probe) {
47
109
  * 本函数只解决「kick 完只能盲猜轮询窗」的时序问题(慢响应被猜短的窗渲成「没有这条车道」)。
48
110
  * 绝不 reject(探测失败=位维持未判,调用方按缺席降级)。
49
111
  */
50
- export function engineCapsSettled(baseUrl) {
112
+ export async function engineCapsSettled(baseUrl) {
51
113
  if (!baseUrl)
52
- return Promise.resolve();
53
- const p = settleByBase.get(baseUrl);
54
- return p ? p.then(() => undefined, () => undefined) : Promise.resolve();
114
+ return;
115
+ // 🔴 **跨代际接力**(#307 S25 对抗复审第二轮 [medium] 采纳,2026-08-19)。此前本函数抓住**一条**
116
+ // promise 就不再回头看:调用方 W 抓的是旧代际探测 P0,随后 {@link invalidateEngineCaps} 推进代际
117
+ // 并起了 P1 —— P0 先落地就把 W 放走,而 P0 已被代际闸挡住不许写缓存 ⇒ W 当场读到空缓存,把
118
+ // **新引擎的能力位当成缺席**。这正是本批要消灭的那类假缺席,只是搬到了 settle 面上。
119
+ // 修 = 每等完一条就**重新求值**表里的当代条目([loop-termination-requires-reevaluation]):
120
+ // 表里换上了更新代际的那条就改等它。代际严格递增 ⇒ 循环必然终止;每轮都真 await 一条 promise ⇒
121
+ // 不是忙等。
122
+ //
123
+ // 🔴 **等的是「这条探测落地」或「代际变了」两者先到者**(第四轮 [high] 采纳)。只等 promise 是
124
+ // 不够的:引擎重启恰恰是最容易把旧探测**吊死**的时刻(旧进程没了,那个 fetch 可能永远不返回),
125
+ // 于是「P0 挂死 ⇒ 两参原子失效已经起了 P1 且 P1 已经把新能力位写进缓存 ⇒ 而 invalidate 之前取件的
126
+ // 等待者仍永久卡在 P0 上」——缓存里明明是对的,等待者却永远拿不到。代际信号把它叫醒去重新求值。
127
+ // ⚠️ 叫醒 **不是** 放行:醒来后照走本循环 —— 表里换上了新代际(两参原子形保证同拍就在)就接着等
128
+ // 新探测;没换(单参形、调用方这一刻没有替代探测)才返回,那时判据回到缓存位本身 = 未判 =
129
+ // 诚实缺席。所以「被叫醒」既不会漏掉新引擎的位,也不会硬等一个可能永远不来的 kick。
130
+ for (;;) {
131
+ const entry = settleByBase.get(baseUrl);
132
+ if (entry === undefined)
133
+ return;
134
+ const awaitedGen = entry.gen;
135
+ await Promise.race([
136
+ entry.promise.then(() => undefined, () => undefined),
137
+ entry.superseded,
138
+ ]);
139
+ const next = settleByBase.get(baseUrl);
140
+ // 表里没有**更新代际**的一条 ⇒ 没得再等了(当代要么已落地、要么根本没起新探测:
141
+ // 那种情况下判据回到缓存位本身 = 未判 = 诚实缺席,由调用方按缺席降级)。
142
+ if (next === undefined || next.gen <= awaitedGen)
143
+ return;
144
+ }
55
145
  }
56
146
  /** 同步读口:该 base 的 caps 里 key 是否字面 true(未判/缺键 = false)。 */
57
147
  export function engineCapTrue(baseUrl, key) {
@@ -73,9 +163,65 @@ export function engineCapString(baseUrl, key) {
73
163
  const v = capsByBase.get(baseUrl)?.[key];
74
164
  return typeof v === 'string' && v.length > 0 ? v : undefined;
75
165
  }
166
+ /**
167
+ * **生产失效口**(#307 双扫 S25,client-core 0.37.0):把该 base 的探测结果作废,
168
+ * 使**下一次** {@link kickEngineCapsProbe} 真正重探(而不是被幂等闸原样挡回)。
169
+ *
170
+ * 消费方 = **知道「这个 baseUrl 后面换了一个引擎进程」的那一层**,目前唯一一处是壳的引擎温切:
171
+ * cli `respawn` / `restartEngine` 成功后、重新构造 `createLiveConversationClient` **之前**调用。
172
+ * 库这一层看到的只有一个字符串 base,分辨不出对面是不是同一个进程,所以失效必须由上面显式下达 ——
173
+ * 见模块头注:靠 TTL 或「每次构造清」去猜,是把一个确定事实换成一个定时器。
174
+ *
175
+ * 语义与边界:
176
+ * · **推进代际**({@link genByBase}) + 清 `capsByBase`(已判结果) + 清 `inFlight`(在途去重位)。
177
+ * · **不 abort** 在途探测(没有可 abort 的把手,probe 是调用方给的薄闭包),改用代际让它
178
+ * **安静退场**:被顶掉的旧 run 落地后既不写缓存、也不归还任何位。所以本口在**探测在途时
179
+ * 调用是安全的** —— 旧引擎那次响应绝不会覆盖新引擎的能力位(2026-08-19 对抗复审 [high])。
180
+ * · `settleByBase` **不清**:{@link engineCapsSettled} 的语义是「等**当前这一次**探测落地」,
181
+ * 把在途 promise 抽走会让正在 await 的调用方立即拿到 resolve(假「已落地」)。旧 run 的
182
+ * settle 位在被新 run 顶掉后由代际认领保护,旧 run 的 `finally` 不会误删。
183
+ * · 空串 ⇒ no-op。**从没探过也从没失效过的 base ⇒ 真 no-op**(不留代际条目):那种 base 上
184
+ * 既无判定也无在途 run,没有任何陈旧写入可挡,留条目只会让任意串撑大表。
185
+ * · 绝不 throw(与本模块其余口同款:失效口在错误路径上响 = 把一个清理动作变成新的故障源)。
186
+ *
187
+ * 🔴 **推荐用两参形 `invalidateEngineCaps(baseUrl, probe)`**(对抗复审第三轮 [medium] 采纳,
188
+ * 2026-08-19)。单参形与「下一次 kick」之间有一个**真窗**:失效之后、新探测注册之前,如果旧代际
189
+ * 探测正好在这一拍落地,`settleByBase` 里已经没有更新代际的条目 ⇒ {@link engineCapsSettled} 把
190
+ * 等待者放走,而缓存刚被清空 ⇒ 等待者把**当代引擎的能力位读成缺席**。窗口只在调用方于失效与 kick
191
+ * 之间 `await` 了什么时才张开(同步块里 JS 单线程,旧探测的续体根本插不进来),但引擎温切本身就是
192
+ * 异步流程,所以它是可达的。
193
+ * 两参形把「推进代际」与「注册替代探测」放进**同一个同步块** ⇒ 窗口按构造不存在,等待者会被接力
194
+ * 到新探测上(见 {@link engineCapsSettled} 的跨代际接力)。壳的 respawn/restartEngine 应当用它。
195
+ * 单参形保留给「只想丢掉缓存、这一刻没有替代探测」的调用方 —— 那种情形下等待者读到**未判**
196
+ * 是诚实结局(判据永远是缓存位),不是缺陷:硬等一个可能永远不会来的 kick 才是。
197
+ *
198
+ * @param probe 可选的**替代探测**(与新引擎同一拍注册)。给了就等价于「失效 + 立刻 kick」,
199
+ * 且中间没有任何可插入点。
200
+ */
201
+ export function invalidateEngineCaps(baseUrl, probe) {
202
+ if (!baseUrl)
203
+ return;
204
+ const hadState = capsByBase.has(baseUrl) ||
205
+ inFlight.has(baseUrl) ||
206
+ settleByBase.has(baseUrl) ||
207
+ genByBase.has(baseUrl);
208
+ if (hadState) {
209
+ genByBase.set(baseUrl, capsGeneration(baseUrl) + 1);
210
+ capsByBase.delete(baseUrl);
211
+ inFlight.delete(baseUrl);
212
+ // 代际变更信号:把等在旧代际上的调用方叫醒去重新求值(见 settleByBase 与 engineCapsSettled
213
+ // 头注)。叫醒 ≠ 放行;下面 kick 的注册是**同步**发生的,所以醒来的续体(微任务)必然已经能
214
+ // 看到替代探测,不会从空隙里溜走。
215
+ settleByBase.get(baseUrl)?.wake();
216
+ }
217
+ // 原子替代:同一同步块内注册新代际的探测(上面刚清了 caps/inFlight,kick 的幂等闸必放行)。
218
+ if (probe !== undefined)
219
+ kickEngineCapsProbe(baseUrl, probe);
220
+ }
76
221
  /** 测试钩子。 */
77
222
  export function __resetEngineCapsCacheForTests() {
78
223
  capsByBase.clear();
79
224
  inFlight.clear();
80
225
  settleByBase.clear();
226
+ genByBase.clear();
81
227
  }
@@ -187,7 +187,15 @@ export declare function deriveAgentLabel(agentType: string | undefined, agentNam
187
187
  * 而"登记"本身要过门 —— 登记项必须是 core 认过的改名/墓碑词。
188
188
  */
189
189
  export declare const CONTROL_TOOL_VERBS: ReadonlySet<string>;
190
- /** 行的活动正文:控制面动词 ⇒ 空(187 从不显示它),否则原样。 */
190
+ /**
191
+ * 行的活动正文:控制面动词 ⇒ 空(187 从不显示它),否则按单行呈现载体规整后放行。
192
+ *
193
+ * #307:本函数是描述列的**主臂**(`projectDescription(...) || taskDescFromName(...)`),
194
+ * 修前它把 wire 原串原样返回 —— 描述列的规整只落在 fallback 臂的 `collapseLabel` 上,
195
+ * 主臂给值时那道规整根本不经过([assert-absence-check-fallback-layers] 同族形)。
196
+ * 🔴 压制判定仍按**原值** trim(判据集是 ASCII 动词名,规整不参与放行判定);规整只加在
197
+ * 返回的呈现串上。空/非空面不变:可见转义不产生空白,`|| fallback` 的真假面照旧。
198
+ */
191
199
  export declare function projectDescription(description: string | undefined): string;
192
200
  /**
193
201
  * `toolUses` —— 子代**累计**工具调用数。语义两条(接错会显示一个「看起来很合理」的错数字):
@@ -1,4 +1,4 @@
1
- import { collapseLabel, taskDescFromName } from '../fleetTaskDesc.js';
1
+ import { collapseLabel, escapeDisplayControlChars, taskDescFromName } from '../fleetTaskDesc.js';
2
2
  import { rowIdTail } from '../workflow.js';
3
3
  /**
4
4
  * SDK `FleetTaskStatus` 的终态词汇 —— 非终态即在飞(未知未来值按在飞算,保守)。
@@ -121,10 +121,20 @@ export const CONTROL_TOOL_VERBS = new Set([
121
121
  'ToolSearch',
122
122
  'DeclareDone',
123
123
  ]);
124
- /** 行的活动正文:控制面动词 ⇒ 空(187 从不显示它),否则原样。 */
124
+ /**
125
+ * 行的活动正文:控制面动词 ⇒ 空(187 从不显示它),否则按单行呈现载体规整后放行。
126
+ *
127
+ * #307:本函数是描述列的**主臂**(`projectDescription(...) || taskDescFromName(...)`),
128
+ * 修前它把 wire 原串原样返回 —— 描述列的规整只落在 fallback 臂的 `collapseLabel` 上,
129
+ * 主臂给值时那道规整根本不经过([assert-absence-check-fallback-layers] 同族形)。
130
+ * 🔴 压制判定仍按**原值** trim(判据集是 ASCII 动词名,规整不参与放行判定);规整只加在
131
+ * 返回的呈现串上。空/非空面不变:可见转义不产生空白,`|| fallback` 的真假面照旧。
132
+ */
125
133
  export function projectDescription(description) {
126
134
  const d = description ?? '';
127
- return CONTROL_TOOL_VERBS.has(d.trim()) ? '' : d;
135
+ if (CONTROL_TOOL_VERBS.has(d.trim()))
136
+ return '';
137
+ return escapeDisplayControlChars(d);
128
138
  }
129
139
  // ── wire 值域过滤器(B6:类型面已到货,这里只剩「脏值不当真」)──────────────────────────────────
130
140
  /**
@@ -5,9 +5,13 @@
5
5
  * src/sema/fleetTaskDesc.ts — footer fleet 行「label/描述列」纯投影助手(零依赖,从 fleetClient.ts 提出
6
6
  * 供单测直取——fleetClient 拖 SDK/appState/notification 重图,无法轻量 bundle;行为与提出前逐字同源)。
7
7
  */
8
+ export declare function escapeDisplayControlChars(s: string): string;
8
9
  /** collapse a wire label to a single trimmed line (drops embedded newlines/runs). 187 row labels are always
9
10
  * one short line; the <FleetTree/> column (Math.min(28,…) + wrap="truncate") does the visible ellipsis, so we
10
- * only normalize here — no double-ellipsis, faithful to the 187 render. */
11
+ * only normalize here — no double-ellipsis, faithful to the 187 render.
12
+ * #307:折叠之后再过 {@link escapeDisplayControlChars} —— `\s+` 折叠管不到 C0 非空白段/C1(NEL)/
13
+ * 双向控制符/孤代理项,那四族原样透到终端就是「一行」这个结构账被绕过。空/非空判定不变
14
+ * (可见转义不产生空白,`|| fallback` 那些调用点的真假面照旧)。 */
11
15
  export declare function collapseLabel(s: string): string;
12
16
  /** CC row-body fallback(CC 真身锚 2026-07-20 人类剧本帧:`◯ general-purpose t1calc`——row body 从第一帧
13
17
  * 起就是 Agent 工具的 description)。引擎子行帧不带 `description` 字段,任务描述只在 `name` 里,且有两形
@@ -5,11 +5,48 @@
5
5
  * src/sema/fleetTaskDesc.ts — footer fleet 行「label/描述列」纯投影助手(零依赖,从 fleetClient.ts 提出
6
6
  * 供单测直取——fleetClient 拖 SDK/appState/notification 重图,无法轻量 bundle;行为与提出前逐字同源)。
7
7
  */
8
+ /**
9
+ * 单行呈现载体的**危险字符可见化**(#307 呈现面规整族,2026-08-19)。
10
+ *
11
+ * 🔴 本段注释与正则里的不可见/双向字符一律 `\uXXXX` 转义,绝不字面嵌入(字面形无法目测核实)。
12
+ *
13
+ * 为什么 `\s+` 折叠不够:JS 的 `\s` 只覆盖 `\t\n\r\f\v` + 空格 + `\u00a0` +
14
+ * `\u1680` + `\u2000-\u200a` + `\u2028\u2029` + `\u202f\u205f\u3000\ufeff`。
15
+ * **没被它覆盖、却会被终端执行**的还有四族:
16
+ * · C0 全段 + DEL(`\u0000-\u001f`、`\u007f`):裸 ESC/CSI 能改色、清行、移光标;
17
+ * · C1(`\u0080-\u009f`):NEL(`\u0085`)在终端上就是换行,却**不在** `\s` 里 ——
18
+ * 「fleet 行就一行」这个结构账被它一个字符绕过;
19
+ * · 双向控制符 + 行/段分隔符(`\u061c`、`\u200e\u200f`、`\u2028\u2029`、
20
+ * `\u202a-\u202e`、`\u2066-\u2069`):不是控制字符,却会被终端的双向重排/断行**执行**,
21
+ * 把 wire 给的行描述在屏上排成另一个样子;
22
+ * · 孤代理项:上游可能本来就送半只,或被列宽截断劈开。
23
+ *
24
+ * 形状选型=**可见转义**(`\uXXXX`)而非 `.` 占位:本函数的载体是 footer fleet 行的
25
+ * label/描述**单行列**,可见形既不引入伪行,也不让两个不同的 wire 值在屏上同形。
26
+ *
27
+ * 🔴 射程只到呈现:不改「读不读得出」,也不参与任何放行判定。
28
+ * 🔴 顺序只在 collapseLabel 那侧有要求:`\s+` 折叠必须先跑(把 `\t\n\r` 等空白归一成
29
+ * 空格),反了会把换行渲成 `\u000A` 字面,「多行 name 塌一行」的既有行为就没了。
30
+ * 没经折叠的载体(fleetProjection.projectDescription)直接过本函数 —— `\t\n` 就渲成
31
+ * 可见转义,单行列里那才是对的(单行载体不许保 `\n`)。
32
+ *
33
+ * ⇄ 同族单源:壳仓 `src/sema/untrustedDisplayText.ts` 的 `cleanUntrustedScalar` 是同一判据的
34
+ * cli 侧实现(那份还带 `.` 占位与保排版两个变体),字符集与本函数**同集**。该单源日后上收到
35
+ * 本包时,本函数是它的座位;在那之前改一处必须改另一处(两侧各有逐码位同集断言看守)。
36
+ */
37
+ // eslint-disable-next-line no-control-regex
38
+ const DISPLAY_UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g;
39
+ export function escapeDisplayControlChars(s) {
40
+ return s.replace(DISPLAY_UNSAFE, ch => `\\u${(ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0')}`);
41
+ }
8
42
  /** collapse a wire label to a single trimmed line (drops embedded newlines/runs). 187 row labels are always
9
43
  * one short line; the <FleetTree/> column (Math.min(28,…) + wrap="truncate") does the visible ellipsis, so we
10
- * only normalize here — no double-ellipsis, faithful to the 187 render. */
44
+ * only normalize here — no double-ellipsis, faithful to the 187 render.
45
+ * #307:折叠之后再过 {@link escapeDisplayControlChars} —— `\s+` 折叠管不到 C0 非空白段/C1(NEL)/
46
+ * 双向控制符/孤代理项,那四族原样透到终端就是「一行」这个结构账被绕过。空/非空判定不变
47
+ * (可见转义不产生空白,`|| fallback` 那些调用点的真假面照旧)。 */
11
48
  export function collapseLabel(s) {
12
- return s.replace(/\s+/g, ' ').trim();
49
+ return escapeDisplayControlChars(s.replace(/\s+/g, ' ').trim());
13
50
  }
14
51
  /** CC row-body fallback(CC 真身锚 2026-07-20 人类剧本帧:`◯ general-purpose t1calc`——row body 从第一帧
15
52
  * 起就是 Agent 工具的 description)。引擎子行帧不带 `description` 字段,任务描述只在 `name` 里,且有两形
@@ -22,9 +22,17 @@
22
22
  * {@link registerArmedGateFromQuestionId} 剥前缀取键(= callKey,与卡键同域)。
23
23
  * · plan_review:`planReviewQuestionId(taskId)` 整串作键(保留前缀,与 taskId 直接作 ask 键
24
24
  * 永不撞域);重开腿铸 `…#reopen-*`,归一化剥尾 —— 首呈与重开落同一键。
25
- * 🔴 A-024.4(#250,#244 F1 起分代在包):wire 上没有 gate 实例位([3664] pendingGate 只有
26
- * kind/decidePath),同 run 推进到第二只 plan 门时,恒定键会让 firstSight 恒 false ⇒ 对一张
27
- * 从未呈现过的新卡编造「被关过」历史。plan 键因此按**呈现代数**分代(
25
+ * 🔴 A-024.4(#250,#244 F1 起分代在包;2026-08-19 `ActiveRunPendingGate` 现值订正 ——
26
+ * 原句「wire 上没有 gate 实例位,[3664] pendingGate 只有 kind/decidePath」已过期):
27
+ * `ActiveRunPendingGate`(见 `adapter/runStream.ts`)今天已长到四位(kind/decidePath/
28
+ * governanceForced/**checkpointId**,#285 件2,0.36.0)—— `governanceForced` 是纯归因布尔,
29
+ * 不是身份;`checkpointId` 才是这材料上第一个够得上「实例位」气味的东西,但它自己的头注
30
+ * 明文划了界:**不是跨调用的稳定去重键**(同 session 可并存多条 pending checkpoint、server
31
+ * 选行是无序 `LIMIT 1`,同一情形连续两次 409 可能报出不同 id,也可能 id 不变而
32
+ * `activeTaskId` 已换),且缺席是「未诞生 / 校验未过 / 整只读取失败」三成因合流,读不出
33
+ * 「这不是同一道门」。⇒ 它不能安全替掉本节的按呈现代数分代 —— `pendingGateIsProvablyDifferent`
34
+ * 那种单向谓词(「id 变了 ⇒ 确实不同」)本节用不上:分代要答的是「这是不是同一张**卡**」的
35
+ * 双向问题,checkpointId 只背书单向的那半。plan 键因此仍按**呈现代数**分代(见
28
36
  * {@link planReviewArmedKeyFor} / {@link notePlanReviewAnsweredFor}):0 代键与 canonical
29
37
  * 字节同形,≥1 代接 `#g<N>` 尾;决断性作答推代(消费当代门),下一只门读回首见。
30
38
  *
@@ -155,6 +155,26 @@ export declare class HitlSafetyError extends Error {
155
155
  * 一边加一边不改另一边 = 新码在消费端被静默吞掉(比不加还坏:本地拦住了,判词却丢了)。 */
156
156
  code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit' | 'empty_answer');
157
157
  }
158
+ /** 测试钩:把超时类重试总窗调小(传 undefined 还原缺省)。 */
159
+ export declare function __setDecideTimeoutRetryBudgetForTests(ms?: number): void;
160
+ /**
161
+ * decide 出站在瞬断类失败上重试一次**仍未送达**(两发都没拿到引擎的语义答复)。
162
+ *
163
+ * 🔴 判别位契约:消费方(`toolApprovalWire` 的 catch 臂 / `parkResolver.surfaceGateAndDecide`)
164
+ * 据 `instanceof` 在 outcome 上 stamp `retryExhausted: true`,parkResolver 对该位走**重呈臂**
165
+ * (re-attach ⇒ durable 流重放 park ⇒ 同一张卡重新交给用户),不再合成 `hitl_unanswered` 把 turn
166
+ * 判死 —— run 仍 parked、pending 行仍可决,判死是三条出路里唯一不可逆的那条。
167
+ * 🔴 message 刻意避开 `isAlreadyResolvedGateReason` 的词表(no pending checkpoint / resolved /
168
+ * already / not found):被那把兜底尺误判成「已解决」会走成静默 reattach,判别位就白铸了 ——
169
+ * pure 门(hitl-gate-honesty F6-d)有常驻负控钉着这一条。
170
+ */
171
+ export declare class DecideTransportRetryExhaustedError extends Error {
172
+ /** 实际发出的次数(网络断类 = 2:首发 + 单次重试;超时类 = 总窗内发出的全部)。 */
173
+ readonly attempts: number;
174
+ constructor(
175
+ /** 实际发出的次数(网络断类 = 2:首发 + 单次重试;超时类 = 总窗内发出的全部)。 */
176
+ attempts: number, lastFailureText: string);
177
+ }
158
178
  /** The active gate the downstream stream most recently suspended on (null when running). */
159
179
  export interface ActiveGate {
160
180
  gate: CheckpointGate;
@@ -1,6 +1,7 @@
1
1
  import { askParkForeignGateKind } from '../adapter/activeRunSelfHeal.js';
2
2
  import { eventSeq } from '../adapter/types.js';
3
3
  import { hostLog } from '../host.js';
4
+ import { abortableSleep } from '../abortableSleep.js';
4
5
  // ── deny/plan-review 归因的 wire 窄化(0.28.0 发版扫描 F1/F2/F3 收编;单源,三条腿共用)──────
5
6
  //
6
7
  // server 对 `/decide` 与 plan-review 两条腿的 `reason` 同限 `MAX_APPROVAL_REASON_CHARS`(4096 字符,
@@ -52,6 +53,111 @@ export class HitlSafetyError extends Error {
52
53
  this.name = 'HitlSafetyError';
53
54
  }
54
55
  }
56
+ // ── decide 出站的瞬断分诊与有界重试(Inkglow-1085 P0a,2026-08-19;[4664] server 定谳后对表)────
57
+ //
58
+ // 病形(案卷 INKGLOW-1085 车1 全链):`approvals.decide` 是**非 submit POST** —— SDK transport 对它
59
+ // `maxAttempts = 1`(「a decide must not double-act」),且每发带显式 `AbortSignal.timeout(timeoutMs)`
60
+ // (缺省 60s;per-call opts 只有 `signal`,调用方 signal 与之**合流取先**,只能收短、不能放长 ——
61
+ // timeoutMs 是 client 构造级旋钮,本包对已构造的注入 client 不可配)。于是一次网络瞬断/超时 =
62
+ // decide 单发即死 → 上层拼 `failed` → parkResolver 合成 `hitl_unanswered` 终帧 → ask 死局。
63
+ //
64
+ // 🔴 [4664] server 定谳的真因与过渡期口径(2026-08-19):60s 超时**结构性必炸** —— legacy 任务级
65
+ // decide 腿(`/v1/approvals/:sessionId/decide`)在响应前**同步跑整条 resume**(模型往返,无上限);
66
+ // server 立件 #316 改快速 ack,过渡期口径 = **壳把这条腿当长调用处理(预期上限无界)**。SDK 的
67
+ // per-attempt 60s 帽对注入 client 不可配(调用面只有 signal,合流只能收短)⇒ 本层的消化形:
68
+ // · **超时类**(TimeoutError = 60s 帽掐断,server 多半仍在跑 resume)⇒ 带退避**继续重试**,
69
+ // 总窗 {@link DECIDE_TIMEOUT_RETRY_TOTAL_BUDGET_MS}(10 分钟级 —— 长调用口径);
70
+ // · **网络断类**(ECONNREFUSED / fetch failed 等:连语义答复都没拿到,引擎多半真死)⇒ 重试
71
+ // **恰一次**,让真死尽快显形;
72
+ // · 语义答复类(带 HTTP status 的 4xx/5xx —— 引擎收到并回答了)⇒ 零重试,原样上抛;
73
+ // · `HitlSafetyError`(binding mismatch 等安全信号)⇒ 恒零重试(§9.1 铁律,原有行为不动);
74
+ // · 调用方已中止(用户 Esc)⇒ 零重试(AbortError 是人按的,不是瞬断)。
75
+ //
76
+ // 🔴 为什么重试不违反「decide 绝不 double-act」:①[4664] 原话 —— **重复 decide 不双跑,server CAS
77
+ // 保证**(重试幂等安全,放心重试);②D-1 绑定回显(boundCallId+boundInputHash 逐字回显,server
78
+ // fail-closed on mismatch)—— 第一发其实送达时,重发只能撞 4xx(approval not found / conflict),
79
+ // 绝不可能批掉**另一件**事;那个 4xx 原样上抛,parkResolver 的「已解决重放救回」判据照认。
80
+ // 重试耗尽(超时类窗尽 / 网络断类第二发仍断)⇒ 抛 {@link DecideTransportRetryExhaustedError}
81
+ // (typed 判别位)—— 消费方(toolApprovalWire / parkResolver)据此走**重呈臂**而不是把 turn 判死。
82
+ // 引擎真死时失败也会尽快显形:重呈臂的下一步(approvals.list / runs.events)对死引擎当场失败,
83
+ // 走既有诚实红。
84
+ // 🔴 请托半场(候黑板):SDK decide 若开 per-call timeoutMs(或对 HITL 面单列长缺省),本层的
85
+ // 超时类重试环可整段收敛成一发长等待;#316 落地(快速 ack)后总窗也可回收。
86
+ /** 瞬断重试的起始退避(×2 递增,封顶 {@link DECIDE_RETRY_BACKOFF_MAX_MS};别把 decide 打成连发)。 */
87
+ const DECIDE_TRANSPORT_RETRY_BACKOFF_MS = 750;
88
+ const DECIDE_RETRY_BACKOFF_MAX_MS = 5_000;
89
+ /** 超时类重试的总窗([4664] 长调用口径:legacy decide 腿同步跑整条 resume,60s 帽结构性必炸;
90
+ * 每发本身就要跑满 SDK 的 60s 帽,10 分钟 ≈ 9 发 —— 覆盖长 resume 而不至于无限吊死)。
91
+ * 🔴 窗的语义是**不再起新发**,刻意不掐在飞那一发(codex 复审议题,驳回后成文):给一发可能已被
92
+ * server 受理的 decide 塞截止 signal 换不来任何安全 —— server 侧照跑,客户端只多制造一个「送达
93
+ * 未知」;上冲上界 = 一发(SDK 60s 帽)+ 一拍退避,可预算。 */
94
+ const DECIDE_TIMEOUT_RETRY_TOTAL_BUDGET_MS = 10 * 60_000;
95
+ let decideTimeoutRetryBudgetOverrideMs;
96
+ /** 测试钩:把超时类重试总窗调小(传 undefined 还原缺省)。 */
97
+ export function __setDecideTimeoutRetryBudgetForTests(ms) {
98
+ decideTimeoutRetryBudgetOverrideMs = ms;
99
+ }
100
+ function decideTimeoutRetryBudgetMs() {
101
+ return decideTimeoutRetryBudgetOverrideMs ?? DECIDE_TIMEOUT_RETRY_TOTAL_BUDGET_MS;
102
+ }
103
+ /** 超时类判别(自身或 cause 链上的 TimeoutError —— SDK per-attempt `AbortSignal.timeout` 的产物;
104
+ * [4664] 口径下它多半意味着 server 仍在同步跑 resume,不是引擎死了)。 */
105
+ function isDecideAttemptTimeout(e, depth = 0) {
106
+ if (depth > 3 || e === null || typeof e !== 'object')
107
+ return false;
108
+ const o = e;
109
+ if (o.name === 'TimeoutError')
110
+ return true;
111
+ return isDecideAttemptTimeout(o.cause, depth + 1);
112
+ }
113
+ /** 瞬断类网络错误码(自身或 cause 链上;undici 的 fetch failed 把真因挂在 cause)。 */
114
+ const TRANSIENT_NETWORK_CODES = new Set([
115
+ 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN',
116
+ 'ENETUNREACH', 'EHOSTUNREACH', 'ECONNABORTED',
117
+ 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET',
118
+ ]);
119
+ /**
120
+ * decide 出站失败的瞬断判别(module 私有 —— 唯一消费点是 {@link HitlBridge} 的 decide choke point)。
121
+ * 🔴 方向:判不出一律**非瞬断**(fail 到「不重试」侧 —— 语义拒绝被误判成瞬断才是真事故:那会把
122
+ * 一次已被拒绝的决断再发一遍)。带数字 `status` 的错误 = 引擎**答了**,无论 4xx/5xx 都不是瞬断。
123
+ */
124
+ function isTransientDecideTransportFailure(e, depth = 0) {
125
+ if (depth > 3 || e === null || typeof e !== 'object')
126
+ return false;
127
+ if (e instanceof HitlSafetyError)
128
+ return false;
129
+ const o = e;
130
+ if (typeof o.status === 'number')
131
+ return false;
132
+ if (o.name === 'TimeoutError' || o.name === 'AbortError')
133
+ return true;
134
+ if (typeof o.code === 'string' && TRANSIENT_NETWORK_CODES.has(o.code))
135
+ return true;
136
+ if (typeof o.message === 'string' && o.message.toLowerCase().includes('fetch failed'))
137
+ return true;
138
+ return isTransientDecideTransportFailure(o.cause, depth + 1);
139
+ }
140
+ /**
141
+ * decide 出站在瞬断类失败上重试一次**仍未送达**(两发都没拿到引擎的语义答复)。
142
+ *
143
+ * 🔴 判别位契约:消费方(`toolApprovalWire` 的 catch 臂 / `parkResolver.surfaceGateAndDecide`)
144
+ * 据 `instanceof` 在 outcome 上 stamp `retryExhausted: true`,parkResolver 对该位走**重呈臂**
145
+ * (re-attach ⇒ durable 流重放 park ⇒ 同一张卡重新交给用户),不再合成 `hitl_unanswered` 把 turn
146
+ * 判死 —— run 仍 parked、pending 行仍可决,判死是三条出路里唯一不可逆的那条。
147
+ * 🔴 message 刻意避开 `isAlreadyResolvedGateReason` 的词表(no pending checkpoint / resolved /
148
+ * already / not found):被那把兜底尺误判成「已解决」会走成静默 reattach,判别位就白铸了 ——
149
+ * pure 门(hitl-gate-honesty F6-d)有常驻负控钉着这一条。
150
+ */
151
+ export class DecideTransportRetryExhaustedError extends Error {
152
+ attempts;
153
+ constructor(
154
+ /** 实际发出的次数(网络断类 = 2:首发 + 单次重试;超时类 = 总窗内发出的全部)。 */
155
+ attempts, lastFailureText) {
156
+ super(`decide did not reach the engine after ${attempts} attempts (transient transport failure): ${lastFailureText}`);
157
+ this.attempts = attempts;
158
+ this.name = 'DecideTransportRetryExhaustedError';
159
+ }
160
+ }
55
161
  /**
56
162
  * The single source for "which `PendingCheckpoint` row is the human about to decide on", used by the two
57
163
  * decision wires (`toolApprovalWire.surfaceFsApprovalAndDecide` / `askGateWire.surfaceGateAndDecide`)
@@ -323,19 +429,57 @@ export class HitlBridge {
323
429
  }
324
430
  // ── decide transport (the single choke point; maps 409 binding mismatch → safety stop) ──
325
431
  async decideRaw(sessionId, decision, opts) {
326
- try {
327
- const r = await this.client.approvals.decide(sessionId, decision, opts);
328
- // A successful decide resumes the SAME durable stream; the gate clears on the next running arm.
329
- this.active = null;
330
- return r;
331
- }
332
- catch (e) {
333
- if (isBindingMismatch(e)) {
334
- // contract/04 §2.2 / §9.1: a binding mismatch means the pending action changed under the human.
335
- // SAFETY signal — re-present to the human, NEVER auto-retry / auto-re-decide.
336
- throw new HitlSafetyError('approval_binding_mismatch — the pending action changed under the human; refetch + re-present', 'binding_mismatch');
432
+ // Inkglow-1085 P0a([4664] 对表后形):瞬断类失败带退避有界重试 —— 超时类按长调用总窗,
433
+ // 网络断类恰一次;为什么重试不违反「decide 绝不 double-act」、哪些失败绝不重试,见文件上方
434
+ // DECIDE_TRANSPORT_RETRY 段总注。
435
+ // 🔴 经函数读 aborted(waitForClaimRelease.isAborted 同注):它在 await 两侧会变,直接读两次
436
+ // 会被 tsc 控制流分析把第二次窄成恒假比较(TS2367)。
437
+ const callerAborted = () => opts?.signal?.aborted === true;
438
+ const startedAt = Date.now();
439
+ let attempts = 0;
440
+ let netRetriesUsed = 0;
441
+ let backoff = DECIDE_TRANSPORT_RETRY_BACKOFF_MS;
442
+ for (;;) {
443
+ attempts++;
444
+ try {
445
+ const r = await this.client.approvals.decide(sessionId, decision, opts);
446
+ // A successful decide resumes the SAME durable stream; the gate clears on the next running arm.
447
+ this.active = null;
448
+ return r;
449
+ }
450
+ catch (e) {
451
+ if (isBindingMismatch(e)) {
452
+ // contract/04 §2.2 / §9.1: a binding mismatch means the pending action changed under the human.
453
+ // SAFETY signal — re-present to the human, NEVER auto-retry / auto-re-decide.
454
+ throw new HitlSafetyError('approval_binding_mismatch — the pending action changed under the human; refetch + re-present', 'binding_mismatch');
455
+ }
456
+ // 调用方已中止(用户 Esc)⇒ 这个 AbortError 是人按的,不是瞬断 —— 原错上抛,零重试。
457
+ if (!isTransientDecideTransportFailure(e) || callerAborted())
458
+ throw e;
459
+ if (isDecideAttemptTimeout(e)) {
460
+ // [4664] 长调用口径:60s 帽掐断 ≠ 引擎死了 —— legacy decide 腿在响应前同步跑整条 resume。
461
+ // 重复 decide 由 server CAS 保证不双跑:server 已受理时,下一发要么等到快速答复(conflict/
462
+ // not-found ⇒ 上抛,已解决判据接手),要么继续被长调用吃满 60s —— 总窗兜底。
463
+ if (Date.now() - startedAt >= decideTimeoutRetryBudgetMs()) {
464
+ throw new DecideTransportRetryExhaustedError(attempts, String(e));
465
+ }
466
+ }
467
+ else if (netRetriesUsed >= 1) {
468
+ // 网络断类(连语义答复都没有)重试恰一次 —— 引擎真死时让失败尽快显形。
469
+ throw new DecideTransportRetryExhaustedError(attempts, String(e));
470
+ }
471
+ else {
472
+ netRetriesUsed++;
473
+ }
474
+ hostLog('debug', `hitlBridge: decide transport failure (transient: ${String(e)}) — retrying after ${backoff}ms ` +
475
+ `(attempt ${attempts}; duplicate decide is CAS-safe per [4664] and the D-1 binding echo makes it ` +
476
+ 'at-most-once-effective: a landed first shot turns the retry into a 4xx, never a second act)');
477
+ await abortableSleep(backoff, opts?.signal ?? new AbortController().signal);
478
+ backoff = Math.min(backoff * 2, DECIDE_RETRY_BACKOFF_MAX_MS);
479
+ // 退避期间被中止 ⇒ 不再补发(abortableSleep 对 abort 是提前 resolve,不抛)。
480
+ if (callerAborted())
481
+ throw e;
337
482
  }
338
- throw e;
339
483
  }
340
484
  }
341
485
  }