@sema-agent/client-core 0.75.0 → 0.76.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +7 -2
  3. package/dist/adapt/arms.js +120 -6
  4. package/dist/adapt/panelTasks.d.ts +6 -2
  5. package/dist/adapt/panelTasks.js +6 -3
  6. package/dist/adapter/downstream/eventToSdkMessage.d.ts +45 -0
  7. package/dist/adapter/downstream/eventToSdkMessage.js +121 -6
  8. package/dist/agentSession/backgroundView.js +2 -1
  9. package/dist/agentsWireCaps.d.ts +15 -2
  10. package/dist/engineAgentPanelStore.d.ts +6 -0
  11. package/dist/engineAgentPanelStore.js +113 -10
  12. package/dist/engineErrorCodes.d.ts +4 -0
  13. package/dist/engineErrorCodes.js +14 -0
  14. package/dist/fleet/fleetLedger.js +6 -0
  15. package/dist/fleet/fleetProjection.js +7 -2
  16. package/dist/fleet/fleetRowAgentType.d.ts +9 -0
  17. package/dist/fleet/fleetRowAgentType.js +59 -0
  18. package/dist/fleetAgentPanelProjection.js +24 -3
  19. package/dist/hitl/approvalOutcomeNote.d.ts +0 -10
  20. package/dist/hitl/approvalOutcomeNote.js +35 -8
  21. package/dist/hitl/approvalResolution.d.ts +148 -0
  22. package/dist/hitl/approvalResolution.js +199 -0
  23. package/dist/hitl/approvalsFeed.d.ts +100 -2
  24. package/dist/hitl/approvalsFeed.js +234 -18
  25. package/dist/hitl/livePendingAsk.d.ts +26 -6
  26. package/dist/hitl/livePendingAsk.js +52 -12
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +3 -0
  29. package/dist/memorySpecWire.d.ts +175 -0
  30. package/dist/memorySpecWire.js +320 -0
  31. package/dist/panelRunningHistory.d.ts +21 -0
  32. package/dist/panelRunningHistory.js +25 -0
  33. package/dist/seam.d.ts +64 -5
  34. package/dist/seam.js +10 -1
  35. package/docs/INTEGRATION-CLIENTS.md +112 -9
  36. package/package.json +1 -1
@@ -5,6 +5,64 @@ import { readLivePendingRows } from './livePendingAsk.js';
5
5
  * 取 5 是为了让「偶发一拍畸形 payload」不误判(单次逃逸下一拍就归零),同时不让一条永远抛的腿
6
6
  * 无限期地对着端的自检假装自己还活着。 */
7
7
  const MAX_CONSECUTIVE_POLL_ESCAPES = 5;
8
+ /**
9
+ * 🔴 CC-98(异源复审 R2 [medium] 采修)`pending` 段的**结构窄读**。
10
+ *
11
+ * 修前这一段一个字都没校验:摘要函数对缺键一律 `?? ''`,于是 `{pending:[{}]}` / `{pending:[7]}` /
12
+ * `{pending:{map:()=>[]}}` 这类回体会被当成**合形真快照**发出去,端照着它渲一个确定的数字
13
+ * (`durable: 1`)—— 与「取不到渲成没有」同一个病形的镜像:**读不懂渲成读懂了**。
14
+ *
15
+ * 三分界与 `livePending` 段逐字同:
16
+ * · 不是数组 ⇒ `malformed`(整次不提交、发「不知道」);
17
+ * · **非空数组却一行都读不出** ⇒ `malformed`(不许答「有 0 条」);
18
+ * · **部分**行读不出 ⇒ 逐条丢 + `present` + `dropped`(读得出的行是真的;`dropped` 上快照,
19
+ * 计数口据此把 durable 格答 `null`);空数组 ⇒ `present`(引擎明说一条都没有)。
20
+ *
21
+ * 一行的**最低可读位** = 非空串 `sessionId`:它就是决断口(`POST /v1/approvals/:sessionId/decide`)的钥匙,
22
+ * 也是按会话过滤计数的键;没有它这一行既决断不了也归不了属。`scope`(sdk 型面上也是必填)**刻意不要求** ——
23
+ * 缺它的行仍然决断得了,fail-safe 方向是留行不是丢行(丢一行 = 一条等人的审批在端上消失)。
24
+ */
25
+ function readDurablePendingRows(raw) {
26
+ if (!Array.isArray(raw))
27
+ return { kind: 'malformed' };
28
+ const rows = [];
29
+ let dropped = 0;
30
+ for (const item of raw) {
31
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
32
+ dropped++;
33
+ continue;
34
+ }
35
+ const sid = item.sessionId;
36
+ if (typeof sid !== 'string' || sid === '') {
37
+ dropped++;
38
+ continue;
39
+ }
40
+ rows.push(item);
41
+ }
42
+ if (raw.length > 0 && rows.length === 0)
43
+ return { kind: 'malformed' };
44
+ return { kind: 'present', rows, dropped };
45
+ }
46
+ /**
47
+ * 🔴 CC-98(异源复审 R3 [medium] 采修)**「修前这一形会不会让回调体逃逸」的判据 —— 真去算一遍修前那个摘要**。
48
+ *
49
+ * 为什么不许猜:逃逸是 [2393] hitl-F5 **熔断**的输入(连续 5 次 ⇒ `mode` 诚实转 idle),本车不许悄悄把它拿掉。
50
+ * 上一版在读器里手列了「整段不是数组 / 行里有 null」两形当作逃逸形 —— 一条普通 JSON 就能证伪:
51
+ * `{"pending":[{"sessionId":{"toString":null}}]}` 在修前是 `Array.join` 做字符串转换时抛 TypeError(逃逸),
52
+ * 而手列的两形认不出它 ⇒ 持续坏回体不再触发 F5。⇒ 判据换成**行为等价**:把原摘要在原始值上真算一遍,
53
+ * 抛了就是逃逸形。这样「逐字保留」是被**执行**出来的,不是被声明出来的。
54
+ *
55
+ * 只在坏形路上算(多算一次字符串拼接,常态零成本);本函数自己**绝不**抛。
56
+ */
57
+ function baselineDurableDigestThrows(raw) {
58
+ try {
59
+ durableDigestOf(raw);
60
+ return false;
61
+ }
62
+ catch {
63
+ return true;
64
+ }
65
+ }
8
66
  /**
9
67
  * pending 列表的稳定摘要 —— 只在**内容变了**的时候发快照。
10
68
  * 键里带 `boundInputHash`:同一条 pending 的绑定被服务端换掉(TOCTOU 场景)也算变化,
@@ -24,7 +82,12 @@ const LIVE_SECTION_SEP = String.fromCharCode(3);
24
82
  * 0.72.14:流内 ask 段进摘要 —— 此前只算 `pending`,悬挂 ask 出现 / 消失都不触发快照。
25
83
  * 「未报」与「报了且为空」是两个不同的事实(前者读不出计数),摘要上也分开。
26
84
  */
