@sema-agent/client-core 0.52.0 → 0.54.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
@@ -13,7 +13,7 @@
13
13
  > 不在账上,都红。**发包批的义务** = 往 `FROZEN` **追加**一行(版本号 + 发布 commit + 段 sha256),
14
14
  > **绝不改写既有行** —— 既有行是「那一版当时发了什么」的历史,不是可变配置。
15
15
  >
16
- > 🔴 **阶段一段头义务(0.48.0 勘误后写死,防第三例)**:发布 commit(阶段一)必须**同批**把
16
+ > 🔴 **阶段一段头义务(0.48.0 勘误后写死;0.53.0 第三例后由 ④a0 机械执行)**:发布 commit(阶段一)必须**同批**把
17
17
  > `## <version>(未发布)` 段头转成日期形 `## <version>(YYYY-MM-DD)`(0.47.0 先例)。发布后该段
18
18
  > 字节即被 ④ 门逐字锁定,漏转**不可回改**、只能永久勘误 —— 0.36.0 与 0.48.0 两条勘误的共同根因
19
19
  > 就是这一步没做;此前头注只写了「未发的进 `(未发布)` 段」而没写「发布时转正」,歧义已由本句消除。
@@ -39,6 +39,147 @@
39
39
  > publish 后才发现。该段字节已被 ④a 逐字锁定在 `4fae01b` 上 ⇒ 标题**不回改、且永远不会被改**;
40
40
  > 门侧窄豁免同批登记(`KNOWN_HEADING_ERRATA` `version: '0.48.0', releasedAt: '4fae01b'`),
41
41
  > 与本段互钉,均为永久记录。
