@sema-agent/client-core 0.13.0 → 0.14.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/README.md +1 -0
- package/dist/adapt/arms.d.ts +10 -1
- package/dist/adapt/arms.js +12 -2
- package/dist/adapt/panelTasks.d.ts +6 -1
- package/dist/adapt/textStream.d.ts +15 -1
- package/dist/adapt/turnFlags.d.ts +5 -1
- package/dist/adapt/wireShapes.d.ts +4 -1
- package/dist/adapt.d.ts +1 -1
- package/dist/adapt.js +7 -2
- package/dist/adapter/downstream/eventToSdkMessage.d.ts +4 -2
- package/dist/adapter/downstream/eventToSdkMessage.js +9 -1
- package/dist/adapter/downstream/terminalToSdkResult.js +4 -2
- package/dist/adapter/runStream.d.ts +8 -1
- package/dist/adapter/runStream.js +70 -4
- package/dist/adapter/types.d.ts +26 -0
- package/dist/hitl/approvalsFeed.d.ts +22 -1
- package/dist/hitl/approvalsFeed.js +76 -5
- package/dist/hitl/frameRouter.js +49 -8
- package/dist/hitl/gateLedger.d.ts +13 -1
- package/dist/hitl/gateLedger.js +2 -2
- package/dist/hitl/parkResolver.js +10 -1
- package/dist/hitl/planReviewWire.js +48 -20
- package/dist/hitl/toolApprovalWire.js +11 -1
- package/dist/hooksWireCaps.js +10 -2
- package/dist/liveQuestionStore.d.ts +13 -0
- package/dist/liveQuestionStore.js +15 -0
- package/dist/notifications.d.ts +15 -1
- package/dist/notifications.js +90 -10
- package/dist/printToolResultFrame.d.ts +12 -4
- package/dist/printToolResultFrame.js +11 -21
- package/dist/unrefTimer.d.ts +14 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -226,6 +226,7 @@ public-surface guard checks that last one).
|
|
|
226
226
|
| `scripts/run-usage-verbatim-channel-test.mjs` | The two complementary usage disciplines (core 3.0.0 metering semantics): the CC `ModelUsage` mirror stays pure (five pinned keys, `totalInputTokens` has no seat), while the sema-owned channel forwards the engine `turn_end.usage` object **verbatim** (six keys, incl. `totalInputTokens`) via `last_turn_usage.engineUsage` / `handle.latestEngineUsage` — honest absence on pre-3.0.0 engines, no fabricated zeros |
|
|
227
227
|
| `scripts/run-plan-review-decide-verify-test.mjs` | `decidePlanReview`'s post-decide honesty ([2315]/[2316], engine RB-471 family): a 2xx from the decide endpoint is **not** a terminal — the wire re-pulls the task status and words the outcome by the real shape (still-locked / legal new gate / genuinely left park / unverified), never claiming success it hasn't earned. Driven against a real fake-engine HTTP server through the shipped dist |
|
|
228
228
|
| `scripts/run-shell-gate-durable-allow-test.mjs` | #110: the durable approval leg for **shell** gates. The tool_end HOLD/REJECT predicate must cover Bash the same way park detection already does (otherwise the park poison frame `Operation aborted` hits the transcript, `endedCalls` swallows the real replayed result, and the user who pressed Yes watches a command that really ran be reported as aborted); a replayed, already-decided park must resume reading the stream instead of being reported as a failed turn; `lastEventId` must track numeric `seq` too. Mutation-proven: each of the three fixes reverted turns the gate red |
|
|
229
|
+
| `scripts/run-hitl-gate-honesty-test.mjs` | [2393] the four HITL disciplines that a passing type-check cannot see. (1) The park predicate and the `tool_end` predicate must cover the **same** set — the park side admits a first-class `kind:'tool_approval'` gate for *any* tool name, and a `tool_end` frame carries no `kind`, so the frame-level judge falls back to the engine's exact abort marker; otherwise the poison frame hits the transcript and `markEnded` swallows the real replayed result (the #110 disease, reopened on kind-only gates). (2) The already-decided identity criterion is **one-shot**: its two inputs are monotonic, so without consumption one successful decide makes every later park failure — including a real `approvals.list` outage — read as "already resolved" until the 24-hop budget runs out and reports a cause that has nothing to do with what happened. (3) A `plan_review` card dismissed without an answer must be re-presentable: the idempotent re-arm short-circuit re-publishes the still-armed card, and a stale armed id (responder gone) re-arms from scratch rather than presenting a card nobody can answer. (4) `HitlSafetyError` is a safety signal — the `remember` fallback arm must re-raise it instead of auto-retrying the decide, while a plain unknown-key 400 still falls back. (5) The polling leg reschedules after an escaping throw and flips `mode()` to `idle` once it consistently fails, so the honesty surface stops reporting a dead feed as live |
|
|
229
230
|
| `scripts/run-public-surface-test.mjs` | The outward promises: the npm export surface baseline (an **exact set**, both directions — a new export that never entered the baseline is one nobody watched leave, and deleting it later would not be red), the peer floor witness, and this README's claims |
|
|
230
231
|
| `scripts/run-client-core-message-branching-test.mjs` | §B8 (branching on error **text**) and §B10 (truthiness standing in for existence when the value can be `0`). AST + type-checker census over `src/`, a named ALLOW list carrying owner and expiry, a known-site floor, and two fixed corpora with a known verdict judged by the same classifier on every run |
|
|
231
232
|
| `scripts/run-client-core-failloud-test.mjs` | §C1/§C2: an empty `catch` with no comment anywhere inside it, a pure-swallow `catch` nobody reasoned about, and `void <write>` that really returns a Promise with no `.catch`. The exemption instrument is a comment saying why *this* failure may die; the documented-swallow count is a ratchet that only goes down |
|
package/dist/adapt/arms.d.ts
CHANGED
|
@@ -35,7 +35,16 @@ export interface ProjectionArmDeps {
|
|
|
35
35
|
readonly ctx: AdapterContext;
|
|
36
36
|
readonly idOf: IdOf;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* 有状态臂的 deps —— 矩阵 §3.5 收窄成的六件 **+ `flags`,共七件**,不多给。
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ ADAPT-F7 事实纠正(2026-08-02):原文写「收窄成的六件,不多给」,而实现一直是七件 ——
|
|
42
|
+
* 矩阵 §3.5 那份六件清单把 M6a/M6b 的消费留在 M0 驱动壳的「A7 编排半场」,但落码时
|
|
43
|
+
* `result` / `turn_usage` / `retry_status` 三臂被整条搬进臂表,于是必须多注一件 `TurnFlags`。
|
|
44
|
+
* 偏离本身有理由(且 §3.3 要求 M6a/M6b 两半同模块,`turnFlags.ts` 满足),但**用一个对不上的
|
|
45
|
+
* 数字自证封闭性**正是 [refactor-pipeline-form] 记的「封闭性声明必须机械验证(5/11 案)」的入口:
|
|
46
|
+
* 下一棒拿六去数七,只会得出「多注了一件,删掉」的错结论。数目与理由一起写明。
|
|
47
|
+
*/
|
|
39
48
|
export interface ArmDeps extends ProjectionArmDeps {
|
|
40
49
|
readonly text: TextStream;
|
|
41
50
|
readonly cards: ToolCardLedger;
|
package/dist/adapt/arms.js
CHANGED
|
@@ -19,8 +19,18 @@ const assistantArm = function* (m, { ctx, idOf, text, cards, inst }) {
|
|
|
19
19
|
// 收货裁定(codex 复审 #4):旧版此处直接取值使用,畸形帧(accessor/稀疏形,非 JSON 数据帧)
|
|
20
20
|
// 会抛 TypeError;guard 保留=有意的健壮性变化 —— 正常 wire 帧(JSON.parse 产物)下
|
|
21
21
|
// `findIndex >= 0 ⇒ 恒非 undefined`,零行为差。
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
//
|
|
23
|
+
// 🔴 ADAPT-F9 收口(2026-08-02 全窗复审):上面那句「零行为差」只覆盖了正常帧。**非正常帧上**
|
|
24
|
+
// 旧码抛 TypeError(响亮),而首版 guard 写成 `if (original !== undefined) {…}` ⇒ 取不到块时
|
|
25
|
+
// 不 return,而是**继续往下落进 text/thinking 半场** —— 后果不是「静默降级」而是**落进另一条臂**:
|
|
26
|
+
// 可能产出一条不该有的 assistant 文本消息 + 置 `markEmittedText()`(压掉 `result` 臂的终答兜底),
|
|
27
|
+
// 而卡永远不开 ⇒ 后到的 `tool_end_result` 被当「无对应开卡」直接丢。fail-loud→fail-soft 可以,
|
|
28
|
+
// 但绝不许 fail 到隔壁臂上去。判据锚在**决定分支的量**:`toolUseIdx >= 0` 就是「这是一条 tool_use
|
|
29
|
+
// 帧」,它一旦成立,text/thinking 半场就与本帧无关 —— 取不到块只能早退。
|
|
30
|
+
if (toolUseIdx >= 0) {
|
|
31
|
+
const original = blocks[toolUseIdx];
|
|
32
|
+
if (original === undefined)
|
|
33
|
+
return; // 畸形:声称有 tool_use 块却取不到 ⇒ 本帧到此为止
|
|
24
34
|
const decision = decisionOf(original);
|
|
25
35
|
const san = sanitizeToolUseBlock(original);
|
|
26
36
|
const renderBlocks = blocks.map((b, i) => (i === toolUseIdx ? san.block : b));
|
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
*
|
|
20
20
|
* 🔴 module 台账 `engineAgentPanelStore` 的两个写口(mark/clearEnginePanelTaskResident)拆分前有
|
|
21
21
|
* A4/A9 **双所有者**。按记忆 `paired-mechanisms-must-share-premise` 的「整臂让位」:两处写口
|
|
22
|
-
*
|
|
22
|
+
* 现在**在 adapt 域内只在本文件**(`noteResidency()` 与 `settleFromNotification()`),不许 A 层
|
|
23
|
+
* 两个模块各写各的。
|
|
24
|
+
* ⚠️ ADAPT-F4 判据纠偏(2026-08-02):原文写的是「只在本文件」,而 `src/notifications.ts:915`
|
|
25
|
+
* 的 bg 合成半场也调 `clearEnginePanelTaskResident`(它自陈是「真终态唯二来源」之一,是设计内的
|
|
26
|
+
* 另一条腿、不是被劈开的臂)。照字面 grep 验这条判据的人当场得到证伪,只能二选一:误判成回归去
|
|
27
|
+
* 删 notifications 那半场,或判定这条钉不可信而整条丢掉 —— 两条都是坏结局。判据的作用域必须写准。
|
|
23
28
|
*
|
|
24
29
|
* 🔴 时序留钉(矩阵 §2.1②):`start()` 里 `markFiredSubagentStart` 必须在 `yield` **之前** ——
|
|
25
30
|
* yield* 展开后判重+记号与交付在**同一次** `.next()` 里,「已标记」⟺「已交付」原子成立;
|
|
@@ -37,7 +37,21 @@ export interface TextStream {
|
|
|
37
37
|
hasPendingContent(): boolean;
|
|
38
38
|
/** D5 攒批节拍:一个间隔至多一次 flush(判据与 `lastFlushAt` 一起收在本模块内)。 */
|
|
39
39
|
maybeFlushOnBeat(nowMs: number): Generator<AdapterOutput>;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* A1 durable text 臂:把整块 text 计进 `answer`(它不走增量通道)。
|
|
42
|
+
*
|
|
43
|
+
* ── ADAPT-F8 留/删裁定(2026-08-02;矩阵 §338 点名要的那条裁定,此前仓内零落痕)──────────
|
|
44
|
+
* 事实:`answer` 的**唯一读者**是驱动壳 D8 收口的 `ctx.log?.('debug', … answerLen=…)`;拆分把
|
|
45
|
+
* 旧本一行 `void answer`(自陈「保留供调试/后续批」)换成了 `appendAnswer` + `answerLength`
|
|
46
|
+
* 两个模块级出口 —— 净效果与矩阵意图相反(包袱没裁,反而升了格)。
|
|
47
|
+
* **裁定 = 留**,理由与代价一起记明:
|
|
48
|
+
* · 留:D8 那行是「这个 turn 到底产没产出文本」的**唯一**分诊锚,而它恰好是本域最常翻车的
|
|
49
|
+
* 一类现象(终答兜底/段序/markEmittedText 三处任一漏配都在这里现形)。删掉等于把一条真在
|
|
50
|
+
* 用的诊断腿换成「下次再从头加仪器」。它不是「不报错的妥协」,是有读者的可观测面。
|
|
51
|
+
* · 代价(认领,不粉饰):两个出口是 turn 级状态的模块 API 面,`appendAnswer` 的调用点漂了
|
|
52
|
+
* 没有门会说话(答案长度只进 debug 行,没人对账)。所以它**不许再长第三个读者** —— 想读
|
|
53
|
+
* 正文的人去读段缓冲,别把这个累加器当答案来源。
|
|
54
|
+
*/
|
|
41
55
|
appendAnswer(text: string): void;
|
|
42
56
|
/** 「本 turn 已经产过 assistant 文本」的具名写口(A1 durable / A7 终答兜底两处)。 */
|
|
43
57
|
markEmittedText(): void;
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* 所以本文件的对外面是**动作**(onFrameBeforeDispatch / noteRetryStatus / …),而不是三个布尔位。
|
|
12
12
|
*
|
|
13
13
|
* 判据留给下一个人:本文件之外的任何地方都不该出现 `messageStartEmitted` / `endEmitted` /
|
|
14
|
-
* `retryOverlayActive`
|
|
14
|
+
* `retryOverlayActive` 这三个名字**的代码引用**。grep 到了就是有人把状态机又劈开了。
|
|
15
|
+
* ⚠️ ADAPT-F5 判据纠偏(2026-08-02):原文没排除注释面,而它在落码当天就已被同批文件证伪 ——
|
|
16
|
+
* `arms.ts` 的帧序耦合注释(`⟨帧序耦合 7/7⟩` 那一处与臂序总表)按名字点这两个位是**有意的交叉
|
|
17
|
+
* 索引**,不是状态机被劈开。一条恒假阳性的判据等于没有判据([probe-must-prove-it-speaks] 同族),
|
|
18
|
+
* 所以判据锚在「代码引用」:grep 后先剔注释行,剩下的才算命中。
|
|
15
19
|
*/
|
|
16
20
|
import type { AdapterContext, AdapterOutput } from '../seam.js';
|
|
17
21
|
import { type Frame } from './ids.js';
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 这里全是**纯函数与常量**:零可变量、零 module 台账、零 ctx 依赖。所以它既不出现在
|
|
5
5
|
* 读写矩阵(ADAPT-RW-MATRIX §1)的任何一行里,也不需要任何时序约束 —— 判据锚在
|
|
6
|
-
*
|
|
6
|
+
* 「本文件零 **module 级**可变绑定,也不 import 任何台账」,拆分后的状态归属讨论永远绕开它。
|
|
7
|
+
* ⚠️ ADAPT-F6 判据纠偏(2026-08-02):原文写的是「本文件没有 `let`」,而 `estimateCjkTokens` 的
|
|
8
|
+
* 累加器就是一个 `let t = 0`(函数体局部,进不了任何跨调用状态)。结论(零 turn 级状态)没错,
|
|
9
|
+
* 写下的判据是错的:照它 grep 当场红,而真要防的东西(module 级 `let`)它一个字没说。
|
|
7
10
|
*
|
|
8
11
|
* 🔴 公面纪律:下面标了「公面」的六件在 0.1.x 起就是 `@sema-agent/client-core` 的导出名,
|
|
9
12
|
* `adapt.ts` 用**具名再导出**把它们原样送出门(不是 `export *` —— 那会把 `TASK_TOOL_NAMES` /
|
package/dist/adapt.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface AdapterLedgerState {
|
|
|
50
50
|
/** 本批覆盖清单——差分守卫按它区分「已覆盖臂必须逐字段等价」与「已声明未覆盖臂」。 */
|
|
51
51
|
export declare const ADAPTER_COVERAGE: {
|
|
52
52
|
/** 已落码的帧臂。 */
|
|
53
|
-
readonly frames: readonly ["assistant", "user", "stream_event", "turn_usage", "result", "system", "task_notification", "workflow_complete", "diagnostics", "steering_injected", "workspace_changed", "prompt_suggestions", "retry_status", "task_progress", "tool_end_result(label 补位 + 关卡 settle + response-id 复位 +
|
|
53
|
+
readonly frames: readonly ["assistant", "user", "stream_event", "turn_usage", "result", "system", "task_notification", "workflow_complete", "diagnostics", "steering_injected", "workspace_changed", "prompt_suggestions", "retry_status", "task_progress", "tool_end_result(label 补位 + 关卡 settle + response-id 复位 + 开卡台账出栈 + 经 cards.close 铸 tool_result)"];
|
|
54
54
|
/** 已落码臂产出的 transcript 消息类目(差分守卫的比对域)。 */
|
|
55
55
|
readonly transcriptKinds: readonly ["assistant_text", "assistant_thinking", "assistant_tool_use", "user_tool_result_decision", "user_task_notification", "system_passthrough"];
|
|
56
56
|
/** B3(0.5.0)新落码的臂/半场 —— 从 todo 移过来的,别再在 todo 里留同名条目。 */
|
package/dist/adapt.js
CHANGED
|
@@ -34,7 +34,11 @@ export const ADAPTER_COVERAGE = {
|
|
|
34
34
|
'prompt_suggestions',
|
|
35
35
|
'retry_status',
|
|
36
36
|
'task_progress',
|
|
37
|
-
|
|
37
|
+
// ⚠️ ADAPT-F10 事实纠正(2026-08-02):括注原写「不铸 tool_result」,而 B5 起这条臂**正是**
|
|
38
|
+
// 铸卡本体的地方(`arms.ts` tool_end 臂 → `cards.close()` → `toolCards.ts` yield 一条
|
|
39
|
+
// `{type:'user', content:[{type:'tool_result'…}]}`)。pure 门只按子串断言臂名在场,对括注里的
|
|
40
|
+
// 假话全盲 —— 存量假话不是本窗引入的,但它就长在本域文件上,顺手纠。
|
|
41
|
+
'tool_end_result(label 补位 + 关卡 settle + response-id 复位 + 开卡台账出栈 + 经 cards.close 铸 tool_result)',
|
|
38
42
|
],
|
|
39
43
|
/** 已落码臂产出的 transcript 消息类目(差分守卫的比对域)。 */
|
|
40
44
|
transcriptKinds: [
|
|
@@ -202,7 +206,8 @@ class WireToCcAdapterImpl {
|
|
|
202
206
|
const panel = createPanelTaskLedger(ctx, cards, inst);
|
|
203
207
|
// ── M6a 响应度量 + M6b 重试覆盖层(两个小状态机;矩阵 §3.3:必须连驱动壳那一半一起搬)────
|
|
204
208
|
const flags = createTurnFlags(ctx, turnStartAt);
|
|
205
|
-
/** 臂表的注入面 —— 矩阵 §3.5
|
|
209
|
+
/** 臂表的注入面 —— 矩阵 §3.5 的六件 **+ `flags`(七件,理由见 `ArmDeps` 头注 / ADAPT-F7)**,
|
|
210
|
+
* 不做全量注入(全量注入 = 封闭性声明失去判别力)。 */
|
|
206
211
|
const deps = { ctx, idOf, text, cards, panel, flags, inst };
|
|
207
212
|
// turn 开场卫生(T38 三清,cli 逐条同序):清上一 turn 的 retry 覆盖层 + **上一轮的建议批** +
|
|
208
213
|
// 思考活动行,再发 request-start(T39)。三清缺一都是"上一轮的东西挂着不掉"。
|
|
@@ -46,14 +46,16 @@ export type EventProjectionNoneReason =
|
|
|
46
46
|
/** 已在 SDK union 里、但本切片**有意**不投影的臂(逐条理由见各 case 注释)。 */
|
|
47
47
|
| 'not_in_slice'
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
49
|
+
* 臂在本切片内、帧也不畸形,但**载荷筛完是空的**。两个来源:`suggestions` 全被空白过滤掉、
|
|
50
|
+
* `diagnostics` 的 `files: []`(LSP 的「这一轮没有诊断」正常形,ADAPTER-F6 收平)。
|
|
50
51
|
* 单列一档的理由与 REF-CC-058 本身同源:把它并进 `not_in_slice` 会说「这条臂我们不投影」——
|
|
51
52
|
* 那是假话(我们投影它,只是这一帧没内容);并进 `dropped` 则是把引擎的正常空批诬告成丢帧。
|
|
52
53
|
*/
|
|
53
54
|
| 'empty_payload';
|
|
54
55
|
/** 「这条臂我看不懂/嫌它坏,丢了」的**如实分类**(REF-CC-057/058)。 */
|
|
55
56
|
export type EventProjectionDropReason =
|
|
56
|
-
/** 帧在但必填位畸形(非串 runId /
|
|
57
|
+
/** 帧在但必填位畸形(非串 runId / 非数组 files / 空 source …)—— 引擎发了,我们读不动。
|
|
58
|
+
* ⚠️ 「筛完是空的」不算畸形(那是 `empty_payload`,见上;ADAPTER-F6)。 */
|
|
57
59
|
'malformed'
|
|
58
60
|
/** 类型面根本不认识这条臂(引擎比本包新)。 */
|
|
59
61
|
| 'unknown_arm';
|
|
@@ -247,8 +247,16 @@ export function eventToSdkMessage(ev, ctx) {
|
|
|
247
247
|
// into the CC `diagnostics` attachment row (DiagnosticsDisplay renders it, zero new UI).
|
|
248
248
|
// parentToolCallId rides through so the bridge can keep a sub-flow's diagnostics off the leader transcript.
|
|
249
249
|
case 'diagnostics': {
|
|
250
|
-
|
|
250
|
+
// ADAPTER-F6(2026-08-02 全窗复审):两种「files 不可渲染」此前塌成同一个 `malformed` ——
|
|
251
|
+
// · **非数组** = 必填位畸形,引擎发了我们读不动 ⇒ dropped/malformed(留痕该吼);
|
|
252
|
+
// · **空数组** = LSP 语义上完全正常的「这一轮没有诊断 / 诊断已清空」⇒ 那是**空批**,
|
|
253
|
+
// 不是坏帧。判它 malformed 会在每条这样的帧上打一行「It renders NOWHERE」的告警,
|
|
254
|
+
// 把引擎的正常行为诬告成故障 —— 而 `empty_payload` 单列一档的理由(见该词头注)
|
|
255
|
+
// 恰恰就是这件事,只是当初只对 suggestions 用了。同一判据两处不一致,在此收平。
|
|
256
|
+
if (!Array.isArray(ev.files))
|
|
251
257
|
return dropped('malformed', 'diagnostics');
|
|
258
|
+
if (ev.files.length === 0)
|
|
259
|
+
return nothing('empty_payload');
|
|
252
260
|
return projected(stamp(ctx, armBody({
|
|
253
261
|
type: 'diagnostics',
|
|
254
262
|
files: ev.files,
|
|
@@ -161,7 +161,9 @@ function errorResult(ctx, parts) {
|
|
|
161
161
|
errors: [...parts.errors],
|
|
162
162
|
...(parts.errorCode !== undefined && parts.errorCode.length > 0 ? { errorCode: parts.errorCode } : {}),
|
|
163
163
|
...(parts.degraded !== undefined ? { degraded: parts.degraded } : {}),
|
|
164
|
-
|
|
164
|
+
// ADAPTER-F4:具名 additive 位(见 `ErrorResultParts.salvagedResult`)。展开的是**一个已知键**,
|
|
165
|
+
// 覆写不到上面任何一个不变量;要加第二个位必须动这里,而动这里在 diff 里显形。
|
|
166
|
+
...(parts.salvagedResult !== undefined ? { result: parts.salvagedResult } : {}),
|
|
165
167
|
});
|
|
166
168
|
}
|
|
167
169
|
/** `done` → SDKResultSuccess (contract 02 §2.10 / 08 CS-10). */
|
|
@@ -268,7 +270,7 @@ export function doneToSdkResult(ev, ctx) {
|
|
|
268
270
|
],
|
|
269
271
|
// 写出窗 salvage(additive seam 字段):deadline 前引擎救回的最终文本非空时随帧带出——
|
|
270
272
|
// CC error 帧无 result 键,但静默丢弃已救回的答案对 -p 集成面是净损失(TB qe** 实证)。
|
|
271
|
-
...(salvaged !== undefined ? {
|
|
273
|
+
...(salvaged !== undefined ? { salvagedResult: salvaged } : {}),
|
|
272
274
|
});
|
|
273
275
|
}
|
|
274
276
|
// [909]B1 — blocked 终态:agent 自报无法推进(assemble-result.js:99,只带 blockedReason 无
|
|
@@ -34,7 +34,11 @@ import type { AgentEvent } from '@sema-agent/sdk';
|
|
|
34
34
|
import { type SDKMessage, type EmitContext, type ModelUsage } from './types.js';
|
|
35
35
|
import type { EngineTurnUsage } from './downstream/turnUsageToModelUsage.js';
|
|
36
36
|
/** #114/[C65] 单源化:三端共用的 409 active-run 拒收终帧判别(富信号形,包级唯一权威)。
|
|
37
|
-
*
|
|
37
|
+
* 与壳侧抄件(cli `src/sema/activeRunSelfHeal.ts`)同义——0.13.0 到货后壳换包导入删本地抄件。
|
|
38
|
+
* 🔧 2026-08-02 事实纠正([C77]③):原文写的是「与壳/desktop **各自**照抄件同义……各端换包
|
|
39
|
+
* 导入删本地抄件」,而 desktop 全树扫描(`activeRunBusySignal` / `session_active_run` /
|
|
40
|
+
* `activeTaskId` 的 409 判别族)**零命中** —— 那边根本没有抄件,「各端」是句不实的话。
|
|
41
|
+
* 删掉不实的那半:今天的抄件只有壳一份。 */
|
|
38
42
|
export interface ActiveRunBusySignal {
|
|
39
43
|
/** 占锁 run 的 id;wire 没带 ⇒ null(诚实缺席,绝不铸造)。 */
|
|
40
44
|
readonly activeTaskId: string | null;
|
|
@@ -56,6 +60,9 @@ export interface ActiveRunBusySignal {
|
|
|
56
60
|
export declare function activeRunBusySignal(ev: unknown): ActiveRunBusySignal | null;
|
|
57
61
|
/** 测试钩:清空「已上报过的臂」去重表(去重是**跨调用**状态,不清就只有第一条用例看得见)。 */
|
|
58
62
|
export declare function _resetDroppedFrameReportForTest(): void;
|
|
63
|
+
/** 测试钩:去重表当前条数 —— ADAPTER-F5 的上限断言要读的**决定结果的量**(行数只证「吼了几次」,
|
|
64
|
+
* 证不了「表有没有涨」;两者在到顶之后恰好分道扬镳,所以必须直接读表)。 */
|
|
65
|
+
export declare function _droppedFrameMemoSizeForTest(): number;
|
|
59
66
|
export interface RunStreamHandle {
|
|
60
67
|
/** The latest folded turn usage (footer counters); updated on each turn_end. */
|
|
61
68
|
latestUsage?: ModelUsage;
|
|
@@ -82,6 +82,32 @@ function classifyActiveRunBusy(input) {
|
|
|
82
82
|
* 几行 stderr,不影响正确性(dupRisk=low)。
|
|
83
83
|
*/
|
|
84
84
|
const reportedDroppedTypes = new Set();
|
|
85
|
+
/**
|
|
86
|
+
* 去重表的容量上限(ADAPTER-F5,2026-08-02)。
|
|
87
|
+
*
|
|
88
|
+
* 表的键取自 **wire 值**(`String(ev.type)` —— 引擎发什么就是什么),而表是 module 级、生产期
|
|
89
|
+
* 不清:一个乱发 `type` 的引擎/中间件能让它无界膨胀。上限到顶后**停止登记**(不是清空):
|
|
90
|
+
* 清空会让同一批乱码 type 循环重登、每轮再吼一遍,交替序列下上限永远到不了
|
|
91
|
+
* (记忆 `loop-termination-requires-reevaluation` 的兜底推论);停止登记的代价只是「顶之后的
|
|
92
|
+
* 新臂每条都吼」—— 而那正是「引擎发了 64 种我不认识的臂」时应该吵的场面。
|
|
93
|
+
*/
|
|
94
|
+
const DROPPED_TYPE_MEMO_CAP = 64;
|
|
95
|
+
/** 留痕文案里 `type` 的呈现上限(超出截断);见 {@link sanitizeFrameType}。 */
|
|
96
|
+
const DROPPED_TYPE_DISPLAY_CAP = 60;
|
|
97
|
+
/**
|
|
98
|
+
* 把不可信的 wire `type` 收拾成**一行日志安全**的呈现形(ADAPTER-F5)。
|
|
99
|
+
*
|
|
100
|
+
* 未处理时它原样进 `console.error` 的模板:一个带 `\n[client-core] …` 的 type 能在运维的 stderr
|
|
101
|
+
* 里伪造出额外的整行(日志注入),控制符还能改终端状态。判据锚在「换行/控制符出不去 + 长度有界」,
|
|
102
|
+
* 不锚在「引擎不会那么发」—— 后者是对上游的假设,不是本层的性质。
|
|
103
|
+
*/
|
|
104
|
+
function sanitizeFrameType(type) {
|
|
105
|
+
// eslint-disable-next-line no-control-regex
|
|
106
|
+
const flattened = type.replace(/[\u0000-\u001f\u007f-\u009f]/g, '\uFFFD');
|
|
107
|
+
return flattened.length > DROPPED_TYPE_DISPLAY_CAP
|
|
108
|
+
? `${flattened.slice(0, DROPPED_TYPE_DISPLAY_CAP)}…`
|
|
109
|
+
: flattened;
|
|
110
|
+
}
|
|
85
111
|
/**
|
|
86
112
|
* 未知/畸形帧的**留痕**(§C5 丢弃必须留痕,且痕迹要到得了能处置它的人)。
|
|
87
113
|
*
|
|
@@ -90,19 +116,59 @@ const reportedDroppedTypes = new Set();
|
|
|
90
116
|
* console 在 Node 与浏览器都在,与 `host.ts` 自己那条「口没装就吼一声」的 miss 提示同款姿势。
|
|
91
117
|
* 🔴 已知限制(诚实披露):Ink 挂载后宿主会吞 console,所以 TUI 里这行未必上屏 —— 那正是
|
|
92
118
|
* `EventProjection.dropped` 存在的意义:想在自己那一端把它渲出来的宿主,读返回值即可。
|
|
119
|
+
*
|
|
120
|
+
* ── [C77]① 宿主出口 `ctx.onDroppedFrame`(2026-08-02,小黑板 web-client 请托)────────────────
|
|
121
|
+
* 上一段那条「读返回值即可」只对**自己驱动投影器**的宿主成立;经 `runStream` 消费的宿主
|
|
122
|
+
* (web/desktop 都是)看不到 `EventProjection`,于是丢帧对它只剩一行 stderr。装了 sink 的宿主
|
|
123
|
+
* 从此收到结构化的 `{type, why}`,自己那一端想渲成告警条还是计数器随它。
|
|
124
|
+
*
|
|
125
|
+
* 三条语义,各自的理由:
|
|
126
|
+
* ① **不去重**:去重表 `reportedDroppedTypes` 是给 console 防刷屏用的(一条流同一种臂只吼一次)。
|
|
127
|
+
* sink 那边**每一条都发** —— 宿主的告警面要的是真计数,包侧替它去重就是替它撒谎;它想
|
|
128
|
+
* 去重是它自己一行的事,我们把真数给不出来才是不可逆的损失。
|
|
129
|
+
* ② **装了 sink ⇒ console 让位**:§C5 要的是「痕迹到得了能处置它的人」,不是「痕迹出现两次」。
|
|
130
|
+
* sink 是严格更强的通道(结构化 + 到得了 UI),而 console 这条腿自己承认在 Ink 宿主里会被
|
|
131
|
+
* 吞;再者一个直写 stdout/stderr 的旁路会打乱 TUI 渲染。宿主显式装了 sink = 它认领了这件事。
|
|
132
|
+
* 🔴 让位**不改缺省**:没装 sink 的宿主(含今天所有既有调用点)行为一字不变。
|
|
133
|
+
* ③ **sink 抛错 ⇒ 流照常 + 痕迹落回 console**:宿主的 bug 绝不许打断引擎流(与 `emitChrome`
|
|
134
|
+
* 两处 fail-soft 同款);而②的让位前提是「sink 真接住了」,没接住就得把 console 那腿还回来
|
|
135
|
+
* —— 否则一个坏 sink 会让丢帧比装它之前更隐蔽(装了个东西反而更瞎,是最坏的一种)。
|
|
93
136
|
*/
|
|
94
|
-
function reportDroppedFrame(why, type) {
|
|
137
|
+
function reportDroppedFrame(why, type, ctx) {
|
|
138
|
+
if (ctx.onDroppedFrame) {
|
|
139
|
+
try {
|
|
140
|
+
// 以 ctx 为 receiver 调用(宿主写成方法形时 `this` 不丢;与 ctx.emitChrome 同款姿势)。
|
|
141
|
+
ctx.onDroppedFrame({ type, why });
|
|
142
|
+
return; // sink 接住了 ⇒ console 那行让位(理由 ② 见头注)
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
// 宿主 sink 抛错:不 rethrow(理由 ③),但**必须留痕**——先说 sink 自己坏了,
|
|
146
|
+
// 再走下面的 console 腿把这条丢帧本身补上。
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.error(`[client-core] ctx.onDroppedFrame threw (host sink); falling back to console: ${String(e)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ADAPTER-F5 ①:去重表有界。到顶后不再登记(也不清空,理由见 DROPPED_TYPE_MEMO_CAP 头注)——
|
|
152
|
+
// 键是不可信 wire 值,无界表是一条「引擎乱发 type 就能把宿主内存吃掉」的路。
|
|
95
153
|
if (reportedDroppedTypes.has(type))
|
|
96
154
|
return;
|
|
97
|
-
reportedDroppedTypes.
|
|
155
|
+
if (reportedDroppedTypes.size < DROPPED_TYPE_MEMO_CAP)
|
|
156
|
+
reportedDroppedTypes.add(type);
|
|
98
157
|
// eslint-disable-next-line no-console
|
|
99
|
-
console.error(
|
|
158
|
+
console.error(
|
|
159
|
+
// ADAPTER-F5 ②:type 是 wire 值,进日志行前必须 sanitize(裸值能伪造额外的整行)。
|
|
160
|
+
`[client-core] dropped an engine frame (${why}): type="${sanitizeFrameType(type)}" — this build's projector has no arm ` +
|
|
100
161
|
'for it (engine newer than the client, or a malformed frame). It renders NOWHERE.');
|
|
101
162
|
}
|
|
102
163
|
/** 测试钩:清空「已上报过的臂」去重表(去重是**跨调用**状态,不清就只有第一条用例看得见)。 */
|
|
103
164
|
export function _resetDroppedFrameReportForTest() {
|
|
104
165
|
reportedDroppedTypes.clear();
|
|
105
166
|
}
|
|
167
|
+
/** 测试钩:去重表当前条数 —— ADAPTER-F5 的上限断言要读的**决定结果的量**(行数只证「吼了几次」,
|
|
168
|
+
* 证不了「表有没有涨」;两者在到顶之后恰好分道扬镳,所以必须直接读表)。 */
|
|
169
|
+
export function _droppedFrameMemoSizeForTest() {
|
|
170
|
+
return reportedDroppedTypes.size;
|
|
171
|
+
}
|
|
106
172
|
/**
|
|
107
173
|
* Drive one AgentEvent source into a CC SDKMessage stream.
|
|
108
174
|
*
|
|
@@ -311,6 +377,6 @@ async function* runStreamInner(events, ctx, handle = {}) {
|
|
|
311
377
|
// REF-CC-057:`dropped` 是「引擎发了一帧、这个 build 渲不出来」——**必须留痕**;
|
|
312
378
|
// `none` 是设计内无可渲染臂(usage 折叠/终帧代理/HITL 登记),不留痕才是对的。
|
|
313
379
|
if (projection.kind === 'dropped')
|
|
314
|
-
reportDroppedFrame(projection.why, projection.type);
|
|
380
|
+
reportDroppedFrame(projection.why, projection.type, ctx);
|
|
315
381
|
}
|
|
316
382
|
}
|
package/dist/adapter/types.d.ts
CHANGED
|
@@ -43,6 +43,18 @@ export type StampedAgentEvent = AgentEvent & {
|
|
|
43
43
|
/** Read the stamped durable seq off an AgentEvent, if present. */
|
|
44
44
|
export declare function eventSeq(ev: AgentEvent): string | undefined;
|
|
45
45
|
export declare function uuid(): string;
|
|
46
|
+
/**
|
|
47
|
+
* 一条被丢弃的引擎帧的**结构化留痕**([C77]①,`EmitContext.onDroppedFrame` 的载荷)。
|
|
48
|
+
* 两个位都取自 `EventProjection` 的 `dropped` 臂原值,包侧不做任何加工:
|
|
49
|
+
* · `type` = 引擎那一帧的 `type`(未知臂/畸形帧的臂名);
|
|
50
|
+
* · `why` = 投影器给的判词(今天是 `unknown_arm` / `malformed`)。
|
|
51
|
+
* 🔴 **不是**开集枚举:`why` 故意留成 string —— 投影器长出新判词时宿主不该编译不过,
|
|
52
|
+
* 它本来就是「说给人看的一句判词」,不是控制流上的判别位。
|
|
53
|
+
*/
|
|
54
|
+
export interface DroppedFrameInfo {
|
|
55
|
+
readonly type: string;
|
|
56
|
+
readonly why: string;
|
|
57
|
+
}
|
|
46
58
|
/**
|
|
47
59
|
* Per-stream identity context. `session_id` threads every emitted arm; it is the
|
|
48
60
|
* service-minted sessionId (TaskResult.sessionId / RunReceipt.sessionId) once
|
|
@@ -62,6 +74,20 @@ export interface EmitContext {
|
|
|
62
74
|
* 其余点 fire-and-forget —— 与搬迁前的时序逐点对齐,别顺手改成全 await。
|
|
63
75
|
*/
|
|
64
76
|
emitChrome?(event: ChromeEvent): void | Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* [C77]① 新增(小黑板 web-client 请托)—— **丢帧留痕的宿主出口**。runStream 的 `dropped` 臂
|
|
79
|
+
* (引擎发了一帧、这个 build 渲不出来)此前只有包侧一行 `console.error`,宿主拿不到结构化的
|
|
80
|
+
* `{type, why}`:web 想把它并进自己的 frame-warnings 面只能去 scrape stderr,而 Ink 宿主
|
|
81
|
+
* 干脆把 console 吞了。形制与上面的 `emitChrome` 同款 —— **可选注入,缺席 = 该宿主不消费**
|
|
82
|
+
* (缺省行为一字不变:仍走那行 console.error)。
|
|
83
|
+
*
|
|
84
|
+
* 语义三条(实现在 `runStream.ts` 的 `reportDroppedFrame`):
|
|
85
|
+
* · **每一条丢帧都发**,不去重 —— 去重是 console 那条腿为了不刷屏才有的;替宿主去重 =
|
|
86
|
+
* 替它撒谎(两条同型丢帧在它的告警面上会变成一条,计数就不是真的了);
|
|
87
|
+
* · 装了 sink ⇒ console 那行**让位**(sink 是更强的通道,两条腿同时喊会打乱 TUI 渲染);
|
|
88
|
+
* · sink 抛错**绝不影响流**,并且痕迹落回 console —— 让位的前提是它真接住了。
|
|
89
|
+
*/
|
|
90
|
+
onDroppedFrame?(info: DroppedFrameInfo): void;
|
|
65
91
|
}
|
|
66
92
|
/** Stamp `uuid` + `session_id` onto a freshly-built arm body. */
|
|
67
93
|
export declare function stamp<T extends {
|
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
* 2. **poll**:stream 抛(404 无端点 / SDK 连 5 次开不起来 / 连 5 次空转)⇒ 落到定时 `list()`。
|
|
34
34
|
* 并按 `streamRetryMs` 定期**再试一次 stream** —— 回落是降级不是永久放弃
|
|
35
35
|
* (服务端升级上线后不该还要重启客户端)。
|
|
36
|
-
*
|
|
36
|
+
* 🆕 [C77]② 这条腿可由宿主按拍闸门:`opts.shouldPoll`(desktop 的「仅活跃 run 时轮询」)。
|
|
37
|
+
* 跳拍 ≠ 停摆(mode 仍 'poll'、定时器照排、不计 polls、不算 F5 逃逸),契约见该选项的 doc。
|
|
38
|
+
* 3. **idle**:stop() 之后 / signal abort 之后 / F5 判定 poll 腿真挂了。
|
|
37
39
|
*
|
|
38
40
|
* ## 诚实面([probe-must-prove-it-speaks])
|
|
39
41
|
*
|
|
@@ -86,6 +88,25 @@ export interface ApprovalsFeedOptions {
|
|
|
86
88
|
pollIntervalMs?: number;
|
|
87
89
|
/** 降级后多久再试一次 stream;缺省 60_000ms。0 = 不再试(永久轮询)。 */
|
|
88
90
|
streamRetryMs?: number;
|
|
91
|
+
/**
|
|
92
|
+
* [C77]② 轮询条件旋钮(小黑板 desktop 请托:那一端的现行纪律是**仅活跃 run 时轮询**)。
|
|
93
|
+
* 每一拍**触发时**查询一次:`false` ⇒ 跳过本拍取件,但照常排下一拍。缺省(不传)= 恒 true,
|
|
94
|
+
* 行为与加旋钮之前一字不差。
|
|
95
|
+
*
|
|
96
|
+
* 🔴 语义与 `mode()==='idle'` 严格分开(别混同,端的自检读的就是这两个量):
|
|
97
|
+
* · `shouldPoll()===false` = **消费方说现在不用查** —— 腿活着、定时器照排、`mode` 仍是
|
|
98
|
+
* `'poll'`、`stats.polls` 不增(跳掉的那一拍没取件,计成 poll 就是谎报流量)、
|
|
99
|
+
* **不计入** F5 的 `consecutivePollEscapes`(跳拍不是失败,连着跳一百拍也不该转 idle);
|
|
100
|
+
* · `mode()==='idle'` = 这条腿**真挂了**(F5:回调体连续逃逸到上限)或已 `stop()`。
|
|
101
|
+
*
|
|
102
|
+
* 管辖边界(只管轮询腿,三处**不**受它约束,都是有理由的):
|
|
103
|
+
* · **构造期那一次取件**不受管 —— 它是「首帧之前也要有内容」的一次性快照(否则端分不清
|
|
104
|
+
* 「空白」与「没有 pending」);要连这一次都不要,就晚一点再 `startApprovalsFeed`;
|
|
105
|
+
* · **push 腿**(`approvals.stream` 事件驱动的重取)不受管 —— 那是服务端说「变了」,
|
|
106
|
+
* 省掉它 = 明知有变化还不去看,与本旋钮要省的「空转流量」不是一回事;
|
|
107
|
+
* · **`refresh()`** 不受管 —— 那是端自己按下的显式动作。
|
|
108
|
+
*/
|
|
109
|
+
shouldPoll?: () => boolean;
|
|
89
110
|
signal?: AbortSignal;
|
|
90
111
|
}
|
|
91
112
|
export interface ApprovalsFeedHandle {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { hostLog } from '../host.js';
|
|
2
2
|
import { unrefTimer } from '../unrefTimer.js';
|
|
3
|
+
/** [2393] hitl-F5:poll 回调体**连续**逃逸多少次之后判定「这条腿真挂了」并把 `mode` 打成 idle。
|
|
4
|
+
* 取 5 是为了让「偶发一拍畸形 payload」不误判(单次逃逸下一拍就归零),同时不让一条永远抛的腿
|
|
5
|
+
* 无限期地对着端的自检假装自己还活着。 */
|
|
6
|
+
const MAX_CONSECUTIVE_POLL_ESCAPES = 5;
|
|
3
7
|
/**
|
|
4
8
|
* pending 列表的稳定摘要 —— 只在**内容变了**的时候发快照。
|
|
5
9
|
* 键里带 `boundInputHash`:同一条 pending 的绑定被服务端换掉(TOCTOU 场景)也算变化,
|
|
@@ -34,6 +38,8 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
|
|
|
34
38
|
let last = null;
|
|
35
39
|
let pollTimer;
|
|
36
40
|
let retryTimer;
|
|
41
|
+
/** [2393] hitl-F5:连续「回调体抛出」的次数(一次跑完就归零)——见 `schedulePoll` 的 catch 臂。 */
|
|
42
|
+
let consecutivePollEscapes = 0;
|
|
37
43
|
const stats = {
|
|
38
44
|
pushEvents: 0, heartbeats: 0, polls: 0, listErrors: 0, streamFailures: 0, snapshots: 0,
|
|
39
45
|
};
|
|
@@ -41,6 +47,32 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
|
|
|
41
47
|
function arm(t) {
|
|
42
48
|
return unrefTimer(t);
|
|
43
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* [C77]② 这一拍要不要真去取件。缺省(宿主没装旋钮)恒 true —— 缺省路径上连一个函数调用都不多。
|
|
52
|
+
*
|
|
53
|
+
* 两个不确定方向都倒向**「查」**(fail-safe 方向定在这里,理由是代价不对称):多查一次 =
|
|
54
|
+
* 一个 `list()` 的流量;少查一次 = 一条待审批在端上迟迟不出现,而这条腿服务的正是 HITL 面。
|
|
55
|
+
* · 谓词抛错 ⇒ 查(并记一行:宿主的谓词坏了它自己得看得见,不是静默吞);
|
|
56
|
+
* · 谓词返回非布尔(宿主 bug / 忘了 return)⇒ 查,同样记一行。
|
|
57
|
+
*/
|
|
58
|
+
function pollGateOpen() {
|
|
59
|
+
const gate = opts?.shouldPoll;
|
|
60
|
+
if (!gate)
|
|
61
|
+
return true;
|
|
62
|
+
let verdict;
|
|
63
|
+
try {
|
|
64
|
+
verdict = gate();
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
hostLog('debug', `approvalsFeed: shouldPoll() threw — treating this tick as "poll" (fail-safe): ${String(e)}`);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
if (typeof verdict !== 'boolean') {
|
|
71
|
+
hostLog('debug', `approvalsFeed: shouldPoll() returned ${typeof verdict} (not boolean) — treating this tick as "poll"`);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return verdict;
|
|
75
|
+
}
|
|
44
76
|
async function take(via) {
|
|
45
77
|
if (stopped)
|
|
46
78
|
return;
|
|
@@ -86,15 +118,46 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
|
|
|
86
118
|
return;
|
|
87
119
|
pollTimer = arm(setTimeout(() => {
|
|
88
120
|
pollTimer = undefined;
|
|
121
|
+
// [C77]② 跳拍:消费方说这一拍不用查 ⇒ 不取件、不计 `stats.polls`(没取件却计数 = 谎报流量)、
|
|
122
|
+
// **不动** `consecutivePollEscapes`(跳拍不是失败,连着跳一万拍也不该被 F5 判成腿挂了),
|
|
123
|
+
// 但下一拍照排 —— 这是「腿活着但闲着」,不是停摆。
|
|
124
|
+
if (!pollGateOpen()) {
|
|
125
|
+
if (mode === 'poll')
|
|
126
|
+
schedulePoll();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
89
129
|
void (async () => {
|
|
90
130
|
stats.polls++;
|
|
91
131
|
await take('poll');
|
|
132
|
+
consecutivePollEscapes = 0; // 跑完一整拍 ⇒ 之前的逃逸不再连续
|
|
92
133
|
if (mode === 'poll')
|
|
93
134
|
schedulePoll();
|
|
94
135
|
})().catch(e => {
|
|
95
136
|
// REF-CC-024 ①:take() 理论上自己兜底(见 take() 内的 try/catch),但这是它逃逸时的最后一道
|
|
96
137
|
// 接盘 —— 缺了它,轮询定时器的回调体抛出会变成 unhandled rejection(Node 默认致命)。
|
|
138
|
+
//
|
|
139
|
+
// 🔴 [2393] hitl-F5(2026-08-02):光接盘不够,**接盘之后还要接着排下一拍**。旧码在这里只记
|
|
140
|
+
// 一行 debug 就结束:那一拍的 `schedulePoll()` 在 `await take()` 之后、永远轮不到执行 ⇒
|
|
141
|
+
// 轮询腿当场死透。而 `mode()` 照旧报 'poll'、`stats()` 照旧有数 —— 文件头 38-42 卖的正是
|
|
142
|
+
// 「跑了 N 秒之后 mode 不是 idle 且 polls+pushEvents > 0」这条端自检,于是自检看着一个
|
|
143
|
+
// 已经死掉的 feed 说它活着(而「一条 pending 都没有」与「腿挂了」本来就长得一样,这恰恰是
|
|
144
|
+
// 那段诚实面存在的理由)。逃逸不是理论形:`take()` 的 try/catch 只护 `list()` 本身,其后的
|
|
145
|
+
// `digestOf(rows)` 在 wire 出畸形 payload(`pending` 不是数组)时就会从这里出去。
|
|
146
|
+
//
|
|
147
|
+
// 两条腿(不是二选一):① **续排** —— 一次抛出不该判 feed 死刑,下一拍照跑(下一拍在
|
|
148
|
+
// `pollIntervalMs` 之后,天然不忙转;不新开定时器,单例卫兵仍是 `pollTimer`);
|
|
149
|
+
// ② **连续失败上限转 idle** —— 一直抛就是真的挂了,那时 `mode` 必须说真话,让端的自检
|
|
150
|
+
// 看得见([honest-absence-not-fabricated-zero]:诚实缺席优先于一个还在报活的假象)。
|
|
97
151
|
hostLog('debug', `approvalsFeed: poll leg threw unexpectedly (take() should have self-caught): ${String(e)}`);
|
|
152
|
+
consecutivePollEscapes++;
|
|
153
|
+
if (consecutivePollEscapes >= MAX_CONSECUTIVE_POLL_ESCAPES) {
|
|
154
|
+
mode = 'idle';
|
|
155
|
+
hostLog('error', `approvalsFeed: poll leg gave up after ${consecutivePollEscapes} consecutive escapes — mode → idle ` +
|
|
156
|
+
'(停摆就说停摆:端的自检从此看得见,不再拿一个死掉的 feed 当活的)');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (mode === 'poll')
|
|
160
|
+
schedulePoll();
|
|
98
161
|
});
|
|
99
162
|
}, pollIntervalMs));
|
|
100
163
|
}
|
|
@@ -175,17 +238,25 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
|
|
|
175
238
|
}
|
|
176
239
|
// 首帧之前也要有内容:立刻取一次权威快照(否则端在第一个事件到达前是空白的,
|
|
177
240
|
// 而「空白」与「没有 pending」看起来一样)。
|
|
178
|
-
|
|
241
|
+
//
|
|
242
|
+
// 🔴 [2393] hitl-F5(同一条缺陷的**第二个**站点,2026-08-02):catch 必须排在 then **之前**。
|
|
243
|
+
// 旧写法是 `take().then(起两条腿).catch(记一行)` —— 首帧 take 逃逸时 `.then` 整个被跳过,
|
|
244
|
+
// 于是 `runStream()` / `degradeToPoll()` 一条都没起:feed 在 t=0 就是死的,而 `mode()` 还报着
|
|
245
|
+
// 构造期算出来的 'push'/'poll'。把接盘挪到前面,「起腿」这件事就从「首帧成功」这个前提上解耦了
|
|
246
|
+
// ——它本来也不该依赖首帧成不成功([paired-mechanisms-must-share-premise])。
|
|
247
|
+
void take(mode === 'push' ? 'push' : 'poll')
|
|
248
|
+
.catch(e => {
|
|
249
|
+
// REF-CC-024 ①:同款接盘(见 schedulePoll 里那份同款注释)——首帧取件是唯一没被 try/catch
|
|
250
|
+
// 包住的调用点之一,这里补上不让它成为 unhandled rejection。
|
|
251
|
+
hostLog('debug', `approvalsFeed: initial take() threw unexpectedly (take() should have self-caught): ${String(e)}`);
|
|
252
|
+
})
|
|
253
|
+
.then(() => {
|
|
179
254
|
if (stopped)
|
|
180
255
|
return;
|
|
181
256
|
if (client.approvals.stream)
|
|
182
257
|
void runStream();
|
|
183
258
|
else
|
|
184
259
|
degradeToPoll('SDK has no approvals.stream (old SDK / mock)', false);
|
|
185
|
-
}).catch(e => {
|
|
186
|
-
// REF-CC-024 ①:同款接盘(见 schedulePoll 里那份同款注释)——首帧取件是唯一没被 try/catch
|
|
187
|
-
// 包住的调用点之一,这里补上不让它成为 unhandled rejection。
|
|
188
|
-
hostLog('debug', `approvalsFeed: initial take() threw unexpectedly (take() should have self-caught): ${String(e)}`);
|
|
189
260
|
});
|
|
190
261
|
return {
|
|
191
262
|
stop() {
|