@sema-agent/client-core 0.30.8 → 0.32.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,114 @@
16
16
  > 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
17
17
  > `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
18
18
 
19
+ ## 0.32.0(2026-08-16)
20
+
21
+ **1.0.80 发包前扫码三件(#280 件A / #281 / #284)。公开面 additive 两件 + 行为面修一件;
22
+ 无 BREAKING —— 两处签名都是 additive,老调用方逐字零行为变化。**
23
+
24
+ - **#280 件A / steer 取址并入 [4000] Q3=B(行为面,additive 参数)**:
25
+ `steerEngineSubagent(target, text, childTaskId?)` 补第三参。`engineSubagentSteer.ts` 此前直取
26
+ `activeEngineRunId()` —— 它是取址**第四腿**,而 tail / `taskOutput`·`taskStop` / `subagentOutput`
27
+ 三腿已在 0.31.0 翻面,孪生的 resume 腿更是从 #242 批 2 起就按行锚(`resolveOwnerRunId`)。
28
+ 这处不对称正是 `engineSubagentResume` 头注点名的那一条:**同一个 agent 有时能说话有时不能** ——
29
+ 进程级活跃 run 在宿主 turn 收尾即清空(后台子代还在跑,消息却发不出去 = `no-run`),另一条
30
+ turn 正飞时它有值**但可能是别的 run**(打到不相干的 run 上,错值比缺席更坏)。
31
+ 新形三态,与 resume 腿**共用同一个判据函数**(不是第二份手抄):台账有这一行 ⇒ 用行值(哪怕
32
+ 此刻另有 run 在飞);**指名了行**而台账缺席 ⇒ `{ok:false, reason:'no-run'}` + `noteBgOwnerAbsence`
33
+ 留痕(腿名 `subagent-steer`,每 (腿,taskId) 至多一条 warn),绝不回落在飞 run;**没指名行**
34
+ (既有两参调用)⇒ 才回落在飞 run。⚠️ 端侧收益要**端传了 `childTaskId` 才到达用户**:壳/web/桌面
35
+ 凡有行上下文的 steer 调用点(壳 `REPL.tsx` 的 `onAgentSubmit` 已持 `task.id`)应跟一行把它传下来。
36
+ **会话参数同批改二态**(codex 复审 medium):行登记时捕到了会话 ⇒ 用**行的会话**(与行的 runId
37
+ 同源同拍,是唯一自洽的组合;旧形「台账 run + 现势 session」在 session-bound run 上是确定性
38
+ 404,等于修好了 run 定位却仍然 steer 不到);行没有随行会话(`recordBgParentRun` 只写 run,
39
+ 今天 bg 行多数是这一格)⇒ 退现势会话。刻意**不**跟 resume 腿的「没捕到就干脆不带」——本腿遵
40
+ tail/subagentOutput 的 [1498]③ 无条件带 session 纪律,resume 更严是因为它是 AT-MOST-ONCE 的
41
+ 叫醒(不可回收),代价不对称。
42
+ - **#281 / `applyBgNotification` 升级臂的 `task_remove` 前分叉窗(行为面修)**:
43
+ 「终态行帧(completed)在先 + 升级通知(killed)在后 + `task_remove` 丢失」时,旧形只改
44
+ `retained` 不动 live 行,而 `project()` 对留存条目一律 `taskMap.has(id) ⇒ continue` ⇒ 面板读的是
45
+ live 行的**旧终态词**、事实台账已是新词:E4-6 注里点名不许出现的「面板绿而详情页 killed」换了
46
+ 条路复现。常态下同毫秒的 remove 把窗压到不可见,remove 单独丢失且不重连时它**长驻**。
47
+ 修:升级时 `retained` 与 `taskMap` **同改**(仅升级方向;洗绿回声照旧拦截,live 行不动)。
48
+ 在飞读面语义不变(升级后仍是终态词,照旧不进在飞集),`task_remove` 语义不变。
49
+ - **#284 / `onBgNotificationAccepted` 补 `evidence` 第三参(公开面 additive)**:
50
+ 新 type-only 导出 `BgNotificationAcceptEvidence` =
51
+ `'server_fail_closed' | 'own_root' | 'own_parent' | 'absent_parent'`,按**实际命中的放行臂**铸值
52
+ (臂序 = 放行判据求值序,多臂同时成立报第一条)。旧签名把四条**强度不同**的臂压成一个「已放行」
53
+ 布尔事实,端因此无法分级处置。
54
+ 🔴 **三档强度,不是四档递减**(codex 复审 high 采纳;首版注把中间一格写成「硬证据」是过度声称):
55
+ **会话级证明** = `server_fail_closed` / `own_root`;**进程级成员证明** = `own_parent` —— own-run 台账是
56
+ 进程级 `Set`、按会话零分区,`/clear` 或换会话后**旧会话**的 run 仍命中(= 在册局限 **P-13** 在
57
+ 通知面的同一张脸),端**不得**把它读成「属于当前会话」;**非证据** = `absent_parent`(通知没带
58
+ `parentTaskId`;老引擎/老帧形不带该键,fail-closed 会把自家通知整批吞掉)。端拿归属做有副作用的事
59
+ (落库/跨会话搬运/翻别人的卡)时,后两格都应自裁为「未证明当前会话归属」。
60
+ 两参消费方(0.31.0 形)零改动照旧;接入说明见 `docs/INTEGRATION-CLIENTS.md` §6d。
61
+ - **#284 尾件 / `parentTaskId` 脏形 fail-closed(行为面收紧,codex 复审第四轮 high)**:
62
+ own/foreign 隔离门写成 `typeof === 'string' ∧ length>0 ∧ !isOwnEngineRun(…)` —— 对 number /
63
+ object / array / boolean 这类脏值**整条不成立**,门不响、外来通知照收(还会被新 evidence 标成
64
+ `absent_parent`,而那一格的语义是「老引擎**没带**这个键」= 谎报)。fleet 帧从 wire 上来、SDK 只
65
+ `JSON.parse` + rest-spread 不校型,这条路真实可达。现在三态分开:**键缺席 / `null` / 空串**照旧走
66
+ absent-放行臂(与 `wireParentId`「悬空父脏值空串按缺席处理」同规矩,**零新增 drop**);**键在场却
67
+ 不是串** ⇒ 与畸形 `taskId`/`status` 同档 fail-closed:计 `droppedMalformed`、不写状态面、
68
+ 不回调 accepted 钩子、不入通知队列。无正当生产者会发这种形,故不计 BREAKING。
69
+ - **已知局限(本版新增登记,不改行为)**:**P-31**(`docs/INTEGRATION-CLIENTS.md` §7c)——
70
+ fleet 面的会话锚整条走**默认槽**,不跟 ledger 的 `sessionKey`,**两条腿都中招**:
71
+ ①`ownByRoot` 判据用 `engineSessionParam()`(= `hostSessionFor(DEFAULT_SESSION_KEY)`);
72
+ ②更强的一条 —— `serverFailClosed`(meta 两位)**根本不读会话端口**,它信的是「这条流替谁开的」,
73
+ 而本包的开流参数 `fleetStreamOptions()` / `fleetSnapshotOptions()` 是 module 级、只认默认槽会话,
74
+ 连 `sessionKey` 都拿不到;**生产 meta 组合命中的正是这一格**。⇒ **keyed 多会话宿主**上,一条按
75
+ 默认槽开的流接到非默认键 ledger 时,默认槽会话的通知会被放行并报 `evidence` 的前两格。
76
+ 单会话宿主(cli 及今天的三端)不受影响 —— 流与 ledger 恒同会话。
77
+ 🔴 正位解 = fleet 面**整条**换 per-key 会话锚(开流参数 + 归属判据一起动);只改判据那一半会
78
+ 「按默认槽开流、按 keyed 槽判定」自相矛盾、反而丢自己的通知 —— 故本批只登记 + 钉现状,不动行为。
79
+ 同批钉住的另一条:`recordBgParentRun` 今天不写随行会话(resume 腿自己记过的「供给面欠账」),
80
+ 补它要先落 P-31 的会话锚,否则会把**错的**会话写进 owner 记录(比今天不写更坏)。
81
+ - **门**:`scripts/run-client-core-pure-test.mjs` #242 批 3 段 +37 条(B3-Q3c 9 / B3-P3 7 / B3-HKe 11 /
82
+ B3-DIRTY 10),地板 62→99(零松量)。三件各自红先绿后,反钉覆盖 additive 不回归、洗绿方向不放行、
83
+ 台账在场仍照常发起、foreign 丢弃仍零回调、`own_parent` 强度边界不被读成会话证明。
84
+ 变异自证四发(逐发只由目标格抓红,cp 复原后逐字节 cmp 一致):撤 `taskMap.set(id, upgraded)` /
85
+ steer 取址退回 `activeEngineRunId()` / `absent_parent` 铸成 `own_parent` / 撤行会话优先。
86
+
87
+ ## 0.31.0(2026-08-16)
88
+
89
+ **#242 批 3 fleet belt 对账承重批(design-242 §3 批 3;黑板 [4000] Q2=A / Q3=B 裁定执行)。
90
+ 🔴 BREAKING(行为面翻面两处,见下);公开面 additive 三件。**
91
+
92
+ - 🔴 **BREAKING②/Q3 三腿翻面**:`getBgParentRun(x) ?? activeEngineRunId()` 的无条件回落**全部
93
+ 移除**(`subagent/engineSubagentTail.ts` / `engineTaskHandleWire.ts:resolveHostRun` /
94
+ `engineSubagentOutput.ts`)。台账缺席时三条读面从「能连上(可能连错 run)」变成**「不连」**:
95
+ tail 不开流、`fetchEngineTaskOutput`/`fetchEngineSubagentReport` 回 null、`stopEngineTask` 回
96
+ `{ok:false, reason:'unavailable', detail:'no host run mapping'}`。理由 = resume 腿同口径
97
+ (「指名了行就诚实缺席」):进程级活跃 run 被并发 main/fork 流覆写,回落值可能指向**不相干的
98
+ run** —— 错值比缺席更坏(读错人 / stop 打错 run)。缺席**不静默**:新导出
99
+ `noteBgOwnerAbsence`(每 (腿,taskId) 至多一条 warn,`__resetBgOwnerAbsenceForTests` 测试钩)。
100
+ 消费端零施工即得新行为;若端此前**依赖**回落连当前 run(不推荐),需自己喂
101
+ `recordBgParentRun`/`recordSubagentOwner`。
102
+ - 🔴 **BREAKING①/belt 退休姿势根修**(design-242 §4.1 判 bug:belt 写留存池以「行还在 live 集」
103
+ 为前提,而 `project()` 对留存条目一律 `taskMap.has ⇒ continue`,两半互斥 ⇒ 行帧丢失场景 belt
104
+ 结构性零效果=「行永恒 running」主症不治)。`applyBgNotification` 终态臂现在把命中的 live 行
105
+ **退休**(`taskMap.delete`+`rowMeta.delete`+`retained.set`,与「终态行帧+同毫秒 task_remove」
106
+ 逐段同构);**按尾段定位且全部命中**(server 双生行:裸 id 与 `runId childId` 复合形一起退休);
107
+ **零代际账**(不引入周期号/高水位/复活基准,`droppedStaleCycle*` 计数不复活;回声保护=状态面
108
+ 方向规则+通知队列既有 `(taskId,status,seq)` 去重,复活保护=非终态行帧即清账,含双生行别名清扫)。
109
+ - **状态面单一判决点**:留存池行 status 与 `recordBgTerminalFacts` 事实台账共用同一份判决,
110
+ 不分叉(REF-CC-040 腿三不变量延续)。**洗绿拦截**:已有终态真值 ∈ {failed,killed} 时
111
+ `completed` 通知不得改写(E4-2/G3);升级(completed→killed/failed)照常后到覆盖。
112
+ - **留存池更新不走 rowIds 门**(E4-6/G7):退休后同 task 的第二条终态通知仍能改写留存条目;
113
+ 同值幂等重投不续窗(G5)。
114
+ - **通知面与状态面分开裁**(E4-8/G8):状态面判决任何一支都不吞 `enqueueBgChildNotification`。
115
+ - **E4-7 解耦(additive①)**:`EngineActiveBgTask` 新增 `retiredByNotification?: boolean` ——
116
+ belt 退休且未过 `TERMINAL_RETAIN_MS` 窗的行**继续出现在在飞读面**(`readEngineActiveBgTasks`)
117
+ 并带此标,消费方自裁;不消费=保守按在飞计(温切/升级门多 defer ≤60s,换「一条回声不能杀掉
118
+ 真在跑子代」)。🔴 **残局 killed 合成(cap 强切/respawn)必须跳过带标行** —— 对已 completed
119
+ 行合成 killed 是谎报。窗过即从读面消失;行帧车道重新开口(终态覆盖/非终态复活)即撤标。
120
+ - **accepted 钩子(additive②)**:`FleetLedgerHooks.onBgNotificationAccepted?(n, rowIds)` ——
121
+ own/foreign 判别**通过之后**回调(foreign/畸形不回调);`rowIds` = 到达时刻按键域命中的 live
122
+ 行 id 快照(退休前取)。端从此零归属逻辑零复刻(壳侧 meta 两位门同批删)。钩子抛错不拆台账。
123
+ - **additive③**:导出 `FleetBgNotificationWire`(钩子参数型)。
124
+ - 常驻门:pure 门新增 `#242批3` 段(G1-G8 可离线序 + Q3 三腿缺席/反钉 + 洗绿方向规则 + 读面
125
+ 解耦窗界),floor 零松量抬升。
126
+
19
127
  ## 0.30.8(2026-08-15)