42
+ >
43
+ > 🔴 **勘误(0.53.0 发包批,2026-09-04,永久记录 —— 同案第三例,机理同上)**:`## 0.53.0` 段标题的
44
+ > `(未发布)` 是发布批的操作失误 —— 0.53.0 **已经发布**(releasedAt=npm gitHead `2a159e3`,registry
45
+ > 在架,dist.shasum eb59fb2c…),阶段一 commit 应当把段头转正为日期形而没转(施工车按「本批不发包」
46
+ > 写了 `(未发布)`,主收货把「发布即钉死字节」这一格漏了)。该段字节已被 ④a 逐字锁定在 `2a159e3` 上 ⇒
47
+ > 标题**不回改、且永远不会被改**;门侧窄豁免同批登记(`KNOWN_HEADING_ERRATA` `version: '0.53.0',
48
+ > releasedAt: '2a159e3'`),与本段互钉,均为永久记录。**第三例之后的根治**:头注义务只是文字,三次都没
49
+ > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
+ > commit 漏转在发布前就红,不再靠人记。
51
+
52
+ ## 0.54.0(2026-09-05)
53
+
54
+ ### design/385 —— 三条引擎注入车道的类型化投影(`src/peerFrames.ts`;cli L-61② / L-87 5a①5a②)
55
+
56
+ 引擎把三类**根本不是同一种东西**的载荷塞进同一条 `task_notification` 车道,而 core 对这三类
57
+ **不套** `<task-notification>` 壳(`renderTaskNotificationXml` 头三个分支):
58
+
59
+ | 载体键 | 出处 | 模型面 |
60
+ |---|---|---|
61
+ | `agentMessage` | §1.4 d1,同进程子代 `SendMessage("main")` uplink | `<agent-message from="…">` |
62
+ | `crossSessionMessage` | §4.1,另一个会话的消息从本会话信箱 drain | `<cross-session-message from="…" …>` |
63
+ | `crossSessionNotice` | §4.4/§5.2,**本会话自己发出去**那条消息的回执 / idle 通知 | 一行纯文本 |
64
+
65
+ 端此前照泛化卡渲 ⇒ 用户看到「后台任务完成」卡,而模型读到的是一条同事发来的话:**两个面说的
66
+ 不是同一件事**。本批把判定与编解码整只收进包:
67
+
68
+ - `classifyPeerNotification(rawPayload)` —— 三分投影,认不出返 `null`(= 泛化卡,调用方零改动)。
69
+ 🔴 判别位是**类型化载体的在场**,永远不是 `summary` 文本:三条载体只有引擎注入腿铸得出
70
+ (`ExternalNotificationInput` 是 `TaskNotificationPayload` 的真子集,外部 `notify()` 一个都穿不上),
71
+ 而 `summary`/`result` 是任何一条通知都填的字段 —— 锚文本 = 任何后台任务把
72
+ `<agent-message from="…">` 写进 summary 就能冒充一条同事消息。
73
+ - **fail-closed 分两档**:必填位(`from` 空 / `body` 非串 / notice `kind` 不在闭集)⇒ 整帧退泛化卡
74
+ (诚实降级,用户仍看得见);可选位形不合(`fromMode` 写了闭集外的词)⇒ **只丢那一位**。
75
+ - `_sema_provenance` **必须在场且 `kind` 与车道相符**(异源对抗复审 [high] 翻面):首版写的
76
+ 「缺席放行」理由是「要求在场会让另两条车道在老引擎上恒死」,**该理由被证伪** —— 老引擎上那两条
77
+ 车道连载体键都没有、判定根本走不到,而 core 契约明写 provenance「present exactly when the carrier
78
+ is」,在跑的 7.2.0 就是同一处同时铸两者。要求在场对真载荷零代价,对畸形 / 半截注入 / 版本漂移则
79
+ 关上一道门(否则只凭 `from`+`body` 两个字符串就能让屏上出现一张署着任意名字的消息卡)。
80
+ 判据是**四条等式 + 在场**(`kind` + `from`/`taskId`/`seq` 三位**必须在场**且与载体、载荷逐一相等
81
+ —— core 的 `SemaProvenance` 把这三位全声明为必填,三个铸点也都同址写下,所以「两侧都缺」不是
82
+ 合法兼容形而是半截载荷;首版只写 `a === b`,`undefined === undefined` 正好把它放行了):只核 `kind` 拦得住
83
+ 半截载荷,拦不住**同 kind 的伪造** —— 载体署一个可信名字、provenance 三位全对不上,屏上照样出现
84
+ 一条署着那个名字的消息。后三条各有**铸点直证**(uplink 腿与跨会话 drain 腿都在同一个对象字面量里
85
+ 同值写下 `task_id`/`seq`/`from`),铸点一改由 cli 侧 wire 锚 A-K24 的同址探针当场喊红。
86
+ ⚠️ 载荷那一边是 snake `task_id`、provenance 那一边是 camel `taskId` —— 同一个量两个拼法,写混了
87
+ 等式恒不成立、整条车道静默死掉(本模块首版真写混过一次,当场被门抓住)。
88
+ - **通知臂的分支序修正**:三车道判定挪到**完成卡台账早退之前**。`isWorkflowCompletionCardEnqueued`
89
+ 回答的是「这个 run 的**完成卡**是不是已经入过队」,与「这一帧是不是一条同事消息」无关;而子代
90
+ **续跑**时复用同一个 taskId,它的合法 `agentMessage` 帧就会在那道早退上被整条丢掉(零转录输出,
91
+ 且渲染去重账已记过 ⇒ 重放也补不回来)。peer 帧豁免该早退;泛化完成通知照旧被它吞掉(反向钉在门里)。**同因第二件**:peer 帧也不再写
92
+ `markEngineWorkflowNotified` 那本「完成已通知」的跨通道账 —— 一条同事**消息**不是完成事件
93
+ (三条 peer 铸点的 `status` 都是 `"event"`),写脏了这本账,子代自己真正的空闲期完成通知随后会被
94
+ 补发通道整条丢掉(修前实测:同 taskId 的补发入队恒 0)。
95
+ - **拆火编码是单射**:`escapeEnvelopeTag` 连正文里**原本就有**的反斜杠一起数(`<` 后跟任意多个
96
+ 反斜杠再跟标签名时都插一个),`unescape(escape(x)) === x` 对一切 x 成立。core 那一份是**单向**
97
+ 消毒(模型读完就结束、没有反向腿),照抄到这里就是一个非单射映射:合法代码文本 `<\agent-message>`
98
+ 会在往返之后被改写成真标签,既损坏正文又是一条注入路。
99
+ - **解析腿三条硬边界**:信封必须整串锚定;正文里出现**未拆火**的同名标签 ⇒ 整条退 `null`
100
+ —— 贪婪匹配会把「两封拼一起」读成一封(正文合并、两位署名全记第一位头上)、把「信封 + 尾随
101
+ 文本」读成一封(尾随文本静默消失)。退 `null` = 落回普通文本行,一个字节都不丢。
102
+ - 优先序**照抄 core 渲染腿**(agentMessage → crossSessionMessage → crossSessionNotice):模型那一面
103
+ 已经按这个顺序读过了,端另立一套顺序 ⇒ 屏上的卡与模型读到的帧对不上。
104
+ - `renderPeerFrameTranscriptText` / `parsePeerFrameText` 两半**同源在包**:端的消息面(CC 血统:
105
+ 按标签分派)只拿得到文本,两半不同源就会有人在端里再抄一份正则 —— cli-191 A-D1/A-D12 那一族的
106
+ 成因就是这个。通知车道**刻意不进解析腿**(锚 `[Cross-session …]` 前缀会把用户随手打的同样一行
107
+ 认成引擎通知),它本来就该按普通文本行渲(CC 形)。
108
+ - `peerFrameDisplayName` —— 「@谁」的优先序(跨会话 `fromName` > `from`)也在包里,端不许自己排。
109
+ - **发包前异源扫描三修(2026-09-05,三域 opus 扫描 + opus 反证,12 条 11 实证)**:①[high] 正文**权威信封标签拆火**
110
+ ——渲染腿此前只拆同名信封,漏了 core `neutralizePeerBody` 那一步:子代正文里一段 `<task-notification>…<status>completed</status>…`
111
+ 会原样进转录 block,宿主 resume 时 `parseTranscriptNotificationSeeds` 把它读成真完成通知 ⇒ 去重台账被毒化、受害 run
112
+ 的真完成通知被跨通道臂吞掉(dist 真跑实证);现按 core 同形对 8 个权威标签 `<` 后插 ZWSP,单向不还原;通知纯行同拆并拆两个
113
+ 消息信封标签(整体形如信封的通知不再被端认成消息)。②[high] 属性长度非对称——渲染无界、解析封顶(from 400 / from-name 200)
114
+ ⇒ 包自己铸的帧解析回 null、用户看到裸 XML;解析改无界(线性匹配)。③[medium] 署名规范化——`from`/`fromName` 含换行/控制符
115
+ 折成单行单空格,折空按缺席处置。文档同批勘误:README/门契约串/§17c 中「provenance 只否决不认证、缺席放行」为首版残句,
116
+ 实现自定稿起即为「在场 + 四等式」;§0a 版本锚与 §1c 闭包棘轮数字刷新。
117
+
118
+ **老宿主零差异**:三条载体一个都不在场的载荷(= 今天的全部载荷)走的分支与本批之前**逐字节等价**;
119
+ `taskNotificationArm` 只换了转录行那一行的文本来源,去重键 / settle / 面板 / 台账一个都没动
120
+ (它们判的是「哪个 task 的哪个状态」,与这条通知在模型面穿哪件外衣无关)。
121
+
122
+ **新增常驻门**:`scripts/run-peer-frame-projection-test.mjs`(71 checks,含**适配器级**的两条台账回归)。
123
+
124
+ **已知局限(本版新增)**:`crossSessionMessage` / `crossSessionNotice` 两条载体在 **core 7.2.0
125
+ (= ENGINE_PIN 7.57.0 内嵌那一版)上零铸点** —— 本批是消费半场先落地,上游到货前这两条车道恒
126
+ 不触发。`agentMessage` 一条今天就到得了(core 7.2.0 `agents/send-message-tool.js` 直证)。
127
+
128
+ ## 0.53.0(未发布)
129
+
130
+ ### hitl 自愈:陈旧 park —— 「待决行确已不在」给真出路(L-93;现网 sema-bug7)
131
+
132
+ - **病**:审批卡 park 住一条 run 之后过了很久(实测 27.5h),用户退出 TUI 再 `--resume`;重开链去引擎
133
+ 读待决行时**读面答了**、而属主待决行**恰 0**(引擎重启后 `process_local` 会话店丢行 / 审批被引擎
134
+ reap),run 却仍 `suspended` 并占着会话 claim。修前本包对这一形只有 `ask-reopen-failed` 一个结局
135
+ ⇒ 端渲「Your message was NOT sent … 换个新会话」:卡不可能重开(没有行了),run 不会自己结束
136
+ (park 态不在时间型 reap 射程),用户只剩丢掉整段上下文这一条路。
137
+ - **改**(全部 additive):
138
+ - `ReopenCardVerdict` 的 `reopened:false` 臂加 **`pendingRowGone?: true`** —— 宿主的**正面证据**位:
139
+ 读面在有界窗内**答了**且属主过滤后待决行数**恰 0**。读口缺席 / 网络失败 / 抛错 / 窗尽 / 中止 /
140
+ 「找不到能用的那一行」一律**不置**(fail-closed);判据只认严格 `true`。
141
+ - 新臂 `staleParkArm`:该位在场(且非 `decidedWithoutCard`)⇒ 用 `runs.get` **一手复核**真态,分五路
142
+ —— 404 ⇒ 新 kind **`ask-run-not-found`**(幽灵 claim 的 park 半场,不盲发);`running` ⇒ 交既有三选卡臂
143
+ (不打第二发 get);`needs_review` ⇒ 交既有 plan 重开臂(cancel 它 = 替用户丢掉整个 plan,禁区);
144
+ `suspended` ⇒ 新宿主钩子 **`offerStaleParkChoice`**(两选卡:停掉它 / 什么都不做);读不出、表外新词、
145
+ 中止 ⇒ 一律退回既有 `ask-reopen-failed`。
146
+ - 四个新结局:**`stale-park-cancelled`**(处置 `resending`,与 `running-cancelled` 同一条重发腿、同一个
147
+ 「释放已确认」前提)/ **`stale-park-cancel-timeout`** / **`stale-park-cancel-failed`** / **`stale-park-wait`**
148
+ (带 `alreadyOffered?` 登记降级位,清口同 `clearRunningChoiceOffer`)。
149
+ - cancel 那条腿**抽成共享叶 `cancelAndConfirmRelease`**(三选卡与两选卡同源):失败分类
150
+ (`atMostOnceFailureClass`)与释放判据(`CLAIM_RELEASED_STATES`)不许有第二份实现。
151
+ - 🔴 **边界写在判定上,不写在文案上**:退役的自动 cancel 臂的循环病根 =「**待决项仍在**时 cancel = 替用户
152
+ deny ⇒ 模型重试 ⇒ 再 park」。本臂的前提恰是那个待决项**已经不存在**,那条循环在结构上不成立。所以
153
+ `pendingRowGone` 缺席/含糊值一律进不了新臂(负控写进常驻门),且进臂后仍要一手复核 + 用户显式选择,
154
+ 本包任何路径都不自动 cancel。
155
+ - 🔴 **卡后复证 run 真态**(对抗复审 R3 [high] 采纳):卡前那一发 `runs.get` 打在**呈卡之前**,而卡可以
156
+ 停留任意久 —— 这中间 run 可能恢复运行。所以用户选 cancel 之后**再打一发**,状态仍是审批 park 词才
157
+ 继续;变 running / 终态 / 换门 / 读不出 ⇒ fail-closed 退回旧结局(绝不沿用旧卡的授权去停一条正在
158
+ 干活的 run),404 ⇒ `ask-run-not-found`。
159
+ - 🔴 **开枪前再证一次前提**(对抗复审 R1 [high] 采纳):`pendingRowGone` 是宿主**读那一刻**的事实,
160
+ 而两选卡是人在看、可以看任意久 —— 中间待决行可能重新出现,宿主也可能误报。所以在离 `runs.cancel`
161
+ 最近的那一刻用 `deps.listOwnedPendingApprovals`(既有键,带归属过滤)再问一次属主待决行数,**恰 0
162
+ 才开枪**;非 0 / 抛错 / 窗尽 / 中止一律不开枪。该读口因此成为两选卡的**供给前提**:宿主不注入
163
+ ⇒ 卡整个不呈(fail-closed)。复证走共享叶 `readOwnedPendingCount`(与假死锁防御同一只)。
164
+ - 🔴 **老宿主零行为差**:不传 `pendingRowGone`、不传 `offerStaleParkChoice` ⇒ 逐字节维持今天的结局与文案
165
+ (web / desktop 无需动作)。cli 半场(置位 + 两选卡 UI + live 证据)是另一批。
166
+ ⚠️ 「零行为差」说的是**运行期**:`SelfHealOutcome` 多了五个 kind,对它做穷尽 switch + assertNever 的
167
+ 消费方编译期要补臂(与 0.37.0 起历次加 kind 同形);`default` 分支的消费方零改动。
168
+ - 🔴 **文案不越过证据**(同批 [medium] 采纳):`ask-run-not-found` 不断言「那条 run 不存在」(404 分不出
169
+ 「不存在」与「这个会话读不到」),`stale-park-cancelled` 明说那条 run 已终结、那一轮不会恢复
170
+ (SDK `CancelAck`:受理回 `cancelling`,随后 settle 成 `failed` + `errorCode: "cancelled"`)。
171
+ - 🔴 **两条在册边界(第二轮对抗复审如实登记,不假装不存在)**:
172
+ - **P-44** —— 开枪前复证是「查了再做」,**不是原子条件取消**:复证与那一枪之间仍有毫秒级窗口,
173
+ 待决行恰在此间恢复的话那一枪照样落下。客户端关不死,正位解是**引擎侧条件取消**(带审批快照
174
+ 版本 / checkpoint 标识,条件变了回 409 且不取消)。判据只钉可判的那半:调用序必须是
175
+ 呈卡 → 卡后 get 复证 → 待决行复证 → cancel,两发复证都紧跟在用户那句授权之后。
176
+ - **P-45**(存量族)——「run 读到终态」不等于「claim 已落地释放」:`running-cancelled`(0.37.0 起)
177
+ 与本批 `stale-park-cancelled` 共用同一把尺(`CLAIM_RELEASED_STATES`),重发撞回 409 是**已知可能**,
178
+ 自愈树当拍再分诊。刻意**不**单修新那一条 —— 同一判据两份实现必漂;正位解要引擎给 claim 读面。
179
+ 本批的 `stale-park-cancelled` 文案已按这条边界写(只说「引擎报它已终结」+「可能要再发一次」);
180
+ 0.37.0 的 `running-cancelled` 行仍是旧说法,登记为同形存量待属主批(§7b P-45)。
181
+ - 接入形见 `docs/INTEGRATION-CLIENTS.md` §16(缺口登记见 §7b P-44/P-45)。常驻门:
182
+ `scripts/run-selfheal-reopen-test.mjs` G11 段 + `scripts/run-terminal-identity-copy-test.mjs` G2 段。
42
183
 