27
- function digestOf(rows, live) {
85
+ function digestOf(rows, live,
86
+ /** CC-98:读不出的行数也进摘要 —— 读得懂的那几行没变、但「另有几行读不懂」变了,同样是消费端要知道的变化
87
+ * (不进摘要 ⇒ 不发快照 ⇒ 端手上那面「不完整」旗永远是上一拍的)。 */
88
+ dropped = 0,
89
+ /** CC-98:`pending` 段读不出的行数(同理进摘要)。 */
90
+ droppedDurable = 0) {
28
91
  const liveDigest = live === undefined
29
92
  ? 'absent'
30
93
  : live
@@ -32,11 +95,15 @@ function digestOf(rows, live) {
32
95
  .map(r => [r.approvalId, r.toolName, String(r.expiresAtMs), r.sessionId ?? '', r.originTaskId ?? '', r.frame !== undefined ? 'f' : ''].join(LIVE_FIELD_SEP))
33
96
  .sort()
34
97
  .join(LIVE_ROW_SEP);
35
- return `${durableDigestOf(rows)}${LIVE_SECTION_SEP}${liveDigest}`;
98
+ return `${durableDigestOf(rows)}${LIVE_SECTION_SEP}${liveDigest}${LIVE_SECTION_SEP}${dropped}${LIVE_SECTION_SEP}${droppedDurable}`;
36
99
  }
37
100
  /**
38
101
  * 起一条 pending-approvals feed。**幂等性归调用方**:一个 client 起一条就够了,起两条 =
39
102
  * 两倍取件(不是错误,但没意义)。
103
+ *
104
+ * 🔴 CC-98 订阅口收**两臂**({@link ApprovalsFeedEmission}):真快照(`kind:'snapshot'`)与
105
+ * 「这次取不到」({@link ApprovalsFeedUnknownSnapshot},`kind:'unknown'`)。先判 `kind` 再读别的键 ——
106
+ * 把 unknown 臂当成一张空列表就是把「不知道」渲成「没有」。三态的拉面读口是 `reading()`。
40
107
  */
41
108
  export function startApprovalsFeed(client, onSnapshot, opts) {
42
109
  const pollIntervalMs = opts?.pollIntervalMs ?? 5000;
@@ -52,8 +119,22 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
52
119
  let mode = client.approvals.stream ? 'push' : 'poll';
53
120
  let stopped = false;
54
121
  let revision = 0;
122
+ /**
123
+ * 🔴 CC-98(异源复审 R1 [medium] 采修):**内容**变化计数 —— 只在摘要真的变了时 +1。
124
+ * 对账节拍的退避判据(「取件后快照没变 ⇒ 间隔翻倍」)用本位,**不用** `revision`:后者现在还会为
125
+ * 「恢复必发」+1(内容一字未变),拿它判退避就等于每次间歇失败都把节拍无故拉回基础间隔 ——
126
+ * 那是把本车不该动的 KL-15 面动了。两个计数分开之后,对账退避逐字与 0.75.0 同。
127
+ */
128
+ let contentRevision = 0;
55
129
  let lastDigest = null;
56
130
  let last = null;
131
+ /**
132
+ * CC-98:「现在取不到」的粘性态(null = 不盲)。两处承重:
133
+ * ① `reading()` 的第三态;
134
+ * ② **恢复必发**的判据 —— 只要它非空,下一次提交就算摘要与失败前一字不差也要发一张真快照
135
+ * (否则端永远停在「不知道」)。真快照落地即清。
136
+ */
137
+ let blind = null;
57
138
  let pollTimer;
58
139
  let retryTimer;
59
140
  /** [2393] hitl-F5:连续「回调体抛出」的次数(一次跑完就归零)——见 `schedulePoll` 的 catch 臂。 */
@@ -70,6 +151,7 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
70
151
  let lastObservedSeq = 0;
71
152
  const stats = {
72
153
  pushEvents: 0, heartbeats: 0, polls: 0, listErrors: 0, streamFailures: 0, snapshots: 0, reconciles: 0, pushTakeRetries: 0,
154
+ unknowns: 0,
73
155
  };
74
156
  /** 域词表-14 收编:「怎么调 unref」的单一实现见 unrefTimer.ts,本函数只是就地起个短名。 */
75
157
  function arm(t) {
@@ -127,7 +209,13 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
127
209
  pushTakeRetryDelayMs = pushTakeRetryBaseMs;
128
210
  }
129
211
  function noteTakeMissed(via, seq) {
130
- if (via !== 'push' || pushTakeRetryBaseMs <= 0)
212
+ // 🔴 CC-98(异源复审 R2 [high] 采修)原条件是「只在 push 腿记账 —— 轮询模式下轮询腿自己会再来」。
213
+ // 那条**前提**在当前模式已经是 push 时不成立:重连那一拍把 `pollTimer` 清了、也不再续排,
214
+ // 于是一发**轮询腿的**失败(典型:重连那一刻还在途的那一拍)之后再没有任何人来取件 ——
215
+ // 「不知道」没人清([paired-mechanisms-must-share-premise]:配对机制必须共享前提)。
216
+ // ⇒ 条件放宽成「这一发是 push 腿 **或** 现在是 push 模式」。纯轮询模式(两者都是 poll)一字不变:
217
+ // 轮询腿的下一拍照旧自己会来,不额外记账、不额外流量。
218
+ if ((via !== 'push' && mode !== 'push') || pushTakeRetryBaseMs <= 0)
131
219
  return;
132
220
  if (seq < lastLookedSeq)
133
221
  return; // 更晚发起的一眼已经看成 ⇒ 这次迟到的失败不欠
@@ -151,6 +239,41 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
151
239
  });
152
240
  }, delay));
