@sema-agent/client-core 0.30.8 → 0.31.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 CHANGED
@@ -16,6 +16,46 @@
16
16
  > 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
17
17
  > `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
18
18
 
19
+ ## 0.31.0(未发布)
20
+
21
+ **#242 批 3 fleet belt 对账承重批(design-242 §3 批 3;黑板 [4000] Q2=A / Q3=B 裁定执行)。
22
+ 🔴 BREAKING(行为面翻面两处,见下);公开面 additive 三件。**
23
+
24
+ - 🔴 **BREAKING②/Q3 三腿翻面**:`getBgParentRun(x) ?? activeEngineRunId()` 的无条件回落**全部
25
+ 移除**(`subagent/engineSubagentTail.ts` / `engineTaskHandleWire.ts:resolveHostRun` /
26
+ `engineSubagentOutput.ts`)。台账缺席时三条读面从「能连上(可能连错 run)」变成**「不连」**:
27
+ tail 不开流、`fetchEngineTaskOutput`/`fetchEngineSubagentReport` 回 null、`stopEngineTask` 回
28
+ `{ok:false, reason:'unavailable', detail:'no host run mapping'}`。理由 = resume 腿同口径
29
+ (「指名了行就诚实缺席」):进程级活跃 run 被并发 main/fork 流覆写,回落值可能指向**不相干的
30
+ run** —— 错值比缺席更坏(读错人 / stop 打错 run)。缺席**不静默**:新导出
31
+ `noteBgOwnerAbsence`(每 (腿,taskId) 至多一条 warn,`__resetBgOwnerAbsenceForTests` 测试钩)。
32
+ 消费端零施工即得新行为;若端此前**依赖**回落连当前 run(不推荐),需自己喂
33
+ `recordBgParentRun`/`recordSubagentOwner`。
34
+ - 🔴 **BREAKING①/belt 退休姿势根修**(design-242 §4.1 判 bug:belt 写留存池以「行还在 live 集」
35
+ 为前提,而 `project()` 对留存条目一律 `taskMap.has ⇒ continue`,两半互斥 ⇒ 行帧丢失场景 belt
36
+ 结构性零效果=「行永恒 running」主症不治)。`applyBgNotification` 终态臂现在把命中的 live 行
37
+ **退休**(`taskMap.delete`+`rowMeta.delete`+`retained.set`,与「终态行帧+同毫秒 task_remove」
38
+ 逐段同构);**按尾段定位且全部命中**(server 双生行:裸 id 与 `runId childId` 复合形一起退休);
39
+ **零代际账**(不引入周期号/高水位/复活基准,`droppedStaleCycle*` 计数不复活;回声保护=状态面
40
+ 方向规则+通知队列既有 `(taskId,status,seq)` 去重,复活保护=非终态行帧即清账,含双生行别名清扫)。
41
+ - **状态面单一判决点**:留存池行 status 与 `recordBgTerminalFacts` 事实台账共用同一份判决,
42
+ 不分叉(REF-CC-040 腿三不变量延续)。**洗绿拦截**:已有终态真值 ∈ {failed,killed} 时
43
+ `completed` 通知不得改写(E4-2/G3);升级(completed→killed/failed)照常后到覆盖。
44
+ - **留存池更新不走 rowIds 门**(E4-6/G7):退休后同 task 的第二条终态通知仍能改写留存条目;
45
+ 同值幂等重投不续窗(G5)。
46
+ - **通知面与状态面分开裁**(E4-8/G8):状态面判决任何一支都不吞 `enqueueBgChildNotification`。
47
+ - **E4-7 解耦(additive①)**:`EngineActiveBgTask` 新增 `retiredByNotification?: boolean` ——
48
+ belt 退休且未过 `TERMINAL_RETAIN_MS` 窗的行**继续出现在在飞读面**(`readEngineActiveBgTasks`)
49
+ 并带此标,消费方自裁;不消费=保守按在飞计(温切/升级门多 defer ≤60s,换「一条回声不能杀掉
50
+ 真在跑子代」)。🔴 **残局 killed 合成(cap 强切/respawn)必须跳过带标行** —— 对已 completed
51
+ 行合成 killed 是谎报。窗过即从读面消失;行帧车道重新开口(终态覆盖/非终态复活)即撤标。
52
+ - **accepted 钩子(additive②)**:`FleetLedgerHooks.onBgNotificationAccepted?(n, rowIds)` ——
53
+ own/foreign 判别**通过之后**回调(foreign/畸形不回调);`rowIds` = 到达时刻按键域命中的 live
54
+ 行 id 快照(退休前取)。端从此零归属逻辑零复刻(壳侧 meta 两位门同批删)。钩子抛错不拆台账。
55
+ - **additive③**:导出 `FleetBgNotificationWire`(钩子参数型)。
56
+ - 常驻门:pure 门新增 `#242批3` 段(G1-G8 可离线序 + Q3 三腿缺席/反钉 + 洗绿方向规则 + 读面
57
+ 解耦窗界),floor 零松量抬升。
58
+
19
59
  ## 0.30.8(2026-08-15)
20
60
 