43
184
  ## 0.52.0(2026-09-04)
44
185
 
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.52.0
38
+ **Version:** 0.54.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
@@ -263,6 +263,7 @@ public-surface guard checks that last one).
263
263
  | `scripts/run-terminal-identity-copy-test.mjs` | Terminal-state **identity**, in both lanes where a stop gets a name. A run stopped by this deployment's own governance knobs — the open-set `limits.*` family, `output.invalid`, and the `blocked` contract terminal a ReportBlocked agent produces — is not a provider failure, and labelling it `API Error:` sends the reader to check the network, the key and the quota when the handle is the `--max-turns` they passed themselves. Those terminals now render a neutral row; the reverse direction is guarded just as hard, because asserting "this is *not* an API error" on a code the package does not recognise is the same misfiling pointed the other way — a real `gateway HTTP 502`, a `conflict.session_active_run` and any unknown code all keep the `API Error:` prefix, and the row keeps its `isApiErrorMessage` class flag so brief-mode visibility filtering does not silently drop it. The second half is who the rejected submission belonged to: the self-heal copy told every caller "Your message was NOT sent … send it again", which is three separate untruths for a system injection (a plan-review outcome, a cron wake-up, a task notification) — not the user's message, and not re-sendable, since a host queue marks those non-editable and non-recallable. The injected form says so instead, and the one sentence that promises re-delivery is pinned to the single disposition that earns it: `selfHealSubmissionDisposition` is the same function the host consults before putting the item back on its queue, so the promise and the behaviour cannot drift apart, and the arms where no card could be surfaced state plainly that nothing was delivered and nothing will retry |