153
241
  }
242
+ /**
243
+ * CC-98:发一张「不知道」。三条纪律:
244
+ * ① **乱序闸与真快照共用一条**:比「已观察到的最新一号」更早发起的失败,其结论已被后发者取代 ⇒
245
+ * 一个字节都不发(一发迟到的 404 不许把一发更晚发起的成功盖成「不知道」);发得出去就同样推进那一号。
246
+ * ② **一段只发一张**(与「内容没变 ⇒ 不打扰订阅者」同一条纪律):同一成因连着失败不重复发,
247
+ * `stats.listErrors` 照旧逐次记;成因变了(抛 ⇄ 读不懂)是两句话 ⇒ 再发一张。
248
+ * ③ 与**重试 / 退避 / 熔断解耦**:发过「不知道」之后欠账重试照旧排、轮询腿照旧续排、
249
+ * F5 的连续逃逸照旧计数 —— 本臂只说「现在不知道」,不说「不再试了」。
250
+ */
251
+ function publishUnknown(why, via, seq) {
252
+ if (stopped || ac.signal.aborted)
253
+ return;
254
+ // 🔴 CC-98 **失败路只过发起序号闸,不过腿代际闸**(异源复审 R2 [high] 纠 R1 的修法 —— 上一版在这里加了
255
+ // 一道代际闸,换来的是另一半谎:退役腿的失败被整份吞掉,而没有任何**更新的**结论取代它 ⇒ 端继续
256
+ // 读到 `present` 和一个确定的数字,而最近一次取件其实失败了)。
257
+ // 判据只能是「有没有更晚发起的结论已经落地」= 序号;**腿换代不是那件事的证据**(重连只说明推面活了,
258
+ // 不说明有人重新取过件)。「那条退役的腿不会再来一拍」这件事由 `noteTakeMissed` 负责(见其头注)。
259
+ if (seq < lastObservedSeq) {
260
+ hostLog('debug', `approvalsFeed: dropping out-of-order failure #${seq} (newest observed #${lastObservedSeq}) — view untouched`);
261
+ return;
262
+ }
263
+ lastObservedSeq = seq;
264
+ if (blind !== null && blind.why === why)
265
+ return;
266
+ const at = Date.now();
267
+ blind = { why, at };
268
+ stats.unknowns++;
269
+ try {
270
+ onSnapshot({ kind: 'unknown', why, at, mode: via });
271
+ }
272
+ catch (e) {
273
+ // 订阅者抛错绝不能把 feed 打死(与真快照臂同款纪律)。
274
+ hostLog('debug', `approvalsFeed: subscriber threw on the unknown arm (swallowed): ${String(e)}`);
275
+ }
276
+ }
154
277
  async function take(via,
155
278
  /** [F4957-1 二审] 发起这次取件时的轮询代际(只有轮询腿传;push/refresh 不受代际管)。 */
156
279
  leg) {
@@ -159,9 +282,29 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
159
282
  const seq = ++takeSeq;
160
283
  let rows;
161
284
  let live;
285
+ /** CC-98:`livePending` 段里读不出来的行数(0 = 这一段完整)。见 `ApprovalsFeedSnapshot.livePendingDropped`。 */
286
+ let dropped = 0;
287
+ /** CC-98:`pending` 段里读不出来的行数(0 = 这一段完整)。见 `ApprovalsFeedSnapshot.pendingDropped`。 */
288
+ let droppedDurable = 0;
289
+ // 🔴 CC-98 两段 try 是**刻意**分开的,不是洁癖:「没拿到」(取件自己抛 / reject)与「拿到了但不敢信」
290
+ // (回体不是对象 / 键坏形)是两句话,`why` 要分得清 —— 合成一段的话一只 `null` 回体会被报成
291
+ // 「取件失败」,宿主的提示文案与自检口径都会指错方向。两段的处置完全一致(记 listErrors、记欠账、
292
+ // 发一张「不知道」、整次不提交),只有成因词不同。
293
+ let body;
294
+ try {
295
+ body = await client.approvals.list({ signal: ac.signal });
296
+ }
297
+ catch (e) {
298
+ stats.listErrors++;
299
+ hostLog('debug', `approvalsFeed: list() failed (${via} leg): ${String(e)}`);
300
+ noteTakeMissed(via, seq);
301
+ publishUnknown('list_threw', via, seq); // 🔴 CC-98:失败也要有出口,不说 = 端拿上一张当现在
302
+ return;
303
+ }
304
+ /** CC-98:`pending` 段的原始值。**在 try 里取**(回体是 `null` / 取键 getter 抛 ⇒ 与修前同档:不逃逸)。 */
305
+ let rawPending;
162
306
  try {
163
- const body = await client.approvals.list({ signal: ac.signal });
164
- rows = body.pending;
307
+ rawPending = body.pending;
165
308
  // 0.72.14:`livePending` 段按结构窄读。键在场却读不懂 ⇒ **整次取件不提交**(保留上一张快照):
166
309
  // 把「读不懂」提交成「未报 / 为空」会让端撤掉一张还在等人的卡。
167
310
  const reading = readLivePendingRows(body);
@@ -169,20 +312,60 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
169
312
  stats.listErrors++;
170
313
  hostLog('debug', `approvalsFeed: list() body has an unreadable livePending section (${via} leg) — snapshot not committed`);
171
314
  noteTakeMissed(via, seq);
315
+ // 🔴 CC-98:视图不提交,但**必须说一声** —— 不说 = 端继续拿上一张当现在(把「读不懂」渲成「没有」)。
316
+ publishUnknown('unreadable_payload', via, seq);
172
317
  return;
173
318
  }
174
319
  if (reading.kind === 'present') {
175
320
  live = reading.rows;
321
+ dropped = reading.dropped;
176
322
  if (reading.dropped > 0)
177
- hostLog('debug', `approvalsFeed: dropped ${reading.dropped} malformed livePending row(s)`);
323
+ hostLog('debug', `approvalsFeed: dropped ${reading.dropped} malformed livePending row(s) — snapshot carries livePendingDropped`);
178
324
  }
179
325
  }
