@sema-agent/client-core 0.52.0 → 0.53.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 +56 -0
- package/README.md +1 -1
- package/dist/adapter/activeRunSelfHeal.d.ts +99 -2
- package/dist/adapter/activeRunSelfHeal.js +381 -104
- package/docs/INTEGRATION-CLIENTS.md +148 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -40,6 +40,62 @@
|
|
|
40
40
|
> 门侧窄豁免同批登记(`KNOWN_HEADING_ERRATA` `version: '0.48.0', releasedAt: '4fae01b'`),
|
|
41
41
|
> 与本段互钉,均为永久记录。
|
|
42
42
|
|
|
43
|
+
## 0.53.0(未发布)
|
|
44
|
+
|
|
45
|
+
### hitl 自愈:陈旧 park —— 「待决行确已不在」给真出路(L-93;现网 sema-bug7)
|
|
46
|
+
|
|
47
|
+
- **病**:审批卡 park 住一条 run 之后过了很久(实测 27.5h),用户退出 TUI 再 `--resume`;重开链去引擎
|
|
48
|
+
读待决行时**读面答了**、而属主待决行**恰 0**(引擎重启后 `process_local` 会话店丢行 / 审批被引擎
|
|
49
|
+
reap),run 却仍 `suspended` 并占着会话 claim。修前本包对这一形只有 `ask-reopen-failed` 一个结局
|
|
50
|
+
⇒ 端渲「Your message was NOT sent … 换个新会话」:卡不可能重开(没有行了),run 不会自己结束
|
|
51
|
+
(park 态不在时间型 reap 射程),用户只剩丢掉整段上下文这一条路。
|
|
52
|
+
- **改**(全部 additive):
|
|
53
|
+
- `ReopenCardVerdict` 的 `reopened:false` 臂加 **`pendingRowGone?: true`** —— 宿主的**正面证据**位:
|
|
54
|
+
读面在有界窗内**答了**且属主过滤后待决行数**恰 0**。读口缺席 / 网络失败 / 抛错 / 窗尽 / 中止 /
|
|
55
|
+
「找不到能用的那一行」一律**不置**(fail-closed);判据只认严格 `true`。
|
|
56
|
+
- 新臂 `staleParkArm`:该位在场(且非 `decidedWithoutCard`)⇒ 用 `runs.get` **一手复核**真态,分五路
|
|
57
|
+
—— 404 ⇒ 新 kind **`ask-run-not-found`**(幽灵 claim 的 park 半场,不盲发);`running` ⇒ 交既有三选卡臂
|
|
58
|
+
(不打第二发 get);`needs_review` ⇒ 交既有 plan 重开臂(cancel 它 = 替用户丢掉整个 plan,禁区);
|
|
59
|
+
`suspended` ⇒ 新宿主钩子 **`offerStaleParkChoice`**(两选卡:停掉它 / 什么都不做);读不出、表外新词、
|
|
60
|
+
中止 ⇒ 一律退回既有 `ask-reopen-failed`。
|
|
61
|
+
- 四个新结局:**`stale-park-cancelled`**(处置 `resending`,与 `running-cancelled` 同一条重发腿、同一个
|
|
62
|
+
「释放已确认」前提)/ **`stale-park-cancel-timeout`** / **`stale-park-cancel-failed`** / **`stale-park-wait`**
|
|
63
|
+
(带 `alreadyOffered?` 登记降级位,清口同 `clearRunningChoiceOffer`)。
|
|
64
|
+
- cancel 那条腿**抽成共享叶 `cancelAndConfirmRelease`**(三选卡与两选卡同源):失败分类
|
|
65
|
+
(`atMostOnceFailureClass`)与释放判据(`CLAIM_RELEASED_STATES`)不许有第二份实现。
|
|
66
|
+
- 🔴 **边界写在判定上,不写在文案上**:退役的自动 cancel 臂的循环病根 =「**待决项仍在**时 cancel = 替用户
|
|
67
|
+
deny ⇒ 模型重试 ⇒ 再 park」。本臂的前提恰是那个待决项**已经不存在**,那条循环在结构上不成立。所以
|
|
68
|
+
`pendingRowGone` 缺席/含糊值一律进不了新臂(负控写进常驻门),且进臂后仍要一手复核 + 用户显式选择,
|
|
69
|
+
本包任何路径都不自动 cancel。
|
|
70
|
+
- 🔴 **卡后复证 run 真态**(对抗复审 R3 [high] 采纳):卡前那一发 `runs.get` 打在**呈卡之前**,而卡可以
|
|
71
|
+
停留任意久 —— 这中间 run 可能恢复运行。所以用户选 cancel 之后**再打一发**,状态仍是审批 park 词才
|
|
72
|
+
继续;变 running / 终态 / 换门 / 读不出 ⇒ fail-closed 退回旧结局(绝不沿用旧卡的授权去停一条正在
|
|
73
|
+
干活的 run),404 ⇒ `ask-run-not-found`。
|
|
74
|
+
- 🔴 **开枪前再证一次前提**(对抗复审 R1 [high] 采纳):`pendingRowGone` 是宿主**读那一刻**的事实,
|
|
75
|
+
而两选卡是人在看、可以看任意久 —— 中间待决行可能重新出现,宿主也可能误报。所以在离 `runs.cancel`
|
|
76
|
+
最近的那一刻用 `deps.listOwnedPendingApprovals`(既有键,带归属过滤)再问一次属主待决行数,**恰 0
|
|
77
|
+
才开枪**;非 0 / 抛错 / 窗尽 / 中止一律不开枪。该读口因此成为两选卡的**供给前提**:宿主不注入
|
|
78
|
+
⇒ 卡整个不呈(fail-closed)。复证走共享叶 `readOwnedPendingCount`(与假死锁防御同一只)。
|
|
79
|
+
- 🔴 **老宿主零行为差**:不传 `pendingRowGone`、不传 `offerStaleParkChoice` ⇒ 逐字节维持今天的结局与文案
|
|
80
|
+
(web / desktop 无需动作)。cli 半场(置位 + 两选卡 UI + live 证据)是另一批。
|
|
81
|
+
⚠️ 「零行为差」说的是**运行期**:`SelfHealOutcome` 多了五个 kind,对它做穷尽 switch + assertNever 的
|
|
82
|
+
消费方编译期要补臂(与 0.37.0 起历次加 kind 同形);`default` 分支的消费方零改动。
|
|
83
|
+
- 🔴 **文案不越过证据**(同批 [medium] 采纳):`ask-run-not-found` 不断言「那条 run 不存在」(404 分不出
|
|
84
|
+
「不存在」与「这个会话读不到」),`stale-park-cancelled` 明说那条 run 已终结、那一轮不会恢复
|
|
85
|
+
(SDK `CancelAck`:受理回 `cancelling`,随后 settle 成 `failed` + `errorCode: "cancelled"`)。
|
|
86
|
+
- 🔴 **两条在册边界(第二轮对抗复审如实登记,不假装不存在)**:
|
|
87
|
+
- **P-44** —— 开枪前复证是「查了再做」,**不是原子条件取消**:复证与那一枪之间仍有毫秒级窗口,
|
|
88
|
+
待决行恰在此间恢复的话那一枪照样落下。客户端关不死,正位解是**引擎侧条件取消**(带审批快照
|
|
89
|
+
版本 / checkpoint 标识,条件变了回 409 且不取消)。判据只钉可判的那半:调用序必须是
|
|
90
|
+
呈卡 → 卡后 get 复证 → 待决行复证 → cancel,两发复证都紧跟在用户那句授权之后。
|
|
91
|
+
- **P-45**(存量族)——「run 读到终态」不等于「claim 已落地释放」:`running-cancelled`(0.37.0 起)
|
|
92
|
+
与本批 `stale-park-cancelled` 共用同一把尺(`CLAIM_RELEASED_STATES`),重发撞回 409 是**已知可能**,
|
|
93
|
+
自愈树当拍再分诊。刻意**不**单修新那一条 —— 同一判据两份实现必漂;正位解要引擎给 claim 读面。
|
|
94
|
+
本批的 `stale-park-cancelled` 文案已按这条边界写(只说「引擎报它已终结」+「可能要再发一次」);
|
|
95
|
+
0.37.0 的 `running-cancelled` 行仍是旧说法,登记为同形存量待属主批(§7b P-45)。
|
|
96
|
+
- 接入形见 `docs/INTEGRATION-CLIENTS.md` §16(缺口登记见 §7b P-44/P-45)。常驻门:
|
|
97
|
+
`scripts/run-selfheal-reopen-test.mjs` G11 段 + `scripts/run-terminal-identity-copy-test.mjs` G2 段。
|
|
98
|
+
|
|
43
99
|
## 0.52.0(2026-09-04)
|
|
44
100
|
|
|
45
101
|
### hitl:分类器拒绝行带 `ctx`(toolCallId + tool_start args)(L-69⑨;DEBTS-cli)
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
|
|
|
35
35
|
|
|
36
36
|
## Scope
|
|
37
37
|
|
|
38
|
-
**Version:** 0.
|
|
38
|
+
**Version:** 0.53.0
|
|
39
39
|
|
|
40
40
|
- **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
|
|
41
41
|
B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
|
|
@@ -131,10 +131,25 @@ export interface DurableRunCallOpts {
|
|
|
131
131
|
*/
|
|
132
132
|
export type ReopenCardVerdict =
|
|
133
133
|
/** L-80:`decidedWithoutCard` = 重开链**没呈卡但已成功决断**(规则直决 / sync-allow)—— 这是进展,
|
|
134
|
-
* 不是失败;端不许把它渲成「could not reopen … NOT sent」。缺席 = 真失败形(语义零变)。
|
|
134
|
+
* 不是失败;端不许把它渲成「could not reopen … NOT sent」。缺席 = 真失败形(语义零变)。
|
|
135
|
+
*
|
|
136
|
+
* L-93(0.53.0):`pendingRowGone` = 重开链**在有界窗内向引擎读面问过了、而且得到了回答**,
|
|
137
|
+
* 回答是「这条 run 名下**零属主待决行**」——卡不是「呈不出来」,而是**没有东西可呈**
|
|
138
|
+
* (引擎重启后 process_local 会话店丢行 / 审批被引擎 reap,而 run 仍停在 park 态)。
|
|
139
|
+
*
|
|
140
|
+
* 🔴 **置位条件是正面证据,fail-closed**(宿主义务,包侧无法代为判定):
|
|
141
|
+
* · 置 —— 读面(approvals.list 等)在窗内**答了**,且属主过滤后的待决行数**恰 0**;
|
|
142
|
+
* · **不置** —— 读口缺席 / 网络失败 / 抛错 / 窗尽未答 / 调用方中止 / 只是「没找到能用的那一行」
|
|
143
|
+
* (归属证不出、问句缺席、行形坏)。这些都是「不知道」,不是「不在」。
|
|
144
|
+
* 为什么这条边界不能松:`pendingRowGone` 是本层**唯一**允许 cancel 一条 park 态 run 的前提
|
|
145
|
+
* (见 {@link ActiveRunSelfHealDeps.offerStaleParkChoice})。park 态的 cancel 语义 = 替用户否掉
|
|
146
|
+
* 待决项,而「待决项仍在」时那正是已退役的循环病根(cancel = deny ⇒ 模型重试 ⇒ 再 park)。
|
|
147
|
+
* 只有**确已没有任何待决项可被否掉**时,那条循环在结构上不存在。含糊值(`false` / `'true'` /
|
|
148
|
+
* `1` / 缺席)一律不算 —— 判据只认严格 `true`。 */
|
|
135
149
|
{
|
|
136
150
|
reopened: false;
|
|
137
151
|
decidedWithoutCard?: true;
|
|
152
|
+
pendingRowGone?: true;
|
|
138
153
|
} | {
|
|
139
154
|
reopened: true;
|
|
140
155
|
firstSight: boolean;
|
|
@@ -153,6 +168,26 @@ export interface RunningChoiceRequest {
|
|
|
153
168
|
/** cancel 动词在场 **且** 有 `get` 可以确认 claim 真的释放。 */
|
|
154
169
|
canCancel: boolean;
|
|
155
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* 「陈旧 park」两选卡的呈现请求(L-93,0.53.0)—— run 仍停在 park 态,而它在等的那张审批**已经
|
|
173
|
+
* 不在引擎上**(宿主证出 {@link ReopenCardVerdict} 的 `pendingRowGone`)。卡上只有两条路:
|
|
174
|
+
* 停掉这条 run(会话当场释放,消息随后重发)/ 什么都不做。
|
|
175
|
+
*
|
|
176
|
+
* 🔴 这里**没有** steer 选项:steer 的语义是「注入正在跑的那一轮」,对一条 park 住的 run 它落
|
|
177
|
+
* `queued`(排在那个**已经不存在**的 checkpoint 上)—— 递一条注定注入不进去的路 = 假 affordance。
|
|
178
|
+
* 🔴 `canCancel` 是**供给位**(与 {@link RunningChoiceRequest} 同律):cancel 动词在场 **且** 有
|
|
179
|
+
* `get` 能确认 claim 真的释放,两者缺一就兑现不了「停掉它再重发」。
|
|
180
|
+
*/
|
|
181
|
+
export interface StaleParkChoiceRequest {
|
|
182
|
+
taskId: string;
|
|
183
|
+
/** 引擎报的状态(如实转述,不加工)。 */
|
|
184
|
+
status: string;
|
|
185
|
+
/** 「停掉它」这条路今天兑现得了吗 = cancel 动词在场 **且** 有 `get` 能确认 claim 真的释放
|
|
186
|
+
* **且** 有属主待决行读口(`listOwnedPendingApprovals`)能在开枪前把前提再证一次。
|
|
187
|
+
* 三者缺一,本层就不呈这张卡(所以卡到宿主手上时它恒为 `true`;位仍在场是为了与
|
|
188
|
+
* {@link RunningChoiceRequest} 同形,且将来这张卡加第二条路时不必改形)。 */
|
|
189
|
+
canCancel: boolean;
|
|
190
|
+
}
|
|
156
191
|
/** 分诊树的注入口(重开腿/待决卡探询都是宿主生命周期资产,经 deps 进来,本层保持纯)。 */
|
|
157
192
|
export interface ActiveRunSelfHealDeps {
|
|
158
193
|
/** 屏幕上是否已有一张待决卡(有 ⇒ 结局 = decision-pending,绝不重开第二张)。 */
|
|
@@ -186,6 +221,20 @@ export interface ActiveRunSelfHealDeps {
|
|
|
186
221
|
* 不做)。🔴 Esc / 空答 / 读不出的答案一律由呈现层收口成 `wait`。
|
|
187
222
|
*/
|
|
188
223
|
offerRunningChoice?: (req: RunningChoiceRequest) => Promise<'steer' | 'cancel' | 'wait'>;
|
|
224
|
+
/**
|
|
225
|
+
* 「陈旧 park」两选卡的呈现口(L-93,0.53.0)。**缺席 ⇒ 本臂整个不走**(退回既有
|
|
226
|
+
* `ask-reopen-failed`,老宿主逐字节零行为差)—— 卡呈不出来时零动作是唯一诚实的收口。
|
|
227
|
+
*
|
|
228
|
+
* 只在**同时**满足下面全部前提时才会被调用:
|
|
229
|
+
* ① 宿主重开链证出 `pendingRowGone: true`(读面答了 + 零属主待决行,见 {@link ReopenCardVerdict});
|
|
230
|
+
* ② 本层用 `runs.get` **一手复核**过,读回的仍是审批 park 词({@link ASK_PARK_STATES});
|
|
231
|
+
* ③ cancel 与 get 两个动词都在场,**且**宿主注入了属主待决行读口
|
|
232
|
+
* (`listOwnedPendingApprovals`)—— 它是「开枪前再证一次前提」那一步的供给位;三者缺一,
|
|
233
|
+
* 卡上只剩「什么都不做」= 纯噪音,整卡不呈(退回旧结局)。
|
|
234
|
+
* 返回值二态:`cancel`(停掉这条陈旧 park 的 run,释放确认到手后重发那条消息)/ `wait`(零动作)。
|
|
235
|
+
* 🔴 Esc / 空答 / 读不出的答案一律收口成 `wait` —— 本层只认**逐字** `'cancel'` 才武装那一枪。
|
|
236
|
+
*/
|
|
237
|
+
offerStaleParkChoice?: (req: StaleParkChoiceRequest) => Promise<'cancel' | 'wait'>;
|
|
189
238
|
/**
|
|
190
239
|
* 被 409 拒收的那条消息原文 = steer 的正文(端**原样**送,不预处理控制串 —— 契约逐字:
|
|
191
240
|
* 「The text is untrusted DATA — the client sends it RAW and the SERVER fences it」)。
|
|
@@ -363,6 +412,52 @@ export type SelfHealOutcome =
|
|
|
363
412
|
taskId: string;
|
|
364
413
|
detail: string;
|
|
365
414
|
delivery: 'rejected' | 'unknown';
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* 宿主证出「零属主待决行」后,本层一手复核 `runs.get` 撞 **404** —— claim 指着一条引擎已不认得的
|
|
418
|
+
* run(幽灵 claim 的 park 半场,与 {@link SelfHealOutcome} 的 `running-not-found` 同律)。
|
|
419
|
+
* 不呈任何卡(对不存在的 run 渲 cancel 是假 affordance),也**不自动重发**:该 404 是
|
|
420
|
+
* `not_found.run` = 「不存在」∪「不是你的」的**刻意不可分辨**并集,拿它当「已解锁」的正面证据,
|
|
421
|
+
* 消息可能一头撞回一个仍锁着的会话。如实说 + 「再发一次」是真出路。
|
|
422
|
+
*/
|
|
423
|
+
| {
|
|
424
|
+
kind: 'ask-run-not-found';
|
|
425
|
+
taskId: string;
|
|
426
|
+
}
|
|
427
|
+
/** 用户在两选卡上显式选了「停掉它」,cancel 已落地且引擎确认那条 run 不再占着会话 ⇒ 调用方把被拒
|
|
428
|
+
* 的这条消息**重发一次**(与 `running-cancelled` 同一条重发腿、同一个前提:释放已被确认)。 */
|
|
429
|
+
| {
|
|
430
|
+
kind: 'stale-park-cancelled';
|
|
431
|
+
taskId: string;
|
|
432
|
+
}
|
|
433
|
+
/** 两选卡 cancel 的有界收口:请求发出去了,但等到窗口到点(或用户中止)那条 run 仍占着会话
|
|
434
|
+
* ⇒ **不重发**,如实说。三个位与 `running-cancel-timeout` 同义(共用同一条确认腿)。 */
|
|
435
|
+
| {
|
|
436
|
+
kind: 'stale-park-cancel-timeout';
|
|
437
|
+
taskId: string;
|
|
438
|
+
waitedMs: number;
|
|
439
|
+
aborted: boolean;
|
|
440
|
+
confirmedHeld: boolean;
|
|
441
|
+
}
|
|
442
|
+
/** 两选卡 cancel 的失败半场:那一枪没成,且有界确认腿也没看到会话被交出来。`delivery` 与
|
|
443
|
+
* `running-cancel-failed` 同义(`rejected` = 4xx 明确拒;`unknown` = 5xx/连接断,可能已受理)。 */
|
|
444
|
+
| {
|
|
445
|
+
kind: 'stale-park-cancel-failed';
|
|
446
|
+
taskId: string;
|
|
447
|
+
detail: string;
|
|
448
|
+
delivery: 'rejected' | 'unknown';
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* 两选卡②:用户选了「什么都不做」(或卡上读不出答案)⇒ 零动作,如实说清死结在哪、有哪两条真出路。
|
|
452
|
+
* `alreadyOffered`(与 `not-parked` 同款登记降级):同 (sessionKey,taskId) 的两选卡**已呈现过且
|
|
453
|
+
* 用户选了「什么都不做」** ⇒ 本次不再整卡重弹,端据此渲一行提示;清口同
|
|
454
|
+
* {@link clearRunningChoiceOffer}。
|
|
455
|
+
*/
|
|
456
|
+
| {
|
|
457
|
+
kind: 'stale-park-wait';
|
|
458
|
+
taskId: string;
|
|
459
|
+
status: string;
|
|
460
|
+
alreadyOffered?: true;
|
|
366
461
|
};
|
|
367
462
|
/**
|
|
368
463
|
* 撞 409 的那条提交是谁写的。
|
|
@@ -416,7 +511,9 @@ export declare function readSteerReceiptStatus(receipt: unknown): string | null;
|
|
|
416
511
|
/** cancel 之后**有界**等那条 run 交出会话的缺省窗(`POST …/cancel` 是 202 异步 —— 收下 ≠ 已停)。 */
|
|
417
512
|
export declare const CANCEL_RELEASE_WAIT_MS = 10000;
|
|
418
513
|
/** 清掉某条 run 的「Do nothing」登记 —— 宿主「重新打开操作菜单」入口(下一次 409 重新整卡呈现)。
|
|
419
|
-
* `sessionId` 与当时喂给 {@link ActiveRunSelfHealDeps.sessionId} 的值同源(缺席 = 默认键)。
|
|
514
|
+
* `sessionId` 与当时喂给 {@link ActiveRunSelfHealDeps.sessionId} 的值同源(缺席 = 默认键)。
|
|
515
|
+
* 🔴 三选卡与两选卡(L-93)的登记**一起清**:这个入口的语义是「让我重新对这条 run 表态」,
|
|
516
|
+
* 只清其中一张会让另一张在宿主已经答应「重新问」之后仍旧被压掉(paired-mechanisms 同族)。 */
|
|
420
517
|
export declare function clearRunningChoiceOffer(taskId: string, sessionId?: string): void;
|
|
421
518
|
/** 测试钩:清空整张登记表(套件各组之间隔离用)。 */
|
|
422
519
|
export declare function __resetRunningChoiceLedgerForTests(): void;
|
|
@@ -138,6 +138,9 @@ export function selfHealSubmissionDisposition(outcome) {
|
|
|
138
138
|
// reopen-failed 那一臂)⇒ 用户手上确有把手。
|
|
139
139
|
return 'held-for-decision';
|
|
140
140
|
case 'running-cancelled':
|
|
141
|
+
case 'stale-park-cancelled':
|
|
142
|
+
// L-93:与 running-cancelled 同一条重发腿 —— 前提也同一个(引擎**确认**那条 run 不再占着会话,
|
|
143
|
+
// 判据 = CLAIM_RELEASED_STATES;确认不到的两形落 timeout/failed ⇒ default 的 not-delivered)。
|
|
141
144
|
case 'ask-decided-without-card':
|
|
142
145
|
return 'resending';
|
|
143
146
|
case 'ask-decided-release-unknown':
|
|
@@ -230,10 +233,32 @@ function runningChoiceDeclineKey(sessionId, taskId) {
|
|
|
230
233
|
// NUL 分隔:两段都是外来串,可打印分隔符在「sessionId 里恰有它」时会串键。
|
|
231
234
|
return `${sessionId ?? ''}\u0000${taskId}`;
|
|
232
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* L-93 两选卡(陈旧 park)的登记键 —— 与三选卡**同一张表、不同键**。
|
|
238
|
+
* 🔴 为什么不共用同一个键:两张卡问的是不同的问题(「这条**在跑**的 run 怎么办」vs「这条 park 住、
|
|
239
|
+
* 而审批已经**不在引擎上**的 run 怎么办」),用户对其中一张说过「什么都不做」不构成对另一张的表态
|
|
240
|
+
* —— 共键会让一次旧表态把一张他从没见过的卡压掉。共用一张表只为共用那条 FIFO 上界与同一个清口。
|
|
241
|
+
*/
|
|
242
|
+
function staleParkDeclineKey(sessionId, taskId) {
|
|
243
|
+
return `${runningChoiceDeclineKey(sessionId, taskId)}\u0000stale-park`;
|
|
244
|
+
}
|
|
245
|
+
/** 登记一条「用户选了什么都不做」+ FIFO 有界驱逐(两张卡共用 —— 各写一份必漂,而漂的后果是
|
|
246
|
+
* 其中一张卡的登记表悄悄变成无界)。驱逐代价 = 那条 run 的卡重弹一次(= 修前行为,方向安全)。 */
|
|
247
|
+
function rememberDeclinedOffer(key) {
|
|
248
|
+
runningChoiceDeclined.add(key);
|
|
249
|
+
if (runningChoiceDeclined.size > RUNNING_CHOICE_LEDGER_CAP) {
|
|
250
|
+
const oldest = runningChoiceDeclined.values().next().value;
|
|
251
|
+
if (oldest !== undefined)
|
|
252
|
+
runningChoiceDeclined.delete(oldest);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
233
255
|
/** 清掉某条 run 的「Do nothing」登记 —— 宿主「重新打开操作菜单」入口(下一次 409 重新整卡呈现)。
|
|
234
|
-
* `sessionId` 与当时喂给 {@link ActiveRunSelfHealDeps.sessionId} 的值同源(缺席 = 默认键)。
|
|
256
|
+
* `sessionId` 与当时喂给 {@link ActiveRunSelfHealDeps.sessionId} 的值同源(缺席 = 默认键)。
|
|
257
|
+
* 🔴 三选卡与两选卡(L-93)的登记**一起清**:这个入口的语义是「让我重新对这条 run 表态」,
|
|
258
|
+
* 只清其中一张会让另一张在宿主已经答应「重新问」之后仍旧被压掉(paired-mechanisms 同族)。 */
|
|
235
259
|
export function clearRunningChoiceOffer(taskId, sessionId) {
|
|
236
260
|
runningChoiceDeclined.delete(runningChoiceDeclineKey(sessionId, taskId));
|
|
261
|
+
runningChoiceDeclined.delete(staleParkDeclineKey(sessionId, taskId));
|
|
237
262
|
}
|
|
238
263
|
/** 测试钩:清空整张登记表(套件各组之间隔离用)。 */
|
|
239
264
|
export function __resetRunningChoiceLedgerForTests() {
|
|
@@ -363,6 +388,42 @@ export async function waitForClaimRelease(taskId, deps) {
|
|
|
363
388
|
}
|
|
364
389
|
}
|
|
365
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* 有界问一次「引擎侧这个会话名下还有几行**属主**待决审批」(`deps.listOwnedPendingApprovals`)。
|
|
393
|
+
*
|
|
394
|
+
* 返回 `number` = 引擎**答了**这个数;返回 `null` = **没答上来**(抛错 / 窗尽 / 调用方中止)——
|
|
395
|
+
* 两者是完全不同的事实,调用方各自决定怎么用(本文件两个调用点都把 `null` 当「不知道」保守处置)。
|
|
396
|
+
*
|
|
397
|
+
* 🔴 有界且可回收(#244 F1 复审 [high]+[medium]×2 的成文形):probe 恒不落地不许把整棵分诊树挂死;
|
|
398
|
+
* 合流 signal **真交到 probe 上**(掐的是底层请求,不是只掐调用方的 await);竞速败方在 finally 里
|
|
399
|
+
* 统一 abort(快成功时那只 4s 普通计时器不清,`-p` 车道进程会被它多拖活一窗)。
|
|
400
|
+
* 🔴 单源:两个调用点(假死锁防御 / L-93 开枪前复证)共用这一只 —— 各写一份必漂,而这两处漂的
|
|
401
|
+
* 后果分别是「Esc 之后接着弹卡」与「拿一次没答上来的读当成前提成立」。
|
|
402
|
+
*/
|
|
403
|
+
async function readOwnedPendingCount(read, deps) {
|
|
404
|
+
const recheckCtl = new AbortController();
|
|
405
|
+
const onCallerAbort = () => {
|
|
406
|
+
recheckCtl.abort(deps?.signal?.reason);
|
|
407
|
+
};
|
|
408
|
+
deps?.signal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
409
|
+
try {
|
|
410
|
+
const count = await Promise.race([
|
|
411
|
+
read({ signal: recheckCtl.signal }),
|
|
412
|
+
claimPollSleep(OWNED_PENDING_RECHECK_TIMEOUT_MS, recheckCtl.signal).then(() => null),
|
|
413
|
+
]);
|
|
414
|
+
// 已中止(进门前/竞速期间)一律不采信这个数 —— Esc 之后拿它驱动任何动作都是「用户说停还在动」。
|
|
415
|
+
if (deps?.signal?.aborted === true)
|
|
416
|
+
return null;
|
|
417
|
+
return typeof count === 'number' ? count : null;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return null; // 读失败 = 「不知道」,不是「零」
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
deps?.signal?.removeEventListener('abort', onCallerAbort);
|
|
424
|
+
recheckCtl.abort(new Error('owned-pending recheck settled'));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
366
427
|
/**
|
|
367
428
|
* 自愈一次:定门(kind 优先 → status 表回退)→ 按门类重开对应的卡 → 回报结局。**绝不抛**
|
|
368
429
|
* (turn 收尾路径)。kind 在场时不再回查 runs.get —— wire 已指名门身份,省一次往返。
|
|
@@ -390,26 +451,10 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
|
|
|
390
451
|
// · 竞速败方必须收口:快成功时 4s sleep 的普通计时器不清,-p 车道进程会被它多拖活一窗
|
|
391
452
|
// (finally 统一 abort ⇒ abortableSleep 清 timer、在飞 probe 被掐);
|
|
392
453
|
// · 已中止(进门前/竞速期间)一律不采信 count —— Esc 之后接着放行重呈 = 用户说停还弹卡。
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
deps.signal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
398
|
-
try {
|
|
399
|
-
const count = await Promise.race([
|
|
400
|
-
deps.listOwnedPendingApprovals({ signal: recheckCtl.signal }),
|
|
401
|
-
claimPollSleep(OWNED_PENDING_RECHECK_TIMEOUT_MS, recheckCtl.signal).then(() => null),
|
|
402
|
-
]);
|
|
403
|
-
if (count === 0 && !callerAborted())
|
|
404
|
-
pending = false;
|
|
405
|
-
}
|
|
406
|
-
catch {
|
|
407
|
-
/* 复核失败分不出真卡与幽灵 —— 维持 stand down,下一条消息再走一遍本腿 */
|
|
408
|
-
}
|
|
409
|
-
finally {
|
|
410
|
-
deps.signal?.removeEventListener('abort', onCallerAbort);
|
|
411
|
-
recheckCtl.abort(new Error('owned-pending recheck settled'));
|
|
412
|
-
}
|
|
454
|
+
// 这四条的实现在共享叶 {@link readOwnedPendingCount}(L-93 开枪前复证走同一只)。
|
|
455
|
+
const count = await readOwnedPendingCount(deps.listOwnedPendingApprovals, deps);
|
|
456
|
+
if (count === 0 && !callerAborted())
|
|
457
|
+
pending = false;
|
|
413
458
|
}
|
|
414
459
|
if (pending)
|
|
415
460
|
return { kind: 'decision-pending', taskId: signal.activeTaskId };
|
|
@@ -520,8 +565,243 @@ async function askParkArm(taskId, signal, runs, deps) {
|
|
|
520
565
|
}
|
|
521
566
|
return { kind: 'ask-decided-release-unknown', taskId, lastStatus: release.lastStatus };
|
|
522
567
|
}
|
|
568
|
+
// L-93:重开链在有界窗内**读面答了**、而属主待决行**恰 0**(引擎重启丢行 / 审批被 reap),run
|
|
569
|
+
// 却仍 park ⇒ 卡不是「呈不出来」而是「没有东西可呈」,走陈旧 park 臂(见 {@link staleParkArm})。
|
|
570
|
+
// 🔴 位缺席/含糊值一律不进(只认严格 `true`):这是本层允许 cancel 一条 park run 的唯一前提,
|
|
571
|
+
// 松掉它就是把退役 cancel 臂的循环病根放回来。`decidedWithoutCard` 已在上面收口,此处再写一次
|
|
572
|
+
// 是把「两位同时在场时谁优先」钉在判据里,不靠上面那个 return 的顺序当默契。
|
|
573
|
+
if (verdict.reopened === false && verdict.decidedWithoutCard !== true && verdict.pendingRowGone === true) {
|
|
574
|
+
return staleParkArm(taskId, signal, runs, deps);
|
|
575
|
+
}
|
|
523
576
|
return { kind: 'ask-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
|
|
524
577
|
}
|
|
578
|
+
/**
|
|
579
|
+
* 有界打一发 `runs.get` 读真态(lease 带截止 + 合流调用方中止;404 单列,其余错误一律「没答上来」)。
|
|
580
|
+
* 🔴 单源:running 存活对账腿、陈旧 park 臂的**卡前**与**卡后**三处共用这一只 —— 各写一份必漂,
|
|
581
|
+
* 而漂的后果是其中一处忘了 404 / 本发截止 / 奇形记录三种缺席的处置。
|
|
582
|
+
* 🔴 接收者必保:写成 `runs.get(...)` 方法形(把方法摘下来裸调 = 真 SDK 上当场 TypeError)。
|
|
583
|
+
*/
|
|
584
|
+
async function probeRunStatusOnce(taskId, durable, durableGet, deps) {
|
|
585
|
+
let status = null;
|
|
586
|
+
let ghost = false;
|
|
587
|
+
const lease = claimProbeLease(deps?.signal, RUNNING_LIVENESS_RECHECK_TIMEOUT_MS);
|
|
588
|
+
try {
|
|
589
|
+
// 保接收者(`durableGet.call(durable, …)`):摘下来裸调在真 SDK 上当场 TypeError。
|
|
590
|
+
status = readStatus(await durableGet.call(durable, taskId, { signal: lease.signal, ...sessionOpts(deps) }));
|
|
591
|
+
}
|
|
592
|
+
catch (e) {
|
|
593
|
+
if (e?.status === 404)
|
|
594
|
+
ghost = true;
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
lease.release();
|
|
598
|
+
}
|
|
599
|
+
return { status, ghost };
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* L-93 陈旧 park 臂(0.53.0):run 仍停在 park 态,而它在等的那张审批**已经不在引擎上**
|
|
603
|
+
* (宿主经 {@link ReopenCardVerdict} 的 `pendingRowGone` 证出「读面答了 + 零属主待决行」)。
|
|
604
|
+
*
|
|
605
|
+
* ── 为什么这一形可以给 cancel,而退役的那条臂不行 ─────────────────────────────────────────────
|
|
606
|
+
* 退役 cancel 臂的循环病根 = 「**待决项仍在**」时 cancel 的语义是替用户 deny 掉它 ⇒ 模型重试 ⇒
|
|
607
|
+
* 新 run 停在同一道门上 ⇒ 再 park。本臂的前提恰恰是那个待决项**已经不存在**:没有任何东西可被
|
|
608
|
+
* 「否掉」,那条循环在结构上不成立。所以边界写在**判定**上,不写在文案上 ——
|
|
609
|
+
* ① `pendingRowGone` 缺席/含糊值 ⇒ 本臂根本进不来(调用点只认严格 `true`);
|
|
610
|
+
* ② 进来之后仍要用 `runs.get` **一手复核**(宿主的一句话不足以驱动破坏性动作);
|
|
611
|
+
* ③ 复核读回的必须是**审批 park 词**({@link ASK_PARK_STATES});plan park(`needs_review`)交给
|
|
612
|
+
* plan 重开臂 —— 对它 cancel = 替用户丢掉整个 plan,是另一条禁区;
|
|
613
|
+
* ④ 两选卡上用户必须**逐字**选 `cancel` 才动那一枪(Esc/空答/读不出一律 wait)。
|
|
614
|
+
* 任何一条不成立 ⇒ 退回既有 `ask-reopen-failed`(老宿主与不确定情形逐字节零行为差)。
|
|
615
|
+
*/
|
|
616
|
+
async function staleParkArm(taskId, busy, runs, deps) {
|
|
617
|
+
/** 本臂的**保守收口**:与修前逐字同一行(卡呈不出来/读不出真态/不确定 ⇒ 零动作 + 如实说)。 */
|
|
618
|
+
const reopenFailed = {
|
|
619
|
+
kind: 'ask-reopen-failed',
|
|
620
|
+
taskId,
|
|
621
|
+
decidePath: busy.pendingGate?.decidePath ?? null,
|
|
622
|
+
};
|
|
623
|
+
// 读口缺席 ⇒ 一手复核做不成 ⇒ 不猜(宿主的一句话不驱动破坏性动作)。
|
|
624
|
+
// 🔴 **只取来判在场,绝不裸调**(runningChoiceArm R7 同注):真 SDK 的 `RunsResource` 方法体走
|
|
625
|
+
// `this.t.request(...)`,摘下来单独调当场 TypeError —— 下面的真调用一律写成 `runs.get(...)` 方法形,
|
|
626
|
+
// 交给 cancel 腿的那一份也用它保接收者的包装。
|
|
627
|
+
if (runs === undefined || typeof runs.get !== 'function')
|
|
628
|
+
return reopenFailed;
|
|
629
|
+
const durableGet = runs.get;
|
|
630
|
+
// 🔴 经函数读 aborted(waitForClaimRelease.isAborted 同注):它在 await 两侧会变,直接读两次会被
|
|
631
|
+
// tsc 控制流分析把第二次窄成恒假比较(TS2367)。
|
|
632
|
+
const callerAborted = () => deps?.signal?.aborted === true;
|
|
633
|
+
// 进门前已中止 ⇒ 一发出站都不打(Esc 之后还去问引擎 = 用户说停还在动)。
|
|
634
|
+
if (callerAborted())
|
|
635
|
+
return reopenFailed;
|
|
636
|
+
// ── 一手复核(与 running 存活对账腿同款 lease/abort 处理)────────────────────────────────────
|
|
637
|
+
const first = await probeRunStatusOnce(taskId, runs, durableGet, deps);
|
|
638
|
+
// 幽灵 claim:引擎一边说这个会话被这条 run 占着、一边查无此 run(与 running-not-found 同律 ——
|
|
639
|
+
// 404 是「不存在」∪「不是你的」的不可分辨并集,所以只说已证的事实,绝不盲发那条消息)。
|
|
640
|
+
if (first.ghost)
|
|
641
|
+
return { kind: 'ask-run-not-found', taskId };
|
|
642
|
+
// 复核期间用户中止 ⇒ 后续一切动作(派臂/出卡/登记/cancel)都不做。
|
|
643
|
+
if (callerAborted())
|
|
644
|
+
return reopenFailed;
|
|
645
|
+
// 读不出(网络失败 / 本发截止 / 奇形记录)⇒ 「读不到」≠「行不在」⇒ 保守收口,不猜。
|
|
646
|
+
const fresh = first.status;
|
|
647
|
+
if (fresh === null)
|
|
648
|
+
return reopenFailed;
|
|
649
|
+
// 真态是「在跑」⇒ 交给既有三选卡臂(statusFromWire=false:这一发就是新鲜读数,不打第二发)。
|
|
650
|
+
if (RUNNING_STATES.includes(fresh))
|
|
651
|
+
return runningChoiceArm(taskId, fresh, busy, false, runs, deps);
|
|
652
|
+
// 🔴 plan park ⇒ 交给 plan 重开臂:对它 cancel 是替用户把整个 plan 丢掉(禁区),而 plan 卡
|
|
653
|
+
// 本身是能重开的 —— 递一条更好的路,而不是递一把更危险的把手。
|
|
654
|
+
if (PLAN_REVIEW_STATES.includes(fresh))
|
|
655
|
+
return planReviewArm(taskId, busy, deps);
|
|
656
|
+
// 表外的状态词(含今天还不存在的)⇒ 不认得就不给把手,保守收口。
|
|
657
|
+
if (!ASK_PARK_STATES.includes(fresh))
|
|
658
|
+
return reopenFailed;
|
|
659
|
+
// ── 两选卡:停掉它 / 什么都不做 ──────────────────────────────────────────────────────────────
|
|
660
|
+
const offer = deps?.offerStaleParkChoice;
|
|
661
|
+
if (typeof offer !== 'function')
|
|
662
|
+
return reopenFailed; // 老宿主:整臂不走,行为逐字节不变
|
|
663
|
+
// 供给位 = ①cancel 动词在场、②有能确认释放的 `get`(上面的一手复核已证在场)、③有**开枪前
|
|
664
|
+
// 再证一次前提**的属主待决行读口(`deps.listOwnedPendingApprovals`)。
|
|
665
|
+
// 🔴 第③条是 异源对抗复审 R1 [high] 的采纳(见下面开枪前那一段):`pendingRowGone` 是**读那一刻**的
|
|
666
|
+
// 事实,而这张卡是人在看、可以看任意久;没有复证口就没法在开枪前把那个前提再证一次,
|
|
667
|
+
// 于是「停掉它」这条路在本层就**兑现不了它承诺的安全性** ⇒ 按供给位纪律整卡不呈(退回旧结局),
|
|
668
|
+
// 而不是先呈卡再在开枪时才发现证不了。宿主要拿到这张卡,就得把这个读口一起注入(见 §16)。
|
|
669
|
+
// 两条路只剩「什么都不做」时整卡不呈 —— 只有一个空动作的卡是纯噪音(与三选卡 G8②b 同律)。
|
|
670
|
+
const recheckOwnedPending = deps?.listOwnedPendingApprovals;
|
|
671
|
+
const canCancel = typeof runs.cancel === 'function' && typeof recheckOwnedPending === 'function';
|
|
672
|
+
// 第二段是给 tsc 的收窄(`canCancel` 是布尔,narrow 不了 `recheckOwnedPending` 这只可选函数),
|
|
673
|
+
// 判据本身只有 `canCancel` 一条。
|
|
674
|
+
if (!canCancel || typeof recheckOwnedPending !== 'function')
|
|
675
|
+
return reopenFailed;
|
|
676
|
+
const declineKey = staleParkDeclineKey(deps?.sessionId, taskId);
|
|
677
|
+
// 同 (sessionKey,taskId) 已呈过且用户选了「什么都不做」⇒ 不再整卡重弹,给判别位让端渲一行。
|
|
678
|
+
if (runningChoiceDeclined.has(declineKey)) {
|
|
679
|
+
return { kind: 'stale-park-wait', taskId, status: fresh, alreadyOffered: true };
|
|
680
|
+
}
|
|
681
|
+
let choice = null;
|
|
682
|
+
try {
|
|
683
|
+
// 🔴 fail-closed:**只有逐字 `'cancel'`** 才武装那一枪;Esc/空答/表外词一律收口成 wait。
|
|
684
|
+
choice = (await offer({ taskId, status: fresh, canCancel })) === 'cancel' ? 'cancel' : 'wait';
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
choice = null; // 呈卡腿出意外 = 用户没看到卡、更没做决定 ⇒ 退回旧结局(绝不掉进破坏性分支)
|
|
688
|
+
}
|
|
689
|
+
if (choice === null)
|
|
690
|
+
return reopenFailed;
|
|
691
|
+
if (choice === 'wait') {
|
|
692
|
+
// 呈卡腿抛错那次**不**登记(用户没看到卡,压掉后续等于把没呈过的卡当已呈)—— 见上面 catch。
|
|
693
|
+
rememberDeclinedOffer(declineKey);
|
|
694
|
+
return { kind: 'stale-park-wait', taskId, status: fresh };
|
|
695
|
+
}
|
|
696
|
+
// 用户重新表了态 ⇒ 清登记(与三选卡 steer/cancel 同律)。
|
|
697
|
+
runningChoiceDeclined.delete(declineKey);
|
|
698
|
+
// ── 🔴 卡后**复证 run 真态**(异源对抗复审 R3 [high] 采纳)────────────────────────────────────────────
|
|
699
|
+
// 上面那一发 `runs.get` 打在**呈卡之前**,而卡是人在看、可以停留任意久 —— 这中间那条 run 完全
|
|
700
|
+
// 可能已经**恢复运行**(审批在别处被决断 / 会话重新附着 / 引擎自愈)。沿用旧卡上那句 cancel 去停
|
|
701
|
+
// 一条**正在干活**的 run,等于拿「清理一个死结」的授权丢掉正在进行的工作,而用户按下去时屏幕上
|
|
702
|
+
// 写的前提早就不成立了。所以先复证状态仍是审批 park 词,再谈开枪:
|
|
703
|
+
// · 仍 park ⇒ 继续;· 404 ⇒ 幽灵 claim 如实说;· 变成别的状态 / 读不出 / 已中止 ⇒ 一律
|
|
704
|
+
// fail-closed 退回旧结局(下一条消息重新走整棵分诊树,拿到与那时真态相符的那张卡)。
|
|
705
|
+
if (callerAborted())
|
|
706
|
+
return reopenFailed;
|
|
707
|
+
const afterCard = await probeRunStatusOnce(taskId, runs, durableGet, deps);
|
|
708
|
+
if (afterCard.ghost)
|
|
709
|
+
return { kind: 'ask-run-not-found', taskId };
|
|
710
|
+
if (callerAborted())
|
|
711
|
+
return reopenFailed;
|
|
712
|
+
if (afterCard.status === null || !ASK_PARK_STATES.includes(afterCard.status))
|
|
713
|
+
return reopenFailed;
|
|
714
|
+
// ── 🔴 开枪前**再证一次前提**(异源对抗复审 R1 [high] 采纳)──────────────────────────────────────────
|
|
715
|
+
// `pendingRowGone` 是宿主**读那一刻**的事实,而这张卡是人在看、可以看任意久;这中间待决行完全
|
|
716
|
+
// 可能重新出现(store 恢复 / 会话重新附着),宿主也可能把「读失败 / 找不到能用的那一行」误报成
|
|
717
|
+
// gone。而上面那一发 `runs.get` 只证得了「它仍 parked」—— **审批还在**与**审批已丢**两种情形读回
|
|
718
|
+
// 的都是 `suspended`,那一发从来就证不了这个前提。代价不对称:cancel 一条**其实还能被决断**的
|
|
719
|
+
// run = 用户在一句假前提(「那张审批已经不在了」)下把整轮工作丢掉,而他以为自己只是在清理一个
|
|
720
|
+
// 死结。所以在**离 runs.cancel 最近的那一刻**再问一次属主待决行数:
|
|
721
|
+
// · 恰 0(引擎答了)⇒ 前提仍成立,开枪;
|
|
722
|
+
// · 非 0 / 抛错 / 窗尽 / 调用方中止 / 读不出 ⇒ **一律不开枪**(fail-closed),退回旧结局
|
|
723
|
+
// —— 「不知道」和「行又回来了」都不构成销毁一条 run 的授权。
|
|
724
|
+
// 🔴 **在册边界(P-44,异源对抗复审 R2 [high] 如实登记)**:这是「查了再做」,**不是原子条件取消**。
|
|
725
|
+
// 复证与那一枪之间仍有毫秒级窗口,行恰在此间恢复的话那一枪照样落下去。客户端关不死它 ——
|
|
726
|
+
// 真正的关法是引擎侧的**条件取消**(带审批快照版本 / checkpoint 标识,条件变了回 409 且不取消),
|
|
727
|
+
// 那是 wire 能力不是壳能自造的语义。这里能做的是把窗口从「人看卡的任意长时间」压到最小,
|
|
728
|
+
// 并把剩余风险登记在册(docs/INTEGRATION-CLIENTS.md §7b P-44),不假装它不存在。
|
|
729
|
+
const stillGone = await readOwnedPendingCount(recheckOwnedPending, deps);
|
|
730
|
+
if (stillGone !== 0)
|
|
731
|
+
return reopenFailed;
|
|
732
|
+
// 🔴 **复用**同一条 cancel + 有界释放确认腿(不复制):失败分类与释放判据必须与三选卡同源。
|
|
733
|
+
const verdict = await cancelAndConfirmRelease(taskId, runs, durableGet, deps);
|
|
734
|
+
if (verdict.outcome === 'released')
|
|
735
|
+
return { kind: 'stale-park-cancelled', taskId };
|
|
736
|
+
if (verdict.outcome === 'failed') {
|
|
737
|
+
return { kind: 'stale-park-cancel-failed', taskId, detail: verdict.detail, delivery: verdict.delivery };
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
kind: 'stale-park-cancel-timeout',
|
|
741
|
+
taskId,
|
|
742
|
+
waitedMs: verdict.waitedMs,
|
|
743
|
+
aborted: verdict.aborted,
|
|
744
|
+
confirmedHeld: verdict.confirmedHeld,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* cancel 恰一枪 + 有界等那条 run 交出会话。
|
|
749
|
+
*
|
|
750
|
+
* 🔴 cancel 的失败分两类(二次评审 R4 [high]),与 steer 同一把尺:
|
|
751
|
+
* · `rejected`(4xx)—— 服务端答了且明确拒 ⇒ 那一枪确定没生效,立即如实说,不必再等确认腿;
|
|
752
|
+
* · `unknown`(5xx / 连接在回执路上断了)—— 请求**可能已经被受理并在异步执行**。此时直接收口成
|
|
753
|
+
* 「取消失败,那条 run 还占着会话」是对用户下一个证不出的断言 —— 而这条腿手上恰恰有一个能
|
|
754
|
+
* 回答这个问题的读口(`get`)。所以照走有界确认腿:真交出来了就算取消成了,确认不到才如实说
|
|
755
|
+
* 「没能确认」。判据锚在**决定量**(会话有没有被交出来)上,不锚它的一个前置条件(那一枪的
|
|
756
|
+
* 回执长什么样)。
|
|
757
|
+
* 🔴 接收者必保:真 SDK 的 `RunsResource` 方法体走 `this.t.request(...)`,把方法摘下来裸调当场
|
|
758
|
+
* TypeError —— 所以 cancel 写成方法形 `durable.cancel?.(…)`,交给等待器的 `get` 用保接收者的包装。
|
|
759
|
+
*/
|
|
760
|
+
async function cancelAndConfirmRelease(taskId, durable, durableGet, deps) {
|
|
761
|
+
const boundGet = (id, opts) => durableGet.call(durable, id, opts);
|
|
762
|
+
let cancelFailure = null;
|
|
763
|
+
const cancelWaitMs = deps?.cancelReleaseWaitMs ?? CANCEL_RELEASE_WAIT_MS;
|
|
764
|
+
const cancelStartedAt = Date.now();
|
|
765
|
+
const cancelLease = claimProbeLease(deps?.signal, cancelWaitMs);
|
|
766
|
+
try {
|
|
767
|
+
// 🔴 这一枪也必须**带中止口与截止**(二次评审 R5 [high]):`cancel` 的 promise 不落地,后面那条
|
|
768
|
+
// 有界确认腿就根本起不来 —— 用户按了 Esc 既停不掉等待也停不掉出站请求,整条腿零结局。有界性
|
|
769
|
+
// 的执行手段只有一个:把 signal 真交到请求上。
|
|
770
|
+
await durable.cancel?.(taskId, { signal: cancelLease.signal, ...sessionOpts(deps) });
|
|
771
|
+
}
|
|
772
|
+
catch (e) {
|
|
773
|
+
// 用户中止 ⇒ 当拍收口(绝不接着跑一个满窗的确认腿:「UI 说停了、后台还在问」正是禁区)。
|
|
774
|
+
if (deps?.signal?.aborted === true) {
|
|
775
|
+
return { outcome: 'timeout', waitedMs: Date.now() - cancelStartedAt, aborted: true, confirmedHeld: false };
|
|
776
|
+
}
|
|
777
|
+
const delivery = atMostOnceFailureClass(e);
|
|
778
|
+
if (delivery === 'rejected')
|
|
779
|
+
return { outcome: 'failed', detail: describeFailure(e), delivery };
|
|
780
|
+
// 本枪的截止到点(请求挂死被掐)同样是**送达未知** —— 服务端可能已经受理,照走确认腿。
|
|
781
|
+
cancelFailure = { detail: describeFailure(e), delivery };
|
|
782
|
+
}
|
|
783
|
+
finally {
|
|
784
|
+
cancelLease.release();
|
|
785
|
+
}
|
|
786
|
+
// 202 = 「收下了」不是「停了」⇒ 必须真等到那条 run 交出会话,否则重发只会再撞一次 409。
|
|
787
|
+
const verdict = await waitForClaimRelease(taskId, {
|
|
788
|
+
get: boundGet,
|
|
789
|
+
budgetMs: cancelWaitMs,
|
|
790
|
+
...(deps?.signal !== undefined ? { signal: deps.signal } : {}),
|
|
791
|
+
...sessionOpts(deps),
|
|
792
|
+
});
|
|
793
|
+
// 会话真被交出来了 ⇒ 就是取消成功,哪怕那一枪的回执丢在路上(决定量说了算)。
|
|
794
|
+
if (verdict.released)
|
|
795
|
+
return { outcome: 'released' };
|
|
796
|
+
if (cancelFailure !== null)
|
|
797
|
+
return { outcome: 'failed', ...cancelFailure };
|
|
798
|
+
return {
|
|
799
|
+
outcome: 'timeout',
|
|
800
|
+
waitedMs: verdict.waitedMs,
|
|
801
|
+
aborted: verdict.aborted,
|
|
802
|
+
confirmedHeld: verdict.lastStatus !== null && CLAIM_HELD_STATES.includes(verdict.lastStatus),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
525
805
|
/**
|
|
526
806
|
* `running` 形:呈三选卡 → 按用户的决定动手。零决定 = 零动作(`not-parked` 现状行)。
|
|
527
807
|
*
|
|
@@ -566,28 +846,16 @@ async function runningChoiceArm(taskId, status, busy, statusFromWire, runs, deps
|
|
|
566
846
|
// tsc 控制流分析把第二次窄成恒假比较(TS2367)。
|
|
567
847
|
const probeCallerAborted = () => deps?.signal?.aborted === true;
|
|
568
848
|
if (statusFromWire && typeof runs?.get === 'function' && !probeCallerAborted()) {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
try {
|
|
574
|
-
fresh = readStatus(await runs.get(taskId, { signal: lease.signal, ...sessionOpts(deps) }));
|
|
575
|
-
probeAnswered = true;
|
|
576
|
-
}
|
|
577
|
-
catch (e) {
|
|
578
|
-
if (e?.status === 404)
|
|
579
|
-
ghost = true;
|
|
580
|
-
}
|
|
581
|
-
finally {
|
|
582
|
-
lease.release();
|
|
583
|
-
}
|
|
584
|
-
if (ghost)
|
|
849
|
+
// 与陈旧 park 臂共用同一只读叶(同形单源,见 {@link probeRunStatusOnce})。
|
|
850
|
+
const probe = await probeRunStatusOnce(taskId, runs, runs.get, deps);
|
|
851
|
+
const fresh = probe.status;
|
|
852
|
+
if (probe.ghost)
|
|
585
853
|
return { kind: 'running-not-found', taskId };
|
|
586
854
|
// (对抗复审 [medium] 采纳)对账期间用户中止 ⇒ 后续一切动作(派臂/出卡/登记)都不做 ——
|
|
587
855
|
// Esc 之后还弹卡/重开 = 用户说停还在动。零动作现状行收口(与呈卡腿抛错同一条诚实收口)。
|
|
588
856
|
if (probeCallerAborted())
|
|
589
857
|
return notParked;
|
|
590
|
-
if (
|
|
858
|
+
if (fresh !== null && !RUNNING_STATES.includes(fresh)) {
|
|
591
859
|
if (PLAN_REVIEW_STATES.includes(fresh))
|
|
592
860
|
return planReviewArm(taskId, busy, deps);
|
|
593
861
|
if (ASK_PARK_STATES.includes(fresh))
|
|
@@ -618,16 +886,10 @@ async function runningChoiceArm(taskId, status, busy, statusFromWire, runs, deps
|
|
|
618
886
|
// wait = 用户看过卡并选择留着它(呈现层把 Esc/空答也收口成 wait —— 同样是「这次不动它」的表态);
|
|
619
887
|
// steer/cancel = 用户重新表了态 ⇒ 清登记。呈卡腿抛错那次**不**登记(用户没看到卡)。
|
|
620
888
|
if (choice === 'wait') {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
if (runningChoiceDeclined.size > RUNNING_CHOICE_LEDGER_CAP) {
|
|
626
|
-
const oldest = runningChoiceDeclined.values().next().value;
|
|
627
|
-
if (oldest !== undefined)
|
|
628
|
-
runningChoiceDeclined.delete(oldest);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
889
|
+
// (对抗复审 [medium] 轻形采纳)FIFO 有界:长命桌面/多会话宿主不许无界长住(叶
|
|
890
|
+
// {@link rememberDeclinedOffer},与 L-93 两选卡共用同一条上界)。
|
|
891
|
+
if (offerDelivered)
|
|
892
|
+
rememberDeclinedOffer(declineKey);
|
|
631
893
|
}
|
|
632
894
|
else {
|
|
633
895
|
runningChoiceDeclined.delete(declineKey);
|
|
@@ -663,68 +925,21 @@ async function runningChoiceArm(taskId, status, busy, statusFromWire, runs, deps
|
|
|
663
925
|
}
|
|
664
926
|
}
|
|
665
927
|
if (choice === 'cancel' && canCancel && runs?.cancel !== undefined && runs.get !== undefined) {
|
|
666
|
-
//
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
* 🔴 cancel 的失败也分两类(二次评审 R4 [high]),与 steer 同一把尺:
|
|
672
|
-
* · `rejected`(4xx)—— 服务端答了且明确拒 ⇒ 那一枪确定没生效,立即如实说,不必再等确认腿;
|
|
673
|
-
* · `unknown`(5xx / 连接在回执路上断了)—— 请求**可能已经被受理并在异步执行**。此时直接收口成
|
|
674
|
-
* 「取消失败,那条 run 还占着会话」是对用户下一个证不出的断言 —— 而这条腿手上恰恰有一个能
|
|
675
|
-
* 回答这个问题的读口(`get`)。所以照走有界确认腿:真交出来了就是 `running-cancelled`
|
|
676
|
-
* (用户授权的取消其实成了),确认不到才如实说「没能确认」。判据锚在**决定量**(会话有没有
|
|
677
|
-
* 被交出来)上,不锚它的一个前置条件(那一枪的回执长什么样)。
|
|
678
|
-
*/
|
|
679
|
-
let cancelFailure = null;
|
|
680
|
-
const cancelWaitMs = deps?.cancelReleaseWaitMs ?? CANCEL_RELEASE_WAIT_MS;
|
|
681
|
-
const cancelStartedAt = Date.now();
|
|
682
|
-
const cancelLease = claimProbeLease(deps?.signal, cancelWaitMs);
|
|
683
|
-
try {
|
|
684
|
-
// 🔴 这一枪也必须**带中止口与截止**(二次评审 R5 [high]):`cancel` 的 promise 不落地,后面那条
|
|
685
|
-
// 有界确认腿就根本起不来 —— 用户按了 Esc 既停不掉等待也停不掉出站请求,整条腿零结局。有界性
|
|
686
|
-
// 的执行手段只有一个:把 signal 真交到请求上。
|
|
687
|
-
await durable.cancel?.(taskId, { signal: cancelLease.signal, ...sessionOpts(deps) });
|
|
688
|
-
}
|
|
689
|
-
catch (e) {
|
|
690
|
-
// 用户中止 ⇒ 当拍收口(绝不接着跑一个满窗的确认腿:「UI 说停了、后台还在问」正是禁区)。
|
|
691
|
-
if (deps?.signal?.aborted === true) {
|
|
692
|
-
return {
|
|
693
|
-
kind: 'running-cancel-timeout',
|
|
694
|
-
taskId,
|
|
695
|
-
waitedMs: Date.now() - cancelStartedAt,
|
|
696
|
-
aborted: true,
|
|
697
|
-
confirmedHeld: false,
|
|
698
|
-
};
|
|
699
|
-
}
|
|
700
|
-
const delivery = atMostOnceFailureClass(e);
|
|
701
|
-
if (delivery === 'rejected') {
|
|
702
|
-
return { kind: 'running-cancel-failed', taskId, detail: describeFailure(e), delivery };
|
|
703
|
-
}
|
|
704
|
-
// 本枪的截止到点(请求挂死被掐)同样是**送达未知** —— 服务端可能已经受理,照走确认腿。
|
|
705
|
-
cancelFailure = { detail: describeFailure(e), delivery };
|
|
706
|
-
}
|
|
707
|
-
finally {
|
|
708
|
-
cancelLease.release();
|
|
709
|
-
}
|
|
710
|
-
// 202 = 「收下了」不是「停了」⇒ 必须真等到那条 run 交出会话,否则重发只会再撞一次 409。
|
|
711
|
-
const verdict = await waitForClaimRelease(taskId, {
|
|
712
|
-
get: boundGet,
|
|
713
|
-
budgetMs: cancelWaitMs,
|
|
714
|
-
...(deps?.signal !== undefined ? { signal: deps.signal } : {}),
|
|
715
|
-
...sessionOpts(deps),
|
|
716
|
-
});
|
|
717
|
-
// 会话真被交出来了 ⇒ 就是取消成功,哪怕那一枪的回执丢在路上(决定量说了算)。
|
|
718
|
-
if (verdict.released)
|
|
928
|
+
// 🔴 **复用**共享的 cancel + 有界释放确认腿(见 {@link cancelAndConfirmRelease});本臂只做
|
|
929
|
+
// 「判决 → 自己这一族的结局名」的映射。L-93 的两选卡走同一条腿 —— 复制第二份的代价是两条腿
|
|
930
|
+
// 的失败分类与释放判据各自漂,而它们说的是同一件事。
|
|
931
|
+
const verdict = await cancelAndConfirmRelease(taskId, runs, runs.get, deps);
|
|
932
|
+
if (verdict.outcome === 'released')
|
|
719
933
|
return { kind: 'running-cancelled', taskId };
|
|
720
|
-
if (
|
|
721
|
-
return { kind: 'running-cancel-failed', taskId,
|
|
934
|
+
if (verdict.outcome === 'failed') {
|
|
935
|
+
return { kind: 'running-cancel-failed', taskId, detail: verdict.detail, delivery: verdict.delivery };
|
|
936
|
+
}
|
|
722
937
|
return {
|
|
723
938
|
kind: 'running-cancel-timeout',
|
|
724
939
|
taskId,
|
|
725
940
|
waitedMs: verdict.waitedMs,
|
|
726
941
|
aborted: verdict.aborted,
|
|
727
|
-
confirmedHeld: verdict.
|
|
942
|
+
confirmedHeld: verdict.confirmedHeld,
|
|
728
943
|
};
|
|
729
944
|
}
|
|
730
945
|
// 用户选了 ③ / 卡没答上 / 选了一条本轮不可兑现的路 ⇒ 零动作,如实转述现状。
|
|
@@ -990,6 +1205,68 @@ function activeRunSelfHealBaseRow(outcome, signal, wayOut) {
|
|
|
990
1205
|
return (`sema could not cancel run ${outcome.taskId} — the engine rejected that request (${outcome.detail}), so ` +
|
|
991
1206
|
`nothing was cancelled and sema cannot tell whether that run is still holding this session. Your message ` +
|
|
992
1207
|
`was NOT sent; send it again to find out, or ${wayOut}.`);
|
|
1208
|
+
// ── L-93 陈旧 park 的五种结局(0.53.0)────────────────────────────────────────────────────────
|
|
1209
|
+
case 'ask-run-not-found':
|
|
1210
|
+
// 待决行确已不在 + 一手复核撞 404:两个缺席证据指向同一件事,但「已解锁」仍然证不出
|
|
1211
|
+
// (该 404 是 unknown-run ∪ non-owner 的不可分辨并集)⇒ 只说已证的事实 + 「再发一次」这条
|
|
1212
|
+
// 真出路。🔴 不提 decidePath:那张卡已经不在引擎上,POST 过去只会再吃一个 404(假出路)。
|
|
1213
|
+
return (`The previous turn is recorded as parked on a decision (run ${outcome.taskId}), but the approval it ` +
|
|
1214
|
+
`was waiting on is no longer on the engine, and asking the engine about that run directly comes back ` +
|
|
1215
|
+
`not-found — a lookup that cannot separate a run that is really gone from one this session is simply ` +
|
|
1216
|
+
`not allowed to read. Either way sema has nothing it can act on here, so it did NOT cancel anything. ` +
|
|
1217
|
+
`Your message was NOT sent; send it again (if the session really is free it will just run), or ${wayOut}.`);
|
|
1218
|
+
case 'stale-park-cancelled':
|
|
1219
|
+
// 🔴 resending 形绝不说「NOT sent」:释放已被引擎确认(判据 = CLAIM_RELEASED_STATES),
|
|
1220
|
+
// 调用方下一步就是重发那条消息,说它没送出去是对着一个正在发生的动作谎报。
|
|
1221
|
+
return (`Run ${outcome.taskId} was parked on an approval that no longer exists on the engine, so there was ` +
|
|
1222
|
+
`nothing left for you to answer. You chose to cancel that run: the engine now reports it as finished, ` +
|
|
1223
|
+
`so it will not resume — whatever that turn had left to do is not coming back. sema is sending your ` +
|
|
1224
|
+
`message again now; if that run has not fully let go of the session yet, you may have to send it once ` +
|
|
1225
|
+
`more.`);
|
|
1226
|
+
case 'stale-park-cancel-timeout': {
|
|
1227
|
+
// 秒数取**真等了多久**(不是预算),与 running-cancel-timeout 同律。
|
|
1228
|
+
const waited = `${String(Math.max(1, Math.round(outcome.waitedMs / 1000)))}s`;
|
|
1229
|
+
if (outcome.aborted) {
|
|
1230
|
+
return (`sema asked the engine to cancel run ${outcome.taskId} (it was parked on an approval that no longer ` +
|
|
1231
|
+
`exists there) and you interrupted while it was still confirming (${waited} in). Cancelling is ` +
|
|
1232
|
+
`asynchronous, so that run may still be winding down and may still hold this session. Your message ` +
|
|
1233
|
+
`was NOT sent; send it again in a moment, or ${wayOut}.`);
|
|
1234
|
+
}
|
|
1235
|
+
if (!outcome.confirmedHeld) {
|
|
1236
|
+
// 一次都没读出那条 run 的状态 ⇒ 只许说「确认不了」,绝不替引擎断言「它还占着」。
|
|
1237
|
+
return (`sema asked the engine to cancel run ${outcome.taskId} (parked on an approval that no longer exists ` +
|
|
1238
|
+
`there) but could not read that run's state back within ${waited}, so it cannot confirm whether the ` +
|
|
1239
|
+
`session was released. Your message was NOT sent; send it again in a moment (if that run really is ` +
|
|
1240
|
+
`gone it will just run), or ${wayOut}.`);
|
|
1241
|
+
}
|
|
1242
|
+
return (`sema asked the engine to cancel run ${outcome.taskId} (parked on an approval that no longer exists ` +
|
|
1243
|
+
`there), but that run still held this session ${waited} later — cancelling is asynchronous and it may ` +
|
|
1244
|
+
`still be winding down. Your message was NOT sent; send it again in a moment, or ${wayOut}.`);
|
|
1245
|
+
}
|
|
1246
|
+
case 'stale-park-cancel-failed':
|
|
1247
|
+
if (outcome.delivery === 'unknown') {
|
|
1248
|
+
// 送达未知 ⇒ 那条 run 可能已经在停了;说「它还占着会话」是替引擎下一个证不出的断言。
|
|
1249
|
+
return (`sema sent the cancel for run ${outcome.taskId} (parked on an approval that no longer exists on the ` +
|
|
1250
|
+
`engine) but could not confirm what happened to it (${outcome.detail}), and it could not read that ` +
|
|
1251
|
+
`run back as released either. That run may or may not still be holding this session. Your message ` +
|
|
1252
|
+
`was NOT sent; send it again in a moment, or ${wayOut}.`);
|
|
1253
|
+
}
|
|
1254
|
+
return (`sema could not cancel run ${outcome.taskId} — the engine rejected that request (${outcome.detail}), ` +
|
|
1255
|
+
`so nothing was cancelled and sema cannot tell whether that run is still holding this session. Your ` +
|
|
1256
|
+
`message was NOT sent; send it again to find out, or ${wayOut}.`);
|
|
1257
|
+
case 'stale-park-wait':
|
|
1258
|
+
// 两条真出路都必须在这一行里(压噪不压把手):引擎侧 cancel 端点与宿主的 wayOut。
|
|
1259
|
+
// 🔴 这一行**不**说「答那张卡」—— 那张卡正是已经不在了的东西。
|
|
1260
|
+
if (outcome.alreadyOffered === true) {
|
|
1261
|
+
return (`Run ${outcome.taskId} is still parked (status ${outcome.status}) on an approval that no longer ` +
|
|
1262
|
+
`exists on the engine. sema already asked what to do with it and you chose to leave it alone, so it ` +
|
|
1263
|
+
`is not asking again. Your message was NOT sent; cancel that run on the engine ` +
|
|
1264
|
+
`(POST /v1/runs/${outcome.taskId}/cancel — that ends it for good), or ${wayOut}.`);
|
|
1265
|
+
}
|
|
1266
|
+
return (`Run ${outcome.taskId} is still parked (status ${outcome.status}) on an approval that no longer exists ` +
|
|
1267
|
+
`on the engine, so there is no card left to answer and that run will not settle on its own. sema did ` +
|
|
1268
|
+
`NOT cancel it — you chose to leave it alone. Your message was NOT sent; cancel that run on the engine ` +
|
|
1269
|
+
`(POST /v1/runs/${outcome.taskId}/cancel — that ends it for good), or ${wayOut}.`);
|
|
993
1270
|
case 'running-not-found':
|
|
994
1271
|
// Inkglow-1085 P0b②:幽灵 claim —— 引擎一边说这条 run 占着会话、一边查无此 run。「已解锁」
|
|
995
1272
|
// 证不出(404 与 non-owner 同码刻意不可分辨),所以只说已证的事实 + 「再发一次」这条真出路
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
## §0 版本锚与重扫纪律
|
|
17
17
|
|
|
18
|
-
### 0a. 版本锚(2026-09-
|
|
18
|
+
### 0a. 版本锚(2026-09-04)
|
|
19
19
|
|
|
20
20
|
| 项 | 值 | 真源 |
|
|
21
21
|
|---|---|---|
|
|
22
|
-
| 本包 | `@sema-agent/client-core` **0.
|
|
22
|
+
| 本包 | `@sema-agent/client-core` **0.53.0**(工作树**未发**;npm 最新 = **0.52.0**。L-93 那一批进 `CHANGELOG.md` 的 `## 0.53.0(未发布)` 段,冻结账已按两阶段协议插 `pending` 行) | `package.json` `version` |
|
|
23
23
|
| peer:wire 契约 | `@sema-agent/sdk` **>=7.4.0**(value-level,非 type-only;0.48.0 抬版,四条硬理由见 `CHANGELOG.md` 0.48.0 段末的地板影响面账) | `package.json` `peerDependencies` |
|
|
24
24
|
| peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
|
|
25
25
|
| runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
|
|
@@ -1481,6 +1481,8 @@ CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读
|
|
|
1481
1481
|
| **P-41** | low | 🆕 **decide 的 `currentPending` 指路键今天在标准 SDK 路径上恒缺席**(0.47.0 件②,如实登记的射程边界不是缺陷):server 的 409 `approval_stale` 臂**只在调用方回显 `checkpointToken` 时触发**(engine 7.52.1 `http/server.js` 的 `resumeCheckpoint`,`if (binding?.checkpointToken && …)` 真字节),而 sdk 的 `ApprovalDecision` 自 1.0.0 起**刻意删掉**了那一位 ⇒ 经 SDK client 的 decide 拿不到这枚 409。另:ask 腿(`GateOutcome.currentPending`)**包内无消费方** —— `GateOutcome` 是包内型、决断结局不出包;它在场的理由是**同形存量清剿**(两条 decide 失败腿一次改齐) | `src/hitl/hitlBridge.ts`(`readDecideCurrentPending`)、`src/hitl/toolApprovalWire.ts`、`src/hitl/parkResolver.ts` | 按 §4f 接:读得到就一跳重定位,**缺席时退回重拉 `GET /v1/approvals`**,绝不把缺席读成「没有别的 pending 了」。自己注入传输层(非 SDK client)的宿主今天就拿得到 |
|
|
1482
1482
|
|
|
1483
1483
|
| **P-43** | med(**存量、非本批引入** —— 0.42.0 基线上逐字相同) @cli @web @desktop | 🆕 **accept-session 回退臂的错误分类比它自己的注释宽**(#363 异源复审 [high] 的**未收窄那一半**,如实登记):`allowSession` 腿先发 `approve + remember:'session'`,失败时回退一发纯 approve;那条 catch 的注释写的是「**只**兜『老 server 不识别 remember ⇒ 400 未知键』这一形」,而实际形是 **catch-all 减去三条具名再抛**(`HitlSafetyError` / `DecideTransportRetryExhaustedError` / 0.47.0 新加的『拒体带 `currentPending`』)。⇒ 一个 **404 / 5xx / 宿主自抛的无 status 错误**今天仍会被当成「老 server 不识别 remember」并**自动重发**一次纯 approve。收窄成「只认 400」是**行为改动**,不属于 0.47.0 这个 additive 批的射程 | `src/hitl/toolApprovalWire.ts`(`case 'allow'` 的内层 catch) | 端今天不需要做什么(两发都是 approve,不构成跨门的 double-act);**属主批**:下一个愿意改老引擎兼容腿宽度的批把它收窄成精确的 legacy-400,并同批给回退臂补正控/负控 |
|
|
1484
|
+
| **P-44** | 已知缺口(0.53.0 登记;正位解在**引擎侧**)@cli | 🆕 **L-93 两选卡的 cancel 是「查了再做」,不是原子条件取消**(异源对抗复审 R2 [high] 如实登记):卡后那一发 `runs.get` 状态复证 + 开枪前那一发 `listOwnedPendingApprovals` 待决行复证,把窗口从「人看卡的任意长时间」压到「两发复证到一发 cancel」的毫秒级,**但没有关死** —— 待决行若恰在这两步之间恢复(store 恢复 / 会话重新附着),那一枪仍会落在一条**其实还能被决断**的 run 上,而 `runs.cancel` 会把它终态化。**客户端关不死它**:真正的关法是引擎侧的**条件取消**(带审批快照版本 / checkpoint 标识,条件变了回 409 且不取消),那是 wire 能力,不是壳能自造的语义 | `src/adapter/activeRunSelfHeal.ts`(`staleParkArm` 开枪前那一段的头注);§16b 四道防线 | 端照旧只在用户显式选「停掉它」时才走这条路,**卡面必须说清这是放弃这条 run**;要彻底关死,按跨仓宪法向引擎提**条件取消**(`If-Match` 形)。在那之前这条路的剩余风险是**在册**的,不是未知的 |
|
|
1485
|
+
| **P-45** | 在册局限(**存量族**,非本批引入)@cli @web @desktop | 🆕 **「run 读到终态」不等于「claim 已经落地释放」**(同上 R2 [medium]):`waitForClaimRelease` 的判据是 `runs.get` 读回 {@link CLAIM_RELEASED_STATES} 里的终态词,而本档 §12 与 `running-settled` 臂自己记着:409 报的 claim 与 poll 口径**可以短暂相左**。⇒ `running-cancelled`(0.37.0 起)与 🆕 `stale-park-cancelled`(0.53.0)两条 `resending` 结局,理论上都可能在 claim 尚未落地释放时重发一次,代价 = **再吃一个 409**(自愈树当拍再分诊)。**为什么不单修新那一条**:两条问的是同一个问题,给它们两把不同的尺 = 同一判据两份实现,必漂;正位解是一条 **session/claim 专用读面**(今天 `DurableRunVerbs` 里没有这个动词) | `src/adapter/activeRunSelfHeal.ts`(`CLAIM_RELEASED_STATES` 头注 / `waitForClaimRelease` / 两条 `resending` 结局) | 端**不要**把 `resending` 读成「会话一定空了」——它的成文含义是「引擎报那条 run 已终结」。重发撞回 409 是**已知可能**,自愈树会当拍再分诊(不是新 bug);要更强的保证,得等引擎给 claim 读面。🔴 **文案面同形存量**:0.53.0 的 `stale-park-cancelled` 行已按这条边界改写(只说「引擎报它已终结」+ 「可能要再发一次」),而 0.37.0 的 `running-cancelled` 行仍写着「The engine confirmed it is no longer holding this session」—— 同一把尺、两种说法。**属主批**:下一个愿意动那条既有产品文案的批同步收紧(本批不动,因为它是三端在跑的现产品串,改它属行为面/文案面变更,要单独走验收与端对表) |
|
|
1484
1486
|
|
|
1485
1487
|
### 7c. 多会话(sessionKey)面在册局限 —— 多会话端**接之前必读**
|
|
1486
1488
|
|
|
@@ -2446,3 +2448,147 @@ web / desktop 无需动作(老宿主零行为差),件B 对三端都是治理面
|
|
|
2446
2448
|
`src/hitl/hitlHostSurface.ts`(`ClassifierDenyContext`)、`src/classifierVerdictWire.ts`
|
|
2447
2449
|
(`classifierDenyDisplay` / `CLASSIFIER_DENY_DISPLAY_MAX`)、`src/hooksWireCaps.ts`(第三道门)。
|
|
2448
2450
|
**常驻门**:`scripts/run-client-core-pure-test.mjs`(B6 段 L-67④ 16 条 / B7 段 L-69⑨ 31 条)。
|
|
2451
|
+
|
|
2452
|
+
---
|
|
2453
|
+
|
|
2454
|
+
## §16 🆕 陈旧 park:「待决行已不在」的真出路(0.53.0;L-93)
|
|
2455
|
+
|
|
2456
|
+
### 16a. 修的是哪一形(现网 sema-bug7)
|
|
2457
|
+
|
|
2458
|
+
审批卡 park 住一条 run 之后过了很久(实测 27.5h),用户退出 TUI 再 `--resume`。重开链去引擎读
|
|
2459
|
+
待决行时,**读面答了**、而属主待决行**恰 0** —— 行确实没了(引擎重启后 `process_local` 会话店丢行 /
|
|
2460
|
+
审批被引擎 reap),可那条 run 仍是 `suspended`,**仍占着会话 claim**。修前本包对这一形只有一个结局:
|
|
2461
|
+
`ask-reopen-failed` ⇒ 端渲「Your message was NOT sent … 换个新会话」。用户手上一条真出路都没有 ——
|
|
2462
|
+
卡不可能重开(没有行了),run 不会自己结束(park 态不在时间型 reap 射程里),只剩丢掉整段上下文。
|
|
2463
|
+
|
|
2464
|
+
0.53.0 修的**只有这一形**:`reopened:false` **且** 宿主证出 `pendingRowGone: true`。其余全部形态
|
|
2465
|
+
(含宿主没证出这一位的一切情况)行为**逐字节不变**。
|
|
2466
|
+
|
|
2467
|
+
### 16b. 宿主契约:`pendingRowGone` 什么时候才许置
|
|
2468
|
+
|
|
2469
|
+
```ts
|
|
2470
|
+
export type ReopenCardVerdict =
|
|
2471
|
+
| { reopened: false; decidedWithoutCard?: true; pendingRowGone?: true }
|
|
2472
|
+
| { reopened: true; firstSight: boolean; presented?: boolean }
|
|
2473
|
+
```
|
|
2474
|
+
|
|
2475
|
+
🔴 **只在「读面答了 **且** 属主过滤后待决行数恰 0」时置 `true`**,别的一律不置(fail-closed)。
|
|
2476
|
+
这一位不是「我没找到卡」的同义词 —— 它是**正面证据**:引擎回答了,答案是「没有」。
|
|
2477
|
+
|
|
2478
|
+
| 宿主实际情形 | 置 `pendingRowGone`? |
|
|
2479
|
+
|---|---|
|
|
2480
|
+
| 有界窗内 `approvals.list` 答了,属主过滤后行数 = 0 | ✅ 置 |
|
|
2481
|
+
| 读口(`approvals.list` 端口)整个缺席 | ❌ 不置 |
|
|
2482
|
+
| 请求抛错 / 网络失败 / 5xx | ❌ 不置 |
|
|
2483
|
+
| 窗内没答完(超时)/ 调用方中止(Esc) | ❌ 不置 |
|
|
2484
|
+
| 有行,但归属证不出 / 问句缺席 / 行形坏(= 找不到**能用**的那一行) | ❌ 不置 |
|
|
2485
|
+
| 只读了一发就返回、没有等满出生窗 | ❌ 不置 |
|
|
2486
|
+
|
|
2487
|
+
**为什么这条边界是硬约束**:`pendingRowGone` 是本包**唯一**允许对一条 park 态 run 提供 cancel 的前提。
|
|
2488
|
+
park 态的 cancel 语义 = 替用户否掉那个待决项;而「待决项**仍在**」时那正是**已退役**的自动 cancel 臂的
|
|
2489
|
+
循环病根(cancel = deny ⇒ 模型重试 ⇒ 新 run 停在同一道门 ⇒ 再 park)。只有「确已没有任何待决项可被
|
|
2490
|
+
否掉」时,那条循环在结构上不成立。含糊值(`false` / `'true'` / `1` / 缺席)一律不算,判据只认严格 `true`。
|
|
2491
|
+
|
|
2492
|
+
包侧**不只信这一句话**,一共四道防线:
|
|
2493
|
+
|
|
2494
|
+
1. 进臂后用 `runs.get` **一手复核**真态,读回的必须是审批 park 词(`ASK_PARK_STATES` = `suspended`)
|
|
2495
|
+
才可能出卡(running / plan park / 终态 / 读不出的一律走别的臂或退回旧结局);
|
|
2496
|
+
2. 卡上必须**用户逐字选 `cancel`**;
|
|
2497
|
+
3. 🔴 **卡后复证 run 真态**(对抗复审 R3 [high] 采纳):第 1 道防线那一发打在**呈卡之前**,而卡是
|
|
2498
|
+
人在看、可以停留任意久 —— 这中间那条 run 完全可能**恢复运行**(审批在别处被决断 / 会话重新附着 /
|
|
2499
|
+
引擎自愈)。所以用户选 cancel 之后**再打一发** `runs.get`,状态仍是审批 park 词才继续;
|
|
2500
|
+
变成 running / 终态 / 换了门 / 读不出 ⇒ 一律 fail-closed 退回旧结局(**绝不**沿用旧卡的授权去停
|
|
2501
|
+
一条正在干活的 run),404 ⇒ `ask-run-not-found` 如实说;
|
|
2502
|
+
4. 🔴 **开枪前再证一次前提**(对抗复审 R1 [high] 采纳):在离 `runs.cancel` 最近的那一刻,用
|
|
2503
|
+
`deps.listOwnedPendingApprovals` 再问一次属主待决行数,**恰 0 才开枪**;非 0 / 抛错 / 窗尽 /
|
|
2504
|
+
中止 / 读不出一律不开枪(退回 `ask-reopen-failed`)。
|
|
2505
|
+
为什么这一步不可省:`pendingRowGone` 是宿主**读那一刻**的事实,而这张卡是人在看、可以看任意久
|
|
2506
|
+
—— 这中间待决行完全可能重新出现(store 恢复 / 会话重新附着),宿主也可能把「读失败 / 找不到
|
|
2507
|
+
能用的那一行」误报成 gone。而第 1/3 道防线**证不了这件事**:审批还在与审批已丢,`runs.get` 读回
|
|
2508
|
+
的都是 `suspended`。代价不对称 —— cancel 一条**其实还能被决断**的 run,等于用户在一句假前提下
|
|
2509
|
+
把整轮工作丢掉(`runs.cancel` 会把 suspended run 终态化,那一轮不会恢复)。
|
|
2510
|
+
|
|
2511
|
+
第 3、4 两道的调用序被常驻门钉住:**呈卡 → 卡后 get 复证 → 待决行复证 → cancel**,两发复证都紧跟在
|
|
2512
|
+
用户那句授权之后。
|
|
2513
|
+
|
|
2514
|
+
⚠️ 因此 `deps.listOwnedPendingApprovals`(既有键,假死锁防御那一只,带**归属过滤**)是这张卡的
|
|
2515
|
+
**供给前提**:宿主不注入它 ⇒ 两选卡整个不呈,行为退回今天的 `ask-reopen-failed`。这是刻意的
|
|
2516
|
+
fail-closed 形:与其先呈卡、等用户按下去才发现前提证不了,不如干脆不给这条路。
|
|
2517
|
+
|
|
2518
|
+
### 16c. 新宿主钩子:`offerStaleParkChoice`(两选卡)
|
|
2519
|
+
|
|
2520
|
+
```ts
|
|
2521
|
+
export interface StaleParkChoiceRequest {
|
|
2522
|
+
taskId: string
|
|
2523
|
+
/** 引擎报的状态(如实转述)。 */
|
|
2524
|
+
status: string
|
|
2525
|
+
/** 供给位:cancel 动词在场 **且** 有 `get` 能确认 claim 真的释放。 */
|
|
2526
|
+
canCancel: boolean
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
interface ActiveRunSelfHealDeps {
|
|
2530
|
+
offerStaleParkChoice?: (req: StaleParkChoiceRequest) => Promise<'cancel' | 'wait'>
|
|
2531
|
+
}
|
|
2532
|
+
```
|
|
2533
|
+
|
|
2534
|
+
- **缺席 ⇒ 整臂不走**(退回 `ask-reopen-failed`,逐字节零行为差)。老宿主(web / desktop)不传这个键
|
|
2535
|
+
= 今天的行为一字不变。
|
|
2536
|
+
- 卡上**没有 steer**:steer 的语义是「注入正在跑的那一轮」,对一条 park 住的 run 它落 `queued`
|
|
2537
|
+
(排在那个**已经不存在**的 checkpoint 上)—— 递一条注定注入不进去的路是假 affordance。
|
|
2538
|
+
- `canCancel` 的三条供给:`runs.cancel` 在场、`runs.get` 在场(确认释放用)、
|
|
2539
|
+
`deps.listOwnedPendingApprovals` 在场(开枪前复证用)。任一缺席 ⇒ 卡上只剩「什么都不做」= 纯噪音,
|
|
2540
|
+
**整卡不呈**,退回旧结局(所以卡真到宿主手上时 `canCancel` 恒 `true`)。
|
|
2541
|
+
- 🔴 **卡面文案是端的事,但语义由本节钉死**:这张卡上的「停掉它」= `POST /v1/runs/:id/cancel`,
|
|
2542
|
+
按 SDK `CancelAck` 契约 run 随后 settle 成 `failed` + `errorCode: "cancelled"` —— **那一轮的工作
|
|
2543
|
+
不会恢复**。卡面必须让用户看懂这是「放弃这条 run 换回会话」,不是「继续它」。
|
|
2544
|
+
- 🔴 返回值 **fail-closed**:只有**逐字** `'cancel'` 才武装那一枪;`Esc` / 空答 / 表外词 / 抛错一律
|
|
2545
|
+
当「什么都不做」(抛错那次还额外退回旧结局,并且**不登记**——用户没看到卡)。
|
|
2546
|
+
- 呈过一次卡且用户选了「什么都不做」⇒ 同 `(sessionKey, taskId)` 再撞不再整卡重弹,结局带
|
|
2547
|
+
`alreadyOffered: true` 让端降级渲一行;清口 = 既有的 `clearRunningChoiceOffer(taskId, sessionId)`
|
|
2548
|
+
(它**同时**清三选卡与两选卡的登记 —— 宿主「重新打开操作菜单」的语义是「让我重新表态」)。
|
|
2549
|
+
|
|
2550
|
+
### 16d. 新结局与处置表
|
|
2551
|
+
|
|
2552
|
+
| kind | 何时 | `selfHealSubmissionDisposition` | 端要做什么 |
|
|
2553
|
+
|---|---|---|---|
|
|
2554
|
+
| `ask-run-not-found` | 复核 `runs.get` 撞 **404**(幽灵 claim 的 park 半场) | `not-delivered` | 渲行;**不**自动重发(404 = 「不存在」∪「不是你的」不可分辨) |
|
|
2555
|
+
| `stale-park-cancelled` | 用户选 cancel,且引擎**确认**那条 run 不再占会话 | `resending` | 重发被拒的那条消息(与 `running-cancelled` 同一条腿) |
|
|
2556
|
+
| `stale-park-cancel-timeout` | cancel 发了,窗内没等到释放(或用户中止) | `not-delivered` | 渲行;不重发 |
|
|
2557
|
+
| `stale-park-cancel-failed` | 那一枪失败且确认腿也没看到释放(`delivery` 分 `rejected`/`unknown`) | `not-delivered` | 渲行;不重发 |
|
|
2558
|
+
| `stale-park-wait` | 用户选「什么都不做」(带 `alreadyOffered?` 降级位) | `not-delivered` | 渲行(或降级行);不重发 |
|
|
2559
|
+
|
|
2560
|
+
- 复核读回 `running` ⇒ 交给**既有** running 三选卡臂(不新铸分臂,也不打第二发 `runs.get`);
|
|
2561
|
+
- 复核读回 `needs_review`(plan park)⇒ 交给**既有** plan 重开臂 —— 对它 cancel 是替用户把整个 plan
|
|
2562
|
+
丢掉(禁区),而 plan 卡本身是能重开的;
|
|
2563
|
+
- 复核读不出 / 读回表外新词 / 用户中止 ⇒ 一律退回 `ask-reopen-failed`(不确定时不做破坏性动作)。
|
|
2564
|
+
- cancel 那条腿是**复用**的(`cancelAndConfirmRelease`):失败分类(`atMostOnceFailureClass`)与释放
|
|
2565
|
+
判据(`CLAIM_RELEASED_STATES`)与三选卡**同源**,不是第二份实现;开枪前那一发复证走的也是共享叶
|
|
2566
|
+
`readOwnedPendingCount`(与假死锁防御同一只,有界/可回收/中止不采信同律)。
|
|
2567
|
+
- `ask-run-not-found` 的文案**不**断言「那条 run 已经不存在」:该 404 分不出「不存在」与「这个会话
|
|
2568
|
+
读不到它」,行里如实把两种可能都说出来,只对「本层没有可动的东西」下结论。
|
|
2569
|
+
|
|
2570
|
+
### 16e. 射程边界(别把本节读成比它更强)
|
|
2571
|
+
|
|
2572
|
+
- **本批只到包为止**。`pendingRowGone` 由**宿主重开链**置位;cli 半场(`askParkReopen` 置位 + 两选卡
|
|
2573
|
+
UI + live 证据)是另一批,未发之前 cli 的行为与今天相同(键不传 ⇒ 老宿主臂)。
|
|
2574
|
+
- **cancel 一条 suspended run 的引擎侧后果不在本包射程**:run 落终态(SDK `CancelAck` 契约:受理回
|
|
2575
|
+
`cancelling`,随后 run settle 成 `failed` + `errorCode: "cancelled"`),那条 turn 的工作**不会**恢复。
|
|
2576
|
+
卡上必须让用户看懂这是「放弃这条 run 换回会话」,不是「继续它」。
|
|
2577
|
+
- **本包不做自动 cancel**:任何情况下都必须有一次用户的显式选择。没有钩子 = 没有卡 = 没有那一枪。
|
|
2578
|
+
- 🔴 **「零行为差」说的是运行期,不是类型面**(异源对抗复审 [medium] 采纳的措辞订正):
|
|
2579
|
+
`SelfHealOutcome` 是**开放增长**的判别联合,本批加了五个 kind。不传新键的宿主运行期结局与文案
|
|
2580
|
+
逐字节不变,但对 `SelfHealOutcome` 做**穷尽 switch + assertNever** 的消费方在**编译期**要补臂
|
|
2581
|
+
—— 这与 0.37.0(`running-*` 五个)、0.38.0 起历次加 kind 是同一形,不是本批新长出来的义务。
|
|
2582
|
+
端的既有 `default` 分支(本包自己的 `selfHealSubmissionDisposition` 就是这么写的)零改动。
|
|
2583
|
+
- **不承诺「重发一定成功」**:只有 `stale-park-cancelled`(引擎报那条 run 已终结)才判 `resending`;
|
|
2584
|
+
其余形一律 `not-delivered`,端不许自作主张重投。
|
|
2585
|
+
- 🔴 **两条在册边界(第二轮对抗复审登记,详见 §7b)**:**P-44** 开枪前复证是「查了再做」不是原子
|
|
2586
|
+
条件取消(残留毫秒级窗口,正位解在引擎侧条件取消);**P-45** 「读到终态」≠「claim 已落地释放」
|
|
2587
|
+
(存量族,与 `running-cancelled` 共用同一把尺,重发撞回 409 是已知可能)。
|
|
2588
|
+
|
|
2589
|
+
**cli / web / desktop 认领**:cli 侧接点(置位 + 两选卡)在其下一批(表态制);web / desktop **无需动作**
|
|
2590
|
+
(不传新键 ⇒ 逐字节零行为差)。
|
|
2591
|
+
**实现锚**:`src/adapter/activeRunSelfHeal.ts`(`ReopenCardVerdict.pendingRowGone` / `StaleParkChoiceRequest` /
|
|
2592
|
+
`offerStaleParkChoice` / `staleParkArm` / `cancelAndConfirmRelease` / 五个新 kind 的文案臂)。
|
|
2593
|
+
**常驻门**:`scripts/run-selfheal-reopen-test.mjs`(G11 段,含「该位缺席 ⇒ 逐字旧结局且零 cancel」负控)、
|
|
2594
|
+
`scripts/run-terminal-identity-copy-test.mjs`(G2 段:新 kind 的注入形与处置分类)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/client-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.0",
|
|
4
4
|
"description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|