@sema-agent/client-core 0.8.0 → 0.9.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 CHANGED
@@ -23,12 +23,14 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
23
23
 
24
24
  ## Scope
25
25
 
26
- - **Today (0.8.0)** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
26
+ - **Today (0.9.0)** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
27
27
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
28
28
  + downstream projectors), the request-shaping surface, the fleet/panel projections and the
29
- subagent wire family, the workflow monitor contract + live source, and the model catalog tables.
30
- - **Planned** what is still in the shell: HITL / approvals (`hitlBridge`, `liveHitlAskWire`,
31
- `liveToolApprovalWire`, `planReviewWire`) and the single-authority completion event (batch B7);
29
+ subagent wire family, the workflow monitor contract + live source, the model catalog tables, and
30
+ the **HITL family** (`hitl/`: the D-1 approval bridge, the ask-gate stream bridge, the
31
+ tool-approval wire, plan review, and the pending-approvals push feed).
32
+ - **Planned** — what is still in the shell: the print lane tail (batch B8) and the registry
33
+ user-plane hand-copies (they retire onto `@sema-agent/sdk/registry`, not into this package);
32
34
  plus everything that is genuinely host-specific — Ink rendering and keyboard interaction,
33
35
  filesystem persistence, pty/terminal, and the signal-path teardown. The web session view
34
36
  (first target consumer) and the desktop client (second) consume the same package.
@@ -129,7 +131,7 @@ grew the host context to carry what a non-Node host must supply:
129
131
  desktop host coalesces single-frame deltas at 16 ms; the 2.1.207 CLI line we track uses 100 ms),
130
132
  so the cadence is host render policy while the merge *semantics* stay here, in one place.
131
133
 
132
- 0.6.0 – 0.8.0 (batches B4 – B6) completed the frame dispatch and moved in everything that is not
134
+ 0.6.0 – 0.9.0 (batches B4 – B7) completed the frame dispatch and moved in everything that is not
133
135
  host-specific. The blow-by-blow lives in the `src/index.ts` head comment (the source of truth); the
134
136
  short version:
135
137
 
@@ -144,6 +146,16 @@ short version:
144
146
  source, `hooksWireCaps` on the `SettingsPort`, `liveInitToolFace`, the model catalog tables, and
145
147
  `MIGRATED_COMPENSATIONS` — the ledger of which shell-side compensation was split where, which
146
148
  chrome arm carries the host's half, and when upstream lets it retire.
149
+ - **0.9.0** — the HITL family. `hitl/hitlBridge` carries the **D-1 action binding**: the
150
+ `boundCallId` + `boundInputHash` two-tuple is read off the pending row the human saw and echoed
151
+ back **verbatim** — never recomputed — and a 409 binding mismatch is a safety stop that is
152
+ **never** auto-retried. Both code paths are pinned byte-for-byte by the pure gate.
153
+ `hitl/toolApprovalWire` is a split: the gate decisions and both decide legs live here, the
154
+ three-choice card itself is an `ApprovalCardPort` the host installs. `hitl/askGateWire` runs the
155
+ park → decide → re-attach loop; `hitl/planReviewWire` closes the seventh and last `wireConfig()`
156
+ hand-copy. `hitl/approvalsFeed` is the one behavioural change in the batch: a pending-approvals
157
+ push channel over `approvals.stream()` that falls back to polling and periodically retries the
158
+ push leg — it deliberately does **not** take over the D-1 fetch, which must stay authoritative.
147
159
 
148
160
  Known intentional deltas from the CLI are enumerated in `ADAPTER_DIVERGENCES`. The *compensation*
149
161
  ledger is a different table (`MIGRATED_COMPENSATIONS`): the entries there behave identically on
@@ -153,7 +165,7 @@ both sides — what it records is why a compensation exists and when it can be d
153
165
 