180
326
  catch (e) {
327
+ // 回体本身读不出(`null` / 非对象 / `pending` 位是个会抛的 getter)—— 与上一段同档处置,成因不同。
181
328
  stats.listErrors++;
182
- hostLog('debug', `approvalsFeed: list() failed (${via} leg): ${String(e)}`);
329
+ hostLog('debug', `approvalsFeed: list() body unreadable (${via} leg): ${String(e)}`);
183
330
  noteTakeMissed(via, seq);
331
+ publishUnknown('unreadable_payload', via, seq);
184
332
  return;
185
333
  }
334
+ // ── 🔴 CC-98 第三段:`pending` 段按结构窄读(见 readDurablePendingRows 头注:修前一个字都没校验)。
335
+ // 刻意排在上面那个 try **之外** —— 「逃逸形照旧逃出 take()」是 F5 熔断的输入,包在 try 里就等于
336
+ // 悄悄把熔断拿掉了(实测:第一版包在里面,F5 门当场从「连续逃逸转 idle」变成永远 poll)。
337
+ {
338
+ let durableRead;
339
+ try {
340
+ durableRead = readDurablePendingRows(rawPending);
341
+ }
342
+ catch {
343
+ // 载体上的 getter / 撤销过的 Proxy 抛 ⇒ 判 malformed;**是不是逃逸形**由下面那条行为等价判据答
344
+ // (同一份原始值上原摘要同样会抛 ⇒ 照旧逃),不在这里猜。
345
+ durableRead = { kind: 'malformed' };
346
+ }
347
+ // 🔴 F5 输入逐字保留的判据(见 baselineDurableDigestThrows 头注):**只在坏形路上**问一次
348
+ // 「修前那个摘要会不会在这份原始值上抛」。抛 ⇒ 这一形修前是逃逸的,照旧逃(整次不交,连半程都不交);
349
+ // 不抛 ⇒ 修前是**静默发布**成确定数字的那几形,现在改判「不知道」并 return。
350
+ const badDurable = durableRead.kind === 'malformed' || durableRead.dropped > 0;
351
+ const escapes = badDurable && baselineDurableDigestThrows(rawPending);
352
+ if (durableRead.kind === 'malformed' || escapes) {
353
+ hostLog('debug', `approvalsFeed: list() body has an unreadable pending section (${via} leg) — snapshot not committed`);
354
+ // 修前静默发布的那几形现在与 `livePending` 段同档:记一次取件失败。修前**逃逸**的那几形不记
355
+ // —— 它们走下面那条 throw(修前也不记,`listErrors` 的账逐字同)。
356
+ if (!escapes)
357
+ stats.listErrors++;
358
+ noteTakeMissed(via, seq);
359
+ publishUnknown('unreadable_payload', via, seq);
360
+ if (escapes)
361
+ throw new TypeError('approvalsFeed: unreadable pending section (baseline digest would have thrown)');
362
+ return;
363
+ }
364
+ rows = durableRead.rows;
365
+ droppedDurable = durableRead.dropped;
366
+ if (durableRead.dropped > 0)
367
+ hostLog('debug', `approvalsFeed: dropped ${durableRead.dropped} malformed pending row(s) — snapshot carries pendingDropped`);
368
+ }
186
369
  if (stopped)
187
370
  return;
188
371
  // 🔴 摘要排在销账**之前**:回体坏形(例:`pending` 不是数组)会让它抛出、整次取件逃逸(各调用点自己接盘,
@@ -190,24 +373,36 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
190
373
  // 逃逸本身也是「没看成」:在**这里**统一记欠账再原样抛出,四个调用点(起手 / 事件 / refresh / 重试)不必各记各的。
191
374
  let d;
192
375
  try {
193
- d = digestOf(rows, live);
376
+ d = digestOf(rows, live, dropped, droppedDurable);
194
377
  }
195
378
  catch (e) {
196
379
  noteTakeMissed(via, seq);
380
+ // 🔴 CC-98:逃逸(`pending` 不是数组 / 行畸形)同样是「这次取不到」—— 先发臂再原样抛出,
381
+ // 免得四个调用点(起手 / 事件 / refresh / 重试)各自去猜该不该通知端。
382
+ publishUnknown('unreadable_payload', via, seq);
197
383
  throw e;
198
384
  }
199
- // 看成了(回体读得懂)⇒ 销掉**更早发起**的欠账(判据在发起序号上,见 noteTakeLooked;与下面两道提交闸无关:
200
- // 销账问的是「失败之后有没有人再看过」,提交闸问的是「这份结论还新不新」)。
385
+ // 🔴 CC-98(异源复审 R3 [high] 采修)**代际闸排在销账之前**。原注说「销账与提交闸无关」——
386
+ // 那句话只对**序号闸**成立(被序号闸丢掉 = 有更晚的结论已经落地 ⇒ 视图是新的 ⇒ 确实不欠了);
387
+ // 对**代际闸**不成立:那份结论被整份丢掉、没有任何东西取代它,端手上什么都没更新 ——
388
+ // 拿它去销账 = 让「谁来补这一眼」这件事凭空消失(实复现:较晚的成功先被丢掉、较早的失败随后发布
389
+ // 「不知道」,而欠账因 `seq < lastLookedSeq` 不记 ⇒ 没人再取件 ⇒ 永久粘住)。
390
+ // 两句话合成一条纪律:**只有真的改变了端手上那份视图、或被更新的结论取代的取件,才算「有人看过」**。
391
+ if (leg !== undefined && leg !== pollLeg && blind === null) {
392
+ hostLog('debug', `approvalsFeed: dropping stale poll snapshot from leg ${leg} (current ${pollLeg}) — debt untouched`);
393
+ return;
394
+ }
395
+ // 看成了(回体读得懂)⇒ 销掉**更早发起**的欠账(判据在发起序号上,见 noteTakeLooked;与**序号**闸无关:
396
+ // 销账问的是「失败之后有没有人再看过」,序号闸问的是「这份结论还新不新」)。
201
397
  noteTakeLooked(seq);