21
61
  **server 7.23.0 / core 5.35.0 三键透传批(#281 件4b + #263 半场,[4037] 认领件的 client-core 半场)
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.30.8
38
+ **Version:** 0.31.0
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -64,7 +64,7 @@
64
64
  * `src/subagent/engineTaskHandleWire.ts`(`POST /v1/runs/:id/tasks/:handle/stop?session=`)。
65
65
  * **handle 取法**:复合 id `"<runId> <taskId>"` 取尾段 = 引擎 taskId —— 包内已有同语义实现
66
66
  * `rowIdTail()`(`src/workflow.ts:24`,导出;`src/fleetAgentPanelProjection.ts:94` 有零依赖叶
67
- * 的私有同款副本);宿主 run 解析走 `getBgParentRun(handle) ?? activeEngineRunId()`。
67
+ * 的私有同款副本);宿主 run 解析走 `getBgParentRun(handle)`(#242 3 起台账缺席即诚实缺席,绝不回落活跃 run)
68
68
  * **能力门** = `capabilities.taskHandles`(`src/subagent/engineRowStopGate.ts`
69
69
  * `engineTaskHandlesCapable()`),🔴 别 trial-by-404。
70
70
  * **回执纪律**(同文件 `engineRowNeedsStopConfirm()`):只有 **200 算停了**;409
@@ -52,6 +52,20 @@ export interface EngineActiveBgTask {
52
52
  /** fleet 行的 parentId(spawn 该子代的 run 行 id;顶层 run 行缺席)—— killed 残局合成通知的
53
53
  * own/foreign 判别用。 */
54
54
  parentId?: string;
55
+ /**
56
+ * 🔴 #242 批 3(E4-7 解耦,design-242 §2.2-5):true = 该行由 **belt(终态通知)退休**、且仍在
57
+ * `TERMINAL_RETAIN_MS` 窗内。行帧车道**没有**为它 settle 过(终态行帧丢失,状态由通知车道推定)。
58
+ * 语义与消费纪律:
59
+ * · 这行**按在飞计**是保守默认(在读面里出现 ⇒ 温切/升级门 defer)——通知车道没有代际号,
60
+ * 误退休(旧周期回声退休了复活行)不可排除,而「多等一窗可恢复,杀错在跑子代不可恢复」;
61
+ * · 消费方**可以自裁**:它的 `status` 是终态词(诚实),带本标 ⇒ 「通知说它完了、行帧没确认」。
62
+ * 🔴 残局 killed 合成(温切 cap 强切 / configDrift respawn)必须**跳过**带本标的行 ——
63
+ * 对一条已 completed 的行合成 killed 通知是谎报;
64
+ * · 窗过(`TERMINAL_RETAIN_MS`)即从读面消失;行帧车道一旦重新开口(终态行帧到达覆盖 /
65
+ * 非终态行帧复活)本标即撤。
66
+ * 行帧车道 settle 的行(终态行帧 + task_remove)**从不**带本标,也从不进这个读面(与旧行为同)。
67
+ */
68
+ retiredByNotification?: boolean;
55
69
  }
56
70
  export interface EngineActiveBgReading {
57
71
  tasks: EngineActiveBgTask[];
@@ -106,6 +120,18 @@ export interface FleetLedgerHooks {
106
120
  * 返回值忽略;不给 = 该帧被静默丢弃(端没有承载面时的正确行为)。
107
121
  */
108
122
  onHookNotice?(frame: HookNoticeFrame, sessionScoped: boolean): void;
123
+ /**
124
+ * 🔴 #242 批 3(design-242 §2.2-3,壳侧 fleetClient「长线正解已记账:请 client-core 在归属判定
125
+ * **通过之后**给一个 accepted 钩子,壳改接它」的兑现):`bg_notification` 帧经本台账的
126
+ * own/foreign 判别(serverFailClosed / ownByRoot / parentTaskId ∈ own-run 台账)**放行之后**回调。
127
+ * · 被隔离丢弃(foreign)/畸形早退的通知**不**回调 —— 钩子的语义就是「归属判定已通过」;
128
+ * · 终态与非终态通知都回调(状态筛选归消费方);
129
+ * · `rowIds` = 到达时刻 live 集里按键域(整行 id + 尾段)命中的行 id(可能为空 = 行帧车道
130
+ * 没有这行;可能多条 = server 双生行)。快照取在退休动作**之前**;
131
+ * · 钩子抛错不拆台账(try/catch + debug 留痕),与帧消费循环隔离。
132
+ * 端从此**零归属逻辑、零复刻**(壳侧 meta 两位门 [cross-repo-fix-at-source] 同批整条删)。
133
+ */
134
+ onBgNotificationAccepted?(n: FleetBgNotificationWire, rowIds: readonly string[]): void;
109
135
  }
110
136
  /** REF-CC-044(fleet2-06)/REF-CC-039(fleet2-01):`createFleetLedger` 的可选装配位。 */
111
137
  export interface CreateFleetLedgerOptions {
@@ -162,3 +188,11 @@ export interface FleetLedger {
162
188
  * (这条切缝就是设计稿 §2.5 给 fleetClient 划的那一刀:帧体归库、连接归端。)
163
189
  */
164
190
  export declare function createFleetLedger(hooks?: FleetLedgerHooks, opts?: CreateFleetLedgerOptions): FleetLedger;
191
+ /**
192
+ * `bg_notification` 帧的通知体 wire 形(SDK `FleetFrame` 联合的具名臂投影)。
193
+ * #242 批 3 起公开导出 —— `FleetLedgerHooks.onBgNotificationAccepted` 的参数型,端(壳/web/桌面)
194
+ * 接钩子要能写出这个签名。开集读纪律不变:键随 SDK 走,消费方防御读。
195
+ */
196
+ export type FleetBgNotificationWire = Extract<FleetFrame, {
197
+ type: 'bg_notification';
198
+ }>['notification'];
@@ -4,7 +4,7 @@ import { engineSessionParam } from '../engineSessionParam.js';
4
4
  // `isHookNoticeFrame` 不在这里 re-export —— 它已由 index.ts 的 `export * from './notifications.js'`
5
5
  // 出口(重复 re-export 只会让同名绑定在包根变含糊)。端要先分诊再喂台账,从包根 import 即可。
6
6
  import { enqueueBgChildNotification, isOwnWorkflowRun } from '../notifications.js';
7
- import { isOwnEngineRun, recordBgParentRun, recordBgTerminalFacts, recordOwnEngineRun, } from '../subagentContentStore.js';
7
+ import { clearBgTerminalFacts, getBgTerminalFacts, isOwnEngineRun, recordBgParentRun, recordBgTerminalFacts, recordOwnEngineRun, } from '../subagentContentStore.js';
8
8
  import { rowIdTail } from '../workflow.js';
9
9
  import { recordEngineTranscriptId } from '../subagent/engineDelegatedPrompt.js';
10
10
  import { DEFAULT_SESSION_KEY } from '../sessionSlot.js';
@@ -111,7 +111,10 @@ export function createFleetLedger(hooks = {}, opts = {}) {
111
111
  /** [1481]①「elapsed 恒 0s」补偿:记每行**本地接收**时刻(引擎只在活动边界重发行)。
112
112
  * REF-CC-039:字段标域改名 `receivedAtMs` —— 装的是 `nowFn()`,绝不是 wire 上的 `frame.ts`。 */
113
113
  const rowMeta = new Map();
114
- /** [1481]②「完成即消失」补偿:终态行进留存池,宽限窗内继续投影。`expireAt` 同样锚本地钟。 */
114
+ /** [1481]②「完成即消失」补偿:终态行进留存池,宽限窗内继续投影。`expireAt` 同样锚本地钟。
115
+ * #242 批 3:`retiredByNotification` 标 = 该条目由 belt(终态通知)退休写入,行帧车道没为它
116
+ * settle 过 —— 在飞读面(`bgView.read`)对带标且未过窗的条目**继续按在飞计**(E4-7 解耦,
117
+ * 见 `EngineActiveBgTask.retiredByNotification` 顶注);行帧路径写入的条目从不带标。 */
115
118
  const retained = new Map();
116
119
  /** REF-CC-051(fleet2-13):畸形帧/通知早退计数(与同文件的策略性丢弃 debug 行对称留痕)。 */
117
120
  let droppedMalformed = 0;
@@ -128,27 +131,48 @@ export function createFleetLedger(hooks = {}, opts = {}) {
128
131
  if (engineWireDebugEnabled())
129
132
  hostLog('debug', line);
130
133
  };
134
+ /** wire 行 → 在飞读面行的单一投影点(live 臂与 belt 退休臂共用;两臂各写一份就会在 name/status
135
+ * 的三态处理上漂开)。 */
136
+ const toBgTask = (r, retired) => {
137
+ const parentId = wireParentId(r);
138
+ return {
139
+ id: r.id,
140
+ // REF-CC-046(fleet2-08,含 TYPESHAPE-06):与 parentId 同款条件展开 —— `name` 是三态位
141
+ // (server ≥3.5.1 起真的会缺席),无条件落键会让「键在不在」判三态在序列化边界翻面。
142
+ ...(typeof r.name === 'string' && r.name.length > 0 ? { name: r.name } : {}),
143
+ // REF-CC-052(fleet2-14):`status` 是 SDK 必填闭集,但脏值/缺席仍可能到达 wire —— 不知道
144
+ // 就说不知道,`'unknown'` 是自描述哨兵,绝不编造一个具体的、看似活跃的 `'running'`。
145
+ status: r.status ?? 'unknown',
146
+ // REF-CC-049(fleet2-11):经 wireParentId 单一铸点(悬空父脏值空串按缺席处理)。eopt:
147
+ // 局部变量先取一次(两次裸调用之间 TS 不共享窄化,双调用会重新变宽成 string|undefined)。
148
+ ...(parentId !== undefined ? { parentId } : {}),
149
+ ...(retired ? { retiredByNotification: true } : {}),
150
+ };
151
+ };
131
152
  const bgView = {
132
- read: () => ({
133
- connected,
134
- rows: [...taskMap.values()]
153
+ read: () => {
154
+ const rows = [...taskMap.values()]
135
155
  .filter(r => !TERMINAL_FLEET_TASK_STATUSES.has(r.status ?? ''))
136
- .map(r => {
137
- const parentId = wireParentId(r);
138
- return {
139
- id: r.id,
140
- // REF-CC-046(fleet2-08,含 TYPESHAPE-06):与 parentId 同款条件展开 —— `name` 是三态位
141
- // (server ≥3.5.1 起真的会缺席),无条件落键会让「键在不在」判三态在序列化边界翻面。
142
- ...(typeof r.name === 'string' && r.name.length > 0 ? { name: r.name } : {}),
143
- // REF-CC-052(fleet2-14):`status` 是 SDK 必填闭集,但脏值/缺席仍可能到达 wire —— 不知道
144
- // 就说不知道,`'unknown'` 是自描述哨兵,绝不编造一个具体的、看似活跃的 `'running'`。
145
- status: r.status ?? 'unknown',
146
- // REF-CC-049(fleet2-11):经 wireParentId 单一铸点(悬空父脏值空串按缺席处理)。eopt:
147
- // 局部变量先取一次(两次裸调用之间 TS 不共享窄化,双调用会重新变宽成 string|undefined)。
148
- ...(parentId !== undefined ? { parentId } : {}),
149
- };
150
- }),
151
- }),
156
+ .map(r => toBgTask(r, false));
157
+ // 🔴 #242 批 3(E4-7 解耦,design-242 §2.2-5):belt 退休、仍在留存窗内的行**继续出现在
158
+ // 在飞读面**(带 `retiredByNotification` 标,消费方自裁;不消费该标 = 保守按在飞计)。
159
+ // 为什么不能直接消失:这条读面的下游是引擎温切门(`readEngineActiveBgTasks`
160
+ // `engineSwapGate` 空集即 SIGTERM 旧引擎)—— 通知车道无代际号,误退休(旧周期回声)
161
+ // 不可排除;从读面瞬间消失 = 一条回声就能杀掉真在跑的子代,不可恢复且无留痕。
162
+ // 窗界:`expireAt`(= 退休时刻 + TERMINAL_RETAIN_MS)自查,不依赖 `project()` 的惰性清扫
163
+ // (headless 车道可能整窗不投影);live 行回场(复活/终态行帧)即让位(taskMap 优先)。
164
+ const nowMs = nowFn();
165
+ for (const [id, r] of retained) {
166
+ if (r.retiredByNotification !== true)
167
+ continue;
168
+ if (nowMs > r.expireAt)
169
+ continue;
170
+ if (taskMap.has(id))
171
+ continue;
172
+ rows.push(toBgTask(r.row, true));
173
+ }
174
+ return { connected, rows };
175
+ },
152
176
  sessionScoped: () => sessionScopedMeta,
153
177
  clearRetained: () => {
154
178
  retained.clear();
@@ -190,6 +214,19 @@ export function createFleetLedger(hooks = {}, opts = {}) {
190
214
  for (const row of frame.tasks) {
191
215
  taskMap.set(row.id, row);
192
216
  rowMeta.set(row.id, { receivedAtMs });
217
+ // #242 批 3(codex P-F1,红先绿后=B3-G4b):snapshot(重连 REPLACE)腿与增量 task 帧腿
218
+ // 的复活语义**同权** —— snapshot 带回的非终态行,其全部别名(同尾段)的 belt 退休条目
219
+ // 一并清,否则重连别名切换(裸 id 退休 → 复合 id running 回归)会产出「复合 running +
220
+ // 裸 completed(带标)」双影。终态行不清(与增量腿同判);snapshot 里**没有**的任务,
221
+ // 留存条目照旧保留(R7 反钉:重连不抽走已完成行)。
222
+ if (!TERMINAL_FLEET_TASK_STATUSES.has(row.status ?? '')) {
223
+ const tail = rowIdTail(row.id);
224
+ for (const id of [...retained.keys()]) {
225
+ if (rowIdTail(id) === tail)
226
+ retained.delete(id);
227
+ }
228
+ clearBgTerminalFacts(tail); // 复活=新周期,与增量腿同权(B3-N3)
229
+ }
193
230
  }
194
231
  for (const row of frame.workflows)
195
232
  wfMap.set(row.id, row);
@@ -242,9 +279,28 @@ export function createFleetLedger(hooks = {}, opts = {}) {
242
279
  // 的 `!retained.has()` 前提失守,第二周期的终态被首周期的陈旧快照挡在门外。
243
280
  if (TERMINAL_FLEET_TASK_STATUSES.has(row.status ?? '')) {
244
281
  retained.set(row.id, { row, expireAt: receivedAtMs + TERMINAL_RETAIN_MS });
282
+ // 终态行帧到达=行帧车道开口(顶注契约:本标即撤)——双生行**另一别名**的 belt 退休
283
+ // 条目一并删(0.31.0 扫码 P3,B3-N2),否则同一任务整 60s 窗按在飞多算。
284
+ const tailT = rowIdTail(row.id);
285
+ for (const id of [...retained.keys()]) {
286
+ if (id !== row.id && rowIdTail(id) === tailT)
287
+ retained.delete(id);
288
+ }
245
289
  }
246
290
  else {
247
291
  retained.delete(row.id);
292
+ // #242 批 3(G4 双生行半场):复活 = 这个任务活着,**全部别名**(裸 id ↔ `runId childId`
293
+ // 复合形共享同一尾段 = 引擎 taskId 键域)的陈旧终态快照一并清 —— 只清整 id 会留下另一
294
+ // 别名的 belt 退休条目,project 会把它当第二条行渲出来(一活一死的双影),读面也会把
295
+ // 一条真在跑的任务同时按「退休中」计。尾段全局唯一(引擎 handle 域),不会误清邻行。
296
+ const tail = rowIdTail(row.id);
297
+ for (const id of [...retained.keys()]) {
298
+ if (id !== row.id && rowIdTail(id) === tail)
299
+ retained.delete(id);
300
+ }
301
+ // 复活=新周期:旧周期终态事实作废(0.31.0 扫码 P2,B3-N3)——不清则 factsAccepted
302
+ // 的方向核把上周期 killed 当先例,第二周期合法 completed 被当洗绿回声拒收。
303
+ clearBgTerminalFacts(tail);
248
304
  }
249
305
  break;
250
306
  }
@@ -320,58 +376,177 @@ export function createFleetLedger(hooks = {}, opts = {}) {
320
376
  return;
321
377
  }
322
378
  debug(`[fleet-frame] bg_notification taskId=${n.taskId} status=${n.status} seq=${n.seq ?? '-'}`);
379
+ // 键域(#242 批 3):`n.taskId` = registry handle = 行 id 的**尾段**。live 集按「整行 id 或
380
+ // 尾段命中」收齐**全部**别名(server 双生行:裸 id 与 `runId childId` 复合形同时在场时两条
381
+ // 一起处理)—— 快照取在任何退休动作之前,原样交给 accepted 钩子。首段(runId)绝不作键
382
+ // (同 run 兄弟行共享首段,按它匹配 = 一条通知误退休全部并发兄弟)。
383
+ const rowIds = [];
384
+ for (const id of taskMap.keys()) {
385
+ if (id === n.taskId || rowIdTail(id) === n.taskId)
386
+ rowIds.push(id);
387
+ }
323
388
  if (TERMINAL_FLEET_TASK_STATUSES.has(n.status)) {
324
389
  if (typeof n.parentTaskId === 'string' && n.parentTaskId) {
325
390
  recordBgParentRun(n.taskId, n.parentTaskId);
326
391
  }
327
392
  // `transcriptId`(server 1.278.0)= 委派 prompt 的取件锚。现势:通知帧是它**今天真的在场**
328
393
  // 的主要位置(行帧那条腿已同款接好,引擎一发即亮 —— 见 case 'task')。
394
+ // (寻址/取件锚不随下方状态面判决走:回声通知携带的 id 映射仍是真映射。)
329
395
  if (typeof n.transcriptId === 'string' && n.transcriptId.length > 0) {
330
396
  recordEngineTranscriptId(n.taskId, n.transcriptId);
331
397
  }
398
+ // ── #242 批 3 belt 根修(design-242 §4.1 判 bug;E4-1/2/6/7 覆盖,零代际账)────────────
399
+ // 旧形的配对失配:belt 只在 `taskMap.get(n.taskId)` 命中时写 `retained`,而 `project()` 对
400
+ // 留存条目一律 `taskMap.has(id) ⇒ continue` —— 两条件互斥,belt 写进去的那条当拍必被跳过,
401
+ // 唯一显形路径是随后的 `task_remove`,而 belt 存在的全部理由恰是「终态行帧(与它同毫秒的
402
+ // remove)丢了」⇒ 对主症(行永恒 running)结构性零效果。
403
+ // 新形:通知命中 live 行 ⇒ **退休**(`taskMap.delete` + `retained.set`),与「终态行帧 +
404
+ // 同毫秒 task_remove」那条路逐段同构;定位按尾段且全部命中(双生行一起退休)。
405
+ //
406
+ // 🔴 状态面单一判决点(两读面 —— 留存池行 status 与 `recordBgTerminalFacts` 台账 —— 共用
407
+ // 同一份判决,绝不各判各的;REF-CC-040 腿三「不分叉」不变量的延续):
408
+ // · **洗绿拦截**(E4-2/G3 的可执行形):已有终态真值 ∈ {failed, killed} 时,`completed`
409
+ // 通知不得改写它 —— 把失败洗成成功是伤害型谎报。「已终态行不覆盖 retained」守卫按
410
+ // 方向落地而非按字面:字面全禁会与 REF-CC-040 腿三(行帧 completed 在先、通知 killed
411
+ // 在后 ⇒ 通知是 settle 权威,两读面必须同说 killed)直接矛盾 —— 同为「行车道已终态 +
412
+ // 通知异词」,升级(completed→killed/failed)是新知识,降级洗绿是回声。
413
+ // · **不引入任何代际账**(E4-1/3/4/5 的共同宿主整套不复活):回声保护 = 方向规则(无状态)
414
+ // + `enqueueBgChildNotification` 既有 (taskId,status,seq) 去重;复活保护 = 非终态行帧
415
+ // 即清账(case 'task' else 臂,含双生行别名清扫)。`droppedStaleCycle*` 计数不复活。
416
+ // · **无早退**(E4-8/G8):状态面判决无论落哪支,通知面(`enqueueBgChildNotification`)
417
+ // 与 accepted 钩子照常执行 —— 通知面与状态面分开裁。
418
+ const isWashGreen = (prior, next) => next === 'completed' && (prior === 'failed' || prior === 'killed');
419
+ const coerced = n.status;
420
+ const nowMs = nowFn();
421
+ const writtenIds = new Set();
422
+ let applied = false;
423
+ let washBlocked = false;
424
+ for (const id of rowIds) {
425
+ const known = taskMap.get(id);
426
+ if (!known)
427
+ continue;
428
+ const rowStatus = known.status ?? '';
429
+ if (TERMINAL_FLEET_TASK_STATUSES.has(rowStatus)) {
430
+ // 行车道已 settle 本行(终态行帧在先):retained 已由 case 'task' 写入行帧真值。
431
+ if (isWashGreen(rowStatus, n.status)) {
432
+ washBlocked = true;
433
+ debug(`[fleet-frame] bg_notification WASH-GREEN blocked(row=${id} row-status=${rowStatus} notif=${n.status})`);
434
+ continue; // G3:`task_remove` 之后投影仍是行帧真值(failed),不被通知洗绿
435
+ }
436
+ if (rowStatus !== n.status) {
437
+ // 升级(REF-CC-040 腿三):通知是 settle 权威,两读面同步改写,不分叉。
438
+ retained.set(id, { row: { ...known, status: coerced }, expireAt: nowMs + TERMINAL_RETAIN_MS });
439
+ }
440
+ writtenIds.add(id);
441
+ applied = true;
442
+ // live 终态行不动(remove 帧随后清;终态行不进在飞读面,留着无害且行车道语义完整)。
443
+ }
444
+ else {
445
+ // 退休:live 行离开在飞集,进留存池,带 E4-7 解耦标(见 bgView.read)。
446
+ // elapsed 在退休时刻**铸一次冻结值**(project() 对 live 行的同一公式):行帧丢失场景
447
+ // wire 上最后一帧的 elapsedMs 是陈旧值(常为 0)——不铸的话完成行的计时器渲 0,
448
+ // 与 A-024.8「翻 status 与冻 elapsed 必须同一刀」同病。诚实边界:冻的是通知到达时刻,
449
+ // 是上界不是精确完成时刻(通知不带可信完成时钟)。
450
+ const started = known.startedAt;
451
+ const meta = rowMeta.get(id);
452
+ const frozenElapsed = typeof started === 'number' && started > 0 && started <= nowMs
453
+ ? nowMs - started
454
+ : meta !== undefined
455
+ ? (known.elapsedMs ?? 0) + Math.max(0, nowMs - meta.receivedAtMs)
456
+ : known.elapsedMs;
457
+ taskMap.delete(id);
458
+ rowMeta.delete(id);
459
+ retained.set(id, {
460
+ row: {
461
+ ...known,
462
+ status: coerced,
463
+ ...(typeof frozenElapsed === 'number' ? { elapsedMs: frozenElapsed } : {}),
464
+ },
465
+ expireAt: nowMs + TERMINAL_RETAIN_MS,
466
+ retiredByNotification: true,
467
+ });
468
+ writtenIds.add(id);
469
+ applied = true;
470
+ }
471
+ }
472
+ // E4-6(G7):留存池更新**不以 live 行在场为前提**,按尾段独立成路 —— 退休后同 task 的
473
+ // 第二条终态通知(completed→killed)必须仍能改写留存池,否则面板绿而详情页 killed。
474
+ for (const [id, entry] of retained) {
475
+ if (writtenIds.has(id))
476
+ continue;
477
+ if (id !== n.taskId && rowIdTail(id) !== n.taskId)
478
+ continue;
479
+ const prior = entry.row.status ?? '';
480
+ if (prior === n.status) {
481
+ applied = true; // 幂等重投(G5):同值不动、**不续窗**(回声风暴不得无限延长读面在飞窗)
482
+ continue;
483
+ }
484
+ if (isWashGreen(prior, n.status)) {
485
+ washBlocked = true;
486
+ debug(`[fleet-frame] bg_notification WASH-GREEN blocked(retained=${id} prior=${prior} notif=${n.status})`);
487
+ continue;
488
+ }
489
+ retained.set(id, {
490
+ row: { ...entry.row, status: coerced },
491
+ expireAt: nowMs + TERMINAL_RETAIN_MS, // 状态真变 = 新知识,续窗让修正可见
492
+ ...(entry.retiredByNotification === true ? { retiredByNotification: true } : {}),
493
+ });
494
+ applied = true;
495
+ }
332
496
  // [1481]① 查看态事实喂给:终态通知带 summary+recentSteps,这是端当前能拿到的关于后台子代
333
497
  // 结局的【全部】内容(终报正文不在 wire 上)。查看态对无终报的终态行渲染这些真实事实。
334
- recordBgTerminalFacts(n.taskId, {
335
- status: n.status,
336
- ...(typeof n.summary === 'string' ? { summary: n.summary } : {}),
337
- // 🔴 SDK 0.0.117 把 `recentSteps` 的三键 declare 成**必填** string,但那是**声称**不是保证:
338
- // 帧从 wire 上来,脏项(null / 缺键 / 非串)在类型面之外仍可能到达,而 BgTerminalFacts
339
- // 那侧三键**全可选** ⇒ 这里的逐键 typeof 不是「防御读没退干净」,是 wire 脏值过滤(与
340
- // fleetProjection `wire*` 值域校验同一档次)。谓词类型必须是元素型本身,不能是
341
- // `Record<string, unknown>`(那不是参数型的子型 TS2677)
342
- ...(Array.isArray(n.recentSteps)
343
- ? {
344
- recentSteps: n.recentSteps
345
- .filter((s) => Boolean(s) && typeof s === 'object')
346
- .map(s => ({
347
- ...(typeof s.tool === 'string' ? { tool: s.tool } : {}),
348
- ...(typeof s.target === 'string' ? { target: s.target } : {}),
349
- ...(typeof s.outcome === 'string' ? { outcome: s.outcome } : {}),
350
- })),
351
- }
352
- : {}),
353
- });
354
- // belt:终态**行帧**丢失(kill 竞态等)时,已知行也随通知转终态进留存池。
355
- // REF-CC-040(fleet2-02):无条件覆盖(不再靠 `!retained.has()` 判「池里没有它」)—— 「谁写得
356
- // 对」不该依赖清理时序;'task' 非终态分支现在会显式 delete,belt 这里覆盖同样安全。
357
- // REF-CC-039:`expireAt` 同一铸点(`nowFn()`),不用 `Date.now()` 另开一个时钟域。
358
- //
359
- // 🔴 取舍写死(P3 wave2 回炉,复审 advisory①:finding proposal 两种形都允许,本处取的是
360
- // 「无条件覆盖」而不是「按 seq 择新」,两个方向的后果各钉了一条常驻断言):
361
- // · **通知晚于终态行帧** ⇒ 覆盖。通知是 settle 权威(下方 `recordBgTerminalFacts` 就是
362
- // 无条件按 `n.status` 记的账),留存池若不跟着覆盖,面板行渲 `completed` 而详情页写
363
- // `killed` = 同一帧两写分叉。钉:pure 门「留存池行的 status 与终态事实台账不分叉」。
364
- // · **非终态行帧晚于 belt** ⇒ 抹掉 belt 结果(`case 'task'` 的 `retained.delete`)。这正是
365
- // 复活语义:行又活了,首周期的终态快照本就不该继续投影。钉:pure 门「复活(非终态)
366
- // 一到就清留存条目 ⇒ 首周期陈旧终态行不得复现为幽灵」。
367
- // 要改成 seq 择新形,先把这两条断言的期望值一起改 —— 它们是这个取舍的可执行形。
368
- const known = taskMap.get(n.taskId);
369
- if (known) {
370
- retained.set(n.taskId, {
371
- row: { ...known, status: n.status },
372
- expireAt: nowFn() + TERMINAL_RETAIN_MS,
498
+ // 🔴 与留存池共用上方同一份判决:状态面收下才记账;洗绿回声整条不入事实台账(status
499
+ // completed 而面板渲 failed = 同一帧两写分叉,REF-CC-040 腿三的反面)。零命中且零拦截
500
+ // (行帧车道整条丢失、留存已过窗)⇒ 按既有事实台账自身做同方向核(killed/failed 在账,
501
+ // completed 回声不得洗绿它)。
502
+ const factsAccepted = applied ||
503
+ (!washBlocked &&
504
+ (() => {
505
+ const prior = getBgTerminalFacts(n.taskId);
506
+ return !(prior !== undefined && isWashGreen(prior.status, n.status));
507
+ })());
508
+ if (factsAccepted) {
509
+ recordBgTerminalFacts(n.taskId, {
510
+ status: n.status,
511
+ ...(typeof n.summary === 'string' ? { summary: n.summary } : {}),
512
+ // 🔴 SDK 0.0.117 `recentSteps` 的三键 declare 成**必填** string,但那是**声称**不是保证:
513
+ // 帧从 wire 上来,脏项(null / 缺键 / 非串)在类型面之外仍可能到达,而 BgTerminalFacts
514
+ // 那侧三键**全可选** ⇒ 这里的逐键 typeof 不是「防御读没退干净」,是 wire 脏值过滤(与
515
+ // fleetProjection 的 `wire*` 值域校验同一档次)。谓词类型必须是元素型本身,不能是
516
+ // `Record<string, unknown>`(那不是参数型的子型 ⇒ TS2677)
517
+ ...(Array.isArray(n.recentSteps)
518
+ ? {
519
+ recentSteps: n.recentSteps
520
+ .filter((s) => Boolean(s) && typeof s === 'object')
521
+ .map(s => ({
522
+ ...(typeof s.tool === 'string' ? { tool: s.tool } : {}),
523
+ ...(typeof s.target === 'string' ? { target: s.target } : {}),
524
+ ...(typeof s.outcome === 'string' ? { outcome: s.outcome } : {}),
525
+ })),
526
+ }
527
+ : {}),
373
528
  });
374
529
  }
530
+ else {
531
+ debug(`[fleet-frame] bg_notification state-plane rejected as wash-green echo(taskId=${n.taskId} notif=${n.status})—— 通知面照常入队`);
532
+ }
533
+ }
534
+ // accepted 钩子(#242 批 3):归属判别已通过;终态/非终态都回调,状态筛选归消费方。
535
+ // 钩子抛错不拆帧消费(留痕即可)。
536
+ try {
537
+ // 钩子类型虽 void,宿主传 async 函数合法(P-F3):返回 thenable 时必须承接拒绝,
538
+ // 否则 async 消费方一炸就是 unhandledRejection = Node 缺省整进程崩(TUI 最坏形,
539
+ // engineSubagentTail meta sink 同族先例)。同步抛与异步拒同一条留痕出口。
540
+ const out = hooks.onBgNotificationAccepted?.(n, rowIds);
541
+ if (out && typeof out.then === 'function') {
542
+ void Promise.resolve(out).catch((e) => {
543
+ hostLog('debug', `[fleet-frame] onBgNotificationAccepted hook rejected: ${String(e).slice(0, 160)}`);
544
+ });
545
+ }
546
+ }
547
+ catch (e) {
548
+ // 消费端钩子的异常不许拆帧消费循环;留痕走 hostLog(端的 debug 汇集面)。
549
+ hostLog('debug', `[fleet-frame] onBgNotificationAccepted hook threw: ${String(e).slice(0, 160)}`);
375
550
  }
376
551
  enqueueBgChildNotification({
377
552
  taskId: n.taskId,
package/dist/index.d.ts CHANGED
@@ -207,6 +207,7 @@ export * from './subagent/engineSubagentResume.js';
207
207
  export * from './subagent/engineTaskHandleWire.js';
208
208
  export * from './subagent/engineCompactWire.js';
209
209
  export * from './subagent/engineSubagentTail.js';
210
+ export * from './subagent/subagentOwnerAbsence.js';
210
211
  export * from './engineSessionParam.js';
211
212
  export * from './engineWireTarget.js';
212
213
  export * from './principalWire.js';
package/dist/index.js CHANGED
@@ -269,6 +269,8 @@ export * from './subagent/engineSubagentResume.js';
269
269
  export * from './subagent/engineTaskHandleWire.js';
270
270
  export * from './subagent/engineCompactWire.js';
271
271
  export * from './subagent/engineSubagentTail.js';
272
+ // #242 批 3([4000] Q3=B):三腿宿主 run 台账缺席的响亮留痕单源(honest-absence 翻面配套)。
273
+ export * from './subagent/subagentOwnerAbsence.js';
272
274
  export * from './engineSessionParam.js';
273
275
  export * from './engineWireTarget.js';
274
276
  // ── A-028 族E(#244 F3,2026-08-15)────────────────────────────────────────────────────────────
@@ -33,7 +33,8 @@ import { hostLog } from '../host.js';
33
33
  import { makeEngineWireClient } from '../engineWireSdk.js';
34
34
  import { engineWireTarget } from '../engineWireTarget.js';
35
35
  import { getBgParentRun } from '../subagentContentStore.js';
36
- import { activeEngineRunId, engineSessionParam } from '../engineSessionParam.js';
36
+ import { engineSessionParam } from '../engineSessionParam.js';
37
+ import { noteBgOwnerAbsence } from './subagentOwnerAbsence.js';
37
38
  // ── caps 探测(true 固化 / false TTL / 失败不缓存)────────────────────────────────────────────
38
39
  const capByBase = new Map();
39
40
  const CAP_FALSE_RETRY_TTL_MS = 5 * 60_000;
@@ -64,12 +65,15 @@ export async function fetchEngineSubagentReport(taskId, opts) {
64
65
  const cfg = engineWireTarget();
65
66
  if (!cfg)
66
67
  return null;
67
- // 🔴 候裁([4000] Q3 / design-242 §5 Q3,#242 批 2 **刻意不动**):这条回落与 resume 腿的
68
- // `resolveOwnerRunId`(指名了行就诚实缺席)口径相反;统一口径是真行为翻面,候三端表态。
69
- // 同族另两处:`engineSubagentTail.ts:tailEngineSubagent` / `engineTaskHandleWire.ts:resolveHostRun`。
70
- const runId = getBgParentRun(taskId) ?? activeEngineRunId();
71
- if (!runId)
68
+ // 🔴 #242 批 3([4000] Q3=B 裁定执行,行为翻面):台账缺席即**诚实缺席,绝不回落**
69
+ // `activeEngineRunId()` —— 与 resume 腿 `resolveOwnerRunId` 同口径(错值比缺席更坏:回落
70
+ // 到「此刻在飞的 run」对 session-bound 旧 run 是 404 撞墙,对同名子代是读错人)。缺席不
71
+ // 静默(noteBgOwnerAbsence warn 留痕);调用方按既有 null 回落 bg 事实渲染,fail-soft。
72
+ const runId = getBgParentRun(taskId);
73
+ if (!runId) {
74
+ noteBgOwnerAbsence('subagent-output', taskId);
72
75
  return null;
76
+ }
73
77
  const client = makeEngineWireClient({
74
78
  baseUrl: cfg.baseUrl,
75
79
  ...(cfg.token ? { token: cfg.token } : {}),
@@ -43,14 +43,16 @@
43
43
  *
44
44
  * 生命周期:查看态进入开,退出/终态/行消失关;每 taskId 至多一条(幂等);caps 探测
45
45
  * capabilities.subagentStream(true 固化/false TTL/失败不缓存,engineSubagentOutput 同纪律);
46
- * 寻址 runId=getBgParentRun ?? 活跃 run、无条件带 `?session=`(同 [1498]③ 纪律)
46
+ * 寻址 runId=getBgParentRun **唯一源**(#242 3 起台账缺席即诚实缺席不回落,warn 留痕)
47
+ * 无条件带 `?session=`(同 [1498]③ 纪律)。
47
48
  * 一切失败 fail-soft:tail 断=回到占位行现状,绝不 throw。
48
49
  */
49
50
  import { hostLog } from '../host.js';
50
51
  import { makeEngineWireClient } from '../engineWireSdk.js';
51
52
  import { engineWireTarget } from '../engineWireTarget.js';
52
- import { activeEngineRunId, engineSessionParam } from '../engineSessionParam.js';
53
+ import { engineSessionParam } from '../engineSessionParam.js';
53
54
  import { coerceOutput, getBgParentRun, parentToolCallIdOf, publishSubagentContentEvent, settleSubagentContent, } from '../subagentContentStore.js';
55
+ import { noteBgOwnerAbsence } from './subagentOwnerAbsence.js';
54
56
  // ── caps 探测(true 固化 / false TTL / 失败不缓存)────────────────────────────────────────────
55
57
  const capByBase = new Map();
56
58
  const CAP_FALSE_RETRY_TTL_MS = 5 * 60_000;
@@ -114,14 +116,17 @@ export function tailEngineSubagent(taskId) {
114
116
  const cfg = engineWireTarget();
115
117
  if (!cfg)
116
118
  return;
117
- // 🔴 候裁([4000] Q3 / design-242 §5 Q3,#242 批 2 **刻意不动**):这条 `?? activeEngineRunId()`
118
- // 与 resume 腿的 `resolveOwnerRunId`(指名了行就诚实缺席,绝不回落)口径**相反**。壳侧对抗
119
- // 复审的结论是「错值比缺席更坏」;统一到那个口径 台账缺席时本腿从「能连上(可能连错 run)
120
- // 变成「不连」= 真行为翻面,需三端(cli/web/desktop)表态后单批改。同族另两处:
121
- // `engineTaskHandleWire.ts:resolveHostRun` / `engineSubagentOutput.ts:fetchEngineSubagentReport`。
122
- const runId = getBgParentRun(taskId) ?? activeEngineRunId();
123
- if (!runId)
119
+ // 🔴 #242 批 3([4000] Q3=B 裁定执行,行为翻面):台账缺席即**诚实缺席,绝不回落**
120
+ // `activeEngineRunId()` —— 与 resume `resolveOwnerRunId` 同口径(错值比缺席更坏:回落值
121
+ // 被并发 main/fork 流覆写,可能连到不相干的 run)。缺席不静默:留痕见 noteBgOwnerAbsence。
122
+ // 供给面:本表由 fleet 行帧 parentId / bg 通知 parentTaskId / task_progress tick 三腿喂
123
+ // (fleetLedger.ts:394/:496 + recordSubagentOwnerFromProgress),正常 bg 子代出生帧即有值;
124
+ // 缺席 = 真不知道宿主(壳重启且行帧未重播等),此时连「当前活跃 run」纯属赌。
125
+ const runId = getBgParentRun(taskId);
126
+ if (!runId) {
127
+ noteBgOwnerAbsence('subagent-tail', taskId);
124
128
  return;
129
+ }
125
130
  const client = makeEngineWireClient({
126
131
  baseUrl: cfg.baseUrl,
127
132
  ...(cfg.token ? { token: cfg.token } : {}),
@@ -41,9 +41,10 @@ import { TaskStopConflictError } from '@sema-agent/sdk';
41
41
  import { hostLog } from '../host.js';
42
42
  import { makeEngineWireClient } from '../engineWireSdk.js';
43
43
  import { engineWireDebugEnabled, engineWireTarget } from '../engineWireTarget.js';
44
- import { activeEngineRunId, engineSessionParamSpread } from '../engineSessionParam.js';
44
+ import { engineSessionParamSpread } from '../engineSessionParam.js';
45
45
  import { getBgParentRun } from '../subagentContentStore.js';
46
46
  import { engineTaskHandlesCapable } from './engineRowStopGate.js';
47
+ import { noteBgOwnerAbsence } from './subagentOwnerAbsence.js';
47
48
  // REF-CC-域词表-06 提单源:spool 全量/增量判读的单一真源现在在 toolResult.ts(它也消费同一份
48
49
  // 协议标记表 PROTOCOL_MARKERS)—— 本文件不再自己 `.includes()` 抄一份判读。
49
50
  import { spoolMarkerOf } from '../toolResult.js';
@@ -54,13 +55,20 @@ import { STOP_CONFLICT_CODES, STOP_NOT_LANDED, STOP_NOT_LOCAL, STOP_PARKED, STOP
54
55
  // ⚠️ 此处**不再 re-export**——它与原定义同进 index.ts 的 `export *` barrel 会构成双出口,
55
56
  // esbuild 对 star-export 歧义直接 build 失败(tsc 同源 symbol 不报=假绿;0.8.0 壳收批实撞,
56
57
  // idle-terminal-settle 套逮住)。包内消费者直接 import 原叶。
57
- /** 宿主 run 解析:fleet 行/通知帧喂的 parent 映射优先(行 id=`${runId} ${handle}` 的 parentId
58
- * 投影),缺席退当前交互 run(register 时刻的活跃 run 即宿主——engineSide bash 行的出生形)。 */
58
+ /** 宿主 run 解析:fleet 行/通知帧喂的 parent 映射(行 id=`${runId} ${handle}` 的 parentId 投影,
59
+ * engineSide bash/monitor 行的出生帧即带)。
60
+ * 🔴 #242 批 3([4000] Q3=B 裁定执行,行为翻面):台账缺席即**诚实缺席**,绝不回落
61
+ * `activeEngineRunId()` —— 旧回落读的是 fetch 时刻的活跃 run,不是 register 时刻的宿主(头注
62
+ * 旧说法与实现不符):跨 turn 读旧句柄时回落值必错(404 撞别的 run),stop 打错 run 更坏。
63
+ * 缺席不静默(noteBgOwnerAbsence warn 留痕);两个消费口各自 fail-soft(output→null 回落
64
+ * receipt/outputFile 渲染,stop→unavailable + detail,UI 不翻行)。 */
59
65
  function resolveHostRun(handle) {
60
- // 🔴 候裁([4000] Q3 / design-242 §5 Q3,#242 批 2 **刻意不动**):这条回落与 resume 腿的
61
- // `resolveOwnerRunId`(指名了行就诚实缺席)口径相反;统一口径是真行为翻面,候三端表态。
62
- // 同族另两处:`engineSubagentTail.ts:tailEngineSubagent` / `engineSubagentOutput.ts`。
63
- return getBgParentRun(handle) ?? activeEngineRunId() ?? null;
66
+ const runId = getBgParentRun(handle);
67
+ if (!runId) {
68
+ noteBgOwnerAbsence('task-handle', handle);
69
+ return null;
70
+ }
71
+ return runId;
64
72
  }
65
73
  // 游标累积(按 handle;进程内存态,与查看态生命周期同级——端重启即空,重读从引擎再取)
66
74
  const accumulated = new Map();
@@ -0,0 +1,4 @@
1
+ /** 台账缺席留痕(每 (leg, taskId) 至多一条 warn;绝不 throw)。 */
2
+ export declare function noteBgOwnerAbsence(leg: string, taskId: string): void;
3
+ /** 测试钩:清留痕去重集(同进程多组断言之间互不污染)。 */
4
+ export declare function __resetBgOwnerAbsenceForTests(): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * subagentOwnerAbsence — #242 批 3(黑板 [4000] Q3=B 裁定落地):bg 子代宿主 run 台账缺席时的
3
+ * **响亮留痕**单源。
4
+ *
5
+ * 三条读面腿(`engineSubagentTail` / `engineTaskHandleWire` / `engineSubagentOutput`)此前一律
6
+ * `getBgParentRun(x) ?? activeEngineRunId()` 无条件回落 —— 与 resume 腿的 `resolveOwnerRunId`
7
+ * (指名了行就诚实缺席,绝不回落)口径相反。Q3 裁定统一到 resume 口径:**台账缺席即诚实缺席,
8
+ * 绝不回落**。理由(壳 `subagentOwnerLedger` 对抗复审结论,已入包表头注):进程级
9
+ * `activeEngineRunId()` 被并发 main/fork 流互相覆写、又被每个 query 的 finally 清空,回落出来的
10
+ * 宿主可能是**别的 run** —— 错值比缺席更坏(读面打到不相干的 run;stop 打错 run 甚至可能停掉
11
+ * 别人的同名句柄)。
12
+ *
13
+ * 但缺席也不许**静默空转**(clay 原话「有问题必暴露」):本模块对每个 (腿, taskId) 组合 warn
14
+ * **一次**(查看态 watcher / 面板 tick 会按拍重试同一缺席,逐次 warn = 刷屏;去重集有界 FIFO)。
15
+ * 消费方照旧 fail-soft 返回缺席值(null/不开流)—— 响亮的是留痕,不是抛错。
16
+ *
17
+ * 🔴 独立小叶而不进 `subagentContentStore`(台账属主):那个 store 是**零 import 的内核闭包件**
18
+ * (portability 门的 runStream 闭包文件数棘轮只许降),拉 `host.js` 进去会撑大内核闭包;三条腿
19
+ * 都在 `subagent/`、都已依赖 host,落这里零新增图边。
20
+ */
21
+ import { hostLog } from '../host.js';
22
+ const noted = new Set();
23
+ const MAX_NOTED = 512;
24
+ /** 台账缺席留痕(每 (leg, taskId) 至多一条 warn;绝不 throw)。 */
25
+ export function noteBgOwnerAbsence(leg, taskId) {
26
+ const key = `${leg}:${taskId}`;
27
+ if (noted.has(key))
28
+ return;
29
+ if (noted.size >= MAX_NOTED) {
30
+ const oldest = noted.values().next().value;
31
+ if (oldest !== undefined)
32
+ noted.delete(oldest);
33
+ }
34
+ noted.add(key);
35
+ try {
36
+ hostLog('warn', `[subagent-owner] ${leg}: no host-run mapping for ${taskId} — not connecting (owner ledger absent ⇒ honest absence; active-run fallback removed per [4000] Q3)`);
37
+ }
38
+ catch {
39
+ // 诊断端口自己抛错(P-F4):①绝不向三腿调用方外溢(留痕是旁路,不是三腿的失败模式);
40
+ // ②撤销去重键 —— warn 没真发出去,留痕机会不许被一次坏端口永久烧掉,端口恢复后
41
+ // 下一次同键缺席仍会 warn 一次。
42
+ noted.delete(key);
43
+ }
44
+ }
45
+ /** 测试钩:清留痕去重集(同进程多组断言之间互不污染)。 */
46
+ export function __resetBgOwnerAbsenceForTests() {
47
+ noted.clear();
48
+ }
@@ -191,6 +191,11 @@ export interface BgTerminalFacts {
191
191
  }>;
192
192
  }
193
193
  export declare function recordBgTerminalFacts(taskId: string, facts: BgTerminalFacts): void;
194
+ /** 复活清账(#242 批 3 / 0.31.0 扫码 P2):非终态行帧/snapshot 复活 = 新周期开启,旧周期的
195
+ * 终态事实作废 —— 不清账时 factsAccepted 的方向核会拿上周期的 killed/failed 当先例,把
196
+ * 第二周期的合法 completed 整条拒收(status 与 summary 都不入账)。只删本 taskId 键;
197
+ * taskToParent 映射不动(寻址面与事实面分离)。 */
198
+ export declare function clearBgTerminalFacts(taskId: string): void;
194
199
  export declare function getBgTerminalFacts(taskId: string): BgTerminalFacts | undefined;
195
200
  /** 一行子代的宿主记录。`sessionId` = 登记时刻那条流的会话(per-id 读面要拼 `?session=`)。 */
196
201
  export interface SubagentOwnerRecord {
@@ -351,6 +351,13 @@ export function recordBgTerminalFacts(taskId, facts) {
351
351
  bgFacts.set(taskId, facts);
352
352
  scheduleNotify(taskId); // 打开中的查看态借既有 notify 链重建消息
353
353
  }
354
+ /** 复活清账(#242 批 3 / 0.31.0 扫码 P2):非终态行帧/snapshot 复活 = 新周期开启,旧周期的
355
+ * 终态事实作废 —— 不清账时 factsAccepted 的方向核会拿上周期的 killed/failed 当先例,把
356
+ * 第二周期的合法 completed 整条拒收(status 与 summary 都不入账)。只删本 taskId 键;
357
+ * taskToParent 映射不动(寻址面与事实面分离)。 */
358
+ export function clearBgTerminalFacts(taskId) {
359
+ bgFacts.delete(taskId);
360
+ }
354
361
  export function getBgTerminalFacts(taskId) {
355
362
  const key = bgFacts.has(taskId) ? taskId : (taskToParent.get(taskId) ?? '');
356
363
  touchLru(bgFacts, key);
@@ -371,8 +378,9 @@ export function getBgTerminalFacts(taskId) {
371
378
  // 所以合表不是二选一,是把两条 lane 的事实并进同一个答案。
372
379
  // 键域两侧本就同域,合表零改键:fleet 腿写 `rowIdTail(row.id)` = 裸引擎 taskId、通知腿写
373
380
  // `n.taskId` = registry handle(同一裸 id)、tick 腿写 `task_progress.taskId`。
374
- // ⚠️ 那三处 `?? activeEngineRunId()` 回落口径**本批刻意不动**(engineSubagentTail.ts:172 /
375
- // engineTaskHandleWire.ts:69 / engineSubagentOutput.ts:82),候 [4000] Q3 三端表态。
381
+ // #242 批 3([4000] Q3=B 裁定):三处 `?? activeEngineRunId()` 回落已全部翻面 —— tail /
382
+ // taskOutput·taskStop / subagentOutput 三腿一律「台账缺席即诚实缺席,绝不回落」,缺席
383
+ // warn 留痕(`subagent/subagentOwnerAbsence.ts`),与 resume 腿口径统一。
376
384
  //
377
385
  // 🔴 写入纪律(壳表头注 :20-22 的教训**原文搬**):宿主 run **必须由持有 stream-local 值的
378
386
  // 调用方显式传入**,本模块绝不从进程级 `activeEngineRunId()` 推断 —— 那个值被并发的
@@ -15,19 +15,19 @@
15
15
 
16
16
  ## §0 版本锚与重扫纪律
17
17
 
18
- ### 0a. 版本锚(2026-08-14)
18
+ ### 0a. 版本锚(2026-08-16)
19
19
 
20
20
  | 项 | 值 | 真源 |
21
21
  |---|---|---|
22
- | 本包 | `@sema-agent/client-core` **0.29.0** | `package.json` `version` |
22
+ | 本包 | `@sema-agent/client-core` **0.31.0** | `package.json` `version` |
23
23
  | peer:wire 契约 | `@sema-agent/sdk` **>=6.17.2**(value-level,非 type-only) | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
- | 公开导出面 | **739** 个运行期符号(+ 36 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **740** 个运行期符号(+ 37 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
27
  | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
28
  | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
29
 
30
- ⚠️ **本表描述的是工作树(即将发布的 0.30.0),不是 npm 上那一版**:npm 的 `0.29.0` 是发布 commit
30
+ ⚠️ **本表描述的是工作树(即将发布的 0.31.0),不是 npm 上那一版**:npm 的 `0.29.0` 是发布 commit
31
31
  `0ab959d`,它的 peer floor 是 **`>=6.16.0`**,也没有 0.30.0 段里那几件(relay 形 / durable 卡两展示键 /
32
32
  SDK 行形 type 再导出 / P-26 铸口 / #158 移交)。装着 npm `0.29.0` 的端**按 `CHANGELOG.md` 的
33
33
  `## 0.29.0` 段对表**,不要按本表 —— 本表的组合在 npm 上今天还不存在。
@@ -99,7 +99,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
99
99
 
100
100
  ## §2 公共导出面地图(按域)
101
101
 
102
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**739** 项)。
102
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**740** 项)。
103
103
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
104
104
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
105
105
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -109,7 +109,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
109
109
 
110
110
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
111
111
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
112
- 实测:739 项 **100% 是运行期导出,零 type-only**。
112
+ 实测:740 项 **100% 是运行期导出,零 type-only**。
113
113
 
114
114
  **推论(端必须知道)**:
115
115
  - barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
@@ -118,19 +118,19 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
118
118
  端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
119
119
  - `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
120
120
 
121
- 739 项的内部构成(帮助端估读表大小):**218** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
121
+ 740 项的内部构成(帮助端估读表大小):**218** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
122
122
  (矩阵、键集、env 名、锚串)而非可调用物;**4** 项是 PascalCase 运行期值
123
123
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError`);
124
124
  **39** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
125
125
 
126
- ### 2b. 域图(16 域,逐域计数之和 = 739)
126
+ ### 2b. 域图(16 域,逐域计数之和 = 740)
127
127
 
128
128
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
129
129
  |---|---|---|---|---|---|
130
130
  | 1 | **适配内核(下行主链)** | 29 | `adapt` · `createWireToCcAdapter` · `runStream` · `eventToSdkMessage` · `terminalToSdkResult` · `turnUsageToModelUsage` · `isRunStreamActive` · `ADAPTER_DIVERGENCES` | 引擎 SSE `AgentEvent` → 端要渲的**双面输出**:transcript(`SDKMessage`)+ chrome(瞬态 `ChromeEvent`)。**本包存在的理由** | `src/adapt.ts`、`src/adapt/{arms,wireShapes,panelTasks}.ts`(经 `adapt.ts` 再导出)、`src/adapter/runStream.ts`、`src/adapter/downstream/*`、`src/adapter/types.ts` |
131
131
  | 2 | **seam 公共契约** | 2(其余为 type-only) | `CHROME_ARMS` · `deriveTranscriptId` | 公共词汇 + **id 确定性不变量**(同一条流重放 ⇒ 同一串 id)。`CHROME_ARMS` = 端「我要消费哪些 chrome 臂」的对照清单 | `src/seam.ts` |
132
132
  | 3 | **HITL 决断卡链**(§4/§5 主战场) | 119 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `waitForGateArmed(For)`/`onGateArmed(For)`/`gateArmedWaitMs`(#244 F1 呈现回执事件源) · `planReviewArmedKey(For)`/`notePlanReviewAnswered(For)`/`notePlanReviewAnsweredIfDecisive(For)`(A-024.4 plan 呈现分代) · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` · `persistedRulesLaneAvailable`/`persistedRulesGovernanceAvailable` · `classifyRulesFailure` · `listAllPersistedRules` · `classifySkippedReason` · `readRulePersistOutcome`(#244 F2:persist-ack 读口与 `readToolApprovalRespondAck` 合成一处) · `parseLocalAllowRule`(durable 腿本地落规则窄化骨架,谓词经 `LocalAllowRuleDeps` 注入) | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层)、`persistedRulesWire.ts`、`localAllowRule.ts`(#244 F2 规则侧) |
133
- | 4 | **子代 wire + 面板侧信道台账** | 80 | `tailEngineSubagent` · `installSubagentActivitySink` · `installSubagentTailMetaSink`(#280 件2:tail meta 帧发布口,`contentFrames` 判别位载体)· `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent` · `resumeSettledSubagent` + `resolveSubagentResumeContext` + `resolveOwnerRunId` + `classifySubagentResumeFailure` + `subagentResumeAvailable`(#242 批 2 A-028.7:resume 判定半场上收,与 steer 孪生同居;取址三态 = 台账有行用行值 / 指名了行但台账缺席则**诚实缺席绝不回落在飞 run** / 没指名行才回落。出路文案归端)· `recordSubagentOwnerFromProgress` + `getBgParentRunOwner`(A-028.6:「子代 → 宿主 run」**单表**,宿主 run 必须由持 stream-local 值的调用方显式传入,包内绝不从 `activeEngineRunId()` 推断)· `auditRetainWithoutWake`([4000] Q5:引擎宣示 `subagentResume` + 本端在付 `retainSubagentSessions` + 端未实现 `wakeSubagent` ⇒ 响亮一条;`CLIENT_VERBS.wakeSubagent` 维持 fail-soft)· `subscribeSubagentContent` · `subscribeEngineAgentPanel` · `publishQuestionFrame` / `respondToQuestion` | 驱动与观测委派子代;经 module 级台账喂活体 agent/task 面板。全部**能力位 gate**(§5b) | `src/subagent/*.ts`、`src/subagentContentStore.ts`、`src/engineAgentPanelStore.ts`、`src/engineInlineTaskStats.ts`、`src/engineToolLabelStore.ts`、`src/liveQuestionStore.ts` |
133
+ | 4 | **子代 wire + 面板侧信道台账** | 81 | `tailEngineSubagent` · `installSubagentActivitySink` · `installSubagentTailMetaSink`(#280 件2:tail meta 帧发布口,`contentFrames` 判别位载体)· `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent` · `resumeSettledSubagent` + `resolveSubagentResumeContext` + `resolveOwnerRunId` + `classifySubagentResumeFailure` + `subagentResumeAvailable`(#242 批 2 A-028.7:resume 判定半场上收,与 steer 孪生同居;取址三态 = 台账有行用行值 / 指名了行但台账缺席则**诚实缺席绝不回落在飞 run** / 没指名行才回落。出路文案归端)· `recordSubagentOwnerFromProgress` + `getBgParentRunOwner`(A-028.6:「子代 → 宿主 run」**单表**,宿主 run 必须由持 stream-local 值的调用方显式传入,包内绝不从 `activeEngineRunId()` 推断)· `noteBgOwnerAbsence`(#242 批 3 [4000] Q3=B:tail/taskOutput·taskStop/subagentOutput 三腿台账缺席即诚实缺席**绝不回落在飞 run**,缺席 warn 留痕每 (腿,taskId) 一条)· `auditRetainWithoutWake`([4000] Q5:引擎宣示 `subagentResume` + 本端在付 `retainSubagentSessions` + 端未实现 `wakeSubagent` ⇒ 响亮一条;`CLIENT_VERBS.wakeSubagent` 维持 fail-soft)· `subscribeSubagentContent` · `subscribeEngineAgentPanel` · `publishQuestionFrame` / `respondToQuestion` | 驱动与观测委派子代;经 module 级台账喂活体 agent/task 面板。全部**能力位 gate**(§5b) | `src/subagent/*.ts`、`src/subagentContentStore.ts`、`src/engineAgentPanelStore.ts`、`src/engineInlineTaskStats.ts`、`src/engineToolLabelStore.ts`、`src/liveQuestionStore.ts` |
134
134
  | 5 | **fleet 投影** | 43 | `createFleetLedger` · `projectTasks` · `projectWorkflows` · `projectFleetAgentRows` · `readEngineActiveBgTasks` · `FLEET_TASK_VIEW_KEYS` | 老 `fleetClient` 那一刀的成品:**帧体归库、连接归端** —— 端持 SSE 连接,库做行投影 + 保留台账 | `src/fleet/fleetProjection.ts`、`src/fleet/fleetLedger.ts`、`src/fleetAgentPanelProjection.ts`、`src/fleetTaskDesc.ts` |
135
135
  | 6 | **请求装配(上行唯一构造口)** | 8 | `buildTaskRequest` · `REQUEST_FIELD_MATRIX` · `unregisteredRequestKeys` · `applyLiveRequestDefaults` · `taskNotificationToPrintFrame` | 两条车道(`interactive`/`print`)出站请求的**唯一**构造器;`unregisteredRequestKeys` 是可执行门 —— 端偷带一个未登记键上 wire 就红 | `src/request/taskRequest.ts`、`src/request/printNotification.ts` |
136
136
  | 7 | **通知与 outstanding 台账** | 37 | `installNotificationQueuePort` · `normalizeTaskNotification` · `taskNotificationDedupKeyFromWire` · `registerOutstandingBgTask` / `registerOutstandingWorkflowRun` · `notificationQueuePortMisses` · `subscribeOutstandingWorkflows` · `outstandingDeliverableWorkflowCount` | `task_notification` 归一 + 去重 + 投递进宿主命令队列的**一把闸**;`outstandingDeliverableWorkflowCount()` 是 headless `-p` 的**退出门** | `src/notifications.ts`(11 个 module 台账) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.30.8",
3
+ "version": "0.31.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",