@sema-agent/client-core 0.45.0 → 0.47.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 +103 -0
- package/LICENSE +21 -0
- package/README.md +4 -1
- package/dist/adapter/downstream/eventToSdkMessage.js +39 -3
- package/dist/hitl/gateLedger.d.ts +21 -0
- package/dist/hitl/gateLedger.js +17 -0
- package/dist/hitl/hitlBridge.d.ts +50 -0
- package/dist/hitl/hitlBridge.js +35 -0
- package/dist/hitl/parkResolver.d.ts +2 -0
- package/dist/hitl/parkResolver.js +192 -30
- package/dist/hitl/toolApprovalWire.d.ts +16 -2
- package/dist/hitl/toolApprovalWire.js +22 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -0
- package/dist/interactiveHalt.d.ts +150 -0
- package/dist/interactiveHalt.js +131 -0
- package/dist/printToolResultFrame.js +6 -1
- package/dist/toolResult.js +31 -3
- package/dist/wireErrorTriage.d.ts +41 -0
- package/dist/wireErrorTriage.js +88 -1
- package/docs/INTEGRATION-CLIENTS.md +160 -20
- package/package.json +3 -3
- package/docs/REFACTOR-LEDGER.md +0 -392
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DecideTransportRetryExhaustedError, HitlBridge, HitlSafetyError, findPendingForTask } from './hitlBridge.js';
|
|
1
|
+
import { DecideTransportRetryExhaustedError, HitlBridge, HitlSafetyError, findPendingForTask, readDecideCurrentPending, } from './hitlBridge.js';
|
|
2
2
|
import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverlay, } from '../liveQuestionStore.js';
|
|
3
3
|
import { hostLog } from '../host.js';
|
|
4
4
|
import { surfaceFsApprovalAndDecide } from './toolApprovalWire.js';
|
|
@@ -7,6 +7,38 @@ import { flushHeldWithInterruptRewrite, isAskTool } from './frameRouter.js';
|
|
|
7
7
|
import { approvalCallKey, askGateQuestionId } from './gateIdentity.js';
|
|
8
8
|
/** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
|
|
9
9
|
const MAX_GATE_HOPS = 24;
|
|
10
|
+
/**
|
|
11
|
+
* 「已解决 ⇒ reattach」臂的**同因连续命中上限**(#357,现网 P0;[5522] 定谳 / [5525] server 答复)。
|
|
12
|
+
* 同一判据源连续第 `N+1` 次命中即**不再** reattach。
|
|
13
|
+
*
|
|
14
|
+
* ── 病 ──────────────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
* 用户批完两张卡,第三只门 16 分钟长等,最后吐 `gate hop limit (24) exceeded`,工具 abort,
|
|
16
|
+
* 而引擎侧 run 仍 parked。链:run 仍 parked 但取件当拍读不到本 run 的可决行 ⇒ 每一轮重放的 park
|
|
17
|
+
* 都命中「已解决」⇒ reattach ⇒ 引擎重放同一张 park ⇒ 循环到 hop 预算耗尽。24 轮里只有 debug 级
|
|
18
|
+
* 日志,用户零告知,拿到的还是一句与真因无关的预算话术。
|
|
19
|
+
*
|
|
20
|
+
* ── 两件修的分工(**别合并**)──────────────────────────────────────────────────────────────
|
|
21
|
+
* · **主修 = 坐标重探**({@link resolvePark} 的 rescan 臂):server [5525] 定谳这不是「状态失配」
|
|
22
|
+
* 而是**坐标失配** —— decide 受理后 resume **消费**掉 checkpoint(token 一次性),下一 turn 再
|
|
23
|
+
* 撞门就是**新 park / 新 token / 新行**;拿旧坐标去查/去决恒得 409 `approval_stale`(体
|
|
24
|
+
* terminal:"resolved")或 404 no-pending,逐字命中本文件的「已解决」判据集,而 run 真身是
|
|
25
|
+
* parked、**新行真实存在待决**。⇒ 命中「已解决」时先**重读当前队列**(丢掉这张 park 自带的
|
|
26
|
+
* 陈旧身份,让取件腿按当前拍的坐标选行):有行 ⇒ 这一轮就把**新卡**真呈给用户;确无行 ⇒ 才是
|
|
27
|
+
* #110 的重放窗,照旧 reattach。
|
|
28
|
+
* · **兜底 = 本闸**:重探自己也失配/读不动时(网络类、或队列确实空而 run 就是不动),不许再
|
|
29
|
+
* 空转 24 轮 —— 同因连续第 2 次即诚实收场,终帧带真因。
|
|
30
|
+
*
|
|
31
|
+
* 值 = 1 的理由:「已解决」判据的**正当性只覆盖一次重放窗**(#110 成文:`lastEventId` 再准也只能
|
|
32
|
+
* 精确到帧,park 就在重放窗里,而那个窗**只有一次**)。第二次同因命中意味着重探也没能把新坐标
|
|
33
|
+
* 找出来 —— 那时继续 reattach 只是把同一个空转再跑一遍。
|
|
34
|
+
* 🔴 {@link MAX_GATE_HOPS} 总闸**保留**(最后防线):本闸只管「已解决」这一条臂,别的臂
|
|
35
|
+
* (重呈臂每轮真呈卡)仍由总闸兜底。
|
|
36
|
+
*
|
|
37
|
+
* 🔴 键**按语义分不按表征分**(异源复审 finding③):「已解决」在 wire 上有 `no_pending` 码 /
|
|
38
|
+
* 409 体 `resolved` / 404 `not found` 三种长相,它们是同一个真因 —— 按长相分键的话,表征一轮换
|
|
39
|
+
* 计数就重置回 1,闸永远不触发。判据见 {@link alreadyResolvedStreakKey}。
|
|
40
|
+
*/
|
|
41
|
+
const MAX_ALREADY_RESOLVED_REATTACHES = 1;
|
|
10
42
|
/**
|
|
11
43
|
* REF-CC-033:`GateOutcome` 的 `failed` 分支闭集判别码。来源两处:①本文件自己的
|
|
12
44
|
* 「pending 行取不到」早退(`surfaceGateAndDecide` 里 `!pending` 那条);②`HitlBridge` 抛出的
|
|
@@ -51,13 +83,28 @@ function isGateFailureCode(v) {
|
|
|
51
83
|
* (desktop 实测——parked 刚批完、decide 未返回的竞态窗内 Stop 走 parked-deny 路),旧词表
|
|
52
84
|
* 判不中 ⇒ 按真失败 log-only 放弃 ⇒ runs.cancel 永不发出=Stop 哑弹。按上方「宁可宽」方向收编。 */
|
|
53
85
|
export function isAlreadyResolvedGateReason(reason) {
|
|
86
|
+
return alreadyResolvedReasonToken(reason) !== undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 文案兜底臂的**词表**(判据本体;`isAlreadyResolvedGateReason` 是它的布尔投影)。
|
|
90
|
+
* 🔴 #357 把它从「四个 `||` 就地写死」抽成词表,**行为一个字节不变**:一处词表,两个消费点
|
|
91
|
+
* (布尔判定 + 诊断串里「是哪个词命中的」),此前后者只能靠人再读一遍那四个 `||`。
|
|
92
|
+
* 🔴 命中的词**不进限次闸的键** —— 键按语义分,理由见 {@link alreadyResolvedStreakKey}。
|
|
93
|
+
*/
|
|
94
|
+
const ALREADY_RESOLVED_REASON_TOKENS = ['no pending checkpoint', 'resolved', 'already', 'not found'];
|
|
95
|
+
/** 命中的那个词(没命中 ⇒ `undefined`)。顺序即优先级,只进诊断串,不参与判定强弱。 */
|
|
96
|
+
function alreadyResolvedReasonToken(reason) {
|
|
54
97
|
if (typeof reason !== 'string')
|
|
55
|
-
return
|
|
98
|
+
return undefined;
|
|
56
99
|
const r = reason.toLowerCase();
|
|
57
|
-
return
|
|
100
|
+
return ALREADY_RESOLVED_REASON_TOKENS.find((t) => r.includes(t));
|
|
58
101
|
}
|
|
59
|
-
/** 已解决判据(REF-CC-033):code 在场就只信 code
|
|
60
|
-
* `FsApprovalOutcome` 今天没有
|
|
102
|
+
/** 已解决判据(REF-CC-033):code 在场就只信 code,不在场才落回文案兜底。
|
|
103
|
+
* ✅ **记案已销**(#357,0.47.0):原注末句写的是「fs 审批腿的 `FsApprovalOutcome` 今天没有
|
|
104
|
+
* code 字段——C-bridge 卡域,退役条件 = 那边也补上 code」。那个退役条件已兑现:取件腿的
|
|
105
|
+
* `no_pending` 码现在由 `toolApprovalWire.surfaceFsApprovalAndDecide` 原样透传(见
|
|
106
|
+
* `FsApprovalOutcome.code` 头注),两条腿从此同走 code 臂;文案兜底只剩 decide 失败那一类
|
|
107
|
+
* (引擎的 409/404 语义答复文本)与旧引擎路径。
|
|
61
108
|
* 🔴 入参**已经**是窄化到 `{kind:'failed'}` 的那半:一个「true ⇒ 是 failed」的判定不能反过来
|
|
62
109
|
* 当「false ⇒ 不是 failed」的类型谓词用(TS 的 `outcome is Extract<…>` 负向窄化会把整个
|
|
63
110
|
* `'failed'` 分支从联合类型里排除,而这个函数对未解决的真失败照样返回 false——那不是「它不是
|
|
@@ -196,6 +243,8 @@ parkGatedCallId) {
|
|
|
196
243
|
// 这里 instanceof 窄化后再过 isGateFailureCode 白名单——不认得的码(将来 hitlBridge 那边
|
|
197
244
|
// 万一多加一种)一律降级成 undefined,退回文案兜底,不让开集腐蚀这个新判别位。
|
|
198
245
|
const code = e instanceof HitlSafetyError && isGateFailureCode(e.code) ? e.code : undefined;
|
|
246
|
+
// #363:S-02 指路键原样透传 —— 与 `toolApprovalWire` 两条 decide 失败腿同形同源。
|
|
247
|
+
const currentPending = readDecideCurrentPending(e);
|
|
199
248
|
return {
|
|
200
249
|
kind: 'failed',
|
|
201
250
|
gatedCallId,
|
|
@@ -203,6 +252,7 @@ parkGatedCallId) {
|
|
|
203
252
|
...(code !== undefined ? { code } : {}),
|
|
204
253
|
// Inkglow-1085 P0a:ask 腿与 fs 腿同形 —— 瞬断耗尽给判别位,resolvePark 走重呈臂。
|
|
205
254
|
...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
|
|
255
|
+
...(currentPending !== undefined ? { currentPending } : {}),
|
|
206
256
|
};
|
|
207
257
|
}
|
|
208
258
|
}
|
|
@@ -210,22 +260,55 @@ parkGatedCallId) {
|
|
|
210
260
|
function argsByCallOf(led) {
|
|
211
261
|
return new Map(led.gatedStartArgs());
|
|
212
262
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
263
|
+
/**
|
|
264
|
+
* 限次闸的**判据源**(键)——「是哪一**条判据**把这一轮判成已解决的」。
|
|
265
|
+
*
|
|
266
|
+
* 🔴 键**按语义分,不按表征分**(异源复审 finding③ 的采纳):「队列里没有本 run 的可决行」这件事
|
|
267
|
+
* 在 wire 上有好几种长相 —— 取件读到空表落 `code:'no_pending'`,server 的 409 `approval_stale`
|
|
268
|
+
* (体 terminal:"resolved")/ 404 落文案臂的 `resolved` / `not found`。它们是**同一个真因的三种
|
|
269
|
+
* 说法**;按长相分键的话,一个卡死的 gate 只要在这几种表征之间轮换,连续计数就一路重置回 1,
|
|
270
|
+
* 闸永远不触发 —— 正是本闸要消灭的那个空转([anchor-on-the-deciding-quantity]:锚真正决定结果
|
|
271
|
+
* 的量,不锚它的表征)。命中的那个词只进诊断串,不进键。
|
|
272
|
+
*
|
|
273
|
+
* 只有身份腿(`decidedGates` 一次性匹配)另占一个键:它与「已解决」是两条独立判据,而且它一次性
|
|
274
|
+
* 消费、连续最多命中一次,天然不会把闸推到跳闸线。
|
|
275
|
+
*/
|
|
276
|
+
function alreadyResolvedStreakKey(byDecidedIdOnly) {
|
|
277
|
+
return byDecidedIdOnly ? 'decided-id-match' : 'already-resolved';
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* 这一轮的失败是不是「**取件那一步**就没找到本 run 的可决行」—— 即坐标重探唯一有资格跑的那一形。
|
|
281
|
+
*
|
|
282
|
+
* 🔴 判据锚在**结构**上,不锚文案:`code === 'no_pending'`(取件腿的结构化判别位,两条呈卡腿
|
|
283
|
+
* 今天都透传它)**且** `gatedCallId` 缺席。后半条是承重的 —— 两条呈卡腿里,`findPendingForTask`
|
|
284
|
+
* 失败那一条是**唯一**不带 `gatedCallId` 的 failed 出口(行都没选出来,哪来的 call 身份);它之后
|
|
285
|
+
* 的每一个失败出口(卡口缺席 / 无 payload / decide 4xx,包括 `HitlSafetyError.code==='no_pending'`)
|
|
286
|
+
* 都带着 `gatedCallId`。于是这条判据同时证明了一件承重的事:**这一轮没有向用户呈过卡**。
|
|
287
|
+
*
|
|
288
|
+
* 🔴 为什么必须证明「没呈过卡」(异源复审 finding② 的采纳):重探会把整条呈卡腿**再走一遍**。
|
|
289
|
+
* 若允许它在「卡已经呈过、用户答了、decide 回了一句带 resolved 字样的 409」之后跑,用户会在同一轮
|
|
290
|
+
* 里被同一张卡问第二遍(而且可能给出与第一次不同的答案)。把重探限定在取件步失败这一形,
|
|
291
|
+
* 「同一轮重复呈卡」在结构上就不成立。代价:decide 步的 stale 409 不再就地重探 —— 那一形照旧
|
|
292
|
+
* reattach 一次(durable 流本就会把 park 再送一遍),第二次同因命中由限次闸诚实收场。
|
|
293
|
+
*/
|
|
294
|
+
function isFetchStepNoPending(outcome) {
|
|
295
|
+
const code = 'code' in outcome && isGateFailureCode(outcome.code) ? outcome.code : undefined;
|
|
296
|
+
return code === 'no_pending' && outcome.gatedCallId === undefined;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 「呈这张 park 的卡 → 等人决断 → decide」的**唯一**入口(两族各一条腿;fs 腿的 denied 记账随行)。
|
|
300
|
+
*
|
|
301
|
+
* 🔴 #357 抽成一处的理由不是去重:坐标重探臂要用**当前拍的坐标**再走一次同一条腿,两处各写一份
|
|
302
|
+
* 的话,「第一次走的路」与「重探走的路」会各自漂(记账少一笔 / 族判据不同 ⇒ 决断落到另一行)。
|
|
303
|
+
*
|
|
304
|
+
* @param gatedCallId 交给取件腿的**身份**(wire 给的 park 身份;缺席是常态)。首击与坐标重探
|
|
305
|
+
* 传的是**同一个值** —— 重探要的是读数新鲜,不是判据放宽,理由见调用点的红线注。
|
|
306
|
+
*/
|
|
307
|
+
async function surfaceParkGate(park, ctx, gatedCallId) {
|
|
308
|
+
const { deps, led, taskId } = ctx;
|
|
309
|
+
if (park.gate === 'fs') {
|
|
227
310
|
// [816] 放宽腿:fs 写权限 gate → CC 三选卡(vendored PermissionRequest)→ decide。
|
|
228
|
-
outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal,
|
|
311
|
+
const outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
|
|
229
312
|
// #51: `outcome`'s declared type is the wider `GateOutcome |
|
|
230
313
|
// FsApprovalOutcome`; both unions share a 'decided' kind with
|
|
231
314
|
// different optional fields (`answered` vs `denied`), so a plain
|
|
@@ -236,9 +319,27 @@ export async function resolvePark(park, ctx) {
|
|
|
236
319
|
if (outcome.kind === 'decided' && outcomeDenied === true && outcome.gatedCallId) {
|
|
237
320
|
led.markDenied(outcome.gatedCallId); // 重放的报错帧渲 `User rejected …`(见 tool_end 有序臂 ③)
|
|
238
321
|
}
|
|
322
|
+
return outcome;
|
|
323
|
+
}
|
|
324
|
+
return surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
|
|
325
|
+
}
|
|
326
|
+
export async function resolvePark(park, ctx) {
|
|
327
|
+
// `deps` 不在本函数直读:呈卡/决断两族腿统一经 `surfaceParkGate`(#357 单源化,见其头注)。
|
|
328
|
+
const { led, taskId, hops } = ctx;
|
|
329
|
+
let outcome;
|
|
330
|
+
// REF-CC-034:这次 park 的候选 callId(仅 fs 分支填,已决断身份匹配用)——ask 分支不填,
|
|
331
|
+
// 因为 `surfaceGateAndDecide` 的 `!pending` 早退已经自带 `code:'no_pending'`(REF-CC-033),
|
|
332
|
+
// 不需要这条第二判据。用 `lastFsOrShellGatedCallId()` 而非「未收口 fs call 栈」:durable
|
|
333
|
+
// re-attach 重放的 `suspended` park 没有配套的新 tool_start/tool_end,那个栈在这次重放之前
|
|
334
|
+
// 早被 tool_end 的 drop 清空了(见台账声明处注),取不到候选。
|
|
335
|
+
let candidateGatedCallId;
|
|
336
|
+
if (hops > MAX_GATE_HOPS) {
|
|
337
|
+
outcome = { kind: 'failed', reason: `gate hop limit (${MAX_GATE_HOPS}) exceeded` };
|
|
239
338
|
}
|
|
240
339
|
else {
|
|
241
|
-
|
|
340
|
+
if (park.gate === 'fs')
|
|
341
|
+
candidateGatedCallId = led.lastFsOrShellGatedCallId();
|
|
342
|
+
outcome = await surfaceParkGate(park, ctx, park.gatedCallId);
|
|
242
343
|
}
|
|
243
344
|
// #110 缺陷② b/c —— **重放的、早已决断过的 park 不是失败**。
|
|
244
345
|
// durable re-attach 必然会把 park 帧再送一遍(`lastEventId` 再准也只能精确到帧,park 就在
|
|
@@ -250,10 +351,10 @@ export async function resolvePark(park, ctx) {
|
|
|
250
351
|
// 同 session `--resume` 报 `No conversation found` ⇒ **整个会话废掉**。
|
|
251
352
|
// 无限循环由 `hops > MAX_GATE_HOPS` 兜底(本 reattach 照常吃 hop 预算,不绕过它)。
|
|
252
353
|
//
|
|
253
|
-
// REF-CC-034:`isAlreadyResolvedFailure` 是首选判据(code
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
354
|
+
// REF-CC-034:`isAlreadyResolvedFailure` 是首选判据(code/文案双臂;#357 起 fs 腿的取件码也
|
|
355
|
+
// 走 code 臂,见该函数头注的销案段)。失败原因若两臂都判不出(例如 `approvals.list` 自身网络
|
|
356
|
+
// 失败),已决断身份匹配是独立于文案的第二判据 —— 两臂任一命中都按「已解决」处置,方向偏宽
|
|
357
|
+
// (见台账 `markDecided` 处注)。
|
|
257
358
|
//
|
|
258
359
|
// [2393] hitl-F2(2026-08-02):这条身份判据**一次性消费**(`takeDecided` 而不是 `isDecided`)。
|
|
259
360
|
// 它的正当性只覆盖「durable re-attach 把**那一次**已决断的 park 重放一遍」这一个窗口,而它的两个
|
|
@@ -261,15 +362,73 @@ export async function resolvePark(park, ctx) {
|
|
|
261
362
|
// 不消费就等于:本 turn 只要成功决断过一次,之后**每一次** park 失败都被判「已解决」——
|
|
262
363
|
// `approvals.list` 网络失败这类真失败会连吃 24 个 hop,最后吐一句与真因无关的 `gate hop limit`。
|
|
263
364
|
// 消费点写在 `outcome.kind === 'failed'` 之内:决断成功的那一轮压根不该动这个记号。
|
|
365
|
+
//
|
|
366
|
+
// 🔴 #357(现网 P0,[5522]/[5525]):上面那条一次性只关掉了**身份腿**的空转;它的孪生腿
|
|
367
|
+
// (code/文案判据)当时没有任何限次 —— 「run 仍 parked 而取件读到空表」的**坐标失配**态下,
|
|
368
|
+
// 每一轮重放都命中 code 臂,照样烧满 24 个 hop、16 分钟长等、终帧还是那句与真因无关的预算话术
|
|
369
|
+
// ([same-shape-residue-constitution]:同一病形的第二处存量)。下面两件按 `MAX_ALREADY_RESOLVED_REATTACHES`
|
|
370
|
+
// 头注的分工落地:主修 = 坐标重探(拿当前拍的坐标真把新卡呈出去),兜底 = 同因连续限次。
|
|
264
371
|
const alreadyDecidedById = outcome.kind === 'failed' &&
|
|
265
372
|
candidateGatedCallId !== undefined &&
|
|
266
373
|
led.takeDecided(candidateGatedCallId);
|
|
267
374
|
if (outcome.kind === 'failed' && (isAlreadyResolvedFailure(outcome) || alreadyDecidedById)) {
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
375
|
+
const firstReason = outcome.reason;
|
|
376
|
+
// 键按语义分(见 `alreadyResolvedStreakKey` 头注);命中的那个文案词只进诊断串。
|
|
377
|
+
const streakKey = alreadyResolvedStreakKey(!isAlreadyResolvedFailure(outcome) && alreadyDecidedById);
|
|
378
|
+
const reasonToken = alreadyResolvedReasonToken(outcome.reason);
|
|
379
|
+
// ── 主修:坐标重探 ────────────────────────────────────────────────────────────────────
|
|
380
|
+
// 🔴 重探 = **按当前拍再读一次队列**(同一条呈卡腿、**同样的入参**)。有可决行 ⇒ 这一轮就把
|
|
381
|
+
// 新卡真呈给用户(死锁在这里破);无行 ⇒ 它照旧回同一个「已解决」,落下面的限次闸。
|
|
382
|
+
// 坐标轮换在这里是**自动**发生的:取件腿的身份腿在队列里找不到那张 park 的陈旧 callId 时
|
|
383
|
+
// 本就回落 taskId + 族谓词两腿,于是选中的正是当前拍的新行(server [5525] 的新 token 新行)。
|
|
384
|
+
// 🔴 **刻意不把身份入参抹成 `undefined`**(异源门 F10-j 当场证伪的写法):`parkGatedCallId` 在
|
|
385
|
+
// `findPendingForTask` 里同时是「优先命中的身份」**和**「身份与本腿族属冲突时把候选池收紧」
|
|
386
|
+
// 的那把闸。抹掉它就等于把 F10-j 钉住的那条绕道兜底重新打开 —— 身份指着一行反族行、队列里
|
|
387
|
+
// 又没有别的行时,「任意行」兜底会把那行反族行捞回来呈卡 + decide(卡面全错、决断打在另一个
|
|
388
|
+
// checkpoint 上)。重探要的是**读数新鲜**,不是**判据放宽**。
|
|
389
|
+
const rescan = isFetchStepNoPending(outcome) && hops <= MAX_GATE_HOPS
|
|
390
|
+
? await surfaceParkGate(park, ctx, park.gatedCallId)
|
|
391
|
+
: undefined;
|
|
392
|
+
// 🔴 只有**真进展**才采信重探的结果:决断成功 / 用户 Esc / 瞬断耗尽(那一形有自己的重呈臂)。
|
|
393
|
+
// 重探自己失败(网络类、或队列确实没有可决行)⇒ **不采信**,走限次闸兜底 —— 采信的话,
|
|
394
|
+
// 一次瞬时读面故障就会把 #110 那条「重放的 park 不是失败」救回腿改判成 fail-soft(会话废掉
|
|
395
|
+
// 那条路径),代价严重不对称。
|
|
396
|
+
if (rescan !== undefined &&
|
|
397
|
+
(rescan.kind === 'decided' ||
|
|
398
|
+
rescan.kind === 'aborted' ||
|
|
399
|
+
(rescan.kind === 'failed' && rescan.retryExhausted === true))) {
|
|
400
|
+
led.resetAlreadyResolvedGate();
|
|
401
|
+
hostLog('debug', `liveHitlAskWire: gate reported already-resolved (${firstReason}) but a fresh approvals re-read for run ` +
|
|
402
|
+
`${taskId} surfaced a decidable row under the current coordinates — re-presented it (rescan: ${rescan.kind}) ` +
|
|
403
|
+
`instead of re-attaching on the stale park identity`);
|
|
404
|
+
outcome = rescan;
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
const streak = led.noteAlreadyResolvedGate(streakKey);
|
|
408
|
+
if (streak <= MAX_ALREADY_RESOLVED_REATTACHES) {
|
|
409
|
+
const seq = led.lastSeq();
|
|
410
|
+
hostLog('debug', `liveHitlAskWire: gate already resolved (${firstReason}) — replayed park, re-attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn` +
|
|
411
|
+
(alreadyDecidedById ? ` [decidedGates id match: ${candidateGatedCallId}]` : '') +
|
|
412
|
+
(led.decidedCount() > 0 ? ` [decided so far: ${led.decidedCount()}]` : ''));
|
|
413
|
+
return { kind: 'reattach' };
|
|
414
|
+
}
|
|
415
|
+
// ── 兜底:同因连续第 2 次 ⇒ 不再空转,诚实收场 ──────────────────────────────────────
|
|
416
|
+
// 件B(可见告知):真因走**终帧的 errorMessage**(下面 fail-soft 汇流合成的那一条),不是只落
|
|
417
|
+
// debug —— 修前用户在 24 轮里什么都看不到,最后拿到的是一句关于 hop 预算的话。
|
|
418
|
+
// 🔴 如实记边界:`park.pendingDone` 在场(sync leg 的 `done{suspended}`)时,fail-soft 汇流
|
|
419
|
+
// 按既有语义原样吐那一帧、不合成 failed —— 那一格的告知面仍只有本行日志。durable 腿
|
|
420
|
+
// (真实病形所在:`suspended` 事件无 pendingDone)恒走合成帧,真因必然上屏。
|
|
421
|
+
const stalled = `the gate reported already-resolved ${streak} times in a row while the run stayed parked, and a fresh ` +
|
|
422
|
+
`approvals re-read surfaced no decidable row for it — run/approvals coordinate mismatch ` +
|
|
423
|
+
`(last gate reason: ${firstReason})`;
|
|
424
|
+
hostLog('warn', `liveHitlAskWire: refusing to re-attach runs.events(${taskId}) again — ${stalled} ` +
|
|
425
|
+
`[criterion: ${streakKey}${reasonToken !== undefined ? ` via reason token "${reasonToken}"` : ''}]`);
|
|
426
|
+
outcome = {
|
|
427
|
+
kind: 'failed',
|
|
428
|
+
...(outcome.gatedCallId !== undefined ? { gatedCallId: outcome.gatedCallId } : {}),
|
|
429
|
+
reason: stalled,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
273
432
|
}
|
|
274
433
|
// Inkglow-1085 P0a —— **重呈臂**:decide 出站在瞬断类失败上重试一次仍未送达(两发都没拿到引擎的
|
|
275
434
|
// 语义答复)。此刻 run 仍 parked、pending 行仍可决 —— 合成 `hitl_unanswered` 把 turn 判死是三条
|
|
@@ -325,6 +484,9 @@ export async function resolvePark(park, ctx) {
|
|
|
325
484
|
// `park.gatedCallId`(server ≥7.41.0 的 wire 身份)在场就点名摘,缺席则只在同族唯一时摘。
|
|
326
485
|
// 这一行**刻意排在 `if (outcome.gatedCallId)` 之外**:身份在 wire 上时,摘不摘与 pending
|
|
327
486
|
// 行给没给出 callId 无关(park 有主角,decide 也成功了)。
|
|
487
|
+
// #357:决断成功 = 判据链上的**真进展**,「已解决」的同因连续计数归零(否则一个 turn 里两只门
|
|
488
|
+
// 各带一次正当重放,第二只的重放会被第一只的计数顶成「第 2 次」而被闸掉)。
|
|
489
|
+
led.resetAlreadyResolvedGate();
|
|
328
490
|
led.dropHeldForDecidedPark(outcome.gatedCallId, park.gatedCallId);
|
|
329
491
|
if (outcome.gatedCallId) {
|
|
330
492
|
// 🔴 #324:**刻意不把 pending 行的 callId 当成连坐判别的主角身份**。durable `checkpointGate`
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
* 静默截断);真落行与否看 ack 的 `noteRecorded`(缺席 ≠ false)。
|
|
78
78
|
* 🔴 方向纪律:reason/note 只做归因,绝不参与裁决;缺席 ⇒ 现状字节不变。
|
|
79
79
|
*/
|
|
80
|
-
import { type HitlClientLike } from './hitlBridge.js';
|
|
80
|
+
import { type GateCurrentPending, type HitlClientLike } from './hitlBridge.js';
|
|
81
81
|
import type { RuleSuggestion, ToolApprovalRespondAck } from '@sema-agent/sdk';
|
|
82
82
|
/** fs 写权限 gate 判定:未来的一等 kind(tool_approval)或按 toolName(server 桥首批=fs 写三件,
|
|
83
83
|
* [820] 表)。AskUserQuestion 永不进这里(ask 桥先判)。 */
|
|
@@ -108,12 +108,26 @@ export type FsApprovalOutcome = {
|
|
|
108
108
|
/** `retryExhausted`(Inkglow-1085 P0a,0.37.0):decide 出站在**瞬断类**失败上重试一次仍未送达
|
|
109
109
|
* (两发都没拿到引擎的语义答复)—— 在场即真。消费方(parkResolver)据此走**重呈臂**(re-attach
|
|
110
110
|
* 重放 park ⇒ 同一张卡重新交给用户),不合成 `hitl_unanswered` 判死 turn。缺席 = 语义类失败
|
|
111
|
-
* (引擎答了且拒了)或卡面失败,既有 fail-soft 语义逐字节不变。
|
|
111
|
+
* (引擎答了且拒了)或卡面失败,既有 fail-soft 语义逐字节不变。
|
|
112
|
+
* `code`(#357,0.47.0):**取件当拍队列里没有本 run 的可决行** —— `findPendingForTask` 的
|
|
113
|
+
* 结构化判别位原样透传(此前本类型没有这个位,消费点只能拿 `reason` 的文案子串猜,而
|
|
114
|
+
* `parkResolver.isAlreadyResolvedFailure` 的头注把「fs 腿没有 code」记成了已知缺口)。
|
|
115
|
+
* 🔴 只透传取件腿那一个码:decide 失败(409/404 语义答复)不落这个位 —— 那两件事对消费点
|
|
116
|
+
* 是不同处置(前者「这一拍没有行」可以重探,后者是引擎的语义答复)。 */
|
|
117
|
+
/** `currentPending`(#363,0.47.0):**decide 失败**那一类才可能带 —— server S-02(≥7.52)在
|
|
118
|
+
* 409 `approval_stale` 拒体上铸的 additive **指路键**,说的是「你手上那条已经不是当前的了,
|
|
119
|
+
* **当前**这条长这样」。壳据此**一跳重定位**再 decide,免整队重拉。
|
|
120
|
+
* 🔴 缺席是常态,而且缺席**什么都不证明**:老 server / 非工具门 / 行读失败 / 本次失败根本
|
|
121
|
+
* 不是 stale 臂,四种情形在 wire 上同形(server 自己的 F 类留痕账逐字如此)。
|
|
122
|
+
* 🔴 它**不参与任何裁决** —— 见 `GateCurrentPending` 顶注;消费方拿它当指路,不当放行凭据。
|
|
123
|
+
* 🔴 **取件腿的 `no_pending` 恒不带这一位**:那一腿连 decide 都没发,谈不上「你决的那条过期了」。 */
|
|
112
124
|
| {
|
|
113
125
|
kind: 'failed';
|
|
114
126
|
gatedCallId?: string | undefined;
|
|
115
127
|
reason: string;
|
|
128
|
+
code?: 'no_pending' | undefined;
|
|
116
129
|
retryExhausted?: true;
|
|
130
|
+
currentPending?: GateCurrentPending | undefined;
|
|
117
131
|
};
|
|
118
132
|
/** 本桥消费的 wire 面(liveHitlAskWire 的 AskGateWireDeps 同形切片,mock 可注入)。 */
|
|
119
133
|
export interface FsApprovalWireDeps {
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
* 静默截断);真落行与否看 ack 的 `noteRecorded`(缺席 ≠ false)。
|
|
78
78
|
* 🔴 方向纪律:reason/note 只做归因,绝不参与裁决;缺席 ⇒ 现状字节不变。
|
|
79
79
|
*/
|
|
80
|
-
import { DEFAULT_DENY_REASON, DecideTransportRetryExhaustedError, HitlBridge, HitlSafetyError, denyReasonForWire, findPendingForTask, } from './hitlBridge.js';
|
|
80
|
+
import { DEFAULT_DENY_REASON, DecideTransportRetryExhaustedError, HitlBridge, HitlSafetyError, denyReasonForWire, findPendingForTask, readDecideCurrentPending, } from './hitlBridge.js';
|
|
81
81
|
import { hostLog } from '../host.js';
|
|
82
82
|
import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
|
|
83
83
|
import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
|
|
@@ -251,8 +251,10 @@ parkGatedCallId) {
|
|
|
251
251
|
// toolName + fs/shell 名字腿),而上面那个 `matches` 只有名字腿那半 —— 拿它当身份闸会把合法的
|
|
252
252
|
// kind-only 行判出局(五审 finding①)。gateKind = 行上的扁平投影,与 gate.kind 同一权威。
|
|
253
253
|
(row) => isToolApprovalGate({ kind: row.gateKind, toolName: row.toolName }));
|
|
254
|
+
// #357:取件腿的结构化判别位原样透传(见 `FsApprovalOutcome.code` 头注)——「这一拍队列里没有
|
|
255
|
+
// 本 run 的可决行」与「approvals.list 自身炸了」自此对消费点结构可分,不再靠文案子串猜。
|
|
254
256
|
if (!found.ok)
|
|
255
|
-
return { kind: 'failed', reason: found.reason };
|
|
257
|
+
return { kind: 'failed', reason: found.reason, ...(found.code !== undefined ? { code: found.code } : {}) };
|
|
256
258
|
const { pending, gatedCallId } = found;
|
|
257
259
|
const toolName = typeof pending.toolName === 'string' ? pending.toolName : '';
|
|
258
260
|
// 工具入参:优先流上 tool_start.args(完整、零额外语义);缺则 pending.input(service 已 redact+限长
|
|
@@ -337,8 +339,20 @@ parkGatedCallId) {
|
|
|
337
339
|
// Inkglow-1085 P0a:瞬断耗尽同样**不许**回退纯 approve —— 引擎此刻根本够不着,再补一发
|
|
338
340
|
// 纯 approve 只是再烧一轮超时,还把「传输断了」错标成「老 server 不识别 remember」。
|
|
339
341
|
// 上抛给外层 catch ⇒ retryExhausted 判别位 ⇒ parkResolver 走重呈臂。
|
|
342
|
+
// 🔴 **第三条再抛(#363,0.47.0;异源复审 [high] 采纳,真病)**:拒体带 `currentPending`
|
|
343
|
+
// ⇒ 引擎**结构化地告诉我们**「你决的那一行已经不是当前的了,当前是这一条」——
|
|
344
|
+
// 那和 `binding_mismatch` 是同一件事的另一种说法(人看见的那一行在他决断期间被换掉了),
|
|
345
|
+
// 只是走的是 stale 那条码。修前它落进这条 catch-all,被当成「老 server 不识别 remember」
|
|
346
|
+
// **并立刻用人对旧卡给的答案再发一次纯 approve** —— 正是本臂上方那条铁律禁的动作;
|
|
347
|
+
// 而且顺带把这枚指路键吞掉(第二发若也失败,外层拍平拿到的是**第二个**错误,首发的
|
|
348
|
+
// `currentPending` 从此不存在)。⇒ 上抛,由外层拍平臂原样带上指路键,人重新决断。
|
|
349
|
+
// ⚠️ 边界如实说:本条只收「引擎给了指路键」这一格;「回退臂是不是应该收窄成**只**兜
|
|
350
|
+
// 400 未知键」是**本批之前就在的**分类问题(见 §7b 登记),不在本批射程内 ——
|
|
351
|
+
// 在一个 additive 批里悄悄改掉老引擎兼容腿的宽度,是另一种越界。
|
|
340
352
|
if (e instanceof HitlSafetyError || e instanceof DecideTransportRetryExhaustedError)
|
|
341
353
|
throw e;
|
|
354
|
+
if (readDecideCurrentPending(e) !== undefined)
|
|
355
|
+
throw e;
|
|
342
356
|
hostLog('debug', `liveToolApprovalWire: decide(approve+remember) failed (${String(e)}) — falling back to plain approve`);
|
|
343
357
|
}
|
|
344
358
|
}
|
|
@@ -346,12 +360,15 @@ parkGatedCallId) {
|
|
|
346
360
|
return { kind: 'decided', gatedCallId };
|
|
347
361
|
}
|
|
348
362
|
catch (e) {
|
|
363
|
+
// #363:S-02 指路键原样透传(读不出 ⇒ 键不铸,形状逐字节不变)。见 FsApprovalOutcome 同名位。
|
|
364
|
+
const currentPending = readDecideCurrentPending(e);
|
|
349
365
|
return {
|
|
350
366
|
kind: 'failed',
|
|
351
367
|
gatedCallId,
|
|
352
368
|
reason: `decide(approve) failed: ${String(e)}`,
|
|
353
369
|
// Inkglow-1085 P0a:瞬断耗尽的判别位(在场即真)—— 语义失败缺席,reason 字节不变。
|
|
354
370
|
...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
|
|
371
|
+
...(currentPending !== undefined ? { currentPending } : {}),
|
|
355
372
|
};
|
|
356
373
|
}
|
|
357
374
|
case 'deny':
|
|
@@ -364,11 +381,14 @@ parkGatedCallId) {
|
|
|
364
381
|
return { kind: 'decided', gatedCallId, denied: true };
|
|
365
382
|
}
|
|
366
383
|
catch (e) {
|
|
384
|
+
// #363:与 allow 臂**同形同源**(同形存量清剿:两条 decide 失败腿一次改齐,不许只修当格)。
|
|
385
|
+
const currentPending = readDecideCurrentPending(e);
|
|
367
386
|
return {
|
|
368
387
|
kind: 'failed',
|
|
369
388
|
gatedCallId,
|
|
370
389
|
reason: `decide(deny) failed: ${String(e)}`,
|
|
371
390
|
...(e instanceof DecideTransportRetryExhaustedError ? { retryExhausted: true } : {}),
|
|
391
|
+
...(currentPending !== undefined ? { currentPending } : {}),
|
|
372
392
|
};
|
|
373
393
|
}
|
|
374
394
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -237,6 +237,7 @@ export * from './hitl/resumeRunningCard.js';
|
|
|
237
237
|
export * from './hitl/persistedRulesWire.js';
|
|
238
238
|
export * from './hitl/localAllowRule.js';
|
|
239
239
|
export * from './hitl/approvalsFeed.js';
|
|
240
|
+
export * from './interactiveHalt.js';
|
|
240
241
|
export * from './compensations.js';
|
|
241
242
|
export * from './request/printNotification.js';
|
|
242
243
|
export * from './seatContract.js';
|
package/dist/index.js
CHANGED
|
@@ -377,6 +377,13 @@ export * from './hitl/localAllowRule.js';
|
|
|
377
377
|
// B7 ③(census G20,**行为改动**不是搬迁):pending-approvals 推送 feed(stream 优先 / 断流回落
|
|
378
378
|
// 轮询 / 定期再试)。🔴 它**不替换** D-1 的取件 —— 那三处必须继续走权威 `list()`(见文件头)。
|
|
379
379
|
export * from './hitl/approvalsFeed.js';
|
|
380
|
+
// ── #363 件③(0.47.0):交互 Esc 的**停止判定**三端公共上收 ──────────────────────────────────
|
|
381
|
+
// 「Esc ⇒ 先发 turn 级 halt;只有那一发连判决都拿不到、而屏上又确实挂着审批卡时,才升级成 run 级
|
|
382
|
+
// cancel」——TUI/desktop/web 三端都会 Esc、都会撞同一个 parked 格,判定本该在库里。此前整条住在
|
|
383
|
+
// cli 壳(`seamQuery.bestEffortInteractiveHalt` + `interruptWire.needsRunLevelStop`),那也是壳侧
|
|
384
|
+
// 「裸 fetch 直拨 interrupt」那条网络面豁免**唯一**的退役条件。本模块只出**判据**(纯函数 + 引擎
|
|
385
|
+
// 升级码闭集);发射/台账/留痕/UI 反馈仍归各端。🔴 零 IO、零 import、零 module 级状态。
|
|
386
|
+
export * from './interactiveHalt.js';
|
|
380
387
|
// ── B6 余项③(P5):补偿层登记表 —— 把「哪条补偿拆了、拆缝对面是谁、什么时候能退休」做成数据。
|
|
381
388
|
// 🔴 它**不是** `ADAPTER_DIVERGENCES`(那张表说的是 adapt 与 cli 行为不同的地方;本表里的东西
|
|
382
389
|
// 两侧行为相同)。自检口 `compensationSplitViolations()` 恒应为空。
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* interactiveHalt — 交互 **Esc** 的停止判定层(#363 件③,0.47.0;三端公共判定上收)。
|
|
3
|
+
*
|
|
4
|
+
* ── 收的是哪一件事 ────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* 「用户按 Esc ⇒ 先发 **turn 级 halt**;升级成 **run 级 cancel** 恰有**两格**:
|
|
6
|
+
* ① 引擎自己回了升级闭集里的 **409**(它在说「这里没有在飞 turn 可切,run 级停止请用 cancel」);
|
|
7
|
+
* ② 这一发**连判决都没拿到**(传输失败/超时/未武装)**且**屏上确实挂着审批卡(`parked`)。
|
|
8
|
+
* 其余一律不升级。」—— 这条判定 TUI / desktop / web **三端都要**(三端都会 Esc、都会撞
|
|
9
|
+
* 同一个 parked 格),此前整条住在 cli 壳里(`src/sema/seamQuery.ts` 的 `bestEffortInteractiveHalt`
|
|
10
|
+
* + `src/sema/interruptWire.ts` 的 `needsRunLevelStop`)。本模块把**判据**搬进来;
|
|
11
|
+
* **发射**(裸 fetch / SDK verb)、台账、留痕、UI 反馈仍归各端。
|
|
12
|
+
* ⚠️ 别把①漏掉(异源复审 [medium] 采纳的原文歧义):只写「拿不到判决且 parked 才升级」的话,
|
|
13
|
+
* 端会漏接**引擎明确指路**那一格 —— parked run 的会话锁不放,下一条消息照样撞「Session busy」。
|
|
14
|
+
*
|
|
15
|
+
* ── 两个动词各自唯一能做到的格(**cancel 绝不删**,本模块存在的前提)──────────────────────
|
|
16
|
+
* · `interrupt`(turn 级,bare 形 = core `stream.halt()`「切 + 停」)—— 需要一条**活的、在飞的
|
|
17
|
+
* turn**。这是 Esc 的正题:切掉这一轮,run 在边界上终局、同 session 下一 submit 照常续。
|
|
18
|
+
* · `cancel`(run 级)—— 显式整 run 终局手势,而且它是**唯一**能把一条停在审批门上的
|
|
19
|
+
* `suspended`/`needs_review` run 就地终态化、把会话锁放开的动词(server [868] 起的语义)。
|
|
20
|
+
* ⇒ 无条件把 cancel 换成 interrupt = 审批卡挂着按 Esc 的主场景当场回归成「Session busy」病;
|
|
21
|
+
* 无条件补 cancel = 同一条 run 上别的在飞工具(后台 bash 等)被连坐拆掉(#324 的病形)。
|
|
22
|
+
* 本判定就是这两条之间那道**不对称**的闸。
|
|
23
|
+
*
|
|
24
|
+
* ── 🔴 不对称是刻意的(方向安全,两条代价不等价)────────────────────────────────────────
|
|
25
|
+
* 判**不**升级的代价 = 用户退回既有的「Session busy」卡,自己再选一次(**可恢复**);
|
|
26
|
+
* 判**错**升级的代价 = 拆掉一条其实还活着的 run,连坐它身上所有在飞工具(**不可恢复**)。
|
|
27
|
+
* ⇒ 闸往严的一侧设,宁可少升一次;凡「证不出来」一律落**不升级**侧。
|
|
28
|
+
*
|
|
29
|
+
* ── 判据锚在哪 ────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
* · 升级的**第一判据 = 引擎自己的机器码**({@link RUN_LEVEL_STOP_ERROR_CODES} × 409),
|
|
31
|
+
* 不是壳对 UI 状态的猜测([anchor-on-the-deciding-quantity]);
|
|
32
|
+
* · 只有当**引擎连判决都没给**(传输失败/超时/根本没武装)时,才轮到壳自己独立知道的那个
|
|
33
|
+
* 事实(`parked` = 这一拍屏上确实挂着审批卡 ⇒ 那条 run 停在 pending 决断上 ⇒ 本来就没有
|
|
34
|
+
* 在飞 turn 可切 ⇒ 补 cancel 不构成连坐)。
|
|
35
|
+
*
|
|
36
|
+
* 🔴 **纯判定**:零 IO、零 import、零 module 级状态,同一入参恒同一出参。
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* server 在 interrupt 腿上表达「**这条 run 已经 parked、没有在飞 turn 可切**」的 409 机器码
|
|
40
|
+
* (engine 7.52.0 `dist/http/routes/runs.js` 真字节,`sendNoLiveTurn` 的 `isParkedRunStatus` 分支)。
|
|
41
|
+
* · `interrupt.nothing_in_flight` —— run 停在 pending 决断上。**审批卡挂着按 Esc 的主场景就是
|
|
42
|
+
* 这一格**,server 的原话直接指路 steer/cancel。
|
|
43
|
+
*
|
|
44
|
+
* 🔴 **闭集只收这一员**,另外两个 409 都刻意在外:
|
|
45
|
+
* · `interrupt.not_held` —— **不收**。语义是「**本副本**手上没有可切的 live turn face」,而 server
|
|
46
|
+
* 自己的两条原话把它拆得很清楚:一条是「run is live on **another replica**」,另一条是
|
|
47
|
+
* verify/cascade 车道不暴露 live stream。**两条都不证明「全局没有在飞 turn」** —— 尤其第一条,
|
|
48
|
+
* 那条 run 正在别的副本上跑得好好的。对它升级 = 把 **turn 级** Esc 放大成**整 run 终止**。
|
|
49
|
+
* server 提示「run-level stop 可用 cancel」是在告诉你**有这个动词**,不等于用户授权了 run 级停止。
|
|
50
|
+
* · `steering.not_running` —— 不收。run 已终局,没有任何东西要停,补一发 cancel 是纯噪声。
|
|
51
|
+
* ⇒ 只有「引擎结构化地证明了 run 已 parked」这一格才允许升级。等上游给出 owner/parked 判别位
|
|
52
|
+
* (或跨副本路由)之后,`not_held` 才谈得上有安全的处置。
|
|
53
|
+
*
|
|
54
|
+
* 🔴 **为什么是 `Object.freeze` 的数组而不是 `ReadonlySet`**(异源复审 [high] 采纳,真病):
|
|
55
|
+
* `ReadonlySet<string>` 只在**类型面**只读 —— 运行期它就是一只普通 `Set`,而判定查的是**同一个
|
|
56
|
+
* 实例**。任何 JS 消费者(或本包将来某处的一行手滑)`.add('interrupt.not_held')` 之后,同一份入参
|
|
57
|
+
* 就会从 `none` 变成 `escalate-cancel`,把一条**还活着**的 run 不可恢复地拆掉 —— 这正是本模块整段
|
|
58
|
+
* 头注在防的那个方向,却被自己的导出形留了后门;而「纯判定、零 module 级可变态」那句承诺也当场
|
|
59
|
+
* 变成假话。冻结数组在**运行期**真的改不动(ESM 恒 strict:`push`/下标赋值直接抛),于是「公开
|
|
60
|
+
* 面」与「判定源」可以安全地是同一个物,不必铸第二份(两份才会漂)。
|
|
61
|
+
* ⚠️ 判据形随之从 `.has()` 改成 `.includes()` —— 与同仓 `parkResolver.GATE_FAILURE_CODES` 的
|
|
62
|
+
* `as const` 数组 + `includes` 逐字同姿势。闭集只有一员,查找成本不是这里的量。
|
|
63
|
+
*/
|
|
64
|
+
export declare const RUN_LEVEL_STOP_ERROR_CODES: readonly string[];
|
|
65
|
+
/**
|
|
66
|
+
* 一发 bare interrupt 的归一结局(端把自己的发射结果折成这个形喂进来;**绝不是**异常)。
|
|
67
|
+
* 形与 cli `src/sema/interruptWire.ts` 的 `InterruptOutcome` 逐字同形 —— 壳换装时直接对接。
|
|
68
|
+
*/
|
|
69
|
+
export type InteractiveHaltInterruptOutcome =
|
|
70
|
+
/** 2xx:切 + 停受理。`turnCut` = 这一发有没有真切到一轮(重复调用第二次诚实 `false`;
|
|
71
|
+
* 读不出 ⇒ `undefined`,**不硬造 false**)。本判定不读它 —— 受理即受理。 */
|
|
72
|
+
{
|
|
73
|
+
kind: 'halted';
|
|
74
|
+
turnCut?: boolean | undefined;
|
|
75
|
+
}
|
|
76
|
+
/** 非 2xx:引擎**给了判决**。带机器码与状态码,升级闸只认这一类。 */
|
|
77
|
+
| {
|
|
78
|
+
kind: 'refused';
|
|
79
|
+
errorCode?: string | undefined;
|
|
80
|
+
status: number;
|
|
81
|
+
}
|
|
82
|
+
/** 没武装(mock 车道 / live client 还没建)⇒ 一枪没打,**没有判决**。 */
|
|
83
|
+
| {
|
|
84
|
+
kind: 'unarmed';
|
|
85
|
+
}
|
|
86
|
+
/** 传输层失败(超时、连接断)⇒ 一样是**没拿到服务端判决**。 */
|
|
87
|
+
| {
|
|
88
|
+
kind: 'transport';
|
|
89
|
+
detail?: string | undefined;
|
|
90
|
+
};
|
|
91
|
+
/** 本判定的入参。 */
|
|
92
|
+
export interface InteractiveHaltInput {
|
|
93
|
+
/**
|
|
94
|
+
* **这一拍屏上是否挂着审批卡**(壳自己独立知道的事实:cli = `focusedInputDialog === 'tool-permission'`)。
|
|
95
|
+
* 🔴 它只在「interrupt 连判决都没拿到」那一格起作用 —— 见 {@link planInteractiveHalt} 的分支表。
|
|
96
|
+
* 🔴 缺席 / 非 `true` 一律按**非 parked** 处理(fail-closed:证不出 parked 就不给升级资格)。
|
|
97
|
+
*/
|
|
98
|
+
readonly parked?: boolean | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* 首发 interrupt 的结局。**缺席 = 这一发还没打** ⇒ 本函数判 `interrupt`(首发恒行,无条件)。
|
|
101
|
+
* 在场 ⇒ 本函数回答的是「接着还要不要升级」。
|
|
102
|
+
*/
|
|
103
|
+
readonly interruptOutcome?: InteractiveHaltInterruptOutcome | undefined;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* 判决的**机读因由**(闭集;人话文案归端,别拿它当展示串 —— [machine-readable-signal-not-visual-anchor])。
|
|
107
|
+
*/
|
|
108
|
+
export type InteractiveHaltReason =
|
|
109
|
+
/** 还没发过 interrupt ⇒ 首发恒行(这一步不看 `parked`,不看任何东西)。 */
|
|
110
|
+
'first-shot'
|
|
111
|
+
/** 202 受理:这一轮已被切掉,run 在边界上终局 ⇒ 无事可做。 */
|
|
112
|
+
| 'halted'
|
|
113
|
+
/** 引擎**自己**回了升级闭集里的 409 码 ⇒ 它在说「run 级停止请用 cancel」。 */
|
|
114
|
+
| 'engine-says-run-level'
|
|
115
|
+
/** 连判决都没拿到(传输失败/超时/未武装),但壳自证屏上挂着审批卡 ⇒ 这一格本来就没有在飞 turn。 */
|
|
116
|
+
| 'no-verdict-on-parked-card'
|
|
117
|
+
/** 连判决都没拿到且**非** parked ⇒ 那里真可能有在飞工具,凭一次超时拆整条 run 正是要消除的病。 */
|
|
118
|
+
| 'no-verdict-not-parked'
|
|
119
|
+
/** 引擎给了判决,但不在升级闭集里(404 / 400 / 非 409 / `steering.not_running` / 码读不出)。 */
|
|
120
|
+
| 'refused-no-escalation'
|
|
121
|
+
/** 入参不是本模块认得的任何一种结局形 ⇒ fail-closed 不升级(宿主传了新形/坏形)。 */
|
|
122
|
+
| 'unknown-outcome';
|
|
123
|
+
/** 判决值:做什么 + 为什么(机读)。 */
|
|
124
|
+
export interface InteractiveHaltPlan {
|
|
125
|
+
readonly action: 'interrupt' | 'escalate-cancel' | 'none';
|
|
126
|
+
readonly reason: InteractiveHaltReason;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Esc 停止弧的**唯一判定口**。
|
|
130
|
+
*
|
|
131
|
+
* ── 分支表(逐条 = 一条行为承诺)──────────────────────────────────────────────────────────
|
|
132
|
+
* | `interruptOutcome` | `parked` | 判决 | reason |
|
|
133
|
+
* |--------------------------------------------|----------|-------------------|--------|
|
|
134
|
+
* | 缺席(还没打) | 任意 | `interrupt` | `first-shot` |
|
|
135
|
+
* | `halted` | 任意 | `none` | `halted` |
|
|
136
|
+
* | `refused` + 409 + 升级闭集码 | 任意 | `escalate-cancel` | `engine-says-run-level` |
|
|
137
|
+
* | `refused`(其余:非 409 / 码不在闭集 / 码缺席)| 任意 | `none` | `refused-no-escalation` |
|
|
138
|
+
* | `transport` / `unarmed` | `true` | `escalate-cancel` | `no-verdict-on-parked-card` |
|
|
139
|
+
* | `transport` / `unarmed` | 其余 | `none` | `no-verdict-not-parked` |
|
|
140
|
+
* | 认不得的形 | 任意 | `none` | `unknown-outcome` |
|
|
141
|
+
*
|
|
142
|
+
* 🔴 **`parked` 只在「没有判决」那两格被读**:引擎给了判决时,判决说了算 —— 壳的 UI 状态不许覆盖
|
|
143
|
+
* 引擎的结构化答复(反过来也一样:引擎说 parked 时,`parked=false` 不阻止升级)。
|
|
144
|
+
* 🔴 **首发无条件**:`first-shot` 那一格刻意不看 `parked`。审批卡挂着时首发 interrupt 会吃一个
|
|
145
|
+
* 409,那正是升级闸要的**判决**;为了省一次往返而直接跳到 cancel,等于把判据从引擎搬回壳里猜。
|
|
146
|
+
* 🔴 **顺序契约(端必读,不是本函数能保证的那半)**:这一发必须排在「撕 SSE」**之前**。交互车道零
|
|
147
|
+
* `x-detach-on-disconnect`,先撕流 = server 按断连语义当场收尾那条 run,随后落地的 interrupt
|
|
148
|
+
* 只会拿到 409 `steering.not_running`(cli L-11 真机实测:同步撕流形每一轮都是它)。
|
|
149
|
+
*/
|
|
150
|
+
export declare function planInteractiveHalt(input: InteractiveHaltInput): InteractiveHaltPlan;
|