202
398
  // 🔴 [F4957-1 二审 finding②] 代际闸必须排在**提交之前**:下面四行(lastDigest / revision /
203
399
  // last / onSnapshot)一落,视图就已经被改了 —— 守卫排在 take() 之外只挡得住计数与续排,挡不住
204
400
  // 回滚。乱序是真的会发生的:旧 poll 在途 → push 重连并发布新快照 → 旧 poll 才带着**更老**的
205
401
  // 结果返回 ⇒ 端拿到一张 revision 更高、内容更旧的 `mode:'poll'` 快照(刚出现的待审批被抹掉,
206
402
  // 或刚解决的又冒回来)。过期的结论一个字节都不提交。
207
- if (leg !== undefined && leg !== pollLeg) {
208
- hostLog('debug', `approvalsFeed: dropping stale poll snapshot from leg ${leg} (current ${pollLeg})`);
209
- return;
210
- }
403
+ // (代际闸已上移到销账之前 —— 见上面那段 R3 [high] 采修的头注。**盲着时让位**的理由照旧:
404
+ // `blind !== null` 时让位给序号闸,否则旧腿那份读得懂的结论被丢掉、blind 没人清 ⇒ 永久粘住;
405
+ // 让位是安全的,乱序本来就由序号闸守。)
211
406
  // 🔴 [F4957-1 三审 finding②] 乱序闸(三条取件路径共用):比「已观察到的最新一号」更早发起的
212
407
  // 响应,其结论已经被后发者的结论取代 —— 一个字节都不提交。`refresh()` 不带代际,单靠上面那道
213
408
  // 闸盖不住它(端上「用户点了刷新」正是最容易与 push 重取撞车的那一发)。
@@ -216,11 +411,27 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
216
411
  return;
217
412
  }
218
413
  lastObservedSeq = seq;
219
- if (d === lastDigest)
414
+ // 🔴 CC-98 **恢复路**:上一次发布是「不知道」⇒ 即使摘要与失败前一字不差也必须发一张真快照。
415
+ // 少了这一条(修前的形):失败期间内容没变 ⇒ 摘要相等 ⇒ 这里 return ⇒ 「又能取到了、而且还是那 N 条」
416
+ // 这件事没有任何出口,端永远停在「不知道」。
417
+ const recovering = blind !== null;
418
+ const contentChanged = d !== lastDigest;
419
+ if (!contentChanged && !recovering)
220
420
  return; // 内容没变 ⇒ 不打扰订阅者
421
+ blind = null;
422
+ if (contentChanged)
423
+ contentRevision++; // 对账退避只认**内容**变化(见 contentRevision 头注)
221
424
  lastDigest = d;
222
425
  revision++;
223
- const snap = { pending: rows, ...(live !== undefined ? { livePending: live } : {}), mode: via, revision };
426
+ const snap = {
427
+ kind: 'snapshot',
428
+ pending: rows,
429
+ ...(live !== undefined ? { livePending: live } : {}),
430
+ ...(dropped > 0 ? { livePendingDropped: dropped } : {}),
431
+ ...(droppedDurable > 0 ? { pendingDropped: droppedDurable } : {}),
432
+ mode: via,
433
+ revision,
434
+ };
224
435
  last = snap;
225
436
  stats.snapshots++;
226
437
  try {
@@ -340,10 +551,10 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
340
551
  return;
341
552
  }
342
553
  void (async () => {
343
- const before = revision;
554
+ const before = contentRevision; // 🔴 CC-98:退避判据是**内容**变化,不是发布次数(恢复必发也发布)
344
555
  stats.reconciles++;
345
556
  await take('poll');
346
- reconcileDelayMs = revision !== before ? reconcileBaseMs : Math.min(reconcileDelayMs * 2, reconcileMaxMs);
557
+ reconcileDelayMs = contentRevision !== before ? reconcileBaseMs : Math.min(reconcileDelayMs * 2, reconcileMaxMs);
347
558
  })()