264
264
  | `scripts/run-additive-key-passthrough-test.mjs` | The one disease shape behind two legs: a **closed whitelist / flattening arm** dropping a fact that is already on the wire, while both sides of the seam look correct. (1) The `task_progress` projection carries a registered **key ledger** — a frame populated with every key the service really projects is pushed through the shipped `eventToSdkMessage`, and the set of wire keys that survive must equal the registered pass-through list **name for name in both directions**, so quietly forwarding one more key is as red as quietly dropping one. `model` (the child run's model id, minted by core as `prepared.model.id` and projected by the server since 7.52.1) is the key this batch adds, with the same conditional the server itself applies: a non-empty string or no key at all — an empty string is neither a model id nor "unknown". The ledger is also checked against the fenced list in `docs/INTEGRATION-CLIENTS.md` §3d, so a doc that still says seven keys while the code forwards eight is red rather than merely stale. (2) The decide-failure arms carry the server's S-02 `currentPending` pointer key from a 409 `approval_stale` refusal onto the outcome the host reads. The reader is structural rather than `instanceof`, because the client is host-injected and the class identity is not this package's to assume; a half triple never mints (half a pointer cannot relocate anything), an empty string is not presence, and `checkpointToken` never transits. Both the allow and the deny leg are driven end to end through the real durable approval path — as is the accept-session leg, where a refusal carrying the pointer key must now re-raise instead of silently re-sending the human's answer for the **old** card as a plain approve (one decide call, pointer preserved), while a legacy 400 still falls back exactly as before — and all three flattening points must call the one shared reader — the same-shape residue check that makes "fixed one arm and left the twin" red instead of invisible |
265
265
  | `scripts/run-esc-halt-plan-test.mjs` | The Esc stop decision every client shares: fire the **turn-level** halt first, and escalate to a **run-level** cancel in exactly two cases — the engine itself answered with a 409 from the closed code set (it is saying "there is no in-flight turn here; use cancel for a run-level stop"), or that shot came back with no verdict at all *and* the shell can independently prove a permission card was on screen. Everything else does not escalate. The asymmetry is the whole point and every negative control guards the same direction — deciding *not* to escalate costs the user one more choice on a busy-session card (recoverable), deciding to escalate wrongly tears down a run that was alive and takes every in-flight tool with it (not). So: the closed code set is a **frozen** value, not a `ReadonlySet` — type-level immutability does not stop a consumer's `.add()`, and the guard proves it by really trying to mutate the exported value and then checking the verdict did not drift; the escalation gate is the **conjunction** of that closed set and the 409 status, since honouring the code alone lets a 500 that merely quotes it drive a destructive call; `interrupt.not_held` and `steering.not_running` are deliberately outside the set (the first means *this replica* has no live face — the run may be perfectly alive on another); an unreadable code falls to the no-escalation side; a `parked` flag never overrides a verdict the engine did give, and only strict `true` counts when it did not. The first shot is unconditional by construction — it does not consult `parked`, because the 409 it earns is exactly the verdict the gate wants — and the verdict itself is a closed machine-readable reason word, not display copy |
266
+ | `scripts/run-peer-frame-projection-test.mjs` | The three engine-injected lanes design/385 puts on the **one** `task_notification` carrier, which are not the same kind of thing at all: a delegated child's uplink (`agentMessage`), another session's message drained from this session's own box (`crossSessionMessage`), and a receipt about one of *this* session's own outbound messages (`crossSessionNotice`). The engine renders none of them inside a `<task-notification>` shell, so a client that projects them as the generic completion card shows "background task finished" while the model read a colleague's sentence — two faces describing different events. The discriminator is pinned to the **typed carrier being present**, never to the `summary` text: those carriers can only be minted by the engine's injection legs (the external `notify()` input is a strict subset of the payload and can wear none of them), while `summary` is filled by every notification there is — so anchoring on text would let any background task impersonate a colleague's message by writing `<agent-message from="…">` into its own summary, and a positive control asserts exactly that payload still projects as the generic card. Fail-closed has two tiers rather than one: a broken **required** field (empty `from`, a non-string `body`, a notice `kind` outside the closed set) returns absence so the caller falls back to the generic card — an honest downgrade where the user still sees the notification — while a broken **optional** field drops only itself, because losing an attribution note and losing a colleague's whole message are not the same magnitude. The provenance side record is **required and must agree on four points** (`kind` matches the lane; `from`/`taskId`/`seq` are present and equal the carrier/payload — each equality is anchored on a core mint site and pinned by the cli wire-anchor A-K24), so a carrier signed with a trusted name but a disagreeing provenance falls back to the generic card; peer bodies pass the same authority-envelope neutralization core applies (`<task-notification>` etc. are defused) so a colleague's text can never seed the resume dedup ledger. Lane precedence copies the engine renderer's own order, because the model already read the frame in that order and a client ordering of its own would put a card on screen that disagrees with the frame the model saw. Rendering and parsing of the transcript line live in the same module and are round-tripped in both directions, including a body carrying a forged closing tag (a parser fooled there hands half a message to the next row) and a quote inside the sender label (which must not forge a second attribute); the notice lane is deliberately kept **out** of the parser, since recognising it would mean anchoring the `[Cross-session …]` prefix and a user typing that same line would be rendered as engine speech. Hostile carriers are read as own **data** descriptors only and accessors are never invoked at all — `catch` catches throwing, not never returning — proven by a counting getter that must stay at zero calls, alongside a revoked proxy and a prototype-only carrier; and four legacy payload shapes assert the no-carrier path is byte-identical to before, which is the executable form of "zero difference for an older host" |
266
267
 
267
268
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
268
269
  assertions is a failure, not a quieter pass. Guards anchor on the **installed artefact's content**
@@ -1,3 +1,4 @@
1
+ import { classifyPeerNotification, renderPeerFrameTranscriptText } from '../peerFrames.js';
1
2
  import { dropQueuedNotificationsForRun, isEngineWorkflowNotified, isWorkflowCompletionCardEnqueued, markEngineWorkflowNotified, normalizeTaskNotification, noteWorkflowCompletionCardEnqueued, renderTaskNotificationXml, taskNotificationDedupKey } from '../notifications.js';
2
3
  import { mapBrainStatusToRetry } from '../retryStatus.js';
3
4
  import { steeringInjectedToAttachments } from '../steering.js';
@@ -676,19 +677,46 @@ const taskNotificationArm = function* (m, { ctx, idOf, panel, inst }) {
676
677
  yield* panel.settleFromNotification(taskId, rawStatus !== 'completed');
677
678
  }
678
679
  }