154
166
  ```bash
155
167
  npm install
156
- node scripts/run-client-core-pure-test.mjs # 867 consumer-view checks (22 + 140 + 208 + 59 + 101 + 96 + 241); no CLI tree needed
168
+ node scripts/run-client-core-pure-test.mjs # 1030 consumer-view checks (22 + 140 + 208 + 59 + 101 + 96 + 241 + 163); no CLI tree needed
157
169
  node scripts/run-client-core-portability-test.mjs # 39 checks: kernel + A-layer + index closures, browser bundle
158
170
  node scripts/run-client-core-diff-test.mjs # 1397 checks: differential + replay-id + ledger
159
171
  ```
package/dist/adapt.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { deriveTranscriptId } from './seam.js';
2
- import { dropQueuedNotificationsForRun, isEngineWorkflowNotified, isWorkflowCompletionCardEnqueued, listNotifiedRuns, listWorkflowCompletionCardsEnqueued, markEngineWorkflowNotified, normalizeTaskNotification, noteWorkflowCompletionCardEnqueued, renderTaskNotificationXml, taskNotificationDedupKey, } from './notifications.js';
2
+ import { dropQueuedNotificationsForRun, isEngineWorkflowNotified, isWorkflowCompletionCardEnqueued, listNotifiedRuns, listWorkflowCompletionCardsEnqueued, markEngineWorkflowNotified, normalizeTaskNotification, noteWorkflowCompletionCardEnqueued, parseTranscriptNotificationSeeds, renderTaskNotificationXml, taskNotificationDedupKey, } from './notifications.js';
3
3
  import { mapBrainStatusToRetry } from './retryStatus.js';
4
4
  import { steeringInjectedToAttachments } from './steering.js';
5
5
  import { projectDiagnosticsFrame } from './diagnostics.js';