348
559
  .catch(e => {
349
560
  hostLog('debug', `approvalsFeed: reconcile tick threw unexpectedly (take() should have self-caught): ${String(e)}`);
@@ -480,6 +691,11 @@ export function startApprovalsFeed(client, onSnapshot, opts) {
480
691
  mode: () => mode,
481
692
  stats: () => ({ ...stats }),
482
693
  snapshot: () => last,
694
+ reading: () => blind !== null
695
+ ? { kind: 'unknown', why: blind.why, at: blind.at }
696
+ : last !== null
697
+ ? { kind: 'present', snapshot: last }
698
+ : { kind: 'unobserved' },
483
699
  refresh: () => take(mode === 'push' ? 'push' : 'poll'),
484
700
  };
485
701
  }
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * ── 🔴 四条读法 ────────────────────────────────────────────────────────────────────────────
11
11
  * ① **键缺席 ≠ 没有悬挂 ask**:老引擎不发这个键 ⇒ `not_reported`;快照上键缺席,计数读 `null`(不知道),绝不铸 `[]` / `0`。
12
+ * 🔴 CC-98 同一条纪律的第三态:feed 发的「这次取不到」臂(`kind:'unknown'`)喂进本模块三个口时,
13
+ * 计数读 `null`、视图读空、跟踪器**零 delta**(绝不把「取不到」当成「行都消失了」去撤卡)。
12
14
  * ② **数组名即路由判据**:`livePending` 行的决议口是 `POST /v1/tool-approvals/{approvalId}/respond`(流内审批那个口),
13
15
  * **不是** durable 行的 decide 口;两数组不混编。幂等键 = `approvalId`(与流内帧同一个 id)。
14
16
  * ③ **可选旗标 only-if-true**:`fromSubagent` / `governanceForced` / `requiresRealApproval` 缺席绝不编码成 `false`。
@@ -16,7 +18,7 @@
16
18
  *
17
19
  * sdk 的 barrel 没有导出这一行的类型 ⇒ 按**结构**窄读(与 `capabilities.executionLane` 同一姿势),多余成员不过境。
18
20
  */
19
- import type { ApprovalsFeedSnapshot } from './approvalsFeed.js';
21
+ import type { ApprovalsFeedEmission } from './approvalsFeed.js';
20
22
  import { type RespondToolApprovalFn, type ToolApprovalFrame, type ToolApprovalFrameLaneOpts, type ToolApprovalFrameOutcome } from './toolApprovalWire.js';
21
23
  /** 一条悬挂的流内 ask(窄读后的形;键集即全部)。 */
22
24
  export interface LivePendingAskView {
@@ -41,7 +43,11 @@ export interface LivePendingAskView {
41
43
  */
42
44
  frame?: ToolApprovalFrame;
43
45
  }
44
- /** `livePending` 段的三态读数。`malformed` = 键在场但读不懂(**不**折成 `not_reported`,也不折成空)。 */
46
+ /**
47
+ * `livePending` 段的三态读数。`malformed` = 键在场但读不懂(**不**折成 `not_reported`,也不折成空)。
48
+ * 🔴 CC-98:`malformed` 含**非空数组却一行都读不出**那一形(见 {@link readLivePendingRows} 内的分界注);
49
+ * **部分**坏行仍是 `present` + `dropped`。
50
+ */
45
51
  export type LivePendingReading = {
46
52
  kind: 'not_reported';
47
53
  } | {
@@ -51,14 +57,17 @@ export type LivePendingReading = {
51
57
  rows: LivePendingAskView[];
52
58
  dropped: number;
53
59
  };
54
- /** `GET /v1/approvals` 回体 → `livePending` 段读数。四必填位坏形的行丢弃并计数;同 `approvalId` 重复行留第一条。 */
60
+ /**
61
+ * `GET /v1/approvals` 回体 → `livePending` 段读数。四必填位坏形的行丢弃并计数;同 `approvalId` 重复行留第一条。
62
+ * 🔴 CC-98:**非空数组却一行都读不出** ⇒ `malformed`(不是 `present` + 空列表)——分界与理由见函数末那段注。
63
+ */
55
64
  export declare function readLivePendingRows(body: unknown): LivePendingReading;
56
65
  /** 视图过滤:给了 `sessionId` ⇒ 只留该会话的行;行上没有 `sessionId` = 归不了属 ⇒ 不留(不替别的会话出卡)。 */
57
66
  export interface SuspendedAskFilter {
58
67
  sessionId?: string;
59
68
  }
60
69
  /** 快照里**出自子代**的悬挂 ask(宿主自己的流内 ask 走流内帧腿,不在这里出卡)。未报 / 没有快照 ⇒ `[]`。 */
61
- export declare function suspendedSubagentAsks(snapshot: ApprovalsFeedSnapshot | null | undefined, filter?: SuspendedAskFilter): LivePendingAskView[];
70
+ export declare function suspendedSubagentAsks(snapshot: ApprovalsFeedEmission | null | undefined, filter?: SuspendedAskFilter): LivePendingAskView[];
62
71
  /** 「在等人决定」的计数。`null` = 不知道(没有快照 / 引擎没报这一段),**不是** 0。 */
63
72
  export interface ApprovalsAwaitingDecisionCount {
64
73
  /** durable 停驻行数。 */
@@ -68,7 +77,7 @@ export interface ApprovalsAwaitingDecisionCount {
68
77
  /** 其中出自子代的行数。 */
69
78
  suspendedSubagent: number | null;
70
79
  }
71
- export declare function countApprovalsAwaitingDecision(snapshot: ApprovalsFeedSnapshot | null | undefined, filter?: SuspendedAskFilter): ApprovalsAwaitingDecisionCount;
80
+ export declare function countApprovalsAwaitingDecision(snapshot: ApprovalsFeedEmission | null | undefined, filter?: SuspendedAskFilter): ApprovalsAwaitingDecisionCount;
72
81
  /**
73
82
  * 悬挂行 → 流内帧腿的输入。只带行上**真有**的位:窗三键不带(行上只有绝对死线,没有出帧时刻,凑不出可校偏的窗),
74
83
  * `args` 不带(行上没有)。`sourceTaskId` = `originTaskId`(卡头子代身份徽章的来源)。
@@ -95,7 +104,18 @@ export interface SuspendedAskDelta {
95
104
  upgraded: LivePendingAskView[];
96
105
  }
97
106
  export interface SuspendedAskTracker {
98
- ingest(snapshot: ApprovalsFeedSnapshot | null | undefined): SuspendedAskDelta;
107
+ /**
108
+ * 吞一张 feed 发布物。三种入参三句话:
109
+ * · 真快照(`kind:'snapshot'`)⇒ 正常对账(appeared / gone / upgraded);
110
+ * · 🔴 CC-98「这次取不到」(`kind:'unknown'`)⇒ **零 delta**,已出的卡一张都不撤;
111
+ * · `null` / `undefined` ⇒ 由**调用方**声明「现在一条都看不到」(引擎换代 / 拆装配),按「列表空」对账 ⇒ 撤卡。
112
+ * 🔴 后两者是**两句话**,别当一回事:`kind:'unknown'` 说的是「**wire 这一眼没看成**」(包说的),
113
+ * `null` 说的是「**我这边现在没有可看的东西**」(端说的,典型 = 拆装配:卡已经没有决断口,留着才是骗人)。
114
+ * 取不到请把 unknown 臂**原样**喂进来,别自己转成 `null` —— 转一下就把「不知道」变成了「没有」。
115
+ * 🔴 第三种不完整:真快照带 `livePendingDropped > 0`(这一段有读不出来的行)⇒ 照报 `appeared` / `upgraded`,
116
+ * 但**不报 `gone`**(禁止缺席对账;那几行留在账上,等下一张完整快照再判)。
117
+ */
118
+ ingest(snapshot: ApprovalsFeedEmission | null | undefined): SuspendedAskDelta;
99
119
  /**
100
120
  * 流内帧腿出卡**之前**调:认领这只 ask。返回 `true` = 帧腿可以出卡(此后快照不再为它出第二张);
101
121
  * 返回 `false` = 它已经从快照出过卡 / 已被认领 / 已决断 ⇒ 帧腿**跳过出卡**(两条通道同一只 ask 只有一张卡、一次决断)。
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * ── 🔴 四条读法 ────────────────────────────────────────────────────────────────────────────
11
11
  * ① **键缺席 ≠ 没有悬挂 ask**:老引擎不发这个键 ⇒ `not_reported`;快照上键缺席,计数读 `null`(不知道),绝不铸 `[]` / `0`。
12
+ * 🔴 CC-98 同一条纪律的第三态:feed 发的「这次取不到」臂(`kind:'unknown'`)喂进本模块三个口时,
13
+ * 计数读 `null`、视图读空、跟踪器**零 delta**(绝不把「取不到」当成「行都消失了」去撤卡)。
12
14
  * ② **数组名即路由判据**:`livePending` 行的决议口是 `POST /v1/tool-approvals/{approvalId}/respond`(流内审批那个口),
13
15
  * **不是** durable 行的 decide 口;两数组不混编。幂等键 = `approvalId`(与流内帧同一个 id)。
14
16
  * ③ **可选旗标 only-if-true**:`fromSubagent` / `governanceForced` / `requiresRealApproval` 缺席绝不编码成 `false`。
@@ -23,7 +25,10 @@ function nonEmptyString(v) {
23
25
  function nonNegativeFinite(v) {
24
26
  return typeof v === 'number' && Number.isFinite(v) && v >= 0;
25
27
  }
26
- /** `GET /v1/approvals` 回体 → `livePending` 段读数。四必填位坏形的行丢弃并计数;同 `approvalId` 重复行留第一条。 */
28
+ /**
29
+ * `GET /v1/approvals` 回体 → `livePending` 段读数。四必填位坏形的行丢弃并计数;同 `approvalId` 重复行留第一条。
30
+ * 🔴 CC-98:**非空数组却一行都读不出** ⇒ `malformed`(不是 `present` + 空列表)——分界与理由见函数末那段注。
31
+ */
27
32
  export function readLivePendingRows(body) {
28
33
  if (body === null || typeof body !== 'object' || Array.isArray(body))
29
34
  return { kind: 'malformed' };
@@ -66,10 +71,22 @@ export function readLivePendingRows(body) {
66
71
  ...(isToolApprovalFrame(o.frame) && o.frame.type === 'tool_approval' && o.frame.approvalId === o.approvalId ? { frame: o.frame } : {}),
67
72
  });
68
73
  }
74
+ // 🔴 CC-98 **分界**(主会话裁定):**非空数组、却一行都读不出来** ⇒ 判 `malformed`(= 这一眼没看成)。
75
+ // 不许答 `present` + 空列表 —— 那是把「取不到」冒充「一条都没有」,与 `list()` 抛是同一个病形,
76
+ // 只是换了个入口(端照着它渲「0 条在等你」,而引擎明明报了 N 行)。
77
+ // 分界的另一半照旧:**部分**坏行逐条丢 + `present`(与 server 的逐行律同形 —— 读得出来的行是真的,
78
+ // 丢掉那几条由 `dropped` 如实交代,不因为一条坏行就把整段作废)。
79
+ // 🔴 空数组**不在**本条射程:`[]` 是引擎明说「一条都没有」,恒 `present`(这正是要守住的那一态)。
80
+ if (raw.length > 0 && rows.length === 0)
81
+ return { kind: 'malformed' };
69
82
  return { kind: 'present', rows, dropped };
70
83
  }
71
84
  /** 快照里**出自子代**的悬挂 ask(宿主自己的流内 ask 走流内帧腿,不在这里出卡)。未报 / 没有快照 ⇒ `[]`。 */
72
85
  export function suspendedSubagentAsks(snapshot, filter) {
86
+ // CC-98:「这次取不到」⇒ 手上没有行,出不了卡。但这**不是**「一条都没有」——
87
+ // 计数口答 `null`、跟踪器零 delta(见下面两处),端别拿本函数的空数组当「没有悬挂 ask」的证据。
88
+ if (snapshot?.kind === 'unknown')
89
+ return [];
73
90
  const rows = snapshot?.livePending;
74
91
  if (!Array.isArray(rows))
75
92
  return [];
@@ -78,10 +95,20 @@ export function suspendedSubagentAsks(snapshot, filter) {
78
95
  export function countApprovalsAwaitingDecision(snapshot, filter) {
79
96
  if (snapshot === null || snapshot === undefined)
80
97
  return { durable: null, live: null, suspendedSubagent: null };
98
+ // 🔴 CC-98:「这次取不到」与「没有快照」同一档 —— 三格全 `null`(不知道),绝不折成 0。
99
+ // 底栏 / 页脚据此渲破折号;渲 0 = 告诉用户「没人在等你」,而真相是这一眼没看成。
100
+ if (snapshot.kind === 'unknown')
101
+ return { durable: null, live: null, suspendedSubagent: null };
81
102
  const bySession = (rows) => filter?.sessionId === undefined ? [...rows] : rows.filter(r => r.sessionId === filter.sessionId);
82
- const durable = Array.isArray(snapshot.pending) ? bySession(snapshot.pending).length : null;
103
+ // 🔴 CC-98:`pending` 段有读不出来的行(`pendingDropped > 0`)⇒ durable 格答 `null`(不知道确切数)。
104
+ const durable = (snapshot.pendingDropped ?? 0) > 0 ? null : Array.isArray(snapshot.pending) ? bySession(snapshot.pending).length : null;
83
105
  if (!Array.isArray(snapshot.livePending))
84
106
  return { durable, live: null, suspendedSubagent: null };
107
+ // 🔴 CC-98(异源复审 R1 [high] 采修):这一段有**读不出来的行** ⇒ 流内两格答 `null`(不知道确切数)。
108
+ // 引擎照旧在报那几行,只是这一拍读不懂 —— 拿读得懂的那几条报精确数 = 「0 条在等你」而其实有人在等
109
+ // (与「取不到渲成没有」同一个病形,只是小一号)。durable 段不受影响,它自己读得出来。
110
+ if ((snapshot.livePendingDropped ?? 0) > 0)
111
+ return { durable, live: null, suspendedSubagent: null };
85
112
  const live = bySession(snapshot.livePending);
86
113
  return { durable, live: live.length, suspendedSubagent: live.filter(r => r.fromSubagent === true).length };
87
114
  }
@@ -153,6 +180,11 @@ export function createSuspendedAskTracker(filter) {
153
180
  };
154
181
  return {
155
182
  ingest(snapshot) {
183
+ // 🔴 CC-98:「这次取不到」是**零信息**,不是「行都消失了」⇒ 零 delta。
184
+ // 走下面那条路的后果:`rows` 空 ⇒ 全部 shown 行进 `gone` ⇒ 屏上还在等人的卡被撤掉,
185
+ // 而那只 ask 在引擎上照旧挂着、照旧没人答 —— 「取不到当成没有」最坏的一形。
186
+ if (snapshot?.kind === 'unknown')
187
+ return { appeared: [], gone: [], upgraded: [] };
156
188
  const rows = suspendedSubagentAsks(snapshot, filter);
157
189
  const present = new Set(rows.map(r => r.approvalId));
158
190
  const appeared = [];
@@ -174,18 +206,26 @@ export function createSuspendedAskTracker(filter) {
174
206
  shownWithFrame.add(r.approvalId);
175
207
  appeared.push(r);
176
208
  }
209
+ // 🔴 CC-98(异源复审 R1 [high] 采修):这一张快照里有**读不出来的行**(`livePendingDropped > 0`)⇒
210
+ // **禁止缺席对账**:一行「不在列表里」可能只是这一拍读不懂,引擎照旧在报它。据此撤卡 =
211
+ // 把一张还在等人的卡撤掉,而 ask 在引擎上照旧挂着没人答(「取不到当成没有」最坏的一形)。
212
+ // 新出现的可读行照旧出卡(`appeared`)、补帧照旧升级(`upgraded`)—— 只有「消失」这一句话不许说。
213
+ // 账**不清**:那几行还留在 `shown` 里,等下一张**完整**的快照再判 gone(撤卡延后,不是丢掉)。
214
+ const incomplete = snapshot !== null && snapshot !== undefined && (snapshot.livePendingDropped ?? 0) > 0;
177
215
  const gone = [];
178
- for (const id of [...shown]) {
179
- if (present.has(id))
180
- continue;
181
- shown.delete(id);
182
- shownWithFrame.delete(id);
183
- gone.push(id);
216
+ if (!incomplete) {
217
+ for (const id of [...shown]) {
218
+ if (present.has(id))
219
+ continue;
220
+ shown.delete(id);
221
+ shownWithFrame.delete(id);
222
+ gone.push(id);
223
+ }
224
+ // 见过、现在不在列表里的认领 = 这只 ask 已结算 ⇒ 离账
225
+ for (const [id, seen] of [...claimed])
226
+ if (seen && !present.has(id))
227
+ claimed.delete(id);
184
228
  }
185
- // 见过、现在不在列表里的认领 = 这只 ask 已结算 ⇒ 离账
186
- for (const [id, seen] of [...claimed])
187
- if (seen && !present.has(id))
188
- claimed.delete(id);
189
229
  return { appeared, gone, upgraded };
190
230
  },
191
231
  noteSurfacedByStream(approvalId) {
package/dist/index.d.ts CHANGED
@@ -140,6 +140,7 @@ export * from './steering.js';
140
140
  export * from './diagnostics.js';
141
141
  export * from './retryStatus.js';
142
142
  export * from './sessionMemoryStatus.js';
143
+ export * from './memorySpecWire.js';
143
144
  export * from './adapt.js';
144
145
  export * from './adapt/textSegmentAuthority.js';
145
146
  export * from './engineHttpTools.js';
@@ -257,6 +258,7 @@ export * from './model/providerPresets.js';
257
258
  export * from './hitl/hitlBridge.js';
258
259
  export * from './hitl/suspendedReopen.js';
259
260
  export * from './hitl/approvalOutcomeNote.js';
261
+ export * from './hitl/approvalResolution.js';
260
262
  export { toolEndOutputText, ENGINE_ABORT_TOOL_RESULT } from './hitl/frameRouter.js';
261
263
  export { isAskTool } from './hitl/frameRouter.js';
262
264
  export * from './hitl/hitlHostSurface.js';
package/dist/index.js CHANGED
@@ -151,6 +151,7 @@ export * from './retryStatus.js';
151
151
  // 把「部署没这个面」说成「你这个会话不存在」;② 五键缺席语义**逐键不同**,健康会话上就有两键
152
152
  // 合法缺席,一律读成「没有/关着/0」就是对用户下一个证不出的断言。
153
153
  export * from './sessionMemoryStatus.js';
154
+ export * from './memorySpecWire.js';
154
155
  export * from './adapt.js';
155
156
  // ── L-318(0.68.2):`text_end` 权威段替换的**判决单源** + print 形车道的段账状态机 ──────────
156
157
  // 修前这套六形判据有两份实现:本包 `adapt/textStream.replaceAnswerSegment`(交互车道)与 cli
@@ -446,6 +447,8 @@ export * from './hitl/hitlBridge.js';
446
447
  export * from './hitl/suspendedReopen.js';
447
448
  // 0.74.5 CC-88:流内帧腿审批结局 → 宿主便签的单源映射(三端手拼退役)
448
449
  export * from './hitl/approvalOutcomeNote.js';
450
+ // CC-77 ①:「一次审批决断最后怎么了」的单源判别联合 + 唯一映射口(additive;既有结局型面一字不动)
451
+ export * from './hitl/approvalResolution.js';
449
452
  // [C93] P0 直讨(0.17.1):toolEndOutputText 单一真源导出——web/desktop 被点名「两端务必接」的
450
453
  // 6.0.0 output content-block 归一,此前 frameRouter 内私有,两端手抄=漂移温床。frameRouter 只
451
454
  // 挑名导出这一件(整星导出会把路由内部面全泄进公面)。