680
+ // ── design/385:三条引擎注入车道的判定**必须在下面那道完成卡早退之前**做 ────────────────
681
+ // 🔴 异源对抗复审 r2 [high] 实测的吞消息形:`isWorkflowCompletionCardEnqueued(taskId)` 回答的是
682
+ // 「这个 run 的**完成卡**是不是已经入过队」,与「这一帧是不是一条同事消息」毫无关系。而一个
683
+ // 子代**续跑**时复用同一个 taskId(`_sema_provenance.taskId === task_id === senderId`,core
684
+ // 7.2.0 uplink 铸点直证),它的合法 `agentMessage` 帧就会在这道早退上被整条丢掉:零转录输出,
685
+ // 且渲染去重账已经记过 ⇒ 重放也补不回来。复现序:先 `noteWorkflowCompletionCardEnqueued(id)`,
686
+ // 再送同 id 的 uplink 帧 ⇒ 修前只剩 chrome 收尾事件、消息正文完全缺席。
687
+ // ⇒ peer 帧**豁免**这道早退(它不是完成卡,不参与完成卡的双通道去重)。
688
+ const peerFrame = classifyPeerNotification(n);
679
689
  // 双通道反向去重:probe 合成的完成卡已入队 ⇒ 后到的推送帧只做上面的记账,不渲第二条。