@@ -336,53 +336,17 @@ class WireToCcAdapterImpl {
336
336
  * task-id/status/type="external",恰可重建**无 seq 键形**;Monitor 的 `:seq` 批键形不同,绝不误伤。
337
337
  */
338
338
  importLedgerFromTranscript(messages) {
339
- const unescape = (s) => s
340
- .replace(/&lt;/g, '<')
341
- .replace(/&gt;/g, '>')
342
- .replace(/&quot;/g, '"')
343
- .replace(/&#39;/g, "'")
344
- .replace(/&amp;/g, '&');
345
- let seeded = 0;
346
- for (const m of messages) {
347
- const rec = m;
348
- if (rec?.type !== 'user')
349
- continue;
350
- const content = rec.message?.content;
351
- const texts = [];
352
- if (typeof content === 'string')
353
- texts.push(content);
354
- else if (Array.isArray(content)) {
355
- for (const b of content) {
356
- const bb = b;
357
- if (bb?.type === 'text' && typeof bb.text === 'string')
358
- texts.push(bb.text);
359
- }
360
- }
361
- for (const text of texts) {
362
- if (!text.includes(`<${TASK_NOTIFICATION_TAG}`))
363
- continue;
364
- const blockRe = new RegExp(`<${TASK_NOTIFICATION_TAG}(\\s[^>]*)?>([\\s\\S]*?)</${TASK_NOTIFICATION_TAG}>`, 'g');
365
- for (const bm of text.matchAll(blockRe)) {
366
- const attrs = bm[1] ?? '';
367
- const body = bm[2] ?? '';
368
- const idM = body.match(new RegExp(`<${TASK_ID_TAG}>([\\s\\S]*?)</${TASK_ID_TAG}>`));
369
- const stM = body.match(new RegExp(`<${STATUS_TAG}>([\\s\\S]*?)</${STATUS_TAG}>`));
370
- if (!idM || !stM)
371
- continue;
372
- const taskId = unescape(idM[1] ?? '');
373
- const status = unescape(stM[1] ?? '');
374
- if (taskId.length === 0 || status.length === 0)
375
- continue;
376
- const external = /\btype="external"/.test(attrs);
377
- this.renderedNotifications.add(`${external ? 'external:' : ''}${taskId}:${status}`);
378
- if (!external && (status === 'completed' || status === 'failed' || status === 'killed')) {
379
- // cli seedEngineTaskNotificationDedupFromTranscript 同位:回植走 module 台账
380
- // (outstanding 药丸 / idle watcher 收摊那半场读的就是它)。
381
- markEngineWorkflowNotified(taskId);
382
- }
383
- seeded++;
384
- }
385
- }
339
+ // 🔴 B7(T3):解析半场收成 `notifications.parseTranscriptNotificationSeeds`(**唯一一份**)
340
+ // 此前本方法与 cli `seedEngineTaskNotificationDedupFromTranscript` 各写了一遍同样的
341
+ // XML 反解 —— 两份手写解析里任何一份漂了,resume 回植就在那一侧静默失效(而两边代码
342
+ // 看着都对)。壳侧 shim 化后两个入口都落到同一个解析器上。
343
+ const { renderedKeys, notifiedKeys, seeded } = parseTranscriptNotificationSeeds(messages);
344
+ for (const k of renderedKeys)
345
+ this.renderedNotifications.add(k);
346
+ for (const taskId of notifiedKeys) {
347
+ // cli seedEngineTaskNotificationDedupFromTranscript 同位:回植走 module 台账
348
+ // (outstanding 药丸 / idle watcher 收摊那半场读的就是它)
349
+ markEngineWorkflowNotified(taskId);
386
350
  }
387
351
  return seeded;
388
352
  }
@@ -46,7 +46,8 @@ export interface CompensationRecord {
46
46
  retireOn: string | null;
47
47
  }
48
48
  /**
49
- * B6 P5 处置的八条(T32/T34/T47/T48/T49 整条进包 · T35/T36/T44 拆)
49
+ * B6 P5 处置的八条(T32/T34/T47/T48/T49 整条进包 · T35/T36/T44 拆)+ **B7 完成事件族五条**
50
+ * (T1/T2/T3/T25/T53,全部「只搬不删」—— P1-3 未到货)。
50
51
  *
51
52
  * 🔴 事实核对(B6 余项批实测,不是照抄设计稿):这八条的落码**都发生在 B4**
52
53
  * (`adapt.ts` A 层第 14 臂 `task_progress` 收官那一批),本批做的是**核实 + 登记 + 定案**,
@@ -27,7 +27,8 @@
27
27
  */
28
28
  import { CHROME_ARMS } from './seam.js';
29
29
  /**
30
- * B6 P5 处置的八条(T32/T34/T47/T48/T49 整条进包 · T35/T36/T44 拆)
30
+ * B6 P5 处置的八条(T32/T34/T47/T48/T49 整条进包 · T35/T36/T44 拆)+ **B7 完成事件族五条**
31
+ * (T1/T2/T3/T25/T53,全部「只搬不删」—— P1-3 未到货)。
31
32
  *
32
33
  * 🔴 事实核对(B6 余项批实测,不是照抄设计稿):这八条的落码**都发生在 B4**
33
34
  * (`adapt.ts` A 层第 14 臂 `task_progress` 收官那一批),本批做的是**核实 + 登记 + 定案**,
@@ -102,6 +103,75 @@ export const MIGRATED_COMPENSATIONS = [
102
103
  // Set 恒空 ⇒ 判据④ 静默失效」复活,所以 chromeArm 恒 null,拆的是**过滤策略**不是台账。
103
104
  retireOn: 'P1-2 到货后 fleet 行自带 parentToolCallId ⇒ 双生行不需要按尾段查',
104
105
  },
106
+ // ── B7 处置的五条(完成事件族 T1/T2/T3/T25/T53)──────────────────────────────────────────────
107
+ // 🔴 **P1-3(完成事件单一权威)未到货 ⇒ 这五条全是「只搬不删」**:四道去重原样保留。
108
+ // 每条的 `retireOn` 写的就是「P1-3 到货后**降哪一道**」——退役条件必须是**可执行的判据**,
109
+ // 不是「以后再说」。设计稿 §3 B7:「P1-3 到货 ⇒ 四道去重降到一道 + 删 XML 反解回植」。
110
+ // ⚠️ 「降到一道」不等于「删三道」:下面 T53 行逐道写清了哪一道是权威、另外三道各防什么,
111
+ // 否则到货那天没人分得清哪些能删([anchor-on-the-deciding-quantity]:先定这个量门控什么)。
112
+ {
113
+ id: 'T1',
114
+ what: '三个跨 turn 台账(渲染去重 / 跨通道记账 / 完成卡已入队)—— completion inbox 是 at-least-once,' +
115
+ '客户端必须自己记「这条完成我处理过了」',
116
+ disposition: 'in-library',
117
+ libraryHalf: 'notifications.ts 的 module 台账(notifiedRunIds / cardEnqueuedRunIds)+ ' +
118
+ '适配器实例的 renderedNotifications;序列化口 exportLedger/importLedger',
119
+ chromeArm: null,
120
+ hostHalf: null,
121
+ // 长期职责:只要投递是 at-least-once,客户端就得有去重记忆。P1-3 换的是**键**不是台账。
122
+ retireOn: null,
123
+ },
124
+ {
125
+ id: 'T2',
126
+ what: '去重键客户端重算(`[external:]taskId:status[:seq]`)—— wire 上没有「这次完成的唯一 id」',
127
+ disposition: 'in-library',
128
+ libraryHalf: 'notifications.ts taskNotificationDedupKey + taskNotificationDedupKeyFromWire(裸 wire 入口)',
129
+ chromeArm: null,
130
+ hostHalf: null,
131
+ retireOn: 'P1-3 到货(帧带 completionId)⇒ 键改成**读** completionId,重算算法整条删;' +
132
+ '删之前要先有一批「同一完成在两条通道上 completionId 相同」的实测(否则换成了一把更差的键)',
133
+ },
134
+ {
135
+ id: 'T3',
136
+ what: '从落盘转录的 `<task-notification>` XML 反解回植去重键 —— 去重面纯内存,重启即蒸发,' +
137
+ '「通知 user-turn 已落盘、ack 未落」的 crash 窗口会二次喂模型(#63)',
138
+ disposition: 'in-library',
139
+ libraryHalf: 'notifications.ts parseTranscriptNotificationSeeds(**唯一一份**解析)+ ' +
140
+ 'adapt.ts importLedgerFromTranscript(消费口)',
141
+ chromeArm: null,
142
+ hostHalf: null,
143
+ retireOn: 'P1-3 到货 ⇒ **回植仍要做**(crash 窗口客观存在),但不必再解 XML —— 转录里直接带 ' +
144
+ 'completionId。所以退的是「XML 反解」这一段,不是整条回植',
145
+ },
146
+ {
147
+ id: 'T25',
148
+ what: 'workflow 终态轮询卡经过投影 ⇒ 预 seed runId 去重(补发通道对同 runId 丢弃)—— ' +
149
+ '同一个完成会从「轮询卡」和「补发通道」两个方向到达',
150
+ disposition: 'in-library',
151
+ libraryHalf: 'toolResult.ts 终态轮询卡臂的预 seed + notifications.markEngineWorkflowNotified',
152
+ chromeArm: null,
153
+ hostHalf: null,
154
+ retireOn: 'P1-3 到货 ⇒ 两个方向带同一个 completionId,预 seed 这一手(按 runId 抢跑)可删',
155
+ },
156
+ {
157
+ id: 'T53',
158
+ what: '同一次完成四通道扇出 ⇒ 四道去重(设计稿列为最贵 #4)',
159
+ disposition: 'in-library',
160
+ // 四道各自防什么(P1-3 到货那天要按这个清单逐道判,别一刀切):
161
+ // ① renderedNotifications(键=T2)—— 防**同一帧重放**(durable re-attach / at-least-once 重投)。
162
+ // ⇒ P1-3 后这一道**留**,只是键换成 completionId。这是「降到一道」里的那一道。
163
+ // ② isWorkflowCompletionCardEnqueued —— 防「Path B 已入队的完成卡」与「Path A 推送帧」双投。
164
+ // ⇒ P1-3 后可删:两条通道带同一 completionId,①就能盖住。
165
+ // ③ isEngineWorkflowNotified(workflow_complete 臂首行)—— 防补发通道在引擎已 server-side
166
+ // inject 之后再喂一遍。⇒ P1-3 后可删,同②。
167
+ // ④ dropQueuedNotificationsForRun —— 回声到达时把还躺在队列里的同 run 条目捞回来。
168
+ // ⇒ **不是去重是撤单**,P1-3 后仍需要(队列里那条已经排上了,得有人撤)。
169
+ libraryHalf: 'adapt.ts task_notification / workflow_complete 两臂的四道门(逐道说明见本行上方注释)',
170
+ chromeArm: null,
171
+ hostHalf: null,
172
+ retireOn: 'P1-3 到货 ⇒ 降到「①渲染去重(键换 completionId)+ ④队列撤单」两道;②③ 删。' +
173
+ '🔴 判据不是「四变一」而是逐道过一遍上面那四行 —— 把 ④ 当成去重删掉会让队列里的旧条目重新上屏',
174
+ },
105
175
  {
106
176
  id: 'T49',
107
177
  what: 'subagent alias 只能在 tick 上学 —— content 帧只带 parentToolCallId、没有 taskId',
@@ -0,0 +1,102 @@
1
+ /**
2
+ * ⇄ B7 ③(census **G20**,设计稿 §3 B7 末条):`approvals.list` 取件 → **SDK 原生推送
3
+ * `approvals.stream()`**,断流/无端点**回落轮询**的韧性臂保留。这是**行为改动**(不是搬迁),
4
+ * 所以单独一个文件、单独一条测试、单独一个 commit。
5
+ *
6
+ * ## 先纠一个事实(census G20 的措辞会误导下一棒)
7
+ *
8
+ * G20 原话是「`hitlBridge.ts:197` 等三处今天靠 `approvals.list` 轮询」。**实测不是轮询** ——
9
+ * 那三处(`hitlBridge.findPending` / `askGateWire.surfaceGateAndDecide` /
10
+ * `toolApprovalWire.surfaceFsApprovalAndDecide`)都是**按需单次取件**:流上已经来了 park 信号,
11
+ * 才去取那一行。真正缺的不是「把轮询换成推送」,而是**根本没有推送通道**:
12
+ * · 别的客户端(web/桌面/另一个 TUI)解掉了一条 pending —— 本端不知道;
13
+ * · 后台子代 lane 的 park 不在宿主自己的流上 —— 本端只有等下一次自己去 list 才看得见;
14
+ * · 端要渲一个「有 N 条待审批」的常驻指示 —— 今天只能自己起定时器。
15
+ *
16
+ * ⇒ 本文件提供的是那条**缺失的推送通道**(pending 快照的订阅面),而不是把上面三处改成读缓存。
17
+ *
18
+ * ## 🔴 它**不替换** D-1 的取件(这条是安全边界,别"顺手优化"掉)
19
+ *
20
+ * D-1 两元组必须取自**人看见的那一行**。让 `findPending` 去读一个可能陈旧的快照 =
21
+ * 用一个过期的 `boundInputHash` 去 decide ⇒ server fail-closed 409 ⇒ 一个合法审批被拒
22
+ * (或者更坏:快照比服务端新旧关系不确定时,人看到的和绑定的不是同一件事)。
23
+ * 所以那三处**继续走权威 `list()`**,本 feed 只做「有变化了」的通知面与快照面。
24
+ * pure 门 B7③ 段对这条有断言(feed 跑着的时候,decide 路径仍然打 `list()`)。
25
+ *
26
+ * ## 两条腿与降级次序
27
+ *
28
+ * 1. **push**:`approvals.stream()`(SDK 内部已带断线重连 + heartbeat 静默重连)。
29
+ * 收到**非 heartbeat** 事件 ⇒ 重取权威 `list()`。
30
+ * 🔴 绝不解析 delta 载荷 —— SDK 自述 `ApprovalStreamEvent` 是 **FORWARD-DRAFT**
31
+ * (「payload fields are FORWARD-DRAFT pending service's shape confirmation」),
32
+ * 照着未确认的形写解析 = 服务端一改就静默错。
33
+ * 2. **poll**:stream 抛(404 无端点 / SDK 连 5 次开不起来 / 连 5 次空转)⇒ 落到定时 `list()`。
34
+ * 并按 `streamRetryMs` 定期**再试一次 stream** —— 回落是降级不是永久放弃
35
+ * (服务端升级上线后不该还要重启客户端)。
36
+ * 3. **idle**:stop() 之后 / signal abort 之后。
37
+ *
38
+ * ## 诚实面([probe-must-prove-it-speaks])
39
+ *
40
+ * 「一条快照都没有」既可能是「真的没有 pending」,也可能是「两条腿都挂了」。所以 feed 暴露
41
+ * `stats()`(pushEvents / polls / listErrors / streamFailures / snapshots)与 `mode()`,
42
+ * 端的自检可以断言「跑了 N 秒之后 mode 不是 idle 且 polls+pushEvents > 0」。
43
+ */
44
+ import type { PendingCheckpoint } from '@sema-agent/sdk';
45
+ /** feed 消费的 wire 切片。`stream` 可选 —— 老 SDK / mock 没有它就直接走轮询腿。 */
46
+ export interface ApprovalsFeedClientLike {
47
+ approvals: {
48
+ list(opts?: {
49
+ signal?: AbortSignal;
50
+ }): Promise<PendingCheckpoint[]>;
51
+ stream?(opts?: {
52
+ signal?: AbortSignal;
53
+ }): AsyncGenerator<{
54
+ type?: unknown;
55
+ [k: string]: unknown;
56
+ }>;
57
+ };
58
+ }
59
+ export type ApprovalsFeedMode = 'push' | 'poll' | 'idle';
60
+ export interface ApprovalsFeedSnapshot {
61
+ /** 权威 pending 列表(**恒来自 `list()`**,不是 delta 拼出来的)。 */
62
+ pending: PendingCheckpoint[];
63
+ /** 这次快照是被哪条腿触发的。 */
64
+ mode: 'push' | 'poll';
65
+ /** 单调递增的修订号(端可用它判「我看到的是不是最新的」)。 */
66
+ revision: number;
67
+ }
68
+ export interface ApprovalsFeedStats {
69
+ /** 收到的**非 heartbeat** 推送事件数。 */
70
+ pushEvents: number;
71
+ /** heartbeat 帧数(证明「连着但没事发生」,与「断了」区分开)。 */
72
+ heartbeats: number;
73
+ /** 轮询腿真取件的次数。 */
74
+ polls: number;
75
+ /** `list()` 抛错的次数(两条腿共用计数 —— 取件失败不该被静默)。 */
76
+ listErrors: number;
77
+ /** stream 抛出(404 / 反复失败)导致降级的次数。 */
78
+ streamFailures: number;
79
+ /** 真正发给订阅者的快照数(内容无变化时不发 ⇒ 这个数会小于 polls+pushEvents)。 */
80
+ snapshots: number;
81
+ }
82
+ export interface ApprovalsFeedOptions {
83
+ /** 回落轮询间隔;缺省 5000ms。 */
84
+ pollIntervalMs?: number;
85
+ /** 降级后多久再试一次 stream;缺省 60_000ms。0 = 不再试(永久轮询)。 */
86
+ streamRetryMs?: number;
87
+ signal?: AbortSignal;
88
+ }
89
+ export interface ApprovalsFeedHandle {
90
+ stop(): void;
91
+ mode(): ApprovalsFeedMode;
92
+ stats(): ApprovalsFeedStats;
93
+ /** 最近一次快照(还没取过 ⇒ null)。 */
94
+ snapshot(): ApprovalsFeedSnapshot | null;
95
+ /** 立刻取一次权威 list 并(有变化时)发快照。端在「用户点了刷新」之类的场景用。 */
96
+ refresh(): Promise<void>;
97
+ }
98
+ /**
99
+ * 起一条 pending-approvals feed。**幂等性归调用方**:一个 client 起一条就够了,起两条 =
100
+ * 两倍取件(不是错误,但没意义)。
101
+ */
102
+ export declare function startApprovalsFeed(client: ApprovalsFeedClientLike, onSnapshot: (snap: ApprovalsFeedSnapshot) => void, opts?: ApprovalsFeedOptions): ApprovalsFeedHandle;
@@ -0,0 +1,185 @@
1
+ import { hostLog } from '../host.js';
2
+ /**
3
+ * pending 列表的稳定摘要 —— 只在**内容变了**的时候发快照。
4
+ * 键里带 `boundInputHash`:同一条 pending 的绑定被服务端换掉(TOCTOU 场景)也算变化,
5
+ * 否则端会拿着一个「id 没变但绑定变了」的行继续渲染。
6
+ */
7
+ function digestOf(rows) {
8
+ return rows
9
+ .map(r => [r.sessionId ?? '', r.taskId ?? '', r.toolCallId ?? '', r.boundCallId ?? '', r.boundInputHash ?? '', r.toolName ?? '']
10
+ .join(''))
11
+ .sort()
12
+ .join('');
13
+ }
14
+ /**
15
+ * 起一条 pending-approvals feed。**幂等性归调用方**:一个 client 起一条就够了,起两条 =
16
+ * 两倍取件(不是错误,但没意义)。
17
+ */
18
+ export function startApprovalsFeed(client, onSnapshot, opts) {
19
+ const pollIntervalMs = opts?.pollIntervalMs ?? 5000;
20
+ const streamRetryMs = opts?.streamRetryMs ?? 60_000;
21
+ const ac = new AbortController();
22
+ const onOuterAbort = () => ac.abort();
23
+ if (opts?.signal) {
24
+ if (opts.signal.aborted)
25
+ ac.abort();
26
+ else
27
+ opts.signal.addEventListener('abort', onOuterAbort, { once: true });
28
+ }
29
+ let mode = client.approvals.stream ? 'push' : 'poll';
30
+ let stopped = false;
31
+ let revision = 0;
32
+ let lastDigest = null;
33
+ let last = null;
34
+ let pollTimer;
35
+ let retryTimer;
36
+ const stats = {
37
+ pushEvents: 0, heartbeats: 0, polls: 0, listErrors: 0, streamFailures: 0, snapshots: 0,
38
+ };
39
+ function arm(t) {
40
+ ;
41
+ t.unref?.();
42
+ return t;
43
+ }
44
+ async function take(via) {
45
+ if (stopped)
46
+ return;
47
+ let rows;
48
+ try {
49
+ rows = await client.approvals.list({ signal: ac.signal });
50
+ }
51
+ catch (e) {
52
+ stats.listErrors++;
53
+ hostLog('debug', `approvalsFeed: list() failed (${via} leg): ${String(e)}`);
54
+ return;
55
+ }
56
+ if (stopped)
57
+ return;
58
+ const d = digestOf(rows);
59
+ if (d === lastDigest)
60
+ return; // 内容没变 ⇒ 不打扰订阅者
61
+ lastDigest = d;
62
+ revision++;
63
+ const snap = { pending: rows, mode: via, revision };
64
+ last = snap;
65
+ stats.snapshots++;
66
+ try {
67
+ onSnapshot(snap);
68
+ }
69
+ catch (e) {
70
+ // 订阅者抛错绝不能把 feed 打死(与 liveQuestionStore.publishQuestionFrame 同款纪律)。
71
+ hostLog('debug', `approvalsFeed: subscriber threw (swallowed): ${String(e)}`);
72
+ }
73
+ }
74
+ /**
75
+ * 起轮询腿。**幂等**(`pollTimer` 在场就不再排一个)——
76
+ * 🔴 第一版把「排定时器」挂在 `mode !== 'poll'` 这个**状态迁移**上,于是「一开始就没有
77
+ * `approvals.stream`」的宿主(老 SDK / mock)因为初始 mode 本来就是 'poll',永远排不上
78
+ * 定时器 = 只取了起手那一次就再也不动了。测试当场逮住(先红后绿)。
79
+ * 教训与 [paired-mechanisms-must-share-premise] 同族:别把「该做的动作」挂在「状态变了」上,
80
+ * 挂在「现在该处于什么状态」上。
81
+ */
82
+ function schedulePoll() {
83
+ if (stopped || ac.signal.aborted)
84
+ return;
85
+ if (pollTimer !== undefined)
86
+ return;
87
+ pollTimer = arm(setTimeout(() => {
88
+ pollTimer = undefined;
89
+ void (async () => {
90
+ stats.polls++;
91
+ await take('poll');
92
+ if (mode === 'poll')
93
+ schedulePoll();
94
+ })();
95
+ }, pollIntervalMs));
96
+ }
97
+ function scheduleStreamRetry() {
98
+ if (stopped || ac.signal.aborted || streamRetryMs <= 0)
99
+ return;
100
+ if (!client.approvals.stream)
101
+ return;
102
+ retryTimer = arm(setTimeout(() => {
103
+ if (stopped || ac.signal.aborted)
104
+ return;
105
+ hostLog('debug', 'approvalsFeed: retrying push leg (降级是暂时的,服务端升级后不该还要重启客户端)');
106
+ void runStream();
107
+ }, streamRetryMs));
108
+ }
109
+ function degradeToPoll(why) {
110
+ if (stopped)
111
+ return;
112
+ stats.streamFailures++;
113
+ if (mode !== 'poll') {
114
+ hostLog('debug', `approvalsFeed: push leg unavailable (${why}) — falling back to polling every ${pollIntervalMs}ms`);
115
+ }
116
+ mode = 'poll';
117
+ schedulePoll(); // 幂等 —— 见 schedulePoll 头注(挂状态不挂迁移)
118
+ scheduleStreamRetry();
119
+ }
120
+ async function runStream() {
121
+ const stream = client.approvals.stream;
122
+ if (!stream) {
123
+ degradeToPoll('SDK has no approvals.stream (old SDK / mock)');
124
+ return;
125
+ }
126
+ try {
127
+ for await (const ev of stream.call(client.approvals, { signal: ac.signal })) {
128
+ if (stopped || ac.signal.aborted)
129
+ return;
130
+ if (mode !== 'push') {
131
+ // 重试成功:停掉轮询腿(两条腿同时跑 = 双倍取件 + 快照抖动)。
132
+ mode = 'push';
133
+ if (pollTimer !== undefined) {
134
+ clearTimeout(pollTimer);
135
+ pollTimer = undefined;
136
+ }
137
+ hostLog('debug', 'approvalsFeed: push leg re-established — polling leg stopped');
138
+ }
139
+ if (ev?.type === 'heartbeat') {
140
+ stats.heartbeats++;
141
+ continue;
142
+ }
143
+ stats.pushEvents++;
144
+ // 🔴 绝不解析 delta 载荷(SDK 自述 FORWARD-DRAFT)—— 任何非 heartbeat 事件都只当
145
+ // 「变了」的信号,权威值一律重取 list()。
146
+ await take('push');
147
+ }
148
+ if (!stopped && !ac.signal.aborted)
149
+ degradeToPoll('stream ended without progress');
150
+ }
151
+ catch (e) {
152
+ if (stopped || ac.signal.aborted)
153
+ return;
154
+ degradeToPoll(String(e));
155
+ }
156
+ }
157
+ // 首帧之前也要有内容:立刻取一次权威快照(否则端在第一个事件到达前是空白的,
158
+ // 而「空白」与「没有 pending」看起来一样)。
159
+ void take(mode === 'push' ? 'push' : 'poll').then(() => {
160
+ if (stopped)
161
+ return;
162
+ if (client.approvals.stream)
163
+ void runStream();
164
+ else
165
+ degradeToPoll('SDK has no approvals.stream (old SDK / mock)');
166
+ });
167
+ return {
168
+ stop() {
169
+ if (stopped)
170
+ return;
171
+ stopped = true;
172
+ mode = 'idle';
173
+ if (pollTimer !== undefined)
174
+ clearTimeout(pollTimer);
175
+ if (retryTimer !== undefined)
176
+ clearTimeout(retryTimer);
177
+ opts?.signal?.removeEventListener('abort', onOuterAbort);
178
+ ac.abort();
179
+ },
180
+ mode: () => mode,
181
+ stats: () => ({ ...stats }),
182
+ snapshot: () => last,
183
+ refresh: () => take(mode === 'push' ? 'push' : 'poll'),
184
+ };
185
+ }