@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
|
@@ -0,0 +1,131 @@
|
|
|
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 const RUN_LEVEL_STOP_ERROR_CODES = Object.freeze([
|
|
65
|
+
'interrupt.nothing_in_flight',
|
|
66
|
+
]);
|
|
67
|
+
/** 升级闭集的码在 wire 上**只以 409 出现**(engine 7.52.0 `sendNoLiveTurn` 两分支逐字)。 */
|
|
68
|
+
const RUN_LEVEL_STOP_STATUS = 409;
|
|
69
|
+
/**
|
|
70
|
+
* 这一发 interrupt 的结局是不是在说「**改用 run 级停止**」。
|
|
71
|
+
*
|
|
72
|
+
* 判据 = **码闭集({@link RUN_LEVEL_STOP_ERROR_CODES})与 409 状态的合取**,两个条件都必要:
|
|
73
|
+
* · 只认码不认状态 ⇒ 一只 5xx(引擎内部错、代理改写体、老版本复用同名码)只要正文里带上那个码,
|
|
74
|
+
* 就能把我们骗去打一发 **run 级 cancel** —— 那是**破坏性**动作,而它当时其实没有任何判决依据。
|
|
75
|
+
* · 只认 409 不认码 ⇒ `steering.not_running`(run 已终局)也会被升级,对着一条已经结束的 run 补枪。
|
|
76
|
+
*/
|
|
77
|
+
function enginePointsToRunLevelStop(outcome) {
|
|
78
|
+
const o = outcome;
|
|
79
|
+
return (o?.kind === 'refused' &&
|
|
80
|
+
o.status === RUN_LEVEL_STOP_STATUS &&
|
|
81
|
+
typeof o.errorCode === 'string' &&
|
|
82
|
+
RUN_LEVEL_STOP_ERROR_CODES.includes(o.errorCode));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Esc 停止弧的**唯一判定口**。
|
|
86
|
+
*
|
|
87
|
+
* ── 分支表(逐条 = 一条行为承诺)──────────────────────────────────────────────────────────
|
|
88
|
+
* | `interruptOutcome` | `parked` | 判决 | reason |
|
|
89
|
+
* |--------------------------------------------|----------|-------------------|--------|
|
|
90
|
+
* | 缺席(还没打) | 任意 | `interrupt` | `first-shot` |
|
|
91
|
+
* | `halted` | 任意 | `none` | `halted` |
|
|
92
|
+
* | `refused` + 409 + 升级闭集码 | 任意 | `escalate-cancel` | `engine-says-run-level` |
|
|
93
|
+
* | `refused`(其余:非 409 / 码不在闭集 / 码缺席)| 任意 | `none` | `refused-no-escalation` |
|
|
94
|
+
* | `transport` / `unarmed` | `true` | `escalate-cancel` | `no-verdict-on-parked-card` |
|
|
95
|
+
* | `transport` / `unarmed` | 其余 | `none` | `no-verdict-not-parked` |
|
|
96
|
+
* | 认不得的形 | 任意 | `none` | `unknown-outcome` |
|
|
97
|
+
*
|
|
98
|
+
* 🔴 **`parked` 只在「没有判决」那两格被读**:引擎给了判决时,判决说了算 —— 壳的 UI 状态不许覆盖
|
|
99
|
+
* 引擎的结构化答复(反过来也一样:引擎说 parked 时,`parked=false` 不阻止升级)。
|
|
100
|
+
* 🔴 **首发无条件**:`first-shot` 那一格刻意不看 `parked`。审批卡挂着时首发 interrupt 会吃一个
|
|
101
|
+
* 409,那正是升级闸要的**判决**;为了省一次往返而直接跳到 cancel,等于把判据从引擎搬回壳里猜。
|
|
102
|
+
* 🔴 **顺序契约(端必读,不是本函数能保证的那半)**:这一发必须排在「撕 SSE」**之前**。交互车道零
|
|
103
|
+
* `x-detach-on-disconnect`,先撕流 = server 按断连语义当场收尾那条 run,随后落地的 interrupt
|
|
104
|
+
* 只会拿到 409 `steering.not_running`(cli L-11 真机实测:同步撕流形每一轮都是它)。
|
|
105
|
+
*/
|
|
106
|
+
export function planInteractiveHalt(input) {
|
|
107
|
+
const outcome = input.interruptOutcome;
|
|
108
|
+
// 首发恒行:一发都还没打的时候,没有任何东西需要判。
|
|
109
|
+
// 🔴 `null` 与 `undefined` **同判「还没打」**(两种缺席形;宿主用哪一种写「没有结局」都算数)——
|
|
110
|
+
// 而不是落 fail-closed 的 `unknown-outcome`:那会让 Esc 一发都不打(interrupt 是**非破坏性**
|
|
111
|
+
// 动词,把它扣下来比多打一发更坏)。fail-closed 守的是**升级**那一侧,不是首发。
|
|
112
|
+
if (outcome === undefined || outcome === null)
|
|
113
|
+
return { action: 'interrupt', reason: 'first-shot' };
|
|
114
|
+
// 防御读:wire/宿主形在运行期不存在类型(M0/M1),`kind` 可能是新词、可能压根不是对象。
|
|
115
|
+
const kind = outcome.kind;
|
|
116
|
+
if (kind === 'halted')
|
|
117
|
+
return { action: 'none', reason: 'halted' };
|
|
118
|
+
if (kind === 'refused') {
|
|
119
|
+
return enginePointsToRunLevelStop(outcome)
|
|
120
|
+
? { action: 'escalate-cancel', reason: 'engine-says-run-level' }
|
|
121
|
+
: { action: 'none', reason: 'refused-no-escalation' };
|
|
122
|
+
}
|
|
123
|
+
if (kind === 'transport' || kind === 'unarmed') {
|
|
124
|
+
// 没拿到判决。只有壳能**独立证明** parked 的那一格才升级(理由见顶注的不对称段)。
|
|
125
|
+
return input.parked === true
|
|
126
|
+
? { action: 'escalate-cancel', reason: 'no-verdict-on-parked-card' }
|
|
127
|
+
: { action: 'none', reason: 'no-verdict-not-parked' };
|
|
128
|
+
}
|
|
129
|
+
// 宿主传了本模块认不得的形(新 kind / 坏对象)。fail-closed:不对一个读不懂的结局动手。
|
|
130
|
+
return { action: 'none', reason: 'unknown-outcome' };
|
|
131
|
+
}
|
|
@@ -27,7 +27,12 @@ import { flattenWireOutput } from './adapt/wireShapes.js';
|
|
|
27
27
|
function bashExitCodeFailed(toolName, text, structured) {
|
|
28
28
|
if (typeof toolName !== 'string' || toolName.toLowerCase() !== 'bash')
|
|
29
29
|
return false;
|
|
30
|
-
const
|
|
30
|
+
const st = structured;
|
|
31
|
+
// 良性非零退出注记在场(grep/rg 无命中族)⇒ 非错——CC 同款:interpretation 形走 data
|
|
32
|
+
// 路径非 error 路径(pretty223:394251);与 toolResult.ts T11 同判别量(2026-08-27)。
|
|
33
|
+
if (typeof st?.returnCodeInterpretation === 'string' && st.returnCodeInterpretation.length > 0)
|
|
34
|
+
return false;
|
|
35
|
+
const ec = st?.exitCode;
|
|
31
36
|
if (typeof ec === 'number')
|
|
32
37
|
return ec !== 0;
|
|
33
38
|
const m = /^exit code: (\d+)\b/.exec(text);
|
package/dist/toolResult.js
CHANGED
|
@@ -386,7 +386,14 @@ export function wireOutputToBody(toolName, output, isError, truncated, flatten,
|
|
|
386
386
|
}
|
|
387
387
|
: parseModelFacingBash(text);
|
|
388
388
|
if (slots) {
|
|
389
|
-
|
|
389
|
+
// 良性非零退出注记(2026-08-27,1.0.93 现网案):引擎对 grep/rg 无命中族显式给
|
|
390
|
+
// isError:false + structured.returnCodeInterpretation('No matches found')。注记在场 ⇒
|
|
391
|
+
// 非零退出不再翻 failed(CC 同款:interpretation 形走 data 路径非 error 路径,
|
|
392
|
+
// pretty223:394251/815147 直证)。[1948] 裁定不动:无注记的非零退出照旧按退出码补位。
|
|
393
|
+
const benignInterp = typeof s?.returnCodeInterpretation === 'string' && s.returnCodeInterpretation.length > 0
|
|
394
|
+
? s.returnCodeInterpretation
|
|
395
|
+
: undefined;
|
|
396
|
+
const failed = isError || (slots.exitCode != null && slots.exitCode !== 0 && benignInterp === undefined);
|
|
390
397
|
if (failed) {
|
|
391
398
|
// 失败卡走 UserToolErrorMessage,它读 BLOCK content 且非字符串会被吞成
|
|
392
399
|
// "Tool execution failed" —— 失败路径必须给字符串(cli 同,对抗复审 d126727#3)。
|
|
@@ -397,6 +404,15 @@ export function wireOutputToBody(toolName, output, isError, truncated, flatten,
|
|
|
397
404
|
if (slots.exitCode === 137) {
|
|
398
405
|
body += `\nCommand was killed (exit 137 — possibly out-of-memory; consider lowering build parallelism e.g. make -j2)`;
|
|
399
406
|
}
|
|
407
|
+
// [5601]/#362(2026-08-29,live 帧直证 core 5.65.0):「跑过然后被掐」家族
|
|
408
|
+
// (timeout/aborted/callback_error)铸 isError:true + 三槽 {stdout:'',stderr:'',exitCode:null}
|
|
409
|
+
// + output 正文带完整说明——重建体在这形上恒为空串,引擎正文整段被丢,下游渲
|
|
410
|
+
// 「no error detail」空卡。回退判据锚**决定量本身**(重建 body 空而正文有话),
|
|
411
|
+
// 不锚 timedOut 等键名:同家族零输出三臂一次覆盖,且 exitCode 在场时 body 至少含
|
|
412
|
+
// `Exit code N` 恒非空 ⇒ 回退结构上只在 exitCode===null 且双槽空触发,权威臂地位不动。
|
|
413
|
+
// 双空(正文也空)⇒ 如实返回空串,消费端「no error detail」兜底句保持可达。
|
|
414
|
+
if (body.trim() === '' && text.trim() !== '')
|
|
415
|
+
body = text;
|
|
400
416
|
return { content: truncated ? `${body}\n…(truncated)` : body, isError: true };
|
|
401
417
|
}
|
|
402
418
|
const stdout = truncated ? `${slots.stdout}\n…(truncated)` : slots.stdout;
|
|
@@ -404,7 +420,14 @@ export function wireOutputToBody(toolName, output, isError, truncated, flatten,
|
|
|
404
420
|
return {
|
|
405
421
|
content: [processedStdout, slots.stderr.trim()].filter(Boolean).join('\n'),
|
|
406
422
|
isError: false,
|
|
407
|
-
|
|
423
|
+
// 注记过境:BashToolResultMessage 蓝本在零输出分支渲 returnCodeInterpretation
|
|
424
|
+
// (『No matches found』而非『(No output)』),缺席时行为不变。
|
|
425
|
+
toolUseResult: {
|
|
426
|
+
stdout,
|
|
427
|
+
stderr: slots.stderr,
|
|
428
|
+
interrupted: false,
|
|
429
|
+
...(benignInterp !== undefined ? { returnCodeInterpretation: benignInterp } : {}),
|
|
430
|
+
},
|
|
408
431
|
};
|
|
409
432
|
}
|
|
410
433
|
const stdout = truncated ? `${text}\n…(truncated)` : text;
|
|
@@ -518,13 +541,18 @@ modelText) {
|
|
|
518
541
|
if (typeof s.stdout !== 'string' && typeof s.stderr !== 'string')
|
|
519
542
|
return null;
|
|
520
543
|
const exitCode = typeof s.exitCode === 'number' ? s.exitCode : null;
|
|
544
|
+
// 良性非零退出注记同臂(与 wireOutputToBody T11 同判别量,CC pretty223:394251 同源)。
|
|
545
|
+
const benignInterp = typeof s.returnCodeInterpretation === 'string' && s.returnCodeInterpretation.length > 0
|
|
546
|
+
? s.returnCodeInterpretation
|
|
547
|
+
: undefined;
|
|
521
548
|
return {
|
|
522
549
|
toolUseResult: {
|
|
523
550
|
stdout: typeof s.stdout === 'string' ? s.stdout : '',
|
|
524
551
|
stderr: typeof s.stderr === 'string' ? s.stderr : '',
|
|
525
552
|
interrupted: false,
|
|
553
|
+
...(benignInterp !== undefined ? { returnCodeInterpretation: benignInterp } : {}),
|
|
526
554
|
},
|
|
527
|
-
...(exitCode !== null && exitCode !== 0 ? { isError: true } : {}),
|
|
555
|
+
...(exitCode !== null && exitCode !== 0 && benignInterp === undefined ? { isError: true } : {}),
|
|
528
556
|
};
|
|
529
557
|
}
|
|
530
558
|
case 'edit': {
|
|
@@ -49,6 +49,47 @@ export declare function isPreStreamDrainingReject(err: unknown): boolean;
|
|
|
49
49
|
* #166 — `resume_at.*` 错误码族:提交带的 resumeAt 锚服务端不认(Esc 杀锚未持久化的典型形)。
|
|
50
50
|
* 🔴 刻意**不**用 `isRewindFamilyCode`(那是含 `rewind_snapshot.` 的宽形):去锚自动重发的安全性
|
|
51
51
|
* 论证只对 resume_at 族做过,放宽属行为变更(RESUME_AT_ERROR_CODE_PREFIX 头注)。
|
|
52
|
+
*
|
|
53
|
+
* ## #355 D1 —— 两条载体腿(2026-08-27)
|
|
54
|
+
* 同一个语义在 wire 上有**两种形**,此前只认第一种:
|
|
55
|
+
* · **机读形**(既有腿,一字未动):core prepare-throw 经 `resumeAtHttpStatus` 落 4xx,
|
|
56
|
+
* `errorCode` **原样透传**真子码(`resume_at.not_found` / `resume_at.before_target_not_user` …)
|
|
57
|
+
* ⇒ 前缀命中。
|
|
58
|
+
* · **包装形**(本腿新增):server 的**预检腿**在 core 之前就 4xx —— `boot/resolve-spec.ts`
|
|
59
|
+
* 抛 `HttpError(404, "resumeAt: no such message in this session (resume_at.unknown_event)")`
|
|
60
|
+
* 且**不带** `code`,于是 `http/send.ts` 的 `httpErrorCode(404)` 盖上按状态码派生的粗码
|
|
61
|
+
* `not_found.resource`,真子码**只活在人话里**。SDK `classifyApiError` 把 body 的 `error`
|
|
62
|
+
* 原样搬进 `APIError.message`(scrub 只动 Bearer/presigned),所以壳收到的是
|
|
63
|
+
* `{status:404, errorCode:'not_found.resource', message:'…(resume_at.unknown_event)'}`。
|
|
64
|
+
* 旧判据 false ⇒ #166 去锚重发整臂跳过 ⇒ 会话每条消息秒死 404、永久死锁(1.0.93 现网案)。
|
|
65
|
+
*
|
|
66
|
+
* ## 放宽的论证(engineErrorCodes `RESUME_AT_ERROR_CODE_PREFIX` 头注要求「放宽须论证」)
|
|
67
|
+
* 本腿放宽的是**载体**(码位 → 码位∪人话),不是**族**:
|
|
68
|
+
* ① 三重合取收敛误触面:`status === 404`(引擎真应答了,不是网络/内部错)+ 人话里出现
|
|
69
|
+
* **同一个族前缀**字面 + 调用点自己的 `req.resumeAt !== undefined`(seamQuery #166 臂)
|
|
70
|
+
* —— 没带锚的提交压根进不来,与「404 里恰好出现 `resume_at.` 字样」撞车要三件同时成立。
|
|
71
|
+
* ② 族边界一字未松,而且文本腿收成**闭集**:只认 `RESUME_AT_TEXT_COMPAT` 里那两对
|
|
72
|
+
* 「状态 × 括号收尾子码」——`404 × (resume_at.unknown_event)` 与
|
|
73
|
+
* `422 × (resume_at.no_session)`,正是 `boot/resolve-spec.ts` 两处**不带 `code`** 的 throw。
|
|
74
|
+
* `rewind_snapshot.` / `rewind_files_to.` 邻族不入内(邻族的自动重发安全性没论证过);
|
|
75
|
+
* 开集形与裸子串形都已被异源复审证伪(token 边界 + 回显注入两条,论证见
|
|
76
|
+
* {@link RESUME_AT_TEXT_COMPAT})。带 code 的那一路(core prepare-throw 经 `resumeAtHttpStatus`
|
|
77
|
+
* 落 4xx)`errorCode` 原样透传,早被 ① 接住,压根不经过这里。
|
|
78
|
+
* ③ 422 那一支不是「顺手放宽」:它与 404 支同为预检腿的 codeless throw,处置也同款 —— 去锚重发在
|
|
79
|
+
* `no_session` 上不但安全、而且**正是正解**(没有可分支的 session,新起一轮本来就是对的)。
|
|
80
|
+
* 原形把它留在门外、负控还把「422 恒 false」钉成期望值 —— 那不是负控,是把残余固化成契约。
|
|
81
|
+
* ④ 误触的代价上界很低而漏判的代价是死锁:误命中最多多发一次**去锚的**同一条提交(pre-stream
|
|
82
|
+
* 零副作用,与 draining 同一铁律边界)+ 一行人话;漏判 = 会话再也发不出消息。
|
|
83
|
+
* ⑤ 刻意**不**加「粗码必须等于 `request.unprocessable` / `not_found.resource`」这层收窄:那张
|
|
84
|
+
* 状态码→粗码表是 server `http/send.ts` 的实现细节(会随版本增删),把判据押在它上面等于给自己
|
|
85
|
+
* 种一颗静默失效的雷;判别力已由 ② 的闭集承担。
|
|
86
|
+
*
|
|
87
|
+
* 🔴 根治在发端、且**已经落地**:server 给那四条拒绝子码补上了结构化 `code` 参,新 worker 一律走
|
|
88
|
+
* 机读腿 ①。本腿因此是**对已发布旧 server 的过渡垫片**(1.0.93 现网用户跑的正是那些),不是长期形;
|
|
89
|
+
* 等最低 server 版本推过去之后可以整条退役。
|
|
90
|
+
*
|
|
91
|
+
* 🔴 只影响本函数的判定,`RESUME_AT_ERROR_CODE_PREFIX` 常量与它的另一个消费点
|
|
92
|
+
* `isRewindFamilyCode`(rewind 可自解族的**码**判别)一字未动。
|
|
52
93
|
*/
|
|
53
94
|
export declare function isResumeAtRejection(err: unknown): boolean;
|
|
54
95
|
/**
|
package/dist/wireErrorTriage.js
CHANGED
|
@@ -87,14 +87,101 @@ export function isPreStreamDrainingReject(err) {
|
|
|
87
87
|
const e = err;
|
|
88
88
|
return typeof e?.status === 'number' && e.status === 503 && e.errorCode === DRAINING_ERROR_CODE;
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* #355 D1 文本兼容腿认的**闭集**:server 预检腿两处 codeless throw 的「状态 × 子码」配对,
|
|
92
|
+
* 且必须以 server 自己那种**括号收尾**的形出现(`…(resume_at.unknown_event)`)。
|
|
93
|
+
*
|
|
94
|
+
* 🔴 为什么是闭集而不是开集(异源复审第四轮 [medium] 真病修):这条腿的定位是「**已发布**的旧
|
|
95
|
+
* server 的兼容垫片」——一个**历史封闭**的集合。新码一律带结构化 `errorCode`(server 已在发端补齐
|
|
96
|
+
* 四条子码的 `code` 参),走前缀腿 ① 直接命中,根本不经过这里。开集在这里换不来任何前瞻性,却把
|
|
97
|
+
* 判据暴露在**回显注入**下。
|
|
98
|
+
*
|
|
99
|
+
* 🔴 回显注入是真事(亲读 server `config.ts:formatUnmatchableToolNames`):它把**用户送来的**工具名
|
|
100
|
+
* 逐字拼进错误文本(`… names tool(s) …: <用户的名字> → <guidance>`,多条以 `; ` 连接),再由
|
|
101
|
+
* `resolve-spec` 翻成一个**普通 codeless 422**。于是「任意 422 + 文本里出现 resume_at.<任意成员>」
|
|
102
|
+
* 这种开集判据,可以被一条名叫 `resume_at.foo` 的权限规则触发 —— 而那一拍用户的 `resumeAt` 锚是
|
|
103
|
+
* **有效**的,后果是:壳把有效锚删掉、渲一行「恢复点失效」(与事实不符 = 编造归因)、再重发一次。
|
|
104
|
+
*
|
|
105
|
+
* 堵法是**闭集 + 串尾锚**两件一起:
|
|
106
|
+
* · 闭集挡住 `resume_at.<任意成员>`;
|
|
107
|
+
* · 串尾锚(消费点用 `endsWith`)挡住「用户把整串 `(resume_at.no_session)` 连括号写进权限名」——
|
|
108
|
+
* 那一形里回显内容后面必然还跟着 ` → guidance`,永远到不了串尾。少了串尾锚,闭集单独是漏的
|
|
109
|
+
* (异源复审第五轮实测反证)。
|
|
110
|
+
* 串尾成立的依据是实证不是假设:7.42.0–7.50.0 七份已发布 server 产物里,这两句的码后面紧跟的就是
|
|
111
|
+
* 字符串字面量的结束引号。
|
|
112
|
+
*
|
|
113
|
+
* 字面单源:两枚子码由 {@link RESUME_AT_ERROR_CODE_PREFIX} 拼出,常量改了这里自动跟。
|
|
114
|
+
* 认不出 ⇒ 退化成「不自动去锚重发」= 修这一批之前的行为,绝不会更坏。
|
|
115
|
+
*/
|
|
116
|
+
const RESUME_AT_TEXT_COMPAT = [
|
|
117
|
+
{ status: 404, marker: `(${RESUME_AT_ERROR_CODE_PREFIX}unknown_event)` },
|
|
118
|
+
{ status: 422, marker: `(${RESUME_AT_ERROR_CODE_PREFIX}no_session)` },
|
|
119
|
+
];
|
|
90
120
|
/**
|
|
91
121
|
* #166 — `resume_at.*` 错误码族:提交带的 resumeAt 锚服务端不认(Esc 杀锚未持久化的典型形)。
|
|
92
122
|
* 🔴 刻意**不**用 `isRewindFamilyCode`(那是含 `rewind_snapshot.` 的宽形):去锚自动重发的安全性
|
|
93
123
|
* 论证只对 resume_at 族做过,放宽属行为变更(RESUME_AT_ERROR_CODE_PREFIX 头注)。
|
|
124
|
+
*
|
|
125
|
+
* ## #355 D1 —— 两条载体腿(2026-08-27)
|
|
126
|
+
* 同一个语义在 wire 上有**两种形**,此前只认第一种:
|
|
127
|
+
* · **机读形**(既有腿,一字未动):core prepare-throw 经 `resumeAtHttpStatus` 落 4xx,
|
|
128
|
+
* `errorCode` **原样透传**真子码(`resume_at.not_found` / `resume_at.before_target_not_user` …)
|
|
129
|
+
* ⇒ 前缀命中。
|
|
130
|
+
* · **包装形**(本腿新增):server 的**预检腿**在 core 之前就 4xx —— `boot/resolve-spec.ts`
|
|
131
|
+
* 抛 `HttpError(404, "resumeAt: no such message in this session (resume_at.unknown_event)")`
|
|
132
|
+
* 且**不带** `code`,于是 `http/send.ts` 的 `httpErrorCode(404)` 盖上按状态码派生的粗码
|
|
133
|
+
* `not_found.resource`,真子码**只活在人话里**。SDK `classifyApiError` 把 body 的 `error`
|
|
134
|
+
* 原样搬进 `APIError.message`(scrub 只动 Bearer/presigned),所以壳收到的是
|
|
135
|
+
* `{status:404, errorCode:'not_found.resource', message:'…(resume_at.unknown_event)'}`。
|
|
136
|
+
* 旧判据 false ⇒ #166 去锚重发整臂跳过 ⇒ 会话每条消息秒死 404、永久死锁(1.0.93 现网案)。
|
|
137
|
+
*
|
|
138
|
+
* ## 放宽的论证(engineErrorCodes `RESUME_AT_ERROR_CODE_PREFIX` 头注要求「放宽须论证」)
|
|
139
|
+
* 本腿放宽的是**载体**(码位 → 码位∪人话),不是**族**:
|
|
140
|
+
* ① 三重合取收敛误触面:`status === 404`(引擎真应答了,不是网络/内部错)+ 人话里出现
|
|
141
|
+
* **同一个族前缀**字面 + 调用点自己的 `req.resumeAt !== undefined`(seamQuery #166 臂)
|
|
142
|
+
* —— 没带锚的提交压根进不来,与「404 里恰好出现 `resume_at.` 字样」撞车要三件同时成立。
|
|
143
|
+
* ② 族边界一字未松,而且文本腿收成**闭集**:只认 `RESUME_AT_TEXT_COMPAT` 里那两对
|
|
144
|
+
* 「状态 × 括号收尾子码」——`404 × (resume_at.unknown_event)` 与
|
|
145
|
+
* `422 × (resume_at.no_session)`,正是 `boot/resolve-spec.ts` 两处**不带 `code`** 的 throw。
|
|
146
|
+
* `rewind_snapshot.` / `rewind_files_to.` 邻族不入内(邻族的自动重发安全性没论证过);
|
|
147
|
+
* 开集形与裸子串形都已被异源复审证伪(token 边界 + 回显注入两条,论证见
|
|
148
|
+
* {@link RESUME_AT_TEXT_COMPAT})。带 code 的那一路(core prepare-throw 经 `resumeAtHttpStatus`
|
|
149
|
+
* 落 4xx)`errorCode` 原样透传,早被 ① 接住,压根不经过这里。
|
|
150
|
+
* ③ 422 那一支不是「顺手放宽」:它与 404 支同为预检腿的 codeless throw,处置也同款 —— 去锚重发在
|
|
151
|
+
* `no_session` 上不但安全、而且**正是正解**(没有可分支的 session,新起一轮本来就是对的)。
|
|
152
|
+
* 原形把它留在门外、负控还把「422 恒 false」钉成期望值 —— 那不是负控,是把残余固化成契约。
|
|
153
|
+
* ④ 误触的代价上界很低而漏判的代价是死锁:误命中最多多发一次**去锚的**同一条提交(pre-stream
|
|
154
|
+
* 零副作用,与 draining 同一铁律边界)+ 一行人话;漏判 = 会话再也发不出消息。
|
|
155
|
+
* ⑤ 刻意**不**加「粗码必须等于 `request.unprocessable` / `not_found.resource`」这层收窄:那张
|
|
156
|
+
* 状态码→粗码表是 server `http/send.ts` 的实现细节(会随版本增删),把判据押在它上面等于给自己
|
|
157
|
+
* 种一颗静默失效的雷;判别力已由 ② 的闭集承担。
|
|
158
|
+
*
|
|
159
|
+
* 🔴 根治在发端、且**已经落地**:server 给那四条拒绝子码补上了结构化 `code` 参,新 worker 一律走
|
|
160
|
+
* 机读腿 ①。本腿因此是**对已发布旧 server 的过渡垫片**(1.0.93 现网用户跑的正是那些),不是长期形;
|
|
161
|
+
* 等最低 server 版本推过去之后可以整条退役。
|
|
162
|
+
*
|
|
163
|
+
* 🔴 只影响本函数的判定,`RESUME_AT_ERROR_CODE_PREFIX` 常量与它的另一个消费点
|
|
164
|
+
* `isRewindFamilyCode`(rewind 可自解族的**码**判别)一字未动。
|
|
94
165
|
*/
|
|
95
166
|
export function isResumeAtRejection(err) {
|
|
96
167
|
const e = err;
|
|
97
|
-
|
|
168
|
+
if (typeof e?.errorCode === 'string' && e.errorCode.startsWith(RESUME_AT_ERROR_CODE_PREFIX))
|
|
169
|
+
return true;
|
|
170
|
+
// 文本兼容腿:只认闭集里那两对「状态 × 括号收尾子码」(见 {@link RESUME_AT_TEXT_COMPAT})。
|
|
171
|
+
const marker = RESUME_AT_TEXT_COMPAT.find((c) => c.status === e?.status)?.marker;
|
|
172
|
+
if (marker === undefined)
|
|
173
|
+
return false;
|
|
174
|
+
// 两个人话载体**各自独立判**(异源复审 [medium] 真病修:原形取「第一个非空」,而 SDK
|
|
175
|
+
// `classifyApiError` 的 `message` 恒非空(缺席时回落 `HTTP <status>`)⇒ `errorMessage` 那条腿在
|
|
176
|
+
// 「泛化 message + 细节在 errorMessage」的包装形下恒不可达 = 死腿)。
|
|
177
|
+
// · `message` = SDK `APIError` 的正身(壳 seamQuery catch 到的就是它,生产真形);
|
|
178
|
+
// · `errorMessage` = 同一句话在 wire 体 / `TaskResult` 上的键位(轮询腿把体直接当错误对象抛时的形)。
|
|
179
|
+
// 结构读不 instanceof:三端共用面,desktop IPC / web 跨 bundle 的同名错误是 plain object。
|
|
180
|
+
// 🔴 `endsWith` 而不是 `includes`(异源复审第五轮 [medium] 真病修):server 自己那两句**以码收尾**
|
|
181
|
+
// (亲验 7.42.0–7.50.0 七份已发布产物,码后紧跟的就是字符串字面量的结束引号);而回显形里用户内容
|
|
182
|
+
// 后面必然还跟着 ` → guidance`。只认串尾 ⇒ 用户就算把整串 `(resume_at.no_session)` 连括号一起
|
|
183
|
+
// 写进权限名,它在错误文本里也永远不在串尾,注入这条路彻底堵死。
|
|
184
|
+
return [e?.message, e?.errorMessage].some((t) => typeof t === 'string' && t.endsWith(marker));
|
|
98
185
|
}
|
|
99
186
|
/**
|
|
100
187
|
* 第 `attempt`(0-based)次 draining 重试前的等待:`err.retryAfterMs`(SDK 若带)封顶优先
|