680
690
  // 🔴 B5 ①:读的是 **module 台账**(cli `isWorkflowCompletionCardEnqueued` 逐字)——
681
691
  // 记账方(两个 enqueue + idle watcher)与清账方(上面的 drop)都在那儿。
682
- if (n.task_type !== 'external' && isWorkflowCompletionCardEnqueued(taskId))
692
+ if (peerFrame === null && n.task_type !== 'external' && isWorkflowCompletionCardEnqueued(taskId))
683
693
  return;
684
694
  // 跨通道记账(cli markEngineWorkflowNotified 同位、同条件:非 external,不看终态):
685
695
  // 这帧到达 = 引擎已 server-side steer-inject 过,补发通道(workflow_complete)不得再注入。
686
696
  // external 豁免([769] 对抗复审收紧:external 从不经补发通道,mark 只会缴械 probe 兜底)。
687
- if (n.task_type !== 'external')
697
+ // 🔴 **peer 帧同样豁免**(异源对抗复审 r3 [high],实测:投一条合法 uplink 之后再给同 taskId 入队
698
+ // `{status:'completed'}`,队列恒 0、`bgCrossChannelDropped` +1)。这本账问的是「这个 run 的
699
+ // **完成**是不是已经被引擎注入过」——而一条同事**消息**不是完成事件(三条 peer 铸点的
700
+ // `status` 都是 `"event"`,core 直证)。子代跑到一半发了句话就把这本账写脏,它自己真正的
701
+ // 空闲期完成通知随后被补发通道整条丢掉,用户只能等下一次开流才可能看见。
702
+ if (peerFrame === null && n.task_type !== 'external')
688
703
  markEngineWorkflowNotified(taskId);
704
+ // 判定单源 = `classifyPeerNotification`(载体在场判别,绝不锚 summary 文本);认不出 ⇒ `null`
705
+ // ⇒ 这一行与本臂此前**逐字节等价**(三条载体一个都不在场的载荷 = 今天的全部载荷,老宿主零差异)。
706
+ // 🔴 除上面那道完成卡早退之外,其余账**全不动**:去重键 / settle / 面板 / 跨通道 mark 判的是
707
+ // 「哪个 task 的哪个状态」,与这条通知在模型面穿哪件外衣无关,跟着换会把几条独立的账搅成一本。
708
+ // (uplink 帧的 `status` 是 `"event"` —— core 7.2.0 铸点直证 ⇒ 终态那几条腿本来就不会对它开火。)
689
709
  yield transcript({
690
710
  type: 'user',
691
- message: { role: 'user', content: [{ type: 'text', text: renderTaskNotificationXml(fields) }] },
711
+ message: {
712
+ role: 'user',
713
+ content: [
714
+ {
715
+ type: 'text',
716
+ text: peerFrame !== null ? renderPeerFrameTranscriptText(peerFrame) : renderTaskNotificationXml(fields),
717
+ },
718
+ ],
719
+ },
692
720
  uuid: idOf(m),
693
721
  session_id: ctx.sessionId,
694
722
  }, ctx.now());
