@sema-agent/client-core 0.29.0 → 0.30.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 +400 -0
- package/README.md +19 -3
- package/dist/adapt/arms.js +24 -1
- package/dist/adapt/wireShapes.d.ts +7 -0
- package/dist/adapt/wireShapes.js +7 -0
- package/dist/adapter/activeRunSelfHeal.d.ts +264 -48
- package/dist/adapter/activeRunSelfHeal.js +501 -17
- package/dist/adapter/runStream.js +13 -3
- package/dist/engineWireSdk.d.ts +10 -2
- package/dist/engineWireSdk.js +7 -3
- package/dist/hitl/approvalDecisionNoteAudit.d.ts +58 -0
- package/dist/hitl/approvalDecisionNoteAudit.js +91 -0
- package/dist/hitl/askParkRowRouting.d.ts +150 -0
- package/dist/hitl/askParkRowRouting.js +183 -0
- package/dist/hitl/gateIdentity.d.ts +8 -0
- package/dist/hitl/gateIdentity.js +8 -0
- package/dist/hitl/hitlBridge.d.ts +7 -0
- package/dist/hitl/hitlBridge.js +11 -2
- package/dist/hitl/parkRowBirthWait.d.ts +63 -0
- package/dist/hitl/parkRowBirthWait.js +192 -0
- package/dist/hitl/resumeRunningCard.d.ts +134 -0
- package/dist/hitl/resumeRunningCard.js +177 -0
- package/dist/hitl/toolApprovalWire.d.ts +46 -9
- package/dist/hitl/toolApprovalWire.js +9 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +14 -0
- package/dist/seatContract.d.ts +27 -0
- package/dist/seatContract.js +42 -0
- package/dist/subagent/engineSubagentTail.d.ts +0 -2
- package/dist/subagent/engineSubagentTail.js +7 -15
- package/dist/subagentContentStore.d.ts +58 -2
- package/dist/subagentContentStore.js +95 -6
- package/dist/toolResult.d.ts +26 -0
- package/dist/toolResult.js +38 -6
- package/dist/workflowClient.d.ts +6 -1
- package/docs/INTEGRATION-CLIENTS.md +844 -0
- package/docs/REFACTOR-LEDGER.md +392 -0
- package/package.json +7 -4
|
@@ -1,14 +1,80 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* adapter/activeRunSelfHeal.ts — 「会话被 active-run claim 锁死」的自愈决断层(A-028.1,
|
|
3
|
+
* #244 族A 包半场,2026-08-12;源形 = cli `src/sema/activeRunSelfHeal.ts` 的四路分诊树 +
|
|
4
|
+
* 结局文案层,判别半场 `activeRunBusySignal` 早已在隔壁 `runStream.ts` —— 本文件是它的
|
|
5
|
+
* 消费侧,两半从此同居包内单源)。
|
|
6
|
+
*
|
|
7
|
+
* ── 事故形态 ────────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
* 引擎的 session claim 是会话级锁(createRun EEXIST ⇒ 409)。终态释放它;而 park 态
|
|
9
|
+
* (`suspended` / `needs_review`)保留它,且重启引擎救不回来(boot 期孤儿回收只捞 running)、
|
|
10
|
+
* 时间型 reap 默认整条腿不跑。于是「上一轮 turn 在审批/提问处 park 了,用户走开」之后,这个
|
|
11
|
+
* 会话的每一条新消息恒被 409 拒收。
|
|
12
|
+
*
|
|
13
|
+
* ── 🔴 处置是**分诊**,不是「见 409 就取消」────────────────────────────────────────────────
|
|
14
|
+
* 分诊真源两级(A-028.1 目标形):**`pendingGate.kind` 优先,status 表回退**。
|
|
15
|
+
* · kind 是 wire 指名的 gate 身份(引擎只在 parked 且真有待决 checkpoint 时带它),比 status
|
|
16
|
+
* 粒度准:run status `needs_review` ≠ gate kind `plan_review` —— dry-run 复核门(gate kind
|
|
17
|
+
* `needs_review`)同样落 status needs_review,按 status 猜臂会把它错路成 plan 审批卡。
|
|
18
|
+
* · kind 缺席(旧引擎 / 非 park / store 降级)才回退 status 表。
|
|
19
|
+
* 四路结局:
|
|
20
|
+
* ① plan_review 门 ⇒ **重开审批卡**,绝不 cancel(cancel = 替用户 reject 掉整个 plan)。
|
|
21
|
+
* ② 审批/提问门(human / irreversible_ask / tool_approval)⇒ **重开那张卡**(把决定权还给
|
|
22
|
+
* 用户;cancel+自动重发臂已整退役 —— 实证它是「新消息→cancel→新 run 同 ask 再 park」的
|
|
23
|
+
* 循环病根;重开失败也只如实告知,绝不回退 cancel)。
|
|
24
|
+
* ③ `running`(或任何非 park 态)⇒ **什么都不做**。持有 claim 的可能是用户背景化的 durable
|
|
25
|
+
* run,正在替他干活;见 409 就 cancel = 销毁他明确要求的工作。
|
|
26
|
+
* ④ kind/status 两级都判不出(含今天还不存在的新门/新状态)⇒ 也什么都不做,如实告知。
|
|
27
|
+
* 不确定时不做破坏性动作;新词到货时最坏结果是保守,而不是拿没想过的词做破坏性动作。
|
|
28
|
+
*
|
|
29
|
+
* ── 出路文案纪律 ────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
* 本模块产出的每一句「你可以做什么」都必须是**真的接了线**的动作。默认串承诺的是 cli 的
|
|
31
|
+
* `/clear`(regenerateSessionId ⇒ 全新引擎会话)与引擎自己的 cancel/decide 端点;没有 `/clear`
|
|
32
|
+
* 概念的宿主**必须**经 {@link ActiveRunSelfHealCopy} 换成自己的真出路 —— 绝不出现「按某个键」
|
|
33
|
+
* 而那个键没有注册。
|
|
34
|
+
*
|
|
35
|
+
* 🔴 portability:本文件的值级 import **只有**包内共享等待叶 `abortableSleep`(#265 起:cancel 后
|
|
36
|
+
* 的 claim 释放轮询要等一拍,而「怎么安全地睡一觉」全仓只许有一份实现;类型仍全部 type-only 借
|
|
37
|
+
* `runStream.ts`),不进内核/A 层闭包;index 闭包已登记。
|
|
38
|
+
*/
|
|
39
|
+
import { abortableSleep } from '../abortableSleep.js';
|
|
40
|
+
/** wire gate kind → plan_review 重开臂的 canonical 成员(core CheckpointGate 词表之一)。 */
|
|
2
41
|
export const PLAN_REVIEW_GATE_KIND = 'plan_review';
|
|
3
42
|
/**
|
|
4
|
-
* wire gate kind →
|
|
5
|
-
*
|
|
43
|
+
* wire gate kind → plan 重开臂的**全表**(#265,2026-08-14 补齐 `dry_run_review`)。
|
|
44
|
+
*
|
|
45
|
+
* server 侧的 kind→决议入口映射(`resumeEntryForGate`)把 `plan_review` 与 `dry_run_review` 送去
|
|
46
|
+
* **同一个**入口 `/v1/assistant/tasks/{taskId}/plan_review`,两者 park 也都落 `needs_review`
|
|
47
|
+
* ⇒ 同臂。此前包内只认 canonical 那一个词,于是一条停在 dry-run 复核门上的 run 走结局④「不动 +
|
|
48
|
+
* 如实说」——话是诚实的,但那张本来可以重开的卡没被重开,会话仍旧锁着。
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ 别把它和 run status 词 `needs_review` 混起来:那是 **status**(两跳间接量),gate kind 叫
|
|
51
|
+
* `dry_run_review`。gate kind `needs_review` 仍是表外词(结局④),这条边界由常驻门逐词钉。
|
|
52
|
+
*/
|
|
53
|
+
export const PLAN_REVIEW_GATE_KINDS = [PLAN_REVIEW_GATE_KIND, 'dry_run_review'];
|
|
54
|
+
/**
|
|
55
|
+
* wire gate kind → ask 重开臂的成员:`human` / `irreversible_ask` / `policy_ask` 是 core
|
|
56
|
+
* CheckpointGate 的审批/提问族(有 approvals.list pending 行可铸卡);`tool_approval` 是 park 判定
|
|
6
57
|
* (`toolApprovalWire.isToolApprovalGate`)已承认的一等 kind —— 两张表必须同集合
|
|
7
58
|
* ([paired-mechanisms-must-share-premise]:park 认得出、分诊路不进去 = 恒 reopen-failed)。
|
|
8
59
|
* 表外 kind(`resource_limit` / `needs_review` / `task_done` / 未来新词)一律走结局④:不动 +
|
|
9
60
|
* 如实说(wire 带的 decidePath 由文案层原样交给用户)。
|
|
61
|
+
*
|
|
62
|
+
* `policy_ask` 是 #265 补齐的第四个成员(server 全表逐名对读):它与 `human` 同族同入口
|
|
63
|
+
* (`/v1/approvals/{sessionId}/decide`),漏一个词的代价 = 那一类门恒走结局④,卡不重开。
|
|
64
|
+
*/
|
|
65
|
+
export const ASK_PARK_GATE_KINDS = ['human', 'irreversible_ask', 'policy_ask', 'tool_approval'];
|
|
66
|
+
/**
|
|
67
|
+
* 行上在场且**不属 ask 门族**的门种(`plan_review` / `resource_limit` / 未来新词);`null` = 该行可入
|
|
68
|
+
* ask/审批臂。kind 缺席(pre-`gate_kind` 历史行)与空串也归 `null` —— 旧行没有更强信号,各臂维持
|
|
69
|
+
* 自己的原判据(toolName 等),门种闸只拦「明说了自己不归这族」的行。
|
|
70
|
+
* 🔴 单源:`classifyAskParkRows` 的门种闸与 `findPendingForTask` 的行过滤必须共用这一只 ——
|
|
71
|
+
* 各写各的 includes 必漂(P-30:`findPendingForTask` 侧漏了这道闸,同 task 停着 plan_review 行时
|
|
72
|
+
* 「任意行」回落把它递给工具审批 wire,弹 toolName 空的卡、decide 撞 409 gate_not_tool_approval)。
|
|
10
73
|
*/
|
|
11
|
-
export
|
|
74
|
+
export function askParkForeignGateKind(row) {
|
|
75
|
+
const kind = typeof row.gateKind === 'string' && row.gateKind.length > 0 ? row.gateKind : null;
|
|
76
|
+
return kind !== null && !ASK_PARK_GATE_KINDS.includes(kind) ? kind : null;
|
|
77
|
+
}
|
|
12
78
|
/**
|
|
13
79
|
* status 回退表(kind 缺席时的粗粒度代理):plan_review park 的引擎 run status。
|
|
14
80
|
* 🔴 动作**不按状态名硬编码枚举**:两张表之外的一切状态(含今天还不存在的)一律「不动它 +
|
|
@@ -17,6 +83,47 @@ export const ASK_PARK_GATE_KINDS = ['human', 'irreversible_ask', 'tool_approval'
|
|
|
17
83
|
export const PLAN_REVIEW_STATES = ['needs_review'];
|
|
18
84
|
/** status 回退表:审批/提问 park(**只有 `suspended`**)。 */
|
|
19
85
|
export const ASK_PARK_STATES = ['suspended'];
|
|
86
|
+
/**
|
|
87
|
+
* 走三选卡臂的状态 —— **只有 `running`**(引擎 RunStatus 里「正在干活」的那一个词)。
|
|
88
|
+
*
|
|
89
|
+
* 🔴 为什么是白名单而不是「park 词表之外的一切」:三选卡上的两条动作路(steer / cancel)都只对
|
|
90
|
+
* 一条**真在跑**的 run 成立 —— steer 的语义是「注入在跑的那一轮」(非 running 会落 `queued` /
|
|
91
|
+
* `parked_for_wake` / 409),cancel 对 park 态则是替用户否掉待决项(禁区)。今天还不存在的新状态
|
|
92
|
+
* 落在表外 ⇒ 照旧「不动它 + 如实说」:不确定时不给用户递一把语义不明的把手。
|
|
93
|
+
*/
|
|
94
|
+
export const RUNNING_STATES = ['running'];
|
|
95
|
+
/**
|
|
96
|
+
* 「引擎确认这条 run **不再占着会话**」的状态词 —— 也就是 cancel 之后允许重发那条消息的**唯一**
|
|
97
|
+
* 判据。
|
|
98
|
+
*
|
|
99
|
+
* 🔴 为什么是白名单而不是「running 之外的一切」:这条会话锁的语义是 **park 态(`suspended` /
|
|
100
|
+
* `needs_review`)保留 claim**。用 `!RUNNING_STATES.includes(status)` 当判据时,一条正在收尾的 run
|
|
101
|
+
* 只要在这一拍被读成 park(cancel 是异步的,run 完全可能先走到一个 checkpoint),就被判成「已释放」
|
|
102
|
+
* ⇒ 上屏说「引擎确认它不再占着这个会话」(假话)+ 立刻重发 ⇒ 那条消息一头撞进还锁着的会话,再吃
|
|
103
|
+
* 一个 409。没想过的新状态词同理落在白名单外:我们不知道那个词是不是「释放了」,就不能替引擎下这个
|
|
104
|
+
* 断言(等到点如实说「还占着」是可收敛的诚实结局,零破坏性)。
|
|
105
|
+
*
|
|
106
|
+
* 词表锚 = SDK `RunStatus`(`running|completed|failed|suspended|needs_review|blocked`)的三个终态
|
|
107
|
+
* + `timeout`。真·404(引擎不认得这条 run 了)另有一条腿,不走这里(见 {@link waitForClaimRelease})。
|
|
108
|
+
*/
|
|
109
|
+
export const CLAIM_RELEASED_STATES = ['completed', 'failed', 'blocked', 'timeout'];
|
|
110
|
+
/**
|
|
111
|
+
* 「这条 run **确实还占着**会话」的状态词 —— 也就是文案敢说「that run still held this session」的
|
|
112
|
+
* **唯一**判据(二次评审 R5 [medium])。
|
|
113
|
+
*
|
|
114
|
+
* 🔴 为什么又是白名单:上面那张表管的是「敢不敢重发」,这张表管的是「敢不敢断言它还占着」,两件事
|
|
115
|
+
* **都**只能由正面证据得出,而它们**不是互补的** —— 两张表之外还有一整片「读到了一个我们不认识的
|
|
116
|
+
* 词」的地带(server 加一个新终态 `cancelled` 就是现成的例子)。用「lastStatus 非空」当持锁判据,
|
|
117
|
+
* 那个新词会被读成「还占着」并渲上屏,而会话其实早就释放了。表外非空词 ⇒ 既不算释放也不算持锁,
|
|
118
|
+
* 收口成「确认不了」。
|
|
119
|
+
*/
|
|
120
|
+
export const CLAIM_HELD_STATES = ['running', 'suspended', 'needs_review'];
|
|
121
|
+
/** 会话作用域位的透传口(缺席即不置键 —— `exactOptionalPropertyTypes` 下 `{session: undefined}`
|
|
122
|
+
* 与「没有这个键」不是一回事,而 SDK 那一侧读的正是「在不在场」)。 */
|
|
123
|
+
function sessionOpts(deps) {
|
|
124
|
+
const sessionId = deps?.sessionId;
|
|
125
|
+
return typeof sessionId === 'string' && sessionId.length > 0 ? { session: sessionId } : {};
|
|
126
|
+
}
|
|
20
127
|
/** 失败细节:只取 message 且截断 —— 错误对象整体 stringify 可能把请求上下文一起带上屏。 */
|
|
21
128
|
function describeFailure(e) {
|
|
22
129
|
if (typeof e === 'object' && e !== null && 'message' in e) {
|
|
@@ -35,6 +142,153 @@ function readStatus(record) {
|
|
|
35
142
|
function reopenDelivered(verdict) {
|
|
36
143
|
return verdict.reopened === true && verdict.presented !== false;
|
|
37
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* 一次**至多一次**(non-idempotent)POST 失败之后:到底是「服务端明确拒了」还是「不知道有没有
|
|
147
|
+
* 落地」。steer 与 cancel 两条腿共用这一把尺 —— 它们同属「这一枪不能盲发第二次」的族,而两类
|
|
148
|
+
* 失败对用户的处置**完全不同**,各写一份必漂。
|
|
149
|
+
*
|
|
150
|
+
* 判据 = HTTP 状态码在不在、是不是 4xx:**4xx = 服务端收到了、解析了、明确拒绝**(steering 族的
|
|
151
|
+
* 拒收码全在 4xx:422 invalid_content / 409 not_running|queue_full|duplicate_input_id / 404 / 413),
|
|
152
|
+
* 那一枪确定没有产生效果;5xx 与「压根没有状态码」(fetch failed / 连接断 / 超时)都是**送达未知**
|
|
153
|
+
* —— 请求可能已经被受理,只是回执没回来。
|
|
154
|
+
* 🔴 方向:不确定时归 `unknown`(保守面在「不对用户断言一件证不出的事」这一侧)。
|
|
155
|
+
*/
|
|
156
|
+
export function atMostOnceFailureClass(e) {
|
|
157
|
+
const status = e?.status;
|
|
158
|
+
return typeof status === 'number' && status >= 400 && status < 500 ? 'rejected' : 'unknown';
|
|
159
|
+
}
|
|
160
|
+
/** SDK `SteerReceipt.delivery`(`applied` / `queued` / `parked_for_wake`)。读不出 = null —— 不编造
|
|
161
|
+
* 一个投递语义:三种 delivery 对用户的意思完全不同,猜错就是假承诺。 */
|
|
162
|
+
export function readSteerDelivery(receipt) {
|
|
163
|
+
if (typeof receipt !== 'object' || receipt === null || !('delivery' in receipt))
|
|
164
|
+
return null;
|
|
165
|
+
const d = receipt.delivery;
|
|
166
|
+
return typeof d === 'string' && d.length > 0 ? d : null;
|
|
167
|
+
}
|
|
168
|
+
/** SDK `SteerReceipt.status` —— 服务端在投递那一刻读到的**行状态词**(`running` /「park 词」/终态)。
|
|
169
|
+
* `delivery:'queued'` 时它是**唯一**能分出「在等哪一类门」的材料(契约逐字:`"suspended"` OR
|
|
170
|
+
* `"needs_review"`),读不出即 null(不猜一个门种)。 */
|
|
171
|
+
export function readSteerReceiptStatus(receipt) {
|
|
172
|
+
if (typeof receipt !== 'object' || receipt === null || !('status' in receipt))
|
|
173
|
+
return null;
|
|
174
|
+
const s = receipt.status;
|
|
175
|
+
return typeof s === 'string' && s.length > 0 ? s : null;
|
|
176
|
+
}
|
|
177
|
+
/** cancel 之后**有界**等那条 run 交出会话的缺省窗(`POST …/cancel` 是 202 异步 —— 收下 ≠ 已停)。 */
|
|
178
|
+
export const CANCEL_RELEASE_WAIT_MS = 10_000;
|
|
179
|
+
/** 轮询退避:200ms 起、×1.5、封顶 2s(短退避,别把一次 cancel 打成连发)。 */
|
|
180
|
+
const CANCEL_POLL_START_MS = 200;
|
|
181
|
+
const CANCEL_POLL_MAX_MS = 2_000;
|
|
182
|
+
/** 缺省退避等待 = 包内共享等待叶(中止即刻 settle 并清 timer;timer **不 unref** —— 等待窗里它
|
|
183
|
+
* 可能是事件循环里唯一的活,unref 会让 `-p` 车道在中途直接退出,连那句诚实的收口行都一起蒸发)。
|
|
184
|
+
* 🔴 就地再写一份「怎么安全地睡一觉」= 第三份中止语义,只会各自漂;无 signal 的调用方给一只
|
|
185
|
+
* 永不 abort 的 signal,照样走同一个叶。 */
|
|
186
|
+
function claimPollSleep(ms, signal) {
|
|
187
|
+
return abortableSleep(ms, signal ?? new AbortController().signal);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* 这一发出站请求的中止口:**只有真把一个截止 signal 交到请求上,墙钟才是个闸** —— 只在下一轮循环
|
|
191
|
+
* 开头查 deadline 是事后统计,一发挂住的 `runs.get`/`runs.cancel` 会跑满 SDK 缺省超时(且 GET 属
|
|
192
|
+
* 可重试类,实际上界是它的数倍),预算形同虚设。合并调用方 signal(用户 Esc)⇒ Esc 之后这一发也
|
|
193
|
+
* 当场收,零后续出站。
|
|
194
|
+
*
|
|
195
|
+
* 🔴 **不用 `AbortSignal.timeout`**(二次评审 R6 实测逼出):它的内部定时器在 Node 上是 **unref 的**
|
|
196
|
+
* —— 等待窗里这只定时器可能是事件循环里唯一的活,那种情况下进程会在截止到点之前直接退出,连那句
|
|
197
|
+
* 诚实的收口行都一起蒸发(仓内 `abortableSleep` / 重查环同一条判据)。这里自己持一只**普通**定时器,
|
|
198
|
+
* 并把清理做成 `release()` 的义务:不清就会把进程按预算多拖活一拍。
|
|
199
|
+
* 🔴 合并也自己做,不走 `AbortSignal.any`:少一条「宿主运行期有没有这个静态面」的分支,行为在所有
|
|
200
|
+
* 宿主上逐字相同。
|
|
201
|
+
*/
|
|
202
|
+
function claimProbeLease(caller, remainingMs) {
|
|
203
|
+
const ctl = new AbortController();
|
|
204
|
+
const timer = setTimeout(() => {
|
|
205
|
+
ctl.abort(new Error('probe deadline'));
|
|
206
|
+
}, Math.max(1, remainingMs));
|
|
207
|
+
const onCaller = () => {
|
|
208
|
+
ctl.abort(caller?.reason);
|
|
209
|
+
};
|
|
210
|
+
if (caller?.aborted === true)
|
|
211
|
+
onCaller();
|
|
212
|
+
else
|
|
213
|
+
caller?.addEventListener('abort', onCaller, { once: true });
|
|
214
|
+
return {
|
|
215
|
+
signal: ctl.signal,
|
|
216
|
+
release: () => {
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
caller?.removeEventListener('abort', onCaller);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 等到那条 run 不再占着会话 ⇒ `released`;窗口到点仍占着 / 读不到 / 调用方中止 ⇒ 不 released
|
|
224
|
+
* (如实说,**绝不重发**那条被拒的消息)。
|
|
225
|
+
*
|
|
226
|
+
* 🔴 判据锚在**决定量**本身:`runs.get` 读回的 status 在不在 {@link CLAIM_RELEASED_STATES} 白名单里
|
|
227
|
+
* (而不是「cancel 回了 202」那个前置条件,也不是「不是 running」那个更弱的反面)。
|
|
228
|
+
* 🔴 读失败(传输错)**不**当成「释放了」:那是「不知道」,而把不知道当成释放 = 把消息喂进一个可能
|
|
229
|
+
* 还锁着的会话(下一次 409 就是它的代价)。只有 404(引擎不认得这条 run 了)才算释放 —— 那种情况
|
|
230
|
+
* 下它连 claim 都不可能还占着。
|
|
231
|
+
* 🔴 有界性由**每一发都带截止 signal**保证(见 {@link claimProbeSignal}),不是靠循环顶那一次减法。
|
|
232
|
+
*/
|
|
233
|
+
export async function waitForClaimRelease(taskId, deps) {
|
|
234
|
+
const now = typeof deps.now === 'function' ? deps.now : () => Date.now();
|
|
235
|
+
const sleep = typeof deps.sleep === 'function' ? deps.sleep : claimPollSleep;
|
|
236
|
+
// 🔴 预算先规范化成**有限非负**数(二次评审 R6 [medium]):`NaN` 会让 `deadline`/`remaining` 全是
|
|
237
|
+
// NaN,`remaining <= 0` 恒假 ⇒ 环永不收口(而每一拍的 `AbortSignal.timeout(NaN)` 抛错又被 catch
|
|
238
|
+
// 吞掉,退化成零延时空转);`Infinity` 同样到不了截止条件。预算是从 env/配置解析来的量,坏值
|
|
239
|
+
// 必须在入口就被读成「不等」,而不是把 turn 卡死。
|
|
240
|
+
const budgetMs = Number.isFinite(deps.budgetMs) && deps.budgetMs > 0 ? deps.budgetMs : 0;
|
|
241
|
+
const startedAt = now();
|
|
242
|
+
const deadline = startedAt + budgetMs;
|
|
243
|
+
const waited = () => now() - startedAt;
|
|
244
|
+
// 🔴 经函数读,别把 `signal?.aborted` 直接写在多处条件里:`AbortSignal.aborted` 在类型面是只读
|
|
245
|
+
// 属性,tsc 的控制流分析会在第一次判过之后把它**窄成 `false | undefined`**,后面每一次重判都被
|
|
246
|
+
// 判成「不可能成立的比较」。而这个量恰恰**会在两次 await 之间变** —— 要终止一个循环,动手的那
|
|
247
|
+
// 一层必须在每一轮里重新求值。函数返回值走声明类型,不进那条窄化链。
|
|
248
|
+
const isAborted = () => deps.signal?.aborted === true;
|
|
249
|
+
let delay = CANCEL_POLL_START_MS;
|
|
250
|
+
let lastStatus = null;
|
|
251
|
+
for (;;) {
|
|
252
|
+
if (isAborted())
|
|
253
|
+
return { released: false, waitedMs: waited(), aborted: true, lastStatus };
|
|
254
|
+
const remaining = deadline - now();
|
|
255
|
+
if (remaining <= 0)
|
|
256
|
+
return { released: false, waitedMs: waited(), aborted: false, lastStatus };
|
|
257
|
+
await sleep(Math.min(delay, remaining), deps.signal);
|
|
258
|
+
if (isAborted())
|
|
259
|
+
return { released: false, waitedMs: waited(), aborted: true, lastStatus };
|
|
260
|
+
delay = Math.min(Math.round(delay * 1.5), CANCEL_POLL_MAX_MS);
|
|
261
|
+
let status;
|
|
262
|
+
const lease = claimProbeLease(deps.signal, deadline - now());
|
|
263
|
+
try {
|
|
264
|
+
status = readStatus(await deps.get(taskId, {
|
|
265
|
+
signal: lease.signal,
|
|
266
|
+
...(deps.session !== undefined ? { session: deps.session } : {}),
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// 这一发读不出来 ⇒ 最新证据是「不知道」,陈旧的成功读数当场作废(见 lastStatus 头注)。
|
|
271
|
+
lastStatus = null;
|
|
272
|
+
lease.release();
|
|
273
|
+
if (isAborted())
|
|
274
|
+
return { released: false, waitedMs: waited(), aborted: true, lastStatus };
|
|
275
|
+
// 🔴 **404 也只是「读不到」,不是「释放了」**(二次评审 [high] 处置,2026-08-14):这个读口的
|
|
276
|
+
// 404 在 SDK 契约上是 `not_found.run` —— 「这条 run 不存在」与「它不是你的」**共用同一个码,
|
|
277
|
+
// 且是刻意设计的不可分辨**(跨租户不给存在性预言机)。凭它断言「那条 run 已经不占着会话」,
|
|
278
|
+
// 就是拿一个缺席信号当正面证据 —— 与本批到处在修的那个错同形。误判的代价也不对称:说成
|
|
279
|
+
// 「已释放」会让调用方立刻重发,而那条消息可能一头撞回还锁着的会话(再吃一个 409,正是这条腿
|
|
280
|
+
// 存在的理由);说成「读不出来」只是让用户自己再发一次。其余错误(传输错/本发的截止中止)同理。
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
lease.release(); // 🔴 定时器必清:这只是**普通**定时器(见 claimProbeLease),不清就把进程多拖活一拍
|
|
284
|
+
lastStatus = status; // 奇形记录(读不出 status)同样把陈旧读数清掉 —— 最新证据仍是「不知道」
|
|
285
|
+
// status 读不出(奇形记录)/ 还在跑 / park / 没想过的新词 ⇒ 都不算释放,继续等到点(保守方向:
|
|
286
|
+
// 宁可不重发)。判据见 {@link CLAIM_RELEASED_STATES}。
|
|
287
|
+
if (status !== null && CLAIM_RELEASED_STATES.includes(status)) {
|
|
288
|
+
return { released: true, waitedMs: waited(), aborted: false, lastStatus: status };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
38
292
|
/**
|
|
39
293
|
* 自愈一次:定门(kind 优先 → status 表回退)→ 按门类重开对应的卡 → 回报结局。**绝不抛**
|
|
40
294
|
* (turn 收尾路径)。kind 在场时不再回查 runs.get —— wire 已指名门身份,省一次往返。
|
|
@@ -60,7 +314,7 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
|
|
|
60
314
|
}
|
|
61
315
|
// ── 一级分诊:pendingGate.kind(wire 指名的门身份;缺席才落 status 表)──────────────────────
|
|
62
316
|
const gateKind = signal.pendingGate?.kind ?? null;
|
|
63
|
-
if (gateKind
|
|
317
|
+
if (gateKind !== null && PLAN_REVIEW_GATE_KINDS.includes(gateKind))
|
|
64
318
|
return planReviewArm(taskId, signal, deps);
|
|
65
319
|
if (gateKind !== null && ASK_PARK_GATE_KINDS.includes(gateKind))
|
|
66
320
|
return askParkArm(taskId, signal, deps);
|
|
@@ -72,7 +326,7 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
|
|
|
72
326
|
}
|
|
73
327
|
let record;
|
|
74
328
|
try {
|
|
75
|
-
record = await runs.get(taskId);
|
|
329
|
+
record = await runs.get(taskId, sessionOpts(deps));
|
|
76
330
|
}
|
|
77
331
|
catch (e) {
|
|
78
332
|
return { kind: 'state-unknown', taskId, detail: describeFailure(e) };
|
|
@@ -91,33 +345,173 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
|
|
|
91
345
|
return planReviewArm(taskId, signal, deps);
|
|
92
346
|
if (ASK_PARK_STATES.includes(status))
|
|
93
347
|
return askParkArm(taskId, signal, deps);
|
|
348
|
+
// 真在跑 ⇒ 把引擎给着的两条路(steer / cancel)+ 现状做成三选卡交给用户;表外的其它状态
|
|
349
|
+
// (含今天还不存在的)照旧「不动它 + 如实说」—— 语义不明的状态不配递把手(见 RUNNING_STATES 注)。
|
|
350
|
+
if (RUNNING_STATES.includes(status))
|
|
351
|
+
return runningChoiceArm(taskId, status, runs, deps);
|
|
94
352
|
return { kind: 'not-parked', taskId, status };
|
|
95
353
|
}
|
|
96
|
-
/** plan_review
|
|
97
|
-
|
|
98
|
-
|
|
354
|
+
/** plan_review 重开口的判决(臂与判决分开:steer 的 `queued` 回执要的是**同一份**重开语义的判决值,
|
|
355
|
+
* 但收口成的是 running-steered 那一行,不是 park 臂的行 —— 各写一份必漂)。 */
|
|
356
|
+
async function planVerdict(taskId, deps) {
|
|
357
|
+
try {
|
|
358
|
+
return (await deps?.reopenPlanReview?.(taskId)) ?? { reopened: false };
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return { reopened: false }; // 重开腿的意外 throw 不许炸 turn 收尾;按没能重开如实说
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** ask 重开口的判决(同上)。 */
|
|
365
|
+
async function askVerdict(taskId, deps) {
|
|
99
366
|
try {
|
|
100
|
-
|
|
367
|
+
return (await deps?.reopenAskPark?.(taskId)) ?? { reopened: false };
|
|
101
368
|
}
|
|
102
369
|
catch {
|
|
103
|
-
|
|
370
|
+
return { reopened: false }; // 同上:意外 throw 按没能重开如实说,绝不回退 cancel
|
|
104
371
|
}
|
|
372
|
+
}
|
|
373
|
+
/** plan_review 门:重开卡,把决定权还给用户。静默 cancel 在这里 = 替他 reject 掉整个 plan。 */
|
|
374
|
+
async function planReviewArm(taskId, signal, deps) {
|
|
375
|
+
const verdict = await planVerdict(taskId, deps);
|
|
105
376
|
return reopenDelivered(verdict)
|
|
106
377
|
? { kind: 'plan-review-reopened', taskId, firstSight: verdict.firstSight === true }
|
|
107
378
|
: { kind: 'plan-review-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
|
|
108
379
|
}
|
|
109
380
|
/** 审批/提问门:重开卡。cancel 在这里的语义 = 替用户否掉待决项,且实证是循环病根 —— 臂已退役。 */
|
|
110
381
|
async function askParkArm(taskId, signal, deps) {
|
|
111
|
-
|
|
382
|
+
const verdict = await askVerdict(taskId, deps);
|
|
383
|
+
return reopenDelivered(verdict)
|
|
384
|
+
? { kind: 'ask-reopened', taskId, firstSight: verdict.firstSight === true }
|
|
385
|
+
: { kind: 'ask-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* `running` 形:呈三选卡 → 按用户的决定动手。零决定 = 零动作(`not-parked` 现状行)。
|
|
389
|
+
*
|
|
390
|
+
* 🔴 三条纪律在这一个函数里同时承重:
|
|
391
|
+
* ① **cancel 只在用户显式选了它时打**,一枪(反钉:客户端自己一枪都不许打);
|
|
392
|
+
* ② **steer 只打一次**,失败不重试也不回退 cancel(非幂等,契约逐字);
|
|
393
|
+
* ③ 选项按**供给**渲(verb 缺席 = 那条路兑现不了 = 整个不渲),cancel 还额外要 `get` ——
|
|
394
|
+
* 没有它就确认不了 claim 真的释放,「取消并重发」就成了猜。
|
|
395
|
+
*/
|
|
396
|
+
async function runningChoiceArm(taskId, status, runs, deps) {
|
|
397
|
+
const notParked = { kind: 'not-parked', taskId, status };
|
|
398
|
+
const offer = deps?.offerRunningChoice;
|
|
399
|
+
if (typeof offer !== 'function')
|
|
400
|
+
return notParked;
|
|
401
|
+
const text = typeof deps?.deniedMessage === 'string' ? deps.deniedMessage : '';
|
|
402
|
+
// 🔴 **只取来判在场,绝不裸调**(二次评审 R7 [high]):真 SDK 的 `RunsResource` 方法体走
|
|
403
|
+
// `this.t.request(...)`,把方法从对象上摘下来单独调会当场 TypeError —— 后果是 steer 恒报「送达
|
|
404
|
+
// 未知」、cancel 先失败再拿一个同样失了接收者的 get 轮询到预算耗尽,三选卡的两条路在生产上**全废**
|
|
405
|
+
// (而箭头函数 mock 恰好把这一形完全遮住)。下面所有真调用一律写成 `runs.xxx(...)` 的方法形,
|
|
406
|
+
// 交给别人的那一发也用**保接收者的包装**。
|
|
407
|
+
const canSteer = typeof runs?.steer === 'function' && text.length > 0;
|
|
408
|
+
const canCancel = typeof runs?.cancel === 'function' && typeof runs?.get === 'function';
|
|
409
|
+
if (!canSteer && !canCancel)
|
|
410
|
+
return notParked;
|
|
411
|
+
let choice = 'wait';
|
|
112
412
|
try {
|
|
113
|
-
|
|
413
|
+
choice = (await offer({ taskId, status, canSteer, canCancel })) ?? 'wait';
|
|
114
414
|
}
|
|
115
415
|
catch {
|
|
116
|
-
|
|
416
|
+
choice = 'wait'; // 呈卡腿出意外 = 用户没做决定 ⇒ 零动作(绝不掉进破坏性分支)
|
|
117
417
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
418
|
+
if (choice === 'steer' && canSteer && runs?.steer !== undefined) {
|
|
419
|
+
try {
|
|
420
|
+
// 🔴 正文原样送(untrusted DATA,server 侧围栏);恰一次,绝不重试。方法形调用保接收者。
|
|
421
|
+
const receipt = await runs.steer(taskId, { text }, sessionOpts(deps));
|
|
422
|
+
const delivery = readSteerDelivery(receipt);
|
|
423
|
+
const receiptStatus = readSteerReceiptStatus(receipt);
|
|
424
|
+
// 🔴 `queued` 是 wire 在**纠正分诊输入**(SDK `SteerReceipt` 头注逐字:run is durably SUSPENDED,
|
|
425
|
+
// 消息被 PARKED 在它的待决 checkpoint 上,`status` = 服务端 park 前读到的那个行词 —— 契约写死
|
|
426
|
+
// 只有两种:`"suspended"` OR `"needs_review"`,后者正是 server 7.16.0 起 plan-review park 的回声)。
|
|
427
|
+
// 进这一臂的前提是 status 读作 running —— 那份读数已经过期。此刻真正的出路是**把那张门卡重开**:
|
|
428
|
+
// 门没被答,run 就不 resume,排进去的消息也就永远注入不进去。
|
|
429
|
+
// 🔴 这里按 status 表分臂**不违反**「kind 优先」:回执上**根本没有 gate kind 位**(SteerReceipt
|
|
430
|
+
// 只有 taskId/status/messageId/priority/note),所以这就是那条成文的「kind 缺席 ⇒ status 表回退」
|
|
431
|
+
// 路径本身,不是拿 status 去压一个在场的 kind。契约把那两个词分别绑死在两个 park 族上,回退表
|
|
432
|
+
// 与它逐词同形。未知 park 词 ⇒ 两臂都不进(不拿没想过的状态驱动已知决断 API)。
|
|
433
|
+
if (delivery === 'queued') {
|
|
434
|
+
const verdict = receiptStatus !== null && PLAN_REVIEW_STATES.includes(receiptStatus)
|
|
435
|
+
? await planVerdict(taskId, deps)
|
|
436
|
+
: receiptStatus !== null && ASK_PARK_STATES.includes(receiptStatus)
|
|
437
|
+
? await askVerdict(taskId, deps)
|
|
438
|
+
: null;
|
|
439
|
+
return { kind: 'running-steered', taskId, delivery, status: receiptStatus, reopened: verdict };
|
|
440
|
+
}
|
|
441
|
+
return { kind: 'running-steered', taskId, delivery, status: receiptStatus, reopened: null };
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
// 🔴 两类失败必须分开报(见 running-steer-failed 注):明确拒收才可以说「没送到、请重发」。
|
|
445
|
+
return { kind: 'running-steer-failed', taskId, detail: describeFailure(e), delivery: atMostOnceFailureClass(e) };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (choice === 'cancel' && canCancel && runs?.cancel !== undefined && runs.get !== undefined) {
|
|
449
|
+
// 交给等待器的那一发也必须保接收者:`get` 摘下来直接传过去 = 同一个 TypeError 换个地方发生。
|
|
450
|
+
const durable = runs;
|
|
451
|
+
const durableGet = runs.get;
|
|
452
|
+
const boundGet = (id, opts) => durableGet.call(durable, id, opts);
|
|
453
|
+
/**
|
|
454
|
+
* 🔴 cancel 的失败也分两类(二次评审 R4 [high]),与 steer 同一把尺:
|
|
455
|
+
* · `rejected`(4xx)—— 服务端答了且明确拒 ⇒ 那一枪确定没生效,立即如实说,不必再等确认腿;
|
|
456
|
+
* · `unknown`(5xx / 连接在回执路上断了)—— 请求**可能已经被受理并在异步执行**。此时直接收口成
|
|
457
|
+
* 「取消失败,那条 run 还占着会话」是对用户下一个证不出的断言 —— 而这条腿手上恰恰有一个能
|
|
458
|
+
* 回答这个问题的读口(`get`)。所以照走有界确认腿:真交出来了就是 `running-cancelled`
|
|
459
|
+
* (用户授权的取消其实成了),确认不到才如实说「没能确认」。判据锚在**决定量**(会话有没有
|
|
460
|
+
* 被交出来)上,不锚它的一个前置条件(那一枪的回执长什么样)。
|
|
461
|
+
*/
|
|
462
|
+
let cancelFailure = null;
|
|
463
|
+
const cancelWaitMs = deps?.cancelReleaseWaitMs ?? CANCEL_RELEASE_WAIT_MS;
|
|
464
|
+
const cancelStartedAt = Date.now();
|
|
465
|
+
const cancelLease = claimProbeLease(deps?.signal, cancelWaitMs);
|
|
466
|
+
try {
|
|
467
|
+
// 🔴 这一枪也必须**带中止口与截止**(二次评审 R5 [high]):`cancel` 的 promise 不落地,后面那条
|
|
468
|
+
// 有界确认腿就根本起不来 —— 用户按了 Esc 既停不掉等待也停不掉出站请求,整条腿零结局。有界性
|
|
469
|
+
// 的执行手段只有一个:把 signal 真交到请求上。
|
|
470
|
+
await durable.cancel?.(taskId, { signal: cancelLease.signal, ...sessionOpts(deps) });
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
// 用户中止 ⇒ 当拍收口(绝不接着跑一个满窗的确认腿:「UI 说停了、后台还在问」正是禁区)。
|
|
474
|
+
if (deps?.signal?.aborted === true) {
|
|
475
|
+
return {
|
|
476
|
+
kind: 'running-cancel-timeout',
|
|
477
|
+
taskId,
|
|
478
|
+
waitedMs: Date.now() - cancelStartedAt,
|
|
479
|
+
aborted: true,
|
|
480
|
+
confirmedHeld: false,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
const delivery = atMostOnceFailureClass(e);
|
|
484
|
+
if (delivery === 'rejected') {
|
|
485
|
+
return { kind: 'running-cancel-failed', taskId, detail: describeFailure(e), delivery };
|
|
486
|
+
}
|
|
487
|
+
// 本枪的截止到点(请求挂死被掐)同样是**送达未知** —— 服务端可能已经受理,照走确认腿。
|
|
488
|
+
cancelFailure = { detail: describeFailure(e), delivery };
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
cancelLease.release();
|
|
492
|
+
}
|
|
493
|
+
// 202 = 「收下了」不是「停了」⇒ 必须真等到那条 run 交出会话,否则重发只会再撞一次 409。
|
|
494
|
+
const verdict = await waitForClaimRelease(taskId, {
|
|
495
|
+
get: boundGet,
|
|
496
|
+
budgetMs: cancelWaitMs,
|
|
497
|
+
...(deps?.signal !== undefined ? { signal: deps.signal } : {}),
|
|
498
|
+
...sessionOpts(deps),
|
|
499
|
+
});
|
|
500
|
+
// 会话真被交出来了 ⇒ 就是取消成功,哪怕那一枪的回执丢在路上(决定量说了算)。
|
|
501
|
+
if (verdict.released)
|
|
502
|
+
return { kind: 'running-cancelled', taskId };
|
|
503
|
+
if (cancelFailure !== null)
|
|
504
|
+
return { kind: 'running-cancel-failed', taskId, ...cancelFailure };
|
|
505
|
+
return {
|
|
506
|
+
kind: 'running-cancel-timeout',
|
|
507
|
+
taskId,
|
|
508
|
+
waitedMs: verdict.waitedMs,
|
|
509
|
+
aborted: verdict.aborted,
|
|
510
|
+
confirmedHeld: verdict.lastStatus !== null && CLAIM_HELD_STATES.includes(verdict.lastStatus),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
// 用户选了 ③ / 卡没答上 / 选了一条本轮不可兑现的路 ⇒ 零动作,如实转述现状。
|
|
514
|
+
return notParked;
|
|
121
515
|
}
|
|
122
516
|
/** cli 的唯一真出路(`/clear` = 新会话 id ⇒ 引擎侧全新 session ⇒ 不受旧 claim 影响)。 */
|
|
123
517
|
const DEFAULT_WAY_OUT = 'run /clear to keep working in a fresh session';
|
|
@@ -223,6 +617,96 @@ function activeRunSelfHealBaseRow(outcome, signal, wayOut) {
|
|
|
223
617
|
`reopen that approval card. It did NOT cancel the run — that would have discarded the plan for ` +
|
|
224
618
|
`you. Your message was NOT sent; ${wayOut} if you no longer want that plan.${viaEngine}`);
|
|
225
619
|
}
|
|
620
|
+
// ── 三选卡的四种动作结局(选 ③ / 没答 / 没呈上都落 not-parked)────────────────────────────
|
|
621
|
+
case 'running-steered': {
|
|
622
|
+
// 🔴 三种 delivery 对用户是三件**完全不同**的事(SDK SteerReceipt 头注),所以整句分形。
|
|
623
|
+
// 🔴 附件披露:wire 的 steer body 只有 `text`(SDK 签名无 images 位),同一次提交里的图片
|
|
624
|
+
// **没有随行**。不说 = 对带图提交谎报「你的消息已投递」。
|
|
625
|
+
const handle = `run ${outcome.taskId}`;
|
|
626
|
+
const textOnly = `Only the TEXT of your message was handed over — a steer carries no attachments, so any images in that submission were NOT sent.`;
|
|
627
|
+
if (outcome.delivery === 'applied') {
|
|
628
|
+
return (`sema handed your message to the run that is already working (${handle}) — the engine applied it to ` +
|
|
629
|
+
`that run, so it is picked up at that run's next step. ${textOnly} Nothing was cancelled, and your ` +
|
|
630
|
+
`message did NOT start a new turn — watch that run for what it does with it.`);
|
|
631
|
+
}
|
|
632
|
+
if (outcome.delivery === 'queued') {
|
|
633
|
+
const parked = outcome.status ? `parked (status ${outcome.status})` : 'parked';
|
|
634
|
+
const next = outcome.reopened !== null && reopenDelivered(outcome.reopened)
|
|
635
|
+
? `sema has surfaced that decision card — answering it is what lets that run resume and pick your message up.`
|
|
636
|
+
: `sema could not surface that decision card here, so nothing will resume that run until that decision is made; ` +
|
|
637
|
+
`${wayOut} if you no longer want it.`;
|
|
638
|
+
return (`${handle} is not actually working right now: the engine reports it ${parked} on a decision it needs ` +
|
|
639
|
+
`from you, so it queued your message on that park instead of running it. ${next} ${textOnly} ` +
|
|
640
|
+
`Nothing was cancelled, and your message did NOT start a new turn.`);
|
|
641
|
+
}
|
|
642
|
+
if (outcome.delivery === 'parked_for_wake') {
|
|
643
|
+
// run 已终结 ⇒ 它已经不占着会话了,所以「再发一次」是**真的**出路(与 rejected 臂同款)。
|
|
644
|
+
// wake 是引擎侧的动作(POST /v1/sessions/:id/wake),客户端这一侧没有接线 —— 如实点名它、
|
|
645
|
+
// 并说清 sema 不会去做,绝不让用户以为屏幕上会有什么在等他。
|
|
646
|
+
// 🔴 **重复执行的披露**(二次评审 R8 [high] 的采纳半场):那条 parked 副本**没有被丢弃** ——
|
|
647
|
+
// 将来任何人 wake 这个会话,同一条指令会再跑一次。带写/删/外部副作用的指令上这是真代价,
|
|
648
|
+
// 必须说在同一句里。
|
|
649
|
+
// 🔵 **驳回的那半**(「干脆别建议重发」):本客户端这一侧既没有 wake 也没有撤销 parked 消息的
|
|
650
|
+
// 接线,不建议重发 = 用户手上一条路都没有 —— 那正是本文件存在的理由(死墙)所要消灭的形。
|
|
651
|
+
// 取舍 = 给出唯一真出路 + 把它的代价说全,而不是把用户留在墙前。
|
|
652
|
+
return (`${handle} had already finished, so the engine parked your message on that finished run instead of ` +
|
|
653
|
+
`running it — a parked message is delivered only if this session is woken on the engine ` +
|
|
654
|
+
`(POST /v1/sessions/:id/wake), which sema does not do here. That run is no longer holding this ` +
|
|
655
|
+
`session: send your message again to run it as a new turn — but note that the parked copy is NOT ` +
|
|
656
|
+
`discarded, so if anything ever wakes this session on the engine that instruction would run a ` +
|
|
657
|
+
`second time. ${textOnly} Nothing was cancelled.`);
|
|
658
|
+
}
|
|
659
|
+
return (`sema handed your message to ${handle}, but the engine did not say how it will be delivered` +
|
|
660
|
+
`${outcome.status ? ` (it reported that run as ${outcome.status})` : ''}. ${textOnly} Nothing was ` +
|
|
661
|
+
`cancelled, and your message did NOT start a new turn — watch that run before sending it again.`);
|
|
662
|
+
}
|
|
663
|
+
case 'running-steer-failed':
|
|
664
|
+
// 🔴 明确拒收 vs 送达未知,两句话不能互换(见 outcome 注):对一条**可能已经注入**的非幂等
|
|
665
|
+
// 指令说「没发出去,再发一遍」,买单的是用户那边重复的副作用。
|
|
666
|
+
if (outcome.delivery === 'rejected') {
|
|
667
|
+
return (`The engine rejected handing your message to the running run (run ${outcome.taskId}): ${outcome.detail}. ` +
|
|
668
|
+
`sema did NOT retry it (a steer is not safe to send twice) and it did NOT cancel that run. ` +
|
|
669
|
+
`Your message was NOT sent; send it again if you still want it.`);
|
|
670
|
+
}
|
|
671
|
+
return (`sema sent your message to the running run (run ${outcome.taskId}) but could not confirm what happened ` +
|
|
672
|
+
`to it (${outcome.detail}). A steer is not safe to send twice, so sema did NOT retry it, and it did NOT ` +
|
|
673
|
+
`cancel that run. That run may or may not have received your message — watch what it does next ` +
|
|
674
|
+
`before sending it again.`);
|
|
675
|
+
case 'running-cancelled':
|
|
676
|
+
return (`You chose to cancel run ${outcome.taskId}. The engine confirmed it is no longer holding this session, ` +
|
|
677
|
+
`so sema is sending your message now.`);
|
|
678
|
+
case 'running-cancel-timeout': {
|
|
679
|
+
// 秒数取**真等了多久**(不是预算):预算说的是打算等多久,这句话说的是已经发生的事。
|
|
680
|
+
const waited = `${String(Math.max(1, Math.round(outcome.waitedMs / 1000)))}s`;
|
|
681
|
+
if (outcome.aborted) {
|
|
682
|
+
return (`sema asked the engine to cancel run ${outcome.taskId} and you interrupted while it was still ` +
|
|
683
|
+
`confirming (${waited} in). Cancelling is asynchronous, so that run may still be winding down and ` +
|
|
684
|
+
`may still hold this session. Your message was NOT sent; send it again in a moment, or ${wayOut}.`);
|
|
685
|
+
}
|
|
686
|
+
if (!outcome.confirmedHeld) {
|
|
687
|
+
// 🔴 一次都没读出那条 run 的状态(404 / 传输错)⇒ 只许说「确认不了」。说「它还占着」是替
|
|
688
|
+
// 引擎下一个证不出的断言,而这条腿存在的全部理由就是不许再对用户下证不出的断言。
|
|
689
|
+
return (`sema asked the engine to cancel run ${outcome.taskId} but could not read that run's state back ` +
|
|
690
|
+
`within ${waited}, so it cannot confirm whether the session was released. Your message was NOT sent; ` +
|
|
691
|
+
`send it again in a moment (if that run really is gone it will just run), or ${wayOut}.`);
|
|
692
|
+
}
|
|
693
|
+
return (`sema asked the engine to cancel run ${outcome.taskId}, but that run still held this session ` +
|
|
694
|
+
`${waited} later — cancelling is asynchronous and it may still be winding down. Your message was NOT ` +
|
|
695
|
+
`sent; send it again in a moment, or ${wayOut}.`);
|
|
696
|
+
}
|
|
697
|
+
case 'running-cancel-failed':
|
|
698
|
+
if (outcome.delivery === 'unknown') {
|
|
699
|
+
// 🔴 送达未知 ⇒ 那条 run 可能已经在停了。说「它还占着会话」是替引擎下一个证不出的断言。
|
|
700
|
+
return (`sema sent the cancel for run ${outcome.taskId} but could not confirm what happened to it ` +
|
|
701
|
+
`(${outcome.detail}), and it could not read that run back as released either. That run may or may ` +
|
|
702
|
+
`not still be holding this session. Your message was NOT sent; send it again in a moment, or ${wayOut}.`);
|
|
703
|
+
}
|
|
704
|
+
// 🔴 「引擎拒了这一枪」证明的只是**这一枪没生效**,证明不了那条 run 此刻还占着会话:拒收码里
|
|
705
|
+
// 404 是 unknown-run ∪ non-owner 的不可分辨并集、409 是 checkpoint 并发决定/过期的竞态,两者都
|
|
706
|
+
// 可能发生在会话**已经释放**之后。所以这一句只说被拒的事实,不再顺带下那个证不出的因果断言。
|
|
707
|
+
return (`sema could not cancel run ${outcome.taskId} — the engine rejected that request (${outcome.detail}), so ` +
|
|
708
|
+
`nothing was cancelled and sema cannot tell whether that run is still holding this session. Your message ` +
|
|
709
|
+
`was NOT sent; send it again to find out, or ${wayOut}.`);
|
|
226
710
|
case 'not-parked':
|
|
227
711
|
// 🔴 「等它跑完」这句只在**它真的在跑**的时候是真话。分诊表外的门/状态里也可能是 park
|
|
228
712
|
// (表外 gate kind 正是走这条),而 wire 恰恰会在那种情况下带 pendingGate —— 对一个
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { eventSeq, } from './types.js';
|
|
2
2
|
import { eventToSdkMessage, turnEndUsage } from './downstream/eventToSdkMessage.js';
|
|
3
3
|
import { terminalToSdkResult } from './downstream/terminalToSdkResult.js';
|
|
4
|
-
import { publishSubagentContentEvent } from '../subagentContentStore.js';
|
|
4
|
+
import { coerceOutput, publishSubagentContentEvent } from '../subagentContentStore.js';
|
|
5
5
|
/**
|
|
6
6
|
* 409 session-busy 拒绝的 **canonical errorCode**([2377]C-1,server main `049ff2c`,随 5.0.0 发)。
|
|
7
7
|
* 引擎把它 stamp 在 `done{status:'failed'}` / `failed` 终帧上,壳据此**结构判读**,不再读人话。
|
|
@@ -226,14 +226,24 @@ async function* runStreamInner(events, ctx, handle = {}) {
|
|
|
226
226
|
const sub = ev;
|
|
227
227
|
publishSubagentContentEvent({
|
|
228
228
|
type: sub.type,
|
|
229
|
-
//
|
|
229
|
+
// 🔴 EventIdentity 上**没有** `taskId`(sdk events.d.ts 的 `interface EventIdentity` 只有
|
|
230
|
+
// eventId / parentToolCallId,加 LIVE 白名单四臂的 sourceTaskId / bgAgentId;`taskId` 只长在
|
|
231
|
+
// `meta` 首帧)⇒ 生产上这里恒走右臂,归账键由 store 的 `parentToTask` 补(canonicalKey)。
|
|
232
|
+
// `??` 保留是**容将来**:哪天上游真在内容帧上发 taskId,这条直接认,不必改形。
|
|
233
|
+
// (0.30.0 发包扫描订正:此前这行注释写「taskId is on every subagent event」,与 store 侧
|
|
234
|
+
// `canonicalKey` 的注释互相矛盾,且被 .d.ts 直接证伪。)
|
|
230
235
|
taskId: sub.taskId ?? sub.parentToolCallId,
|
|
231
236
|
parentToolCallId: sub.parentToolCallId,
|
|
232
237
|
delta: sub.delta,
|
|
233
238
|
toolCallId: sub.toolCallId,
|
|
234
239
|
toolName: sub.toolName,
|
|
235
240
|
args: sub.args,
|
|
236
|
-
|
|
241
|
+
// #158 移交①([3674](d) 姊妹病,2026-08-12):此前是 `typeof sub.output === 'string' ?
|
|
242
|
+
// sub.output : undefined` —— 而 wire 的 `tool_end.output` 是非均匀的(块数组形合法),
|
|
243
|
+
// 于是子代 lane 的块数组 output 经本臂进内容账本**恒空**(查看态卡有工具、结果栏永远空白)。
|
|
244
|
+
// 换用 store 自己的那个唯一字符串化口(tail 腿 engineSubagentTail 用的同一份):两条腿喂
|
|
245
|
+
// 同一个账本,字符串化口就不能有第二份。
|
|
246
|
+
output: coerceOutput(sub.output),
|
|
237
247
|
isError: sub.isError,
|
|
238
248
|
});
|
|
239
249
|
continue;
|