20
128
 
21
129
  **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[];
@@ -100,12 +114,70 @@ export declare function fleetSnapshotOptions(opts?: {
100
114
  export type HookNoticeFrame = Extract<FleetFrame, {
101
115
  type: 'hook_notice';
102
116
  }>;
117
+ /**
118
+ * 🔴 #284:`onBgNotificationAccepted` 的**证据等级** —— 这条通知是**凭哪一条**放行的。
119
+ *
120
+ * 为什么钩子要说这个:四条放行臂的证据强度**不一样**,而旧签名把它们压成一个「已放行」布尔事实,
121
+ * 端因此无法对不同强度的证据分级处置(强证据可直接落库/翻卡,弱证据宜先渲染、别拿它当归属结论)。
122
+ * 判据锚在**实际命中的那条臂**上,不是锚在「有没有 parentTaskId」这类前置条件上。
123
+ *
124
+ * 🔴 **每个词命名的是「放行臂」,不是「归属结论」**(codex 复审两轮收紧:首版把 `own_parent` 写成
125
+ * 「硬证据」、二版只给 `own_root` 挂了会话锚警告,都是过度声称)。强度分档如下 —— 前两格的
126
+ * 会话级读法**带前提**,第三格永远只是进程级,第四格根本不是证据。
127
+ *
128
+ * 🔴 **前两格的共同前提:喂给本 ledger 的那条 fleet 流,是按本 ledger 的会话开的。**
129
+ * 本 ledger **自己不开流**(帧体归库、连接归端),所以它无从校验这一点;而本包给出的开流参数
130
+ * `fleetStreamOptions()` / `fleetSnapshotOptions()` 是 **module 级函数、只认默认槽会话**
131
+ * (`engineSessionParam()` = `hostSessionFor(DEFAULT_SESSION_KEY)`),连 `opts.sessionKey` 都拿不到。
132
+ * ⇒ **单会话宿主**(cli 及今天的三端)前提恒成立,前两格就是会话级证明;
133
+ * ⇒ **keyed 多会话宿主**上,一条按默认槽开的流被接到非默认键 ledger 时,前两格会替**别的会话**
134
+ * 作证(`server_fail_closed` 尤其是**生产 meta 组合下的那一格**)。在册局限 **P-31**
135
+ * (`docs/INTEGRATION-CLIENTS.md` §7c);正位解 = 开流参数与本判据**一起**换 per-key 会话锚,
136
+ * 只改一半会让「按默认槽开流、按 keyed 槽判定」自相矛盾、反而丢自己的通知。
137
+ *
138
+ * · **会话级证明(带上述前提)**:
139
+ * · `server_fail_closed` — server 侧注入路已 fail-closed(meta `bgNotifyFailClosed` ∧ 本连接
140
+ * session-bound):最强,到达即**这条流的**会话,端侧台账判别整体让位([1510])。
141
+ * ⚠️ 这一臂**完全不读会话端口**,它信的就是「这条流是替谁开的」——前提破了它也不会报错;
142
+ * · `own_root` — 通知带 `rootSessionId`(委托树 root 宿主会话,固定点语义)=== `engineSessionParam()`
143
+ * (**默认槽**的 SessionPort,不是本 ledger 的 `sessionKey` 槽)。
144
+ * · **进程级成员证明**(只证明「本进程曾亲手驱动过这条 run」,🔴 **不区分会话代际**):
145
+ * · `own_parent` — `parentTaskId`(spawn 该子代的 leader run)∈ 本端 own-run 台账。台账真源
146
+ * `subagentContentStore.ownEngineRuns` 是**进程级 `Set`、按会话零分区**(它自己的头注:
147
+ * 「进程内存态,壳重启即空」),所以 `/clear` 或换会话之后,**旧会话**登记的 run 仍然命中
148
+ * —— 这正是在册局限 **P-13**(`docs/INTEGRATION-CLIENTS.md` §7c)在通知面的同一张脸。
149
+ * ⇒ 端不得把它当作「属于当前会话」的证明;要按会话归属做事,自注入会话粒度的 own-run
150
+ * 判据(P-13 给的出路),或只认上面那两格。
151
+ * · **非证据**:
152
+ * · `absent_parent` — 通知**没带** `parentTaskId`:🔴 **这不是证据,是 absent-放行姿势**
153
+ * (老引擎/老帧形不带该键,fail-closed 会把自家通知整批吞掉,故按放行处理)。端要拿归属做
154
+ * 有副作用的事(落库、跨会话搬运、翻别人的卡)时,这一格应当自裁为「未证明」。
155
+ *
156
+ * 臂序 = `applyBgNotification` 放行判据的求值序(多臂同时成立时报**第一条**);开集只在本包加臂时
157
+ * 扩,端按未知词兜底(`default` 当 `absent_parent` 一档处理最安全)。
158
+ */
159
+ export type BgNotificationAcceptEvidence = 'server_fail_closed' | 'own_root' | 'session_anchor_untrusted' | 'own_parent' | 'absent_parent';
103
160
  export interface FleetLedgerHooks {
104
161
  /**
105
162
  * hook_notice 帧的**写 store** 半场(判定在 `classifyHookNoticeFrame`,本台账只做分发)。
106
163
  * 返回值忽略;不给 = 该帧被静默丢弃(端没有承载面时的正确行为)。
107
164
  */
108
165
  onHookNotice?(frame: HookNoticeFrame, sessionScoped: boolean): void;
166
+ /**
167
+ * 🔴 #242 批 3(design-242 §2.2-3,壳侧 fleetClient「长线正解已记账:请 client-core 在归属判定
168
+ * **通过之后**给一个 accepted 钩子,壳改接它」的兑现):`bg_notification` 帧经本台账的
169
+ * own/foreign 判别(serverFailClosed / ownByRoot / parentTaskId ∈ own-run 台账)**放行之后**回调。
170
+ * · 被隔离丢弃(foreign)/畸形早退的通知**不**回调 —— 钩子的语义就是「归属判定已通过」;
171
+ * · 终态与非终态通知都回调(状态筛选归消费方);
172
+ * · `rowIds` = 到达时刻 live 集里按键域(整行 id + 尾段)命中的行 id(可能为空 = 行帧车道
173
+ * 没有这行;可能多条 = server 双生行)。快照取在退休动作**之前**;
174
+ * · `evidence`(#284,additive 第三参)= **凭哪一条臂放行的**,见
175
+ * {@link BgNotificationAcceptEvidence} —— 四条臂证据强度不同,`absent_parent` 尤其**不是
176
+ * 归属证明**。两参消费方(0.31.0 形)零改动照旧;
177
+ * · 钩子抛错不拆台账(try/catch + debug 留痕),与帧消费循环隔离。
178
+ * 端从此**零归属逻辑、零复刻**(壳侧 meta 两位门 [cross-repo-fix-at-source] 同批整条删)。
179
+ */
180
+ onBgNotificationAccepted?(n: FleetBgNotificationWire, rowIds: readonly string[], evidence: BgNotificationAcceptEvidence): void;
109
181
  }
110
182
  /** REF-CC-044(fleet2-06)/REF-CC-039(fleet2-01):`createFleetLedger` 的可选装配位。 */
111
183
  export interface CreateFleetLedgerOptions {
@@ -162,3 +234,11 @@ export interface FleetLedger {
162
234
  * (这条切缝就是设计稿 §2.5 给 fleetClient 划的那一刀:帧体归库、连接归端。)
163
235
  */
164
236
  export declare function createFleetLedger(hooks?: FleetLedgerHooks, opts?: CreateFleetLedgerOptions): FleetLedger;
237
+ /**
238
+ * `bg_notification` 帧的通知体 wire 形(SDK `FleetFrame` 联合的具名臂投影)。
239
+ * #242 批 3 起公开导出 —— `FleetLedgerHooks.onBgNotificationAccepted` 的参数型,端(壳/web/桌面)
240
+ * 接钩子要能写出这个签名。开集读纪律不变:键随 SDK 走,消费方防御读。
241
+ */
242
+ export type FleetBgNotificationWire = Extract<FleetFrame, {
243
+ type: 'bg_notification';
244
+ }>['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
  }
@@ -294,6 +350,26 @@ export function createFleetLedger(hooks = {}, opts = {}) {
294
350
  debug('[fleet-frame] MALFORMED bg_notification dropped(taskId 或 status 缺席/非串)');
295
351
  return;
296
352
  }
353
+ // 🔴 #284 尾件(codex 复审第四轮 high,红先绿后=B3-DIRTY):`parentTaskId` 是**隔离判据的锚**,
354
+ // 它的脏形必须先于判据处置。下面那道 own/foreign 门写成
355
+ // `typeof === 'string' && length > 0 && !isOwnEngineRun(…)` —— 对 number/object/boolean
356
+ // 这类脏值**整条不成立** ⇒ 门不响、通知照收,而新的 evidence 还会把它标成 `absent_parent`
357
+ // (「老引擎没带这个键」的兼容臂):键**带了**,只是坏的,标成缺席是谎报。帧从 wire 上来、
358
+ // SDK 只 JSON.parse + rest-spread 不校型(同 `recentSteps` 处的威胁模型),这条路真实可达。
359
+ // 三态分开,只收紧真正的脏形:
360
+ // · 键缺席 / `undefined` / `null` / 空串 ⇒ absent-放行臂**照旧**(与 `wireParentId`
361
+ // 「悬空父脏值空串按缺席处理」同一规矩;零新增 drop —— 老引擎与「本来就没有父」不受伤);
362
+ // · 键在场却**不是串**(含 `null`,0.32.0 扫码收紧)⇒ 与 taskId/status 同档 fail-closed:
363
+ // 计 `droppedMalformed`、不写状态面、不回调 accepted 钩子、不入通知队列(隔离优先,
364
+ // 坏值不许买路)。`null` 不入兼容臂的理由:wire schema 只允许**缺席或 string**——
365
+ // 老引擎是不带这个键,不是带 `null`;把 null 归入缺席等于给「外来 parent 锚被置 null」
366
+ // 这一腐败/漂移形留一条绕过 foreign 检查的路(空串是 string 的退化形,真老形,保留)。
367
+ if (n.parentTaskId !== undefined && typeof n.parentTaskId !== 'string') {
368
+ droppedMalformed++;
369
+ debug(`[fleet-frame] MALFORMED bg_notification dropped(parentTaskId 键在场但非串:${typeof n.parentTaskId}` +
370
+ ` —— 隔离判据的锚不许被坏值旁路;taskId=${n.taskId})`);
371
+ return;
372
+ }
297
373
  // 🔴 own/foreign 判别(双开泄漏案帧级防线;[1498]④ 换锚重写):payload `sessionId` 是**子代
298
374
  // 自身** session uuid,宿主会话在 ownerSessionId 上且出 wire 前被剥 —— 所以判别锚
299
375
  // `parentTaskId`(spawn 该子代的 leader run id)对 own-run 台账:
@@ -319,60 +395,210 @@ export function createFleetLedger(hooks = {}, opts = {}) {
319
395
  debug(`[fleet-frame] bg_notification DROPPED foreign/unowned parentRun(taskId=${n.taskId} parentRun=${n.parentTaskId})`);
320
396
  return;
321
397
  }
322
- debug(`[fleet-frame] bg_notification taskId=${n.taskId} status=${n.status} seq=${n.seq ?? '-'}`);
398
+ // 🔴 #284:放行**凭据**定级(additive 第三参喂 accepted 钩子)。臂序 = 上面那道放行判据的
399
+ // 求值序,逐臂对位:`serverFailClosed` 先短路 → `ownByRoot` 次之 → 剩下两格由
400
+ // `parentTaskId` 在不在分开(走到这里且带非空 parentTaskId ⇒ 它必然 ∈ own-run 台账,
401
+ // 否则上面已丢弃)。多臂同时成立报第一条 —— 报的是「这一帧**实际**靠哪条过的门」,
402
+ // 不是「最强的那条理论上也成立」。
403
+ // 🔴 非默认 sessionKey 封顶(0.32.0 发包前扫码采 codex 折中,P-31 的诚实半步):前两格的
404
+ // 会话级读法前提=「这条流是按本 ledger 的会话开的」,而开流参数只认默认槽 ⇒ keyed ledger
405
+ // 上该前提**无法成立也无法校验**。封顶词=`session_anchor_untrusted`(不降到 own_parent:
406
+ // 经 serverFailClosed/ownByRoot 放行的帧,其 parentTaskId 可能是 foreign——标进程成员是
407
+ // 比错标会话更糟的谎报;也不标 absent_parent:键可能在场)。语义=「这帧被会话级臂放行,
408
+ // 但本 ledger 无法信任那个会话锚」——消费方按非证据档自裁。单会话宿主(默认 key,今日
409
+ // 三端)逐字不变;P-31 正位解(per-key 会话锚全套)落地时撤此封顶。
410
+ const sessionEvidenceTrusted = sessionKey === DEFAULT_SESSION_KEY;
411
+ const evidence = serverFailClosed || ownByRoot
412
+ ? sessionEvidenceTrusted
413
+ ? serverFailClosed
414
+ ? 'server_fail_closed'
415
+ : 'own_root'
416
+ : 'session_anchor_untrusted'
417
+ : typeof n.parentTaskId === 'string' && n.parentTaskId.length > 0
418
+ ? 'own_parent'
419
+ : 'absent_parent';
420
+ debug(`[fleet-frame] bg_notification taskId=${n.taskId} status=${n.status} seq=${n.seq ?? '-'} accept=${evidence}`);
421
+ // 键域(#242 批 3):`n.taskId` = registry handle = 行 id 的**尾段**。live 集按「整行 id 或
422
+ // 尾段命中」收齐**全部**别名(server 双生行:裸 id 与 `runId childId` 复合形同时在场时两条
423
+ // 一起处理)—— 快照取在任何退休动作之前,原样交给 accepted 钩子。首段(runId)绝不作键
424
+ // (同 run 兄弟行共享首段,按它匹配 = 一条通知误退休全部并发兄弟)。
425
+ const rowIds = [];
426
+ for (const id of taskMap.keys()) {
427
+ if (id === n.taskId || rowIdTail(id) === n.taskId)
428
+ rowIds.push(id);
429
+ }
323
430
  if (TERMINAL_FLEET_TASK_STATUSES.has(n.status)) {
324
431
  if (typeof n.parentTaskId === 'string' && n.parentTaskId) {
325
432
  recordBgParentRun(n.taskId, n.parentTaskId);
326
433
  }
327
434
  // `transcriptId`(server 1.278.0)= 委派 prompt 的取件锚。现势:通知帧是它**今天真的在场**
328
435
  // 的主要位置(行帧那条腿已同款接好,引擎一发即亮 —— 见 case 'task')。
436
+ // (寻址/取件锚不随下方状态面判决走:回声通知携带的 id 映射仍是真映射。)
329
437
  if (typeof n.transcriptId === 'string' && n.transcriptId.length > 0) {
330
438
  recordEngineTranscriptId(n.taskId, n.transcriptId);
331
439
  }
440
+ // ── #242 批 3 belt 根修(design-242 §4.1 判 bug;E4-1/2/6/7 覆盖,零代际账)────────────
441
+ // 旧形的配对失配:belt 只在 `taskMap.get(n.taskId)` 命中时写 `retained`,而 `project()` 对
442
+ // 留存条目一律 `taskMap.has(id) ⇒ continue` —— 两条件互斥,belt 写进去的那条当拍必被跳过,
443
+ // 唯一显形路径是随后的 `task_remove`,而 belt 存在的全部理由恰是「终态行帧(与它同毫秒的
444
+ // remove)丢了」⇒ 对主症(行永恒 running)结构性零效果。
445
+ // 新形:通知命中 live 行 ⇒ **退休**(`taskMap.delete` + `retained.set`),与「终态行帧 +
446
+ // 同毫秒 task_remove」那条路逐段同构;定位按尾段且全部命中(双生行一起退休)。
447
+ //
448
+ // 🔴 状态面单一判决点(两读面 —— 留存池行 status 与 `recordBgTerminalFacts` 台账 —— 共用
449
+ // 同一份判决,绝不各判各的;REF-CC-040 腿三「不分叉」不变量的延续):
450
+ // · **洗绿拦截**(E4-2/G3 的可执行形):已有终态真值 ∈ {failed, killed} 时,`completed`
451
+ // 通知不得改写它 —— 把失败洗成成功是伤害型谎报。「已终态行不覆盖 retained」守卫按
452
+ // 方向落地而非按字面:字面全禁会与 REF-CC-040 腿三(行帧 completed 在先、通知 killed
453
+ // 在后 ⇒ 通知是 settle 权威,两读面必须同说 killed)直接矛盾 —— 同为「行车道已终态 +
454
+ // 通知异词」,升级(completed→killed/failed)是新知识,降级洗绿是回声。
455
+ // · **不引入任何代际账**(E4-1/3/4/5 的共同宿主整套不复活):回声保护 = 方向规则(无状态)
456
+ // + `enqueueBgChildNotification` 既有 (taskId,status,seq) 去重;复活保护 = 非终态行帧
457
+ // 即清账(case 'task' else 臂,含双生行别名清扫)。`droppedStaleCycle*` 计数不复活。
458
+ // · **无早退**(E4-8/G8):状态面判决无论落哪支,通知面(`enqueueBgChildNotification`)
459
+ // 与 accepted 钩子照常执行 —— 通知面与状态面分开裁。
460
+ const isWashGreen = (prior, next) => next === 'completed' && (prior === 'failed' || prior === 'killed');
461
+ const coerced = n.status;
462
+ const nowMs = nowFn();
463
+ const writtenIds = new Set();
464
+ let applied = false;
465
+ let washBlocked = false;
466
+ for (const id of rowIds) {
467
+ const known = taskMap.get(id);
468
+ if (!known)
469
+ continue;
470
+ const rowStatus = known.status ?? '';
471
+ if (TERMINAL_FLEET_TASK_STATUSES.has(rowStatus)) {
472
+ // 行车道已 settle 本行(终态行帧在先):retained 已由 case 'task' 写入行帧真值。
473
+ if (isWashGreen(rowStatus, n.status)) {
474
+ washBlocked = true;
475
+ debug(`[fleet-frame] bg_notification WASH-GREEN blocked(row=${id} row-status=${rowStatus} notif=${n.status})`);
476
+ continue; // G3:`task_remove` 之后投影仍是行帧真值(failed),不被通知洗绿
477
+ }
478
+ if (rowStatus !== n.status) {
479
+ // 升级(REF-CC-040 腿三):通知是 settle 权威,两读面同步改写,不分叉。
480
+ // 🔴 #281(0.31.0 扫码 P3 的真窗,红先绿后=B3-P3):`retained` **和** live 行一起改。
481
+ // 只写 retained 的旧形有一个 remove 之前的分叉窗 —— `project()` 对留存条目一律
482
+ // `taskMap.has(id) ⇒ continue`,于是面板读的是 live 行的**旧终态词**(completed)
483
+ // 而事实台账已是 killed:正是 E4-6 注里点名不许出现的「面板绿而详情页 killed」。
484
+ // 常态下同毫秒的 `task_remove` 会把窗压到不可见,但 remove 单独丢失且不重连时它
485
+ // **长驻**(自愈只能等重连 REPLACE 或整窗过期)。live 行改写后两读面当拍同说;
486
+ // 在飞读面不受影响(升级后仍是终态词,照旧不进在飞集),`task_remove` 语义不变。
487
+ const upgraded = { ...known, status: coerced };
488
+ retained.set(id, { row: upgraded, expireAt: nowMs + TERMINAL_RETAIN_MS });
489
+ taskMap.set(id, upgraded);
490
+ }
491
+ writtenIds.add(id);
492
+ applied = true;
493
+ // live 终态行不退休(remove 帧随后清;终态行不进在飞读面)——只在升级时同步它的状态词。
494
+ }
495
+ else {
496
+ // 退休:live 行离开在飞集,进留存池,带 E4-7 解耦标(见 bgView.read)。
497
+ // elapsed 在退休时刻**铸一次冻结值**(project() 对 live 行的同一公式):行帧丢失场景
498
+ // wire 上最后一帧的 elapsedMs 是陈旧值(常为 0)——不铸的话完成行的计时器渲 0,
499
+ // 与 A-024.8「翻 status 与冻 elapsed 必须同一刀」同病。诚实边界:冻的是通知到达时刻,
500
+ // 是上界不是精确完成时刻(通知不带可信完成时钟)。
501
+ const started = known.startedAt;
502
+ const meta = rowMeta.get(id);
503
+ const frozenElapsed = typeof started === 'number' && started > 0 && started <= nowMs
504
+ ? nowMs - started
505
+ : meta !== undefined
506
+ ? (known.elapsedMs ?? 0) + Math.max(0, nowMs - meta.receivedAtMs)
507
+ : known.elapsedMs;
508
+ taskMap.delete(id);
509
+ rowMeta.delete(id);
510
+ retained.set(id, {
511
+ row: {
512
+ ...known,
513
+ status: coerced,
514
+ ...(typeof frozenElapsed === 'number' ? { elapsedMs: frozenElapsed } : {}),
515
+ },
516
+ expireAt: nowMs + TERMINAL_RETAIN_MS,
517
+ retiredByNotification: true,
518
+ });
519
+ writtenIds.add(id);
520
+ applied = true;
521
+ }
522
+ }
523
+ // E4-6(G7):留存池更新**不以 live 行在场为前提**,按尾段独立成路 —— 退休后同 task 的
524
+ // 第二条终态通知(completed→killed)必须仍能改写留存池,否则面板绿而详情页 killed。
525
+ for (const [id, entry] of retained) {
526
+ if (writtenIds.has(id))
527
+ continue;
528
+ if (id !== n.taskId && rowIdTail(id) !== n.taskId)
529
+ continue;
530
+ const prior = entry.row.status ?? '';
531
+ if (prior === n.status) {
532
+ applied = true; // 幂等重投(G5):同值不动、**不续窗**(回声风暴不得无限延长读面在飞窗)
533
+ continue;
534
+ }
535
+ if (isWashGreen(prior, n.status)) {
536
+ washBlocked = true;
537
+ debug(`[fleet-frame] bg_notification WASH-GREEN blocked(retained=${id} prior=${prior} notif=${n.status})`);
538
+ continue;
539
+ }
540
+ retained.set(id, {
541
+ row: { ...entry.row, status: coerced },
542
+ expireAt: nowMs + TERMINAL_RETAIN_MS, // 状态真变 = 新知识,续窗让修正可见
543
+ ...(entry.retiredByNotification === true ? { retiredByNotification: true } : {}),
544
+ });
545
+ applied = true;
546
+ }
332
547
  // [1481]① 查看态事实喂给:终态通知带 summary+recentSteps,这是端当前能拿到的关于后台子代
333
548
  // 结局的【全部】内容(终报正文不在 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,
549
+ // 🔴 与留存池共用上方同一份判决:状态面收下才记账;洗绿回声整条不入事实台账(status
550
+ // completed 而面板渲 failed = 同一帧两写分叉,REF-CC-040 腿三的反面)。零命中且零拦截
551
+ // (行帧车道整条丢失、留存已过窗)⇒ 按既有事实台账自身做同方向核(killed/failed 在账,
552
+ // completed 回声不得洗绿它)。
553
+ const factsAccepted = applied ||
554
+ (!washBlocked &&
555
+ (() => {
556
+ const prior = getBgTerminalFacts(n.taskId);
557
+ return !(prior !== undefined && isWashGreen(prior.status, n.status));
558
+ })());
559
+ if (factsAccepted) {
560
+ recordBgTerminalFacts(n.taskId, {
561
+ status: n.status,
562
+ ...(typeof n.summary === 'string' ? { summary: n.summary } : {}),
563
+ // 🔴 SDK 0.0.117 `recentSteps` 的三键 declare 成**必填** string,但那是**声称**不是保证:
564
+ // 帧从 wire 上来,脏项(null / 缺键 / 非串)在类型面之外仍可能到达,而 BgTerminalFacts
565
+ // 那侧三键**全可选** ⇒ 这里的逐键 typeof 不是「防御读没退干净」,是 wire 脏值过滤(与
566
+ // fleetProjection 的 `wire*` 值域校验同一档次)。谓词类型必须是元素型本身,不能是
567
+ // `Record<string, unknown>`(那不是参数型的子型 ⇒ TS2677)
568
+ ...(Array.isArray(n.recentSteps)
569
+ ? {
570
+ recentSteps: n.recentSteps
571
+ .filter((s) => Boolean(s) && typeof s === 'object')
572
+ .map(s => ({
573
+ ...(typeof s.tool === 'string' ? { tool: s.tool } : {}),
574
+ ...(typeof s.target === 'string' ? { target: s.target } : {}),
575
+ ...(typeof s.outcome === 'string' ? { outcome: s.outcome } : {}),
576
+ })),
577
+ }
578
+ : {}),
579
+ });
580
+ }
581
+ else {
582
+ debug(`[fleet-frame] bg_notification state-plane rejected as wash-green echo(taskId=${n.taskId} notif=${n.status})—— 通知面照常入队`);
583
+ }
584
+ }
585
+ // accepted 钩子(#242 批 3):归属判别已通过;终态/非终态都回调,状态筛选归消费方。
586
+ // 钩子抛错不拆帧消费(留痕即可)。
587
+ try {
588
+ // 钩子类型虽 void,宿主传 async 函数合法(P-F3):返回 thenable 时必须承接拒绝,
589
+ // 否则 async 消费方一炸就是 unhandledRejection = Node 缺省整进程崩(TUI 最坏形,
590
+ // engineSubagentTail meta sink 同族先例)。同步抛与异步拒同一条留痕出口。
591
+ const out = hooks.onBgNotificationAccepted?.(n, rowIds, evidence);
592
+ if (out && typeof out.then === 'function') {
593
+ void Promise.resolve(out).catch((e) => {
594
+ hostLog('debug', `[fleet-frame] onBgNotificationAccepted hook rejected: ${String(e).slice(0, 160)}`);
373
595
  });
374
596
  }
375
597
  }
598
+ catch (e) {
599
+ // 消费端钩子的异常不许拆帧消费循环;留痕走 hostLog(端的 debug 汇集面)。
600
+ hostLog('debug', `[fleet-frame] onBgNotificationAccepted hook threw: ${String(e).slice(0, 160)}`);
601
+ }
376
602
  enqueueBgChildNotification({
377
603
  taskId: n.taskId,
378
604
  status: n.status,
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 } : {}),
@@ -6,5 +6,30 @@ export type SubagentSteerOutcome = {
6
6
  reason: 'no-wire' | 'no-run' | 'not-running' | 'error';
7
7
  detail?: string;
8
8
  };
9
- /** Steer a running engine subagent (target = its parentToolCallId, or agentName). */
10
- export declare function steerEngineSubagent(target: string, text: string): Promise<SubagentSteerOutcome>;
9
+ /**
10
+ * Steer a running engine subagent (target = its parentToolCallId, or agentName).
11
+ *
12
+ * 🔴 #280 件A(取址第四腿并入 [4000] Q3=B 口径):`childTaskId` 在场时**台账优先、缺席即诚实缺席**,
13
+ * 与 tail / taskOutput·taskStop / subagentOutput 三腿同姿势,也与孪生的 resume 腿共用**同一个**
14
+ * 取址判据(`resolveOwnerRunId`,不是第二份手抄)。此前本腿直取 `activeEngineRunId()` —— 正是
15
+ * `engineSubagentResume` 头注点名的那处不对称(「同一个 agent 有时能说话有时不能」):
16
+ * · 进程级活跃 run 被并发 main/fork 流互相覆写、又在每个 query 的 finally 清空 ⇒ 后台子代在
17
+ * 宿主 turn 收尾之后仍在跑时,这里恒空(no-run,消息发不出去);另一条 turn 正飞时它有值
18
+ * 但**是别的 run**(打到不相干的 run 上 —— 错值比缺席更坏,与 Q3 同一条理由)。
19
+ * 三态(`resolveOwnerRunId` 的单一判据):台账有这一行 ⇒ 用行值(哪怕此刻另有 run 在飞);
20
+ * 指名了行而台账缺席 ⇒ `no-run` + `noteBgOwnerAbsence` 留痕(绝不回落在飞 run);
21
+ * **没指名行**(既有两参调用)⇒ 才回落在飞 run —— 老调用方逐字零行为变化,本参数是 additive。
22
+ *
23
+ * 🔴 会话参数**二态**(codex 复审 medium 采纳:「取了台账的 run 却配现势 session」是确定性 404):
24
+ * · 行登记时**捕到了会话** ⇒ 用**行的会话**(与行的 runId 同源同拍,是唯一自洽的组合);
25
+ * · 行**没有**随行会话(`recordBgParentRun` 只写 run —— 今天 bg 行多数是这一格)⇒ 退**现势会话**。
26
+ * 刻意**不**跟 resume 腿的「没捕到就干脆不带」:本腿与 tail/subagentOutput 同族,遵 [1498]③
27
+ * 无条件带 session 的双版本兼容纪律;runId 既已由台账钉死,配错的 `{runId, session}` 组合在
28
+ * session-bound run 上是 server 干净的 fail-closed 404(落既有 `not-running` 出路),**不会**把消息
29
+ * 投到别的会话 —— 而「没捕到就不带」会让今天最常见的那一格整批 404。resume 那边更严是因为它是
30
+ * AT-MOST-ONCE 的叫醒(真跑一轮,不可回收),两者的代价不对称。
31
+ * 诚实边界:行无会话且端已切到别的会话时,对旧行 steer 会得到 `not-running`(而非静默投错人)。
32
+ *
33
+ * @param childTaskId 这一行子代的 taskId(fleet 行 id 尾段;端有行上下文时**应当**传)。
34
+ */
35
+ export declare function steerEngineSubagent(target: string, text: string, childTaskId?: string): Promise<SubagentSteerOutcome>;
@@ -27,15 +27,51 @@
27
27
  import { hostLog } from '../host.js';
28
28
  import { makeEngineWireClient } from '../engineWireSdk.js';
29
29
  import { engineWireTarget } from '../engineWireTarget.js';
30
- import { activeEngineRunId, engineSessionParamSpread } from '../engineSessionParam.js';
31
- /** Steer a running engine subagent (target = its parentToolCallId, or agentName). */
32
- export async function steerEngineSubagent(target, text) {
30
+ import { engineSessionParamSpread } from '../engineSessionParam.js';
31
+ import { getBgParentRunOwner } from '../subagentContentStore.js';
32
+ import { resolveOwnerRunId } from './engineSubagentResume.js';
33
+ import { noteBgOwnerAbsence } from './subagentOwnerAbsence.js';
34
+ /**
35
+ * Steer a running engine subagent (target = its parentToolCallId, or agentName).
36
+ *
37
+ * 🔴 #280 件A(取址第四腿并入 [4000] Q3=B 口径):`childTaskId` 在场时**台账优先、缺席即诚实缺席**,
38
+ * 与 tail / taskOutput·taskStop / subagentOutput 三腿同姿势,也与孪生的 resume 腿共用**同一个**
39
+ * 取址判据(`resolveOwnerRunId`,不是第二份手抄)。此前本腿直取 `activeEngineRunId()` —— 正是
40
+ * `engineSubagentResume` 头注点名的那处不对称(「同一个 agent 有时能说话有时不能」):
41
+ * · 进程级活跃 run 被并发 main/fork 流互相覆写、又在每个 query 的 finally 清空 ⇒ 后台子代在
42
+ * 宿主 turn 收尾之后仍在跑时,这里恒空(no-run,消息发不出去);另一条 turn 正飞时它有值
43
+ * 但**是别的 run**(打到不相干的 run 上 —— 错值比缺席更坏,与 Q3 同一条理由)。
44
+ * 三态(`resolveOwnerRunId` 的单一判据):台账有这一行 ⇒ 用行值(哪怕此刻另有 run 在飞);
45
+ * 指名了行而台账缺席 ⇒ `no-run` + `noteBgOwnerAbsence` 留痕(绝不回落在飞 run);
46
+ * **没指名行**(既有两参调用)⇒ 才回落在飞 run —— 老调用方逐字零行为变化,本参数是 additive。
47
+ *
48
+ * 🔴 会话参数**二态**(codex 复审 medium 采纳:「取了台账的 run 却配现势 session」是确定性 404):
49
+ * · 行登记时**捕到了会话** ⇒ 用**行的会话**(与行的 runId 同源同拍,是唯一自洽的组合);
50
+ * · 行**没有**随行会话(`recordBgParentRun` 只写 run —— 今天 bg 行多数是这一格)⇒ 退**现势会话**。
51
+ * 刻意**不**跟 resume 腿的「没捕到就干脆不带」:本腿与 tail/subagentOutput 同族,遵 [1498]③
52
+ * 无条件带 session 的双版本兼容纪律;runId 既已由台账钉死,配错的 `{runId, session}` 组合在
53
+ * session-bound run 上是 server 干净的 fail-closed 404(落既有 `not-running` 出路),**不会**把消息
54
+ * 投到别的会话 —— 而「没捕到就不带」会让今天最常见的那一格整批 404。resume 那边更严是因为它是
55
+ * AT-MOST-ONCE 的叫醒(真跑一轮,不可回收),两者的代价不对称。
56
+ * 诚实边界:行无会话且端已切到别的会话时,对旧行 steer 会得到 `not-running`(而非静默投错人)。
57
+ *
58
+ * @param childTaskId 这一行子代的 taskId(fleet 行 id 尾段;端有行上下文时**应当**传)。
59
+ */
60
+ export async function steerEngineSubagent(target, text, childTaskId) {
33
61
  const cfg = engineWireTarget();
34
62
  if (!cfg)
35
63
  return { ok: false, reason: 'no-wire' };
36
- const runId = activeEngineRunId();
37
- if (!runId)
64
+ // 空串/非串按「没指名行」处理(与 resolveOwnerRunId 的 named 判据同口径);窄成 string|undefined
65
+ // 而不是 boolean —— 布尔位不给 TS 窄化,留痕那一行会被迫 cast。
66
+ const named = typeof childTaskId === 'string' && childTaskId !== '' ? childTaskId : undefined;
67
+ const runId = resolveOwnerRunId(named);
68
+ if (!runId) {
69
+ // 指名了行才算「台账缺席」——不指名的通用调用只是没有在飞 run,不是宿主映射缺席(两者出路
70
+ // 同为 no-run,但只有前者是 Q3 要响亮的那一类)。
71
+ if (named !== undefined)
72
+ noteBgOwnerAbsence('subagent-steer', named);
38
73
  return { ok: false, reason: 'no-run' };
74
+ }
39
75
  const client = makeEngineWireClient({
40
76
  baseUrl: cfg.baseUrl,
41
77
  ...(cfg.token ? { token: cfg.token } : {}),
@@ -43,9 +79,13 @@ export async function steerEngineSubagent(target, text) {
43
79
  });
44
80
  if (!client)
45
81
  return { ok: false, reason: 'no-wire' };
82
+ // 会话二态(见头注):行登记的会话优先,缺则退现势会话。这里是同一张表的第二次同步读
83
+ // (与上面 `resolveOwnerRunId` 之间无 await ⇒ 同一拍),取址判据本身仍只有那一处,不另铸。
84
+ const rowSession = named !== undefined ? getBgParentRunOwner(named)?.sessionId : undefined;
85
+ const session = rowSession !== undefined ? { session: rowSession } : engineSessionParamSpread();
46
86
  try {
47
87
  // service 1.89 receipt shape: { note: "Message queued for delivery to <name> …", delivery, … }
48
- const body = await client.runs.steerSubagent(runId, target, { content: text }, engineSessionParamSpread());
88
+ const body = await client.runs.steerSubagent(runId, target, { content: text }, session);
49
89
  let receipt = 'Message queued for delivery at the next tool round.';
50
90
  const copy = body?.note ?? body?.message;
51
91
  if (typeof copy === 'string' && copy.length > 0)
@@ -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
+ | 公开导出面 | **741** 个运行期符号(+ 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`(**741** 项)。
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
+ 实测:741 项 **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
+ 741 项的内部构成(帮助端估读表大小):**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 域,逐域计数之和 = 741)
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 + 面板侧信道台账** | 82 | `tailEngineSubagent` · `installSubagentActivitySink` · `installSubagentTailMetaSink`(#280 件2:tail meta 帧发布口,`contentFrames` 判别位载体)· `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent`(0.32.0 未发布 #280 件A:additive 第三参 `childTaskId` —— 端有行上下文时**应当**传,传了就走「台账优先 / 缺席即诚实缺席 + `noteBgOwnerAbsence` 留痕」的 Q3 口径,与 tail·taskOutput·subagentOutput 三腿同姿势、与孪生 resume 腿共用同一个 `resolveOwnerRunId` 判据;**不传**则逐字维持旧行为=回落在飞 run)· `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) 一条)· `clearBgTerminalFacts`(#242 批 3 扫码修:复活=新周期,旧周期终态事实作废——fleetLedger 复活两腿按尾段清账,factsAccepted 方向核不再拿上周期终态当先例)· `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 台账) |
@@ -644,9 +644,35 @@ HITL 面有 `hitlHostSurfaceFor` —— 见 §5a 的 (a) 表;队列口没有,登
644
644
  (fail-closed,跨会话不认领)。desktop 的 `sessionForEngineId` 单腿判定应收敛到本判据,
645
645
  **传 `sessionKey` + 自注入 `isOwnRun`**。
646
646
 
647
+ #### `onBgNotificationAccepted` 的 `evidence` 参数(0.32.0 未发布,additive 第三参)
648
+
649
+ `createFleetLedger` 的 `FleetLedgerHooks.onBgNotificationAccepted(n, rowIds, evidence)` 在 `bg_notification`
650
+ 帧**通过归属判别之后**回调(被隔离丢弃/畸形早退的不回调)。0.32.0 起补第三参 `evidence`
651
+ (`BgNotificationAcceptEvidence`,type-only 导出):这一帧**凭哪一条臂**放行的。
652
+ **两参消费方(0.31.0 形)零改动照旧** —— 多出来的实参 JS 侧自然忽略。
653
+
654
+ 🔴 **每个词命名的是「放行臂」,不是「归属结论」**;三档强度,中间两格**不同档**。
655
+
656
+ 🔴 **前两格的会话级读法带一个前提:喂给这个 ledger 的 fleet 流,是按这个 ledger 的会话开的。**
657
+ ledger **自己不开流**(帧体归库、连接归端),无从校验;而本包的开流参数 `fleetStreamOptions()` /
658
+ `fleetSnapshotOptions()` 是 **module 级、只认默认槽会话**,连 `sessionKey` 都拿不到。
659
+ ⇒ **单会话宿主**(cli 及今天的三端)前提恒成立;⇒ **keyed 多会话宿主**必须自己保证流与 ledger 同会话,
660
+ 否则前两格会替**别的会话**作证 —— 在册局限 **P-31**(§7c)。
661
+
662
+ | `evidence` | 放行臂 | 强度 | 端该怎么读 |
663
+ |---|---|---|---|
664
+ | `server_fail_closed` | meta `bgNotifyFailClosed` ∧ 本连接 session-bound | **会话级证明**(带上述流前提) | 最强:server 侧注入路已 fail-closed,到达即**这条流的**会话。⚠️ 这一臂**完全不读会话端口**,信的就是「这条流替谁开的」;前提破了它不会报错,而它正是**生产 meta 组合**下命中的那一格 |
665
+ | `own_root` | 通知 `rootSessionId` === `engineSessionParam()`(固定点语义) | **会话级证明**(带上述流前提) | 判据读的是 **`DEFAULT_SESSION_KEY` 槽**的 `SessionPort`,**不是**本 ledger 的 `sessionKey` 槽 —— 单会话宿主两者恒同 |
666
+ | `own_parent` | `parentTaskId`(spawn 该子代的 leader run)∈ own-run 台账 | **进程级成员证明** | 🔴 只证明「本进程曾亲手驱动过这条 run」,**不区分会话代际** —— own-run 台账是进程级 `Set`、按会话零分区,`/clear` 或换会话后**旧会话**的 run 仍命中(= 在册局限 **P-13** 在通知面的同一张脸)。**不得**当作「属于当前会话」;要按会话归属做事,按 P-13 的出路自注入会话粒度判据,或只认上面两格 |
667
+ | `absent_parent` | 通知**没带** `parentTaskId` | **非证据** | 🔴 absent-放行姿势(老引擎/老帧形不带该键,fail-closed 会把自家通知整批吞掉)。端要拿归属做有副作用的事(落库/跨会话搬运/翻别人的卡)时,这一格应自裁为「未证明」 |
668
+
669
+ 臂序 = 包内放行判据的**求值序**,多臂同时成立报**实际过门的第一条**(不报「最强的那条理论上也成立」)。
670
+ 词表开集:端对未知词按 `absent_parent` 一档兜底最安全。真源 = `src/fleet/fleetLedger.ts` 的
671
+ `BgNotificationAcceptEvidence` 顶注。
672
+
647
673
  ### 6e. 🔴 已知局限(多会话端**接之前必读**)
648
674
 
649
- 见 §7c 的 **P-10 ~ P-14** 五条 —— 全部是 sessionKey 面的在册局限,
675
+ 见 §7c 的 **P-10 ~ P-14 + P-31** 六条 —— 全部是 sessionKey 面的在册局限,
650
676
  CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读完它们之前接卡口与 plan review。**
651
677
 
652
678
  其中两条会直接把多会话端坑到「装了但不生效」:
@@ -691,6 +717,7 @@ CHANGELOG 0.29.0「已知局限」段与相应 JSDoc 都有成文。**别在读
691
717
  | **P-12** | low | canonical 重呈短路臂**沿用 arm responder** ⇒ `ReopenPlanReviewOpts.deliverDecision` 注入口**不生效**(成文例外 + debug 留痕) | `src/hitl/planReviewWire.ts`(`mintFreshQuestionId:false` 分支) | 任何**包装决断投递**的端(重试/退避/上屏定序,cli 的 `decideRetry` 是参照)必须走默认 `mintFreshQuestionId` 臂 —— 否则你的包装被静默旁路,跑的是裸 `decidePlanReview` 的 fire-and-forget |
692
718
  | **P-13** | 成文局限(不改行为) | 默认键下的 own-run 归属缺省腿是**进程级**证据,**不区分同一宿主进程内的会话代际** —— `/clear` 前登记的 run 在新会话语境下**仍判 owned**。最坏后果逐字:`用户看到自己旧会话的审批卡` | `src/hitl/parkOwnership.ts`(`ParkOwnershipDeps.isOwnRun` JSDoc);`docs/REFACTOR-LEDGER.md` 记为**驳为成文局限** | 多会话端必须**自注入**会话粒度的 `isOwnRun`;或传非默认 `sessionKey` 并接受缺省腿被整条跳过(代价 = 多一次诚实的 reopen-failed) |
693
719
  | **P-14** | high(打包面) | `activeReopenResponders` 的**单活纪律是 module 单例**:两份实例 ⇒ 各退各的,跨份的旧卡退役不掉 —— **退化回修复前的重复活卡形** | `src/hitl/planReviewWire.ts`(`activeReopenResponders`,singleton-manifest 在册) | 见 §8-G:必须保证 bundle 里只有**一份** `@sema-agent/client-core` |
720
+ | **P-31** | med(0.32.0 未发布登记;codex 复审第二/三轮抓出) | **fleet 面的会话锚整条走默认槽,不跟 ledger 的 `sessionKey`** —— 两条腿都中招:①`bg_notification` 的 `ownByRoot` 判据用 `engineSessionParam()` = `hostSessionFor(DEFAULT_SESSION_KEY)`;②更强的一条 —— `serverFailClosed`(meta 两位)**根本不读会话端口**,它信的是「这条流是替谁开的」,而本包给出的开流参数 `fleetStreamOptions()` / `fleetSnapshotOptions()` 是 **module 级函数、只认默认槽会话**,连 `sessionKey` 都拿不到。⇒ keyed 多会话宿主上,一条按默认槽开的流接到**非默认键** ledger 时,属于默认槽会话的通知会被放行并以 `evidence:'server_fail_closed'`(🔴 **生产 meta 组合下命中的正是这一格**)或 `'own_root'` 交给 hook | `src/fleet/fleetLedger.ts`(`serverFailClosed` / `ownByRoot` / `fleetStreamOptions`);`src/host.ts`(`hostSession` = 默认键) | 单会话宿主(cli 及今天的三端)**不受影响**——默认槽就是它的会话,流与 ledger 恒同会话。**keyed 多会话宿主**:①必须自己保证「流按哪个会话开、就接给哪个 ledger」;②在①落实之前,不要把 `evidence` 的前两格当作「属于本 ledger 会话」的证明去做有副作用的事(落库/翻卡/跨会话搬运)。🔴 正位解 = fleet 面**整条**换 per-key 会话锚(开流参数 + 归属判据一起动);**只改判据这一半会自相矛盾**(按默认槽开流、按 keyed 槽判定 ⇒ 反而丢自己的通知),故本批**不动行为**,登记待裁 |
694
721
 
695
722
  ### 7d. 请求面与其它在册件
696
723
 
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.32.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",