@@ -131,10 +131,25 @@ export interface DurableRunCallOpts {
131
131
  */
132
132
  export type ReopenCardVerdict =
133
133
  /** L-80:`decidedWithoutCard` = 重开链**没呈卡但已成功决断**(规则直决 / sync-allow)—— 这是进展,
134
- * 不是失败;端不许把它渲成「could not reopen … NOT sent」。缺席 = 真失败形(语义零变)。 */
134
+ * 不是失败;端不许把它渲成「could not reopen … NOT sent」。缺席 = 真失败形(语义零变)。
135
+ *
136
+ * L-93(0.53.0):`pendingRowGone` = 重开链**在有界窗内向引擎读面问过了、而且得到了回答**,
137
+ * 回答是「这条 run 名下**零属主待决行**」——卡不是「呈不出来」,而是**没有东西可呈**
138
+ * (引擎重启后 process_local 会话店丢行 / 审批被引擎 reap,而 run 仍停在 park 态)。
139
+ *
140
+ * 🔴 **置位条件是正面证据,fail-closed**(宿主义务,包侧无法代为判定):
141
+ * · 置 —— 读面(approvals.list 等)在窗内**答了**,且属主过滤后的待决行数**恰 0**;
142
+ * · **不置** —— 读口缺席 / 网络失败 / 抛错 / 窗尽未答 / 调用方中止 / 只是「没找到能用的那一行」
143
+ * (归属证不出、问句缺席、行形坏)。这些都是「不知道」,不是「不在」。
144
+ * 为什么这条边界不能松:`pendingRowGone` 是本层**唯一**允许 cancel 一条 park 态 run 的前提
145
+ * (见 {@link ActiveRunSelfHealDeps.offerStaleParkChoice})。park 态的 cancel 语义 = 替用户否掉
146
+ * 待决项,而「待决项仍在」时那正是已退役的循环病根(cancel = deny ⇒ 模型重试 ⇒ 再 park)。
147
+ * 只有**确已没有任何待决项可被否掉**时,那条循环在结构上不存在。含糊值(`false` / `'true'` /
148
+ * `1` / 缺席)一律不算 —— 判据只认严格 `true`。 */
135
149
  {
136
150
  reopened: false;
137
151
  decidedWithoutCard?: true;
152
+ pendingRowGone?: true;
138
153
  } | {
139
154
  reopened: true;
140
155
  firstSight: boolean;
@@ -153,6 +168,26 @@ export interface RunningChoiceRequest {
153
168
  /** cancel 动词在场 **且** 有 `get` 可以确认 claim 真的释放。 */
154
169
  canCancel: boolean;
155
170
  }
171
+ /**
172
+ * 「陈旧 park」两选卡的呈现请求(L-93,0.53.0)—— run 仍停在 park 态,而它在等的那张审批**已经
173
+ * 不在引擎上**(宿主证出 {@link ReopenCardVerdict} 的 `pendingRowGone`)。卡上只有两条路:
174
+ * 停掉这条 run(会话当场释放,消息随后重发)/ 什么都不做。
175
+ *
176
+ * 🔴 这里**没有** steer 选项:steer 的语义是「注入正在跑的那一轮」,对一条 park 住的 run 它落
177
+ * `queued`(排在那个**已经不存在**的 checkpoint 上)—— 递一条注定注入不进去的路 = 假 affordance。
178
+ * 🔴 `canCancel` 是**供给位**(与 {@link RunningChoiceRequest} 同律):cancel 动词在场 **且** 有
179
+ * `get` 能确认 claim 真的释放,两者缺一就兑现不了「停掉它再重发」。
180
+ */
181
+ export interface StaleParkChoiceRequest {
182
+ taskId: string;
183
+ /** 引擎报的状态(如实转述,不加工)。 */
184
+ status: string;
185
+ /** 「停掉它」这条路今天兑现得了吗 = cancel 动词在场 **且** 有 `get` 能确认 claim 真的释放
186
+ * **且** 有属主待决行读口(`listOwnedPendingApprovals`)能在开枪前把前提再证一次。
187
+ * 三者缺一,本层就不呈这张卡(所以卡到宿主手上时它恒为 `true`;位仍在场是为了与
188
+ * {@link RunningChoiceRequest} 同形,且将来这张卡加第二条路时不必改形)。 */
189
+ canCancel: boolean;
190
+ }
156
191
  /** 分诊树的注入口(重开腿/待决卡探询都是宿主生命周期资产,经 deps 进来,本层保持纯)。 */
157
192
  export interface ActiveRunSelfHealDeps {
158
193
  /** 屏幕上是否已有一张待决卡(有 ⇒ 结局 = decision-pending,绝不重开第二张)。 */
@@ -186,6 +221,20 @@ export interface ActiveRunSelfHealDeps {
186
221
  * 不做)。🔴 Esc / 空答 / 读不出的答案一律由呈现层收口成 `wait`。
187
222
  */
188
223
  offerRunningChoice?: (req: RunningChoiceRequest) => Promise<'steer' | 'cancel' | 'wait'>;
224
+ /**
225
+ * 「陈旧 park」两选卡的呈现口(L-93,0.53.0)。**缺席 ⇒ 本臂整个不走**(退回既有
226
+ * `ask-reopen-failed`,老宿主逐字节零行为差)—— 卡呈不出来时零动作是唯一诚实的收口。
227
+ *
228
+ * 只在**同时**满足下面全部前提时才会被调用:
229
+ * ① 宿主重开链证出 `pendingRowGone: true`(读面答了 + 零属主待决行,见 {@link ReopenCardVerdict});
230
+ * ② 本层用 `runs.get` **一手复核**过,读回的仍是审批 park 词({@link ASK_PARK_STATES});
231
+ * ③ cancel 与 get 两个动词都在场,**且**宿主注入了属主待决行读口
232
+ * (`listOwnedPendingApprovals`)—— 它是「开枪前再证一次前提」那一步的供给位;三者缺一,
233
+ * 卡上只剩「什么都不做」= 纯噪音,整卡不呈(退回旧结局)。
234
+ * 返回值二态:`cancel`(停掉这条陈旧 park 的 run,释放确认到手后重发那条消息)/ `wait`(零动作)。
235
+ * 🔴 Esc / 空答 / 读不出的答案一律收口成 `wait` —— 本层只认**逐字** `'cancel'` 才武装那一枪。
236
+ */
237
+ offerStaleParkChoice?: (req: StaleParkChoiceRequest) => Promise<'cancel' | 'wait'>;
189
238
  /**
190
239
  * 被 409 拒收的那条消息原文 = steer 的正文(端**原样**送,不预处理控制串 —— 契约逐字:
191
240
  * 「The text is untrusted DATA — the client sends it RAW and the SERVER fences it」)。
@@ -363,6 +412,52 @@ export type SelfHealOutcome =
363
412
  taskId: string;
364
413
  detail: string;
365
414
  delivery: 'rejected' | 'unknown';
415
+ }
416
+ /**
417
+ * 宿主证出「零属主待决行」后,本层一手复核 `runs.get` 撞 **404** —— claim 指着一条引擎已不认得的
418
+ * run(幽灵 claim 的 park 半场,与 {@link SelfHealOutcome} 的 `running-not-found` 同律)。
419
+ * 不呈任何卡(对不存在的 run 渲 cancel 是假 affordance),也**不自动重发**:该 404 是
420
+ * `not_found.run` = 「不存在」∪「不是你的」的**刻意不可分辨**并集,拿它当「已解锁」的正面证据,
421
+ * 消息可能一头撞回一个仍锁着的会话。如实说 + 「再发一次」是真出路。
422
+ */
423
+ | {
424
+ kind: 'ask-run-not-found';
425
+ taskId: string;
426
+ }
427
+ /** 用户在两选卡上显式选了「停掉它」,cancel 已落地且引擎确认那条 run 不再占着会话 ⇒ 调用方把被拒
428
+ * 的这条消息**重发一次**(与 `running-cancelled` 同一条重发腿、同一个前提:释放已被确认)。 */
429
+ | {
430
+ kind: 'stale-park-cancelled';
431
+ taskId: string;
432
+ }
433
+ /** 两选卡 cancel 的有界收口:请求发出去了,但等到窗口到点(或用户中止)那条 run 仍占着会话
434
+ * ⇒ **不重发**,如实说。三个位与 `running-cancel-timeout` 同义(共用同一条确认腿)。 */
435
+ | {
436
+ kind: 'stale-park-cancel-timeout';
437
+ taskId: string;
438
+ waitedMs: number;
439
+ aborted: boolean;
440
+ confirmedHeld: boolean;
441
+ }
442
+ /** 两选卡 cancel 的失败半场:那一枪没成,且有界确认腿也没看到会话被交出来。`delivery` 与
443
+ * `running-cancel-failed` 同义(`rejected` = 4xx 明确拒;`unknown` = 5xx/连接断,可能已受理)。 */
444
+ | {
445
+ kind: 'stale-park-cancel-failed';
446
+ taskId: string;
447
+ detail: string;
448
+ delivery: 'rejected' | 'unknown';
449
+ }
450
+ /**
451
+ * 两选卡②:用户选了「什么都不做」(或卡上读不出答案)⇒ 零动作,如实说清死结在哪、有哪两条真出路。
452
+ * `alreadyOffered`(与 `not-parked` 同款登记降级):同 (sessionKey,taskId) 的两选卡**已呈现过且
453
+ * 用户选了「什么都不做」** ⇒ 本次不再整卡重弹,端据此渲一行提示;清口同
454
+ * {@link clearRunningChoiceOffer}。
455
+ */
456
+ | {
457
+ kind: 'stale-park-wait';
458
+ taskId: string;
459
+ status: string;
460
+ alreadyOffered?: true;
366
461
  };
367
462
  /**
368
463
  * 撞 409 的那条提交是谁写的。
@@ -416,7 +511,9 @@ export declare function readSteerReceiptStatus(receipt: unknown): string | null;
416
511
  /** cancel 之后**有界**等那条 run 交出会话的缺省窗(`POST …/cancel` 是 202 异步 —— 收下 ≠ 已停)。 */
417
512
  export declare const CANCEL_RELEASE_WAIT_MS = 10000;
418
513
  /** 清掉某条 run 的「Do nothing」登记 —— 宿主「重新打开操作菜单」入口(下一次 409 重新整卡呈现)。
419
- * `sessionId` 与当时喂给 {@link ActiveRunSelfHealDeps.sessionId} 的值同源(缺席 = 默认键)。 */
514
+ * `sessionId` 与当时喂给 {@link ActiveRunSelfHealDeps.sessionId} 的值同源(缺席 = 默认键)。
515
+ * 🔴 三选卡与两选卡(L-93)的登记**一起清**:这个入口的语义是「让我重新对这条 run 表态」,
516
+ * 只清其中一张会让另一张在宿主已经答应「重新问」之后仍旧被压掉(paired-mechanisms 同族)。 */
420
517
  export declare function clearRunningChoiceOffer(taskId: string, sessionId?: string): void;
421
518
  /** 测试钩:清空整张登记表(套件各组之间隔离用)。 */
422
519
  export declare function __resetRunningChoiceLedgerForTests(): void;