@sema-agent/client-core 0.49.0 → 0.51.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 +167 -0
- package/README.md +3 -1
- package/dist/adapter/activeRunSelfHeal.d.ts +17 -1
- package/dist/adapter/activeRunSelfHeal.js +45 -7
- package/dist/engineErrorCodes.d.ts +16 -0
- package/dist/engineErrorCodes.js +17 -0
- package/dist/hitl/askGateWire.d.ts +3 -2
- package/dist/hitl/askGateWire.js +82 -6
- package/dist/hitl/frameRouter.d.ts +13 -0
- package/dist/hitl/parkResolver.d.ts +56 -1
- package/dist/hitl/parkResolver.js +167 -50
- package/dist/hitl/toolApprovalWire.d.ts +3 -0
- package/dist/hitl/toolApprovalWire.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -0
- package/dist/selfOrchestrationDenial.d.ts +210 -0
- package/dist/selfOrchestrationDenial.js +255 -0
- package/docs/INTEGRATION-CLIENTS.md +333 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -40,6 +40,173 @@
|
|
|
40
40
|
> 门侧窄豁免同批登记(`KNOWN_HEADING_ERRATA` `version: '0.48.0', releasedAt: '4fae01b'`),
|
|
41
41
|
> 与本段互钉,均为永久记录。
|
|
42
42
|
|
|
43
|
+
## 0.51.0(2026-09-03)
|
|
44
|
+
|
|
45
|
+
### hitl:park 再附着的 hop 预算改「连续非进展轮」计数(L-80;cli [6215]/[6217];#357 复发根治)
|
|
46
|
+
|
|
47
|
+
- **病**(1.0.96 现网,auto 模式):一个 turn 里模型对同一失败 Edit 反复重试、每次真被门真被决,第 25 次 park 撞
|
|
48
|
+
`gate hop limit (24) exceeded`,Edit 报 `Operation aborted`,run 引擎侧仍 parked;下一条消息被 activeRunSelfHeal 判
|
|
49
|
+
`ask-reopen-failed` 拒发(「Your message was NOT sent」)。五根同族:`askGateWire` 对**每次 park** 无条件 `hops++`;
|
|
50
|
+
rescan 找到行即无条件复位 #357 的同因限次;`retryExhausted` 臂无次数闸;24 轮只 debug;耗尽后 selfHeal 对
|
|
51
|
+
「无卡但链已直决成功」仍判失败。
|
|
52
|
+
- **改**:
|
|
53
|
+
- `MAX_GATE_HOPS`(现导出,值 `2`)语义换成**连续非进展轮上限**;`nextHopBudget(prev, 'progress'|'stalled')`
|
|
54
|
+
纯函数导出;驱动侧按「重附着后那段流有没有 `isHostProgressFrame` 帧」判进展 —— 有进展就清零,决断成功但
|
|
55
|
+
引擎原样 park 回来(坐标失配 / reopen 类 re-park `resume.tool_unavailable`)判非进展。
|
|
56
|
+
- 同因限次复位挪到驱动侧的进展观察点;`resolvePark` 内两处无条件复位删除。已解决臂限次保持 1;`retryExhausted`
|
|
57
|
+
臂纳入同一本账、同因连续 2 次即收场。
|
|
58
|
+
- `AskGateWireDeps.onParkReattach?: (e: ParkReattachNotice) => void`(additive):每一轮非进展 reattach 调一次
|
|
59
|
+
`{attempt, max, reason}`,端渲状态行(cli 接 Spinner);缺席只留 debug。
|
|
60
|
+
- 触顶收场:上一轮没呈过卡 ⇒ 先现读队列再呈一次收场卡;呈过 ⇒ 不重复问;终帧 `hitl_unanswered` 的
|
|
61
|
+
`errorMessage` 改为真因(「stayed parked across N consecutive re-attach rounds … (last: …)」/ 传输连断 N 次)
|
|
62
|
+
+ 出路句「decide it on the card when it is shown again, or cancel the run」。**不再出现** `gate hop limit` 字样。
|
|
63
|
+
- `ReopenCardVerdict` 新位 `{ reopened:false, decidedWithoutCard:true }` ⇒ `SelfHealOutcome` 新 kind
|
|
64
|
+
`ask-decided-without-card`(disposition `resending`),文案「…your permission rules decided on their own … sema is
|
|
65
|
+
re-sending your message」;裸 `{reopened:false}` 语义零变。
|
|
66
|
+
- **门**:`scripts/run-park-hop-progress-test.mjs`(39 checks,红先绿后);`run-hitl-gate-honesty-test.mjs` F15-f 夹具
|
|
67
|
+
按新语义补一帧 text_delta(两只正当的门之间本来就有模型推进;修前零推进帧也能过,正是复位挂错位置的证据)。
|
|
68
|
+
- **对抗复审 r1 三条采纳**:①进展**同时**要求 park 身份推进(同 `gatedCallId` 原样回来,中间吐一帧文本也不算)
|
|
69
|
+
+ `MAX_TOTAL_PARKS=64` 硬兜底(tool-less park 无身份只看帧,防病态引擎无限);②`decidedWithoutCard` 只证明「决断受理」,
|
|
70
|
+
库先有界等 claim 释放(与 cancel 释放窗同源 10s,`runs.get`):释放 ⇒ `ask-decided-without-card{released:true}`
|
|
71
|
+
(resending);在跑 ⇒ 既有 running 臂(三选卡 / 等它跑完),**绝不盲发**;仍 parked / 读不出 / 无 `runs.get` ⇒ 新结局 `ask-decided-release-unknown`(不重发、不谎报失败);
|
|
72
|
+
③「上一轮呈过卡」改用**每次调用的真回执**(`FsApprovalWireDeps.onPresented` + ask 腿同一枚旗;r2 又抓出进程级计数跨会话串扰、ask 呈现漏计两条),规则直决记 false ⇒ 触顶时收场卡照呈;④(r2)进展判决挪到 resolve 之后,身份取自真解析到的 pending 行(durable `suspended` 帧常无 toolCallId,缺席不覆盖已知身份)。
|
|
73
|
+
- **对抗复审 r3 两条采纳**:①预算最终在 resolve 后按权威身份一次提交,帧上的 park 身份不再参与预判(重放帧携旧 call A、rescan 解析到新 call B 的形不再误触顶;红门 L10);②`ask-decided-release-unknown` 的处置 = `not-delivered`(r4 撤回 r3 的 held-for-release:没有释放驱动的重投机制就不承诺投递;闭集不加值)。
|
|
74
|
+
- **对抗复审 r6 两条采纳**:①`MAX_TOTAL_PARKS` 只数解析不出身份的 park(70 只真新门全过;L16);②提交后收场先按解析到的身份摘掉已批那只 call 的扣留帧,再排水(L15③)。
|
|
75
|
+
- **对抗复审 r5 三条采纳**:①空读豁免只授予被确认为进展的决断(同 call 可见/不可见交替形不再无限;L13);②self-heal 的 `runs.get` 保留 `this`(真 SDK RunsResource 形;L14);③提交后收场且 decide 已成功 ⇒ 新终帧码 `hitl_stalled`,不再说「could not be answered / still parked」(L15)。
|
|
76
|
+
- **对抗复审 r4 三条采纳**:①有推进帧的轮次不在解析前触顶(先呈先决,提交后仍同 call 才收场;L11);②`resolution.gatedCallId` 只认 pending 行、不回填帧身份,`progress:false` 一票否决(L12);③见上。
|
|
77
|
+
- **端义务**(INTEGRATION-CLIENTS §14):装 `onParkReattach`;消费 `ask-decided-without-card`(自动重发接线
|
|
78
|
+
按 disposition 或按 kind 显式列举 —— cli 现按 kind 等值判,需同批加);终帧文案不再 grep `hop limit`。
|
|
79
|
+
|
|
80
|
+
## 0.50.0(2026-09-03)
|
|
81
|
+
|
|
82
|
+
### 新增:S-81 selfOrchestration 拒绝的三端公共判定(server ≥7.57.0)
|
|
83
|
+
|
|
84
|
+
server 7.57.0 在**半配置的多租户形态**(`REQUIRE_PRINCIPAL=true` + `SELF_ORCHESTRATION_ENABLED=true`
|
|
85
|
+
+ 无中心侧准入解析器)上收窄了三条:`GET /v1/capabilities` 的 `workflows` 由 `true` 变 `false`;
|
|
86
|
+
同一份 caps 上新增 additive 键 `workflowsGate: { engineCan, denial }`;`POST /v1/tasks`(及同闸的
|
|
87
|
+
stream 提交)带 `selfOrchestration:true` 或 `settings.ultracode:true` ⇒ **501
|
|
88
|
+
`capability.self_orchestration_required`**(去掉这两个键则照常受理;非布尔的 `selfOrchestration`
|
|
89
|
+
走 400,不是本码)。单用户 worker / 接了解析器的多租户部署行为**逐字不变**。
|
|
90
|
+
|
|
91
|
+
**新增导出**(5 个运行期符号,additive;公面 795 → **800**)
|
|
92
|
+
- `classifySelfOrchestrationRefusal(e: unknown): SelfOrchestrationRefusal | null` —— 判型:
|
|
93
|
+
**只认** `status === 501` ∧ `errorCode` **恰等**该码,其余一律 `null`(永不抛)
|
|
94
|
+
- `stripSelfOrchestrationIntent(req: TaskRequestLike): TaskRequestLike` —— 去掉顶层
|
|
95
|
+
`selfOrchestration` 与 `settings.ultracode` 两个意图键,返回新对象,幂等
|
|
96
|
+
- `projectWorkflowsGate(caps: unknown): WorkflowsGateProjection | undefined` —— caps 上 workflows
|
|
97
|
+
闸的三位投影(纯函数,永不抛)
|
|
98
|
+
- `SELF_ORCHESTRATION_RETRY_WITHOUT` —— 去键清单单源(`['selfOrchestration', 'settings.ultracode']`)
|
|
99
|
+
- `CAPABILITY_SELF_ORCHESTRATION_REQUIRED` —— 码字面单源(`src/engineErrorCodes.ts`)
|
|
100
|
+
- 类型(type-only,不进运行期基线):`SelfOrchestrationRefusal` / `SelfOrchestrationDenialReason` /
|
|
101
|
+
`WorkflowsGateProjection` / `WorkflowsGateUnknownDenial`
|
|
102
|
+
|
|
103
|
+
**三条判定纪律**(端接线前必读,详见 `docs/INTEGRATION-CLIENTS.md` §13)
|
|
104
|
+
1. **重发判据是合取,且不许放宽成前缀判**:该码是**复用码** —— 与它同前缀的兄弟(别的能力位
|
|
105
|
+
没接线)去掉这两个键**也不会**变成可受理。无码的 501 判不出 ⇒ `null`,绝不挑一个猜。
|
|
106
|
+
2. **去键只减两处**:`settings` 下别的子键与 `deferTools` 一个都不动(连坐删 = 用一次重试静默改掉
|
|
107
|
+
用户的其它设置);只有真删过 `ultracode` 且 `settings` 剩空才整键收口。additive 未知键全保。
|
|
108
|
+
3. **caps 的缺席 ≠ 关着,认不得的 `denial` ≠ 没有拒绝**:`workflows` 读不出 ⇒ 整只 `undefined`
|
|
109
|
+
(端零渲染);闸缺席 ⇒ `engineCan: undefined`;认不得的 `denial` ⇒ `{ unknown: … }`,
|
|
110
|
+
**绝不**折成 `null`(折了就等于向用户断言「没有任何拒绝」)。
|
|
111
|
+
|
|
112
|
+
**端的义务**(本包不做):重发**只一次**、第二次仍 501 按普通失败呈现;重发必须留一行诚实披露
|
|
113
|
+
(用户按的是「用 ultracode 跑」,实际跑的是不带编排的那一发);端若在用幂等键,重发必须换新键
|
|
114
|
+
(body 变了,复用旧键会让 server 回放那条 501)。措辞全归端 —— 本件零文案。
|
|
115
|
+
|
|
116
|
+
**异源对抗复审采纳两条**(均已同批修 + 同批补门,两条变异各自实测翻红):
|
|
117
|
+
- `SELF_ORCHESTRATION_RETRY_WITHOUT` 改为 **`Object.freeze`**(`as const` 只管编译期;判决的
|
|
118
|
+
`retryWithout` 与它是同一只引用 ⇒ 任一消费者 `splice` 一下就污染此后每一次判决,端照它去键
|
|
119
|
+
会删掉别的字段)。
|
|
120
|
+
- 分类器**只在 `501 ∧ 恰码` 两条判据通过之后才读 `denial`**(它不是判据,是判决上的附加位;
|
|
121
|
+
此前无条件先读 = 每一个根本不匹配的抛出物都被跑一次它的 getter)。同批把射程分界写清:
|
|
122
|
+
「getter 零执行」只对读 wire JSON 的 `projectWorkflowsGate` 成立,读抛出物的分类器刻意走普通
|
|
123
|
+
属性读取(抛出物可能是 SDK `APIError` 类实例,判据位坐在原型上),那一侧只承诺「不抛」。
|
|
124
|
+
|
|
125
|
+
**异源对抗复审第二轮再采纳一条**(档面缺陷,同批修 + 同批补门,变异实测翻红):
|
|
126
|
+
- §13b 首版只给了 `try { await submit(req) } catch { … }` 一种接法,而实装 SDK 的
|
|
127
|
+
`tasks.stream()` 是 **async generator** —— 调用它**不发请求**,POST 在 `streamRaw` 里、要等
|
|
128
|
+
**第一次迭代**才跑。照那一段接线的流式端永远进不了 catch ⇒ 分类器不运行、去键重发整条失效,
|
|
129
|
+
半配置部署上用户仍然直接看到 501。现已拆成两段范式(同步腿 / 流腿),并说明 generator 用过
|
|
130
|
+
不能重来、去键后要建新的一条;常驻门用**真 `TasksResource`** + 假传输层离线钉住两条腿的差异
|
|
131
|
+
并与档对账。
|
|
132
|
+
- 同批订正 §13c 的一处自相矛盾:四处不可信读取「accessor 一律当缺席」这句概括与上面的表冲突 ——
|
|
133
|
+
`gate.denial` 的 accessor 落 `{ unknown: '' }` 而**不是** `null`(键不在场才是「没有拒绝」,
|
|
134
|
+
在场却读不出是「拒了但读不出」),端照概括实现正好会犯本节要防的那个错。
|
|
135
|
+
|
|
136
|
+
**异源对抗复审第三轮再采纳三条**(同批修 + 同批补门,三条变异各自实测翻红):
|
|
137
|
+
- **类型面**:`stripSelfOrchestrationIntent` 补 **SDK `TaskRequest` 重载** —— 只留宽形
|
|
138
|
+
(`Record<string, unknown>`)会**擦掉**调用方的类型,一份 `TaskRequest` 进去出来就不再可赋回
|
|
139
|
+
`TaskRequest`,于是 §13b 那句「去键之后直接重发」在 TypeScript 上编不过、端只能靠 `as` 强转。
|
|
140
|
+
本函数删的两位在 `TaskRequest` 里都是**可选位**,所以窄重载是类型面成立的事实而不是宽容。
|
|
141
|
+
- **取消语义**:§13b 的范式补 `signal` 透传与重发前的 `signal?.throwIfAborted()` —— 首发 501 与
|
|
142
|
+
去键重发之间隔着一次分类 + 一次写披露,人完全可能就在这个窗口里按下停止;少了中断闸,端会在
|
|
143
|
+
用户已经喊停之后再发一条会跑工具、会烧 token 的 run,还写一行「已按不带编排的方式继续」。
|
|
144
|
+
- **门的假绿面**:G5 的假传输层原先直接从 `openStream` 抛一只自捏的 `{status, errorCode}`,把 SDK
|
|
145
|
+
真正那一跳(`openStream` 回非 OK `Response` → `errorFrom` 铸 typed error)整个绕过去了。现改为
|
|
146
|
+
回**真的 501 `Response`** 并接 SDK 自己的 `errorFromResponse`,钉调用序;同时把 §13b 两段范式
|
|
147
|
+
**照着实现跑一遍**(恰重发一次 / 第二发真去键 / 第二个 501 直接外溢 / 中断后零重发零披露),
|
|
148
|
+
替掉原先的关键词计数式对账。
|
|
149
|
+
|
|
150
|
+
**异源对抗复审第四轮再采纳三条**(同批修 + 同批补门,两条变异各自实测翻红):
|
|
151
|
+
- **[high] 干净 EOF 不等于跑完**:SDK 的 `tasks.stream()` 只在见到 `done` / `failed` 时 `return`,
|
|
152
|
+
流被中途干净截断(或一帧都没产出)时 `for await` **同样正常结束** ⇒ §13b ② 的 `runOnce` 会把一次
|
|
153
|
+
**被腰斩**的 live task 静默当成功。范式补 `sawTerminal` 判并在缺终帧时响亮抛;门的「成功」假体改发
|
|
154
|
+
真终帧、另加「有帧无终帧 + 干净 EOF」负控。这一条与去键重发**正交**(对每一条流都成立),
|
|
155
|
+
同批写进 §13d 端必读第 5 条。
|
|
156
|
+
- §13b 的 `{ transientOk: true, signal }` 在开了 `exactOptionalPropertyTypes` 的端上 **TS2379**
|
|
157
|
+
(SDK 声明的是 `signal?: AbortSignal`)⇒ 两处改条件展开;并让 G6c 把**档里那两段范式原样**送去
|
|
158
|
+
真 tsc 编(此前 G6 只编去键重载的赋值,档里编不过的范式照样能留着,而端是照档抄的)。
|
|
159
|
+
- 门的中断用例原先绕开共享 `drive` 另写了一段必然先 abort 再 `throwIfAborted` 的局部流程,
|
|
160
|
+
也没有零披露断言 ⇒ 改走同一条真 `drive`(经测试钩在分类后触发 abort),并把披露账改成
|
|
161
|
+
**调用方持有**的 out-param 使抛出路径上也观察得到,同时断言 `signal` 真的透传进了 generator。
|
|
162
|
+
|
|
163
|
+
**异源对抗复审第五轮再采纳四条**(同批修 + 同批补门,三条变异各自实测翻红):
|
|
164
|
+
- **[high] 取消原因保真**:`readSseFrames` 见到 `signal.aborted` 会**正常** return ⇒ 上一轮新加的
|
|
165
|
+
终帧判会把一次**用户主动取消**改写成 `Error: stream ended without a terminal frame`
|
|
166
|
+
(实测复现)。靠 `AbortError` 抑制报错 / 决定要不要重试的端会把取消渲成故障,甚至照着「故障」
|
|
167
|
+
再提交一次。⇒ 范式改成 `signal?.throwIfAborted()` **先于**缺终帧抛,门加「帧到手后在 handler 里
|
|
168
|
+
取消」的真 SDK 用例(终帧晚到的 `ReadableStream` 假体)并在档面钉住两条抛的先后顺序。
|
|
169
|
+
- 门的 `runOnce` 用 `frames.push(ev)` 顶替了档里的 `handle(ev)` ⇒「断言 handler 收到终帧」是假的;
|
|
170
|
+
`drive` 的 rethrow 臂也是 `return` 而不是 `throw`,与档不同形。⇒ 改跑档同形的 `handle` /
|
|
171
|
+
`transcript.note` 真 spy,`drive` 改成逐字同形(认不得就抛),观察挪进 harness 侧薄封装;
|
|
172
|
+
披露账补**引用同一性**正面断言(否则中断腿那条「零披露」是恒真断言)。
|
|
173
|
+
- G6c 把两段范式**合并**成一个虚拟模块编译,于是第二段自身缺 import 搭上了第一段的便车 ⇒
|
|
174
|
+
「两段范式原样编得过」不成立,端只抄第二段会 TS2304。改为**逐块独立**编译、前缀只声明宿主提供位
|
|
175
|
+
(一个库导出都不注入),两段范式各自补全 import,并加「去掉自带 import 必须红」的第二条负控。
|
|
176
|
+
- §13d 标题写「四条」而实际已列五条 ⇒ 标题订正,并把标题计数与真正列出的条数做成门里的闭合检查。
|
|
177
|
+
|
|
178
|
+
**异源对抗复审第六轮再采纳五条**(同批修 + 同批补门,三条变异各自实测翻红):
|
|
179
|
+
- **[high] 终帧优先于取消**:上一轮把取消判放在缺终帧抛之前,但它是**无条件**的 —— 取消恰好落在
|
|
180
|
+
**终帧交付之后**时(实测:在 `handle(done)` 里 abort),一条已经有结局、可能已落副作用的 run 会被
|
|
181
|
+
报成取消,而人多半会再跑一遍 ⇒ 重复执行。⇒ 取消判与截断判都关进 `if (!sawTerminal)`,
|
|
182
|
+
门加 `handle(done)` / `handle(failed)` 内 abort 的两向回归。
|
|
183
|
+
- 门的披露路径没有跑档里那句 `transcript.note(disclose(...))`(harness 直接 push 数组)⇒ 删掉档里的
|
|
184
|
+
`disclose` 或换成错文案都不会红。改成两个真 spy 并让 `disclose` 铸哨兵串,断言 `note` 收到的就是它。
|
|
185
|
+
- 顺序判锚在**整节**上,而整节里第一处 `throwIfAborted()` 是**同步腿**的中断闸 ⇒ 把流腿改成反序仍绿。
|
|
186
|
+
改为先切出流腿 `runOnce` 的函数体再比位置,并加反序正控。
|
|
187
|
+
- G6c 的「去掉自带 import 必须红」负控**恒红** —— 红的是前缀自己(`Parameters<typeof buildTaskRequest>[0]`),
|
|
188
|
+
证不出正文缺 import。前缀改用内联 import 类型与范式解耦,负控加**归因**(诊断必须点名正文里的库符号)
|
|
189
|
+
与「前缀单独编零诊断」前提;提取范围同批扩到 **§13 全节**(§13c 那段端也会单独抄走)。
|
|
190
|
+
- §0a 未发导出说明写「三个 type-only 形」而实际列了四个 ⇒ 订正并把这个计数也纳入门里的闭合检查。
|
|
191
|
+
|
|
192
|
+
**异源对抗复审第七轮再采纳三条**(同批修 + 同批补门,两条变异各自实测翻红):
|
|
193
|
+
- **[high] 每帧取消闸**:一次 SSE 读取会缓冲**多帧**,SDK 连着把它们 yield 出来(真实时序,不属
|
|
194
|
+
§12e 排除族)。只在整个 `for await` 结束后查 signal ⇒ 人已经喊停,仍会消费同一 chunk 里的后续帧 ——
|
|
195
|
+
实测:同 chunk `turn_start` → `done`,在 `handle(turn_start)` 里 abort 仍吃掉 `done` 并**报成功**;
|
|
196
|
+
后一帧若是坏 JSON,取消还会被一条 `SyntaxError` 盖掉。⇒ 非终帧之后立刻 `throwIfAborted()`,
|
|
197
|
+
终帧那一支先 `break`(「终局优先」在这里也成立)。
|
|
198
|
+
- 门的行为矩阵此前跑的是**脚本内手抄的流腿副本**,同步腿更是只验了「首发 501 可分类」⇒ 新增 **G7**:
|
|
199
|
+
把 §13b 两段范式**抽出来转译成可执行模块**(库 import 重写到本仓 `dist`)注入宿主位后**真跑**,
|
|
200
|
+
两条腿各过一遍完整行为矩阵,披露按 `disclose` 铸的哨兵串与档里那一句机械对账。
|
|
201
|
+
- 顺序判此前逐字符切函数体 + `indexOf` 找表达式 ⇒ 注释/字符串里的同名语句能骗过它(实测反例:注释里
|
|
202
|
+
按正确顺序写诱饵、真代码反序,断言照绿)。改成**锚 TS AST**,并带「注释诱饵 + 真语句反序」负控。
|
|
203
|
+
|
|
204
|
+
**常驻门**:`scripts/run-self-orchestration-denial-test.mjs`(207 checks;条数下限零松量)。
|
|
205
|
+
**棘轮**:`public-export-baseline` 795→800;typeshape `unknownExport` 262→264(两处边界读口入参,
|
|
206
|
+
逐条登记);portability index 闭包 140→141。
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
43
210
|
## 0.49.0(2026-09-03)
|
|
44
211
|
|
|
45
212
|
### 新增:崩溃收敛的孤儿审批读面(L-38;server ≥7.55.0)
|
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.
|
|
38
|
+
**Version:** 0.51.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
|
|
@@ -241,6 +241,7 @@ public-surface guard checks that last one).
|
|
|
241
241
|
| `scripts/run-plan-review-decide-verify-test.mjs` | `decidePlanReview`'s post-decide honesty ([2315]/[2316], engine RB-471 family): a 2xx from the decide endpoint is **not** a terminal — the wire re-pulls the task status and words the outcome by the real shape (still-locked / legal new gate / genuinely left park / unverified), never claiming success it hasn't earned. Driven against a real fake-engine HTTP server through the shipped dist |
|
|
242
242
|
| `scripts/run-shell-gate-durable-allow-test.mjs` | #110: the durable approval leg for **shell** gates. The tool_end HOLD/REJECT predicate must cover Bash the same way park detection already does (otherwise the park poison frame `Operation aborted` hits the transcript, `endedCalls` swallows the real replayed result, and the user who pressed Yes watches a command that really ran be reported as aborted); a replayed, already-decided park must resume reading the stream instead of being reported as a failed turn; `lastEventId` must track numeric `seq` too. Mutation-proven: each of the three fixes reverted turns the gate red |
|
|
243
243
|
| `scripts/run-hitl-gate-honesty-test.mjs` | [2393] the four HITL disciplines that a passing type-check cannot see. (1) The park predicate and the `tool_end` predicate must cover the **same** set — the park side admits a first-class `kind:'tool_approval'` gate for *any* tool name, and a `tool_end` frame carries no `kind`, so the frame-level judge falls back to the engine's exact abort marker; otherwise the poison frame hits the transcript and `markEnded` swallows the real replayed result (the #110 disease, reopened on kind-only gates). (2) The already-decided identity criterion is **one-shot**: its two inputs are monotonic, so without consumption one successful decide makes every later park failure — including a real `approvals.list` outage — read as "already resolved" until the 24-hop budget runs out and reports a cause that has nothing to do with what happened. (3) A `plan_review` card dismissed without an answer must be re-presentable: the idempotent re-arm short-circuit re-publishes the still-armed card, and a stale armed id (responder gone) re-arms from scratch rather than presenting a card nobody can answer. (4) `HitlSafetyError` is a safety signal — the `remember` fallback arm must re-raise it instead of auto-retrying the decide, while a plain unknown-key 400 still falls back. (5) The polling leg reschedules after an escaping throw and flips `mode()` to `idle` once it consistently fails, so the honesty surface stops reporting a dead feed as live |
|
|
244
|
+
| `scripts/run-park-hop-progress-test.mjs` | L-80: the park re-attach loop budgets **stalled** rounds, not parks. A turn where the model keeps hitting gates and every one of them is really decided (a card was answered, the engine really moved on) must never be cut off by the hop budget — the budget counts consecutive rounds that produced no progress, and "the engine revived and immediately parked again on the same coordinates" is not progress. The three non-progress arms (already-resolved, decide-transport-exhausted, and a re-scan that was adopted but led nowhere) share one same-cause limit instead of one arm having a limit and the others having none, and every non-progress re-attach is announced once through the host callback rather than only to the debug log. When the limit is spent the resolver reads the approval queue once more and puts whatever is decidable in front of the user before it gives up; only when there is genuinely nothing to show does it fail soft, and the terminal message then carries the real cause and a real way out instead of a sentence about a budget. On the self-heal side, a reopen verdict that reports `decidedWithoutCard` — the chain settled the gate by rule, so there was no card to present — is progress, not a reopen failure, and the user is not told their message was NOT sent. Negative control: a genuinely empty queue with a run that never moves still fails soft |
|
|
244
245
|
| `scripts/run-notif-fleet-honesty-test.mjs` | [2393] the five notification/fleet disciplines a green type-check cannot see, each proven by reverting the fix. (1) The workflow-side dedup `return` keeps a count and a trace — without it "suppressed by design" and "a real completion swallowed because the runId minting changed" are the same observation. (2) `seq` normalisation has exactly one mint point, so a 0-based or fractional wire `seq` cannot make the watcher lane and the frame lane key the same completion differently (which would feed the model twice). (3) The TTL sweep defers to a probe arm that is still inside its own deadline — an entry recorded as "abandoned" must not be delivered a moment later — while an arm that has outlived its deadline never blocks the sweep, so the headless exit gate keeps its liveness. (4) The reset hook really clears every ledger it claims to (the sticky `prompt` ledger leaked across cases). (5) The fleet ledger counts all three drop paths (malformed / unknown frame type / isolation drop), and the panel projection's settled recycling is anchored on the settle instant and skips still-present rows, so the dedup token is never carried off with the entry (which would re-emit `end`) |
|
|
245
246
|
| `scripts/run-public-surface-test.mjs` | The outward promises: the npm export surface baseline (an **exact set**, both directions — a new export that never entered the baseline is one nobody watched leave, and deleting it later would not be red), the peer floor witness, and this README's claims |
|
|
246
247
|
| `scripts/run-client-core-message-branching-test.mjs` | §B8 (branching on error **text**) and §B10 (truthiness standing in for existence when the value can be `0`). AST + type-checker census over `src/`, a named ALLOW list carrying owner and expiry, a known-site floor, and two fixed corpora with a known verdict judged by the same classifier on every run |
|
|
@@ -252,6 +253,7 @@ public-surface guard checks that last one).
|
|
|
252
253
|
| `scripts/run-durable-card-display-keys-test.mjs` | The durable approval row's two display keys survive the row→card recast in `surfaceFsApprovalAndDecide`: `governanceForced` stamps on strict `true` only (absence is "no evidence", never `false`), `ruleSuggestions` passes through the same shape-narrowing reader as the live-frame leg and lands on the **read-only** card key — plus a standing pin that the durable leg never stamps the redeemable `ruleSuggestions` card position (the `/decide` body has no rule slot; offering a "don't ask again" option there would be an affordance nothing can honour) |
|
|
253
254
|
| `scripts/run-session-memory-status-test.mjs` | The session **memory-status** read face (S-53): the two judgements three clients would otherwise each get wrong. First, *same status, different code* — this route's 404 carries two unrelated meanings (`not_found.session` = unknown or non-owned session; `not_found.route` = a pre-7.53 server that has no such route at all), so dispatching on the **status** would report "your deployment lacks this surface" as "your session does not exist". The verdict is anchored on `errorCode`, the two 404s are pinned to **different** verdicts, and — the load-bearing negative control — a 404 carrying **no** code falls to `failed` rather than guessing either way, since a wrong guess in either direction is a false statement a user would act on. 501 is allowed a codeless fallback because both of its arms mean the same thing here, and `capability.*` stays split from `feature.*` because those two share a status while their dispositions are opposite. Second, *absence means something different per key*: `optOutSource` and `lastCaptureAt` are legitimately absent on a **healthy** session (a zero-history session really is `{captureOptedOut:false, committedCount:0, foldedCount:0}` with no degradation at all), so reading absence as "off/none/0" asserts something unprovable. Two combined readers are pinned: capture opt-out is read from **both** its keys (a record-store fault yields `indeterminate`, never `active` — the difference between "your conversation is being remembered" and "nobody knows"), and last-capture is a **three-state** read whose discriminator is the *other* key, because `lastCaptureAt`'s absence alone covers both "ledger unreadable" and "genuinely no contributions" and therefore decides nothing; the two shapes are pinned to different verdicts so a single-key read turns red. The thin wrapper is the only IO: it never throws, drops malformed keys to absence rather than trusting them (an unreadable value must answer "don't know", never render as truth), refuses to spend a request on an empty `sessionId`, and passes `signal` through untouched |
|
|
254
255
|
| `scripts/run-crash-converged-projection-test.mjs` | The `crashConverged` read face on `GET /v1/approvals` (L-38): what the *previous life* of a crashed local engine left behind, projected for every client. Three judgements are pinned. First, **absence is not an empty list** — a missing key (an older server, deps not present, or a carrier that is not an array at all) returns `undefined`, and the client renders nothing; an empty array returns a present zero-count object, which is the server actually saying "none". Folding the first into `{total:0}` would have the client assert "nothing was left behind" on a surface a person uses to decide whether it is safe to re-run something — the worst possible direction for a false statement — so the two cases are pinned to different **return shapes** and a test asserts the two verdicts are unequal. Second, bucketing is a **four-term conjunction**: `orphanState === 'pending'` *and* `resumeSafe === true` *and* both approval-evidence keys (`originalDecision`, `decidedAtMs`) absent. A fifth term rejects any row carrying an **accessor**, and accessors are never invoked at all — reading one means synchronously running someone else's code, and `catch` catches throwing, not *never returning*, so a looping getter would pin the startup thread forever (the row cap does nothing against that shape). The same rule covers the three untrusted reads outside the row as well — the envelope's `crashConverged` key, the carrier's `length`, and every numeric index are read as own property *descriptors* and only data descriptors are used, so accessors and prototype entries read as absent and are never invoked. Such a key is treated as absent: if it was a required field the row is counted as dropped, if it was optional or additive the row survives without it. That also closes the ordering attack, since spreading runs getters in property order and an earlier one could `delete` the approval evidence before it is ever copied (measured before the fix: such a row reached the resume-safe bucket), and the check therefore moves ahead of the read, onto the property descriptors — from which the snapshot is then built directly, because checking descriptors and *then* spreading is two independent observations of the same row, and a non-throwing proxy can make the two `ownKeys` calls disagree (first showing `originalDecision: 'approve'` so the row reads as plain data, then omitting that configurable key so the snapshot loses the evidence; measured before the fix: the dangerous row reached the resume-safe bucket after exactly two enumerations, and after it, one). Keys are written with `Object.defineProperty` rather than plain assignment, because `'__proto__'` is a legal own enumerable key and `o['__proto__'] = x` does not store a value — it calls the prototype setter, letting a row whose own properties are all plain data (so the accessor gate never fires) inject a prototype whose `sessionId` getter deletes the approval evidence from the snapshot during validation; `defineProperty` fires no setter, so the key survives as ordinary additive data and the snapshot keeps `Object.prototype`. A row that simply arrives with a custom prototype is treated the same way, since the snapshot only enumerates own properties: approval evidence sitting on the prototype would never reach it, and a perfectly ordinary object with no proxy and no accessors could otherwise be called safe to re-run — real bodies come from `JSON.parse` and always carry `Object.prototype`, so nothing genuine trips it). Validation itself runs on a **null-prototype** dictionary and the bucketing verdict is carried out of that same pass rather than re-read from the delivered row, because every property lookup on an ordinary `{}` reaches `Object.prototype`: a polluted `sessionId` getter there would delete the approval evidence from the snapshot mid-validation and send the row to the safe bucket (measured before the fix). The row handed to the client is still an ordinary object — the null prototype is an implementation detail of the check, not of the value) — real JSON bodies are all data properties, so only a middle-layer-synthesised payload ever trips it, and it too lands in the human bucket rather than being dropped. The `decided` arm means the human had already approved and side effects may be half-landed, so it always goes to the human bucket, as does `resumeSafe === false` and — the last two terms — any row whose own fields contradict each other, since `pending` claims nothing ran while that evidence says somebody pressed approve. Deciding "not safe" costs one extra question (recoverable); deciding "safe" wrongly has somebody re-run work that already partly happened (not). A 2x2 truth table pins that exactly one cell is resume-safe, so reading either key alone turns red, and the contradictory rows are routed to the human bucket rather than dropped — they are real orphans, and the ones most worth showing. Third, unreadable rows are **dropped and counted**, never thrown and never passed through: the product is declared as `CrashConvergedRow`, so letting a row missing a required field — or carrying one of the wrong type — past would be a lie at the type level, and the closed literal discriminators (`decision` / `cause` / `orphanState`) decide family membership rather than being an open vocabulary. The measuring stick stops at the **type** floor, though: degenerate-but-well-typed values (`ts: NaN`, an empty `toolName`) are kept, because swallowing a real orphan over a decorative field is the worse direction, and the one deliberate exception is `approvalId`, which must be non-empty to be a row identity at all. `dropped` is kept separate from `total` so unreadable rows never inflate "N approvals were affected"; each row is a **one-shot snapshot** — every own enumerable key is read exactly once, and validation, bucketing and the handed-back value all read that same snapshot, so additive upstream keys survive while a **non-idempotent** getter (one that never throws, just answers differently on a second read) can no longer erase the approval evidence between the check and the bucketing (measured before the fix: such a row landed in the resume-safe bucket while its checked value was `"approve"`). Hostile carriers are counted rather than allowed to reject: **every** touch of the carrier is guarded — envelope property reads, `Array.isArray` itself (it throws on a revoked proxy), the `length` read, each indexed read and each row's property reads — and a traversal that dies halfway returns absence rather than a half-counted total. A row that cannot be read never takes the batch with it: its own shape check is inside its own guard, so one revoked-proxy row costs a `dropped` tick rather than collapsing the whole projection to absence — which a client would have read as "this deployment does not offer the surface". Traversal goes by **numeric index, never the carrier's own iterator protocol**, because `for...of` hands the carrier the question of which rows exist: an array carrying an overridden `Symbol.iterator` can yield nothing (measured before the fix: a real orphan became `{total:0}`, which a client reads as "the server said there are none") or swap a dangerous `decided` row for a safe-looking one (measured: `fake-safe` was returned in place of `real-danger`). Row count is capped at 100000 and the cap is checked **before** the walk: requiring only a non-negative integer `length` does not stop a proxy trap reporting a billion, and this surface runs on the startup / `--resume` path, where a synchronous spin freezes the thread (measured before the cap: twenty million rows took 18.3 seconds and twenty million index reads; a billion does not come back). The honest boundary is stated rather than overclaimed — a proxy can still lie in its `length` or index traps, which is the same thing as a host injecting a lying transport — and the widening of `ApprovalsResourceLike.list()` is proven **additive** by really running tsc over a legacy `{ pending }` mock *and* over the real `AgentClient` path — the projector takes `unknown` precisely because a parameter shaped as "an object with an optional `crashConverged`" is a TypeScript weak type that the installed SDK's own `list()` return shape shares no property with, which only a real-client compile would have caught — with a known-red control so a clean run means the checker spoke |
|
|
256
|
+
| `scripts/run-self-orchestration-denial-test.mjs` | The three judgements behind a **denied self-orchestration request** (server 7.57.0), each of which all three clients would otherwise get wrong on their own. First, whether to retry at all is a **conjunction that may not be loosened**: HTTP 501 *and* an `errorCode` that is **exactly** `capability.self_orchestration_required`. That code shares its shape with every other `capability.*` 501, so dispatching on the prefix would drag "some other capability is not wired up" into the retry arm — those requests do not become acceptable once the two keys are gone, so the client would spend a request and then tell the user the wrong reason. Negative controls cover all four directions: a sibling `capability.*` code, a truncated or suffixed variant of the right one, a codeless 501 (it decides nothing, so it decides nothing — no guessing), and the right code under 500 / 400 / 503 or a string `"501"`. The classifier reads structurally rather than by `instanceof` (a host may inject its own transport; across realms or duplicate SDK instances an understandable error would read as unreadable), so a class instance, a bare `{status, errorCode}` literal and an error carrying those fields on its **prototype** all reach the same verdict — and a hostile proxy or a throwing getter yields `null` instead of throwing, because this classifier runs inside a `catch` block where anything it throws escapes the caller's own guard. Second, removing the intent is a **structural** operation, not wording: `selfOrchestration` sits at the top level while `ultracode` sits under `settings` — two different stamping legs — and a client hand-writing `delete` will miss the second one, which costs the user the same failure twice. The single stripper is pinned to touch exactly those two: other `settings` sub-keys and their values survive byte for byte, `deferTools` is left alone (pulling `Workflow` out would be a behaviour change, not a removal of intent), additive unknown keys survive at both levels, the input object is never mutated, `settings` is only dropped entirely when `ultracode` was really there and nothing else remains (an already-empty one is left as is), a non-object `settings` is not touched at all, an `ultracode` that only exists on the prototype does not count, and the whole thing is idempotent. The end-to-end leg runs a real `buildTaskRequest` product through it and asserts the stripped body still passes the registration gate key by key. Third, on the capabilities body, **absence is not "switched off"**: a pre-7.57 server has no `workflowsGate` key at all, so reading absence as "the engine says no" asserts something the server never said, and the mirror-image disease is folding an **unrecognised** `denial` into `null`, which would have the client render "nothing was denied" when the truth is "denied, for a reason I do not recognise". Five shapes are pinned — caps unreadable, gate absent, closed-set member, unknown value, accessor — with the unknown arm carrying the raw token (or an empty one when the value is not even a string) and never collapsing to `null`. All four untrusted reads go through own **data descriptors** only, and the guard pins the getter invocation count at zero, since `catch` catches throwing but not *never returning*; a descriptor trap that throws and a revoked proxy both yield honest absence rather than an exception — though *what* absence means differs by field, and the guard pins that split rather than a blanket rule: an accessor on `workflows`, `workflowsGate` or `engineCan` reads as absent, while an accessor on `denial` reads as `{unknown:''}`, because a key that is **not there** is the gate saying "nothing was denied" whereas a key that is there but cannot be read is "denied, and I could not read why" — folding the second into the first is exactly the false statement this face exists to prevent. Two further pins came out of an adversarial review. The exported retry list is **frozen at runtime**, not merely `as const`: the verdict hands out that same reference, so any consumer splicing it once would poison every later verdict in the process — the guard asserts `Object.isFrozen`, that four different mutation attempts leave it byte-identical, and that a verdict issued *after* those attempts still carries the original two entries. And the classifier reads `denial` only **after** both criteria have passed, since it is not a criterion but an extra field on the verdict: the guard pins the getter invocation count at zero for any error that does not match and at most one for an error that does. The scope line is drawn explicitly rather than overclaimed — "no getter ever runs" holds for `projectWorkflowsGate`, which reads **wire JSON** where every field is an own data property by definition, but not for the classifier, which reads a **thrown value** that may well be an SDK `APIError` class instance carrying `status` and `errorCode` on its prototype; insisting on own data descriptors there would report a perfectly readable error as unreadable, so that side promises only that it never throws. A final pin covers the **integration document's own worked example** rather than the library: the shipped SDK's `tasks.stream()` is an `async` generator, so calling it issues no request at all — the POST happens inside `streamRaw` on the first iteration, and a `try` wrapped around the `stream(...)` call itself can never catch the 501. A client following a submit-shaped recipe on the streaming leg would never run the classifier, and the whole strip-and-retry path would silently do nothing. The guard drives the **real** `TasksResource` against a fake transport, offline, and pins both halves: the synchronous leg is in flight the moment it is called, the streaming leg has issued zero requests after the call and raises on the first `next()` — and it does so through the **real** error path, with `openStream` returning an actual 501 `Response` that the SDK's own `errorFromResponse` turns into the typed error, pinning the `openStream`→`errorFrom` call order so a transport that stops minting `errorCode` cannot pass. The documented recipe is then **executed** rather than keyword-counted: exactly one retry, a second body that really lost both keys while every other setting survives byte for byte, the caller's own request object left untouched, one disclosure and only one, a second 501 propagating with the request count still at two, and — after the first 501 — an abort leaving the count at one with nothing disclosed. A last leg is type-level: `stripSelfOrchestrationIntent` carries an SDK `TaskRequest` overload, because the wide `Record<string, unknown>` form erases the caller's type and the document's "strip and resubmit" line would not compile without an unsafe cast; a real tsc run over a virtual file proves both the narrow and the wide path, with a known-red control — and it compiles the document's two recipes **verbatim**, extracted from the section itself, because a recipe that does not compile is a recipe that was never given: `{ transientOk: true, signal }` is a TS2379 under `exactOptionalPropertyTypes`, which no amount of prose review had caught. The last thing pinned is the one that would have been quietest of all: the SDK's `stream()` returns only on a `done` or `failed` frame, so a stream truncated mid-run — or yielding nothing at all — ends the `for await` just as normally as a completed one. The documented `runOnce` therefore tracks whether it ever saw a terminal frame and raises when it did not, the guard's success fixture emits a real terminal and asserts the handler received it, and a truncated-stream control asserts that shape is reported as a failure with no retry and nothing disclosed. That terminal-frame rule then needed one more turn of its own: the underlying reader returns *normally* when the signal is aborted, so the check as first written rewrote a user's cancellation into a generic stream fault — a client keying off `AbortError` to suppress the error would instead have shown a failure, or resubmitted. Cancellation is therefore checked first, a real-SDK case aborts from inside the handler and asserts the original `AbortError` survives with no retry and nothing disclosed, and the document is checked for that ordering. The harness runs the documented `handle` and `transcript.note` as real spies rather than pushing frames itself, the drive loop rethrows exactly as the document does, and the disclosure ledger is proven to be the caller's own array by a positive identity assertion — without which the cancellation leg's "nothing disclosed" would have been vacuously true. Each recipe is compiled **on its own**, with a preamble that declares only what a host supplies and injects no library symbol, since compiling them together let the second one borrow the first one's imports, and the preamble's own types are decoupled from what the recipes import so the "remove the imports and it must fail" control fails for the right reason — which is checked by attribution, not merely by redness. Ordering is the last thing to get right: the cancellation check must come before the truncation error but **both** must sit behind the terminal-frame test, because a cancellation that lands after the run already reported `done` would otherwise overwrite a real outcome — one that may have already had effects — with "cancelled", and a person reading that will run it again. Aborting from inside `handle(done)` and `handle(failed)` are both pinned to still report success, and the ordering assertion is anchored inside the streaming `runOnce` body rather than the section, since the section's first `throwIfAborted` belongs to the synchronous recipe and would have made a reversed streaming recipe pass — and that ordering check is now anchored on the TypeScript AST rather than on text, since a comment reproducing the two statements in the right order let a genuinely reversed body pass. One more timing fact had to be written into the recipe: a single SSE read buffers several frames and the SDK yields them back to back, so checking the signal only after the loop lets a cancelled run keep consuming the rest of the chunk — measured, an abort inside `handle(turn_start)` still swallowed the `done` that followed and reported success. The recipe therefore re-checks after every non-terminal frame. Finally, the behavioural matrix is no longer run against a copy of the recipe: both recipes are extracted from the document, transpiled, and **executed** with injected host objects, so the disclosure assertion really exercises the document's own `transcript.note(disclose(...))` line, and the synchronous leg gets the same full matrix the streaming one does |
|
|
255
257
|
| `scripts/run-package-hygiene-test.mjs` | Everything `package.json` `files` ships — dist JS/typings and the Markdown docs — is screened line-by-line against a deny-list of strings that must never appear in a published artefact. The guard first proves each pattern still bites on a constructed sample (a screen that cannot fail is worse than none) and honours a per-pattern allow-list for legitimate product vocabulary, so the verdict is "clean surface", not "quiet grep". |
|
|
256
258
|
| `scripts/run-integration-doc-freshness-test.mjs` | The **integration contract** (`docs/INTEGRATION-CLIENTS.md`) and the **changelog** (`CHANGELOG.md`) checked against the code, because a document with no guard rots — this one had a whole nest of drift found on it within a day of being written. Five directions, each a claim a machine can actually evaluate. (1) *Counting discipline*: the version-anchor row for the guard count may no longer carry a hand-copied number at all — it changes every time a guard is added, and writing it down is planting a timer; the export counts that are still hand-copied (the surface total, the test-hook count, the sentence describing the surface's internal composition, the sum of the sixteen domain rows, and the three sub-counts) are each compared against a value **derived** from `public-export-baseline.json`, which is the drift a human reviewer caught last time. (2) *Coordinates alive*: every `src/` `scripts/` `docs/` path the doc quotes must be on disk **and tracked by git** — on disk is not in the repo, and a doc that points readers at a file living only in its author's working tree sends every clone to nothing. A file landing in the same commit takes a named carve-out that **stops applying** the moment the file is really tracked (it can no longer let anything through, and the guard prints a line asking for it to be deleted) — deliberately not a red, since turning red on the very commit that lands the file would just manufacture a break that only a follow-up commit could clear. (3) *Arm tables*: the `hitl_out_of_slice` row and the `not_in_slice` fenced list must equal, name for name and in **both** directions, the case labels that really fall into those two buckets — read through the **TypeScript AST**, since which bucket an arm lands in is decided by the argument to `nothing(...)` and by nothing a comment says. The extractor is anchored to the one production projector: exactly one function named `eventToSdkMessage`, exactly one `switch (ev.type)` inside it, and no repeated case label — anything else is a broken anchor rather than a verdict, because a second same-shaped switch elsewhere in the file would otherwise overwrite the real one's conclusions and leave the doc agreeing with a switch nobody runs. The list is delimited by a machine-readable fence rather than by section headings, because the same section also names the terminal arms as a counter-example and prose boundaries cannot tell a member from a foil. (4) *Released sections are frozen*: an **append-only ledger** carries every version ever published — its number, the commit it was published from, and the sha256 of its section — and each one is checked, not just the current release, since pinning only the latest would set every earlier version free the moment the next one ships. The ledger cannot vouch for itself either: each recorded hash is **re-derived from that release commit** through git, so editing an old section and its constant together no longer passes — and the commit the row names is in turn checked against the `gitHead` npm recorded at publish time, which is the one value this repository cannot rewrite, so pointing an old version at a freshly written commit does not pass either. The *set* of versions that must be frozen comes from the registry too, so deleting an old row together with its section — which would otherwise remove that version from every set the guard looks at — is red rather than invisible. A failed registry call is classified rather than swallowed, and the classification consults the registry's own status code *before* it considers connection-level symptoms, so an auth refusal whose body happens to mention the network is still red rather than a skip. The version set is compared as full SemVer including prereleases — matching only `x.y.z` would silently drop a published `0.30.0-beta.1` and reopen the very hole this direction closes — and section headings are matched on a whole-version boundary so a stable release cannot bind itself to the release-candidate section sitting above it. Publishing itself is a two-phase protocol rather than a paradox: before a release, exactly one row may be marked pending and must name the current `package.json` version, exempt from the checks whose inputs do not exist yet; once the registry has that version the row must be promoted, so the temporary state cannot survive its own release. And because the pending exemption rests entirely on "this version is not out yet," it is refused outright when the registry cannot be reached to confirm that — an unverifiable premise is not a licence. Three reverse directions close the rest: a section claiming to be released but absent from the ledger, a ledger entry whose section has vanished, and a `package.json` version that was never frozen. Publishing appends a row; it never rewrites one. (6) *Sentinels*: the readers §5a hands hosts for "is this port installed" are checked against what the source actually declares it returns — `hasXxx()` is a `boolean`, the card port / HITL surface / wire target return `T | null`, the `installHost` family returns `T | undefined`. Testing a `null`-returning reader for `!== undefined` is *always true*, and a self-check that passes whether or not the port is installed is worse than none, because hosts retire their own fallback on the strength of it. Both directions are red: an implementation that changes its sentinel without the doc following, and a doc that names the wrong one. The roster covers the zero-argument readers and their `*For` variants alike — a multi-session host reads the variants, so leaving them off would let exactly the surface desktop depends on drift unwatched — and the §5a table and the §8-B checklist line are each checked against the source, because hosts tick the checklist, and a guard that only watches the prose table misses the line people actually follow. (5) *Packaging*: the README ships with the package and opens by pointing hosts at the integration doc, and the checklist names two more files as required reading before an upgrade — all three must really appear in the `npm pack` manifest, or an npm consumer follows a relative link that npmjs rewrites onto a private repository. Missing tooling never takes the whole verdict down with it: when git, npm or the registry is unreachable those legs print the `SKIPPED-SECTION` marker and the rest still judges, while a release commit the ledger names but git cannot resolve is red rather than skipped. The guard says in its own header what it does **not** do: it judges counts, coordinates, arm sets, released bytes and the packing list — whether a sentence is *right* is still for review and for the hosts to report |
|
|
257
259
|
| `scripts/run-type-superset-ledger-test.mjs` | The type/wire **superset ledger** (`docs/type-superset.json`): positions this package adds on top of a CC-shaped contract, each carrying the evidence for what CC's own type surface does or does not have there. Completeness is deliberately uneven and the ledger says so. The `_sema_*` private-key class is checked in **both** directions (a key in the source that never entered the ledger is red, naming key and file; a ledger row whose key left the source is red) — but only for keys written as literals, which is the convention the ledger mandates. A key assembled by string arithmetic is beyond what any static rule can enumerate, so the guard fails closed on every shape it *can* decide (a bare `_sema_` prefix is red wherever it appears, save one pinned guard site) and leaves the rest as a convention violation for review to catch, rather than claiming a completeness it does not have. The two hand-surveyed classes are only checked for coordinate and evidence integrity, never discovered. Both directions read the source through the **TypeScript AST**, not a text scan, and they read two different sets out of it. A *key site* is an identifier, or a string whose whole value is the key — so `'_sema_decision-v2'` is carried whole rather than truncated at the first non-identifier character into some *other* key that happens to be registered. A *mention* is the key appearing inside a longer string, which is prose, not usage. The staleness direction counts key sites only: a comment or a doc sentence left behind after the last real mint site is deleted must not keep the row alive (mutation-proven — with both the comment and the prose string untouched, removing the one real site turns the guard red). And because a prefix can be concatenated or interpolated into a key no static set will ever see, the bare `_sema_` literal is refused outright rather than traced: every occurrence is red except the single inline `startsWith` guard the sanitizer needs, because the set of expressions a bare prefix can travel through on its way to a concatenation is open-ended and enumerating it is always one form behind. Every row's `host` must still resolve, with the key being a real **member of that declaration** rather than a string occurring somewhere in the same file — `governanceForced`/`delegation` each live on two different shapes in one file, and a member commented out is a member deleted, which a text-shaped check happily reads as still present. And the direction worth the most: each machine-form `ccAbsenceEvidence` is re-derived from the row's own `key` — the ledger's recorded string must match that derivation verbatim, since a row quietly witnessing `\bnever_present\b` is green forever while watching nothing (mutation-proven: the same edit passes the unbound form and is caught by the bound one) — and the check runs against the names the installed `@sema-agent/agent-types` `.d.ts` set actually declares, parsed with the TypeScript AST rather than grepped, so a name CC merely mentions in a comment cannot force the row into the manual escape hatch and thereby retire the very witness that was supposed to fire the day CC declares that name for real. That escape hatch is gated by an allowlist living **in the guard**, not the ledger, so claiming it costs a reviewed diff. Missing material never reads as a pass, and the verdict splits by *why* it is missing: no TypeScript parser skips the suite before it starts; a missing `agent-types` still runs and prints the first three directions, then exits **1** when `package.json` declares the mirror but it is not installed — a broken install must not retire the repository's only "the day CC declares this name" alarm, and reporting it as a skip would leave "never evaluated" and "evaluated, no drift" indistinguishable to the runner — and exits 3 only when nothing declares the mirror at all, which is the one case where the direction genuinely does not apply. Either way a run that evaluated no witness is never counted as one that did. When the mirror *is* present its **installed version** is witnessed too (the two declared floors must agree with each other and the installed copy must meet them), since four preflight probes are satisfied by an arbitrarily stale mirror — they prove the extractor speaks, not that it is current. Every direction carries a positive control — known-present CC symbols, a comment-only sample proving the extractor distinguishes declaration from mention, and synthetic corpora fed through the **same** discriminator function the real verdict uses, so a verdict quietly rewritten to return nothing takes its own control down with it |
|
|
@@ -129,8 +129,12 @@ export interface DurableRunCallOpts {
|
|
|
129
129
|
* 断言不成立 —— 按重开失败臂说话(说「已重开」而宿主明知没渲 = 谎报);true/缺席不降级。
|
|
130
130
|
* 回执机制的实现留宿主端,包只成文这个契约位。
|
|
131
131
|
*/
|
|
132
|
-
export type ReopenCardVerdict =
|
|
132
|
+
export type ReopenCardVerdict =
|
|
133
|
+
/** L-80:`decidedWithoutCard` = 重开链**没呈卡但已成功决断**(规则直决 / sync-allow)—— 这是进展,
|
|
134
|
+
* 不是失败;端不许把它渲成「could not reopen … NOT sent」。缺席 = 真失败形(语义零变)。 */
|
|
135
|
+
{
|
|
133
136
|
reopened: false;
|
|
137
|
+
decidedWithoutCard?: true;
|
|
134
138
|
} | {
|
|
135
139
|
reopened: true;
|
|
136
140
|
firstSight: boolean;
|
|
@@ -280,6 +284,18 @@ export type SelfHealOutcome =
|
|
|
280
284
|
taskId: string;
|
|
281
285
|
decidePath: string | null;
|
|
282
286
|
}
|
|
287
|
+
/** L-80:重开链无卡直决成功 ⇒ run 已恢复;消息按 `resending` 处置(端接自动重发,见 disposition)。 */
|
|
288
|
+
| {
|
|
289
|
+
kind: 'ask-decided-without-card';
|
|
290
|
+
taskId: string;
|
|
291
|
+
released: true;
|
|
292
|
+
}
|
|
293
|
+
/** L-80:直决成功但 run 释放与否**不知道**(无 `runs.get` / 读不出 status / 窗内没释放):不重发、不谎报失败。 */
|
|
294
|
+
| {
|
|
295
|
+
kind: 'ask-decided-release-unknown';
|
|
296
|
+
taskId: string;
|
|
297
|
+
lastStatus: string | null;
|
|
298
|
+
}
|
|
283
299
|
/**
|
|
284
300
|
* 三选卡①:用户选了 steer,消息交给了那条持锁的 run。`delivery` = SDK `SteerReceipt.delivery`
|
|
285
301
|
* (`applied` / `queued` / `parked_for_wake`,读不出即 null)—— 三种投递语义**完全不同**,文案
|
|
@@ -138,7 +138,12 @@ export function selfHealSubmissionDisposition(outcome) {
|
|
|
138
138
|
// reopen-failed 那一臂)⇒ 用户手上确有把手。
|
|
139
139
|
return 'held-for-decision';
|
|
140
140
|
case 'running-cancelled':
|
|
141
|
+
case 'ask-decided-without-card':
|
|
141
142
|
return 'resending';
|
|
143
|
+
case 'ask-decided-release-unknown':
|
|
144
|
+
// L-80(对抗复审 r4 [high]③):决断成功、释放未确认。**没有**释放驱动的重投机制就不许承诺「稍后投递」——
|
|
145
|
+
// 注入件按既有 not-delivered 处置(端:归因上屏 + 问一次待决队列,不回灌不盲发);用户消息文案说「再发一次」。
|
|
146
|
+
return 'not-delivered';
|
|
142
147
|
case 'running-steered':
|
|
143
148
|
return 'handed-off';
|
|
144
149
|
default:
|
|
@@ -421,7 +426,7 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
|
|
|
421
426
|
if (gateKind !== null && PLAN_REVIEW_GATE_KINDS.includes(gateKind))
|
|
422
427
|
return planReviewArm(taskId, signal, deps);
|
|
423
428
|
if (gateKind !== null && ASK_PARK_GATE_KINDS.includes(gateKind))
|
|
424
|
-
return askParkArm(taskId, signal, deps);
|
|
429
|
+
return askParkArm(taskId, signal, runs, deps);
|
|
425
430
|
// ── 状态真源两级:wire 直供 > 自己查一趟 runs.get(kind 缺席/表外 kind 的诚实陈述都要它)──
|
|
426
431
|
let status = signal.activeTaskStatus;
|
|
427
432
|
if (status === null) {
|
|
@@ -448,7 +453,7 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
|
|
|
448
453
|
if (PLAN_REVIEW_STATES.includes(status))
|
|
449
454
|
return planReviewArm(taskId, signal, deps);
|
|
450
455
|
if (ASK_PARK_STATES.includes(status))
|
|
451
|
-
return askParkArm(taskId, signal, deps);
|
|
456
|
+
return askParkArm(taskId, signal, runs, deps);
|
|
452
457
|
// 真在跑 ⇒ 把引擎给着的两条路(steer / cancel)+ 现状做成三选卡交给用户;表外的其它状态
|
|
453
458
|
// (含今天还不存在的)照旧「不动它 + 如实说」—— 语义不明的状态不配递把手(见 RUNNING_STATES 注)。
|
|
454
459
|
// Inkglow-1085 P0b②:`statusFromWire` = 这个 running 是 409 终帧直供的投影(best-effort,可陈旧
|
|
@@ -486,11 +491,36 @@ async function planReviewArm(taskId, signal, deps) {
|
|
|
486
491
|
: { kind: 'plan-review-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
|
|
487
492
|
}
|
|
488
493
|
/** 审批/提问门:重开卡。cancel 在这里的语义 = 替用户否掉待决项,且实证是循环病根 —— 臂已退役。 */
|
|
489
|
-
async function askParkArm(taskId, signal, deps) {
|
|
494
|
+
async function askParkArm(taskId, signal, runs, deps) {
|
|
490
495
|
const verdict = await askVerdict(taskId, deps);
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
496
|
+
if (reopenDelivered(verdict))
|
|
497
|
+
return { kind: 'ask-reopened', taskId, firstSight: verdict.firstSight === true };
|
|
498
|
+
// L-80:无卡但链已成功决断(规则直决)—— 它只证明「决断受理」,不证明「run 交出了会话」。三分:
|
|
499
|
+
// · 有界等到 run 释放 ⇒ `ask-decided-without-card`(resending,与 running-cancelled 同一条重发腿的前提相同);
|
|
500
|
+
// · 没释放但 run 在跑 ⇒ 走既有 running 三选卡(steer / cancel / wait),绝不盲发;
|
|
501
|
+
// · 没释放且 run 仍 parked / 读不到 ⇒ 如实 reopen-failed(旧文案;plan 腿无此形,规则不决 plan)。
|
|
502
|
+
if (verdict.reopened === false && verdict.decidedWithoutCard === true) {
|
|
503
|
+
// 决断成功是正面事实,**不许**再落 reopen-failed(那句「could not reopen … NOT sent」会掩盖它)。
|
|
504
|
+
if (typeof runs?.get !== 'function')
|
|
505
|
+
return { kind: 'ask-decided-release-unknown', taskId, lastStatus: null };
|
|
506
|
+
// r5 [high]②:真 SDK 的 RunsResource 方法依赖 `this`(与 cancel 路径 `boundGet` 同款保留接收者;裸函数会恒 TypeError
|
|
507
|
+
// 被轮询器吞掉、10s 后错落 release-unknown)。
|
|
508
|
+
const durableGet = runs.get;
|
|
509
|
+
const get = (id, opts) => durableGet.call(runs, id, opts);
|
|
510
|
+
const release = await waitForClaimRelease(taskId, {
|
|
511
|
+
get,
|
|
512
|
+
budgetMs: deps?.cancelReleaseWaitMs ?? CANCEL_RELEASE_WAIT_MS, // 与 cancel 释放窗同源(10s),慢恢复不被 4s 误判
|
|
513
|
+
...(deps?.signal !== undefined ? { signal: deps.signal } : {}),
|
|
514
|
+
...sessionOpts(deps),
|
|
515
|
+
});
|
|
516
|
+
if (release.released)
|
|
517
|
+
return { kind: 'ask-decided-without-card', taskId, released: true };
|
|
518
|
+
if (release.lastStatus !== null && RUNNING_STATES.includes(release.lastStatus)) {
|
|
519
|
+
return runningChoiceArm(taskId, release.lastStatus, signal, false, runs, deps);
|
|
520
|
+
}
|
|
521
|
+
return { kind: 'ask-decided-release-unknown', taskId, lastStatus: release.lastStatus };
|
|
522
|
+
}
|
|
523
|
+
return { kind: 'ask-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
|
|
494
524
|
}
|
|
495
525
|
/**
|
|
496
526
|
* `running` 形:呈三选卡 → 按用户的决定动手。零决定 = 零动作(`not-parked` 现状行)。
|
|
@@ -561,7 +591,7 @@ async function runningChoiceArm(taskId, status, busy, statusFromWire, runs, deps
|
|
|
561
591
|
if (PLAN_REVIEW_STATES.includes(fresh))
|
|
562
592
|
return planReviewArm(taskId, busy, deps);
|
|
563
593
|
if (ASK_PARK_STATES.includes(fresh))
|
|
564
|
-
return askParkArm(taskId, busy, deps);
|
|
594
|
+
return askParkArm(taskId, busy, runs, deps);
|
|
565
595
|
// (对抗复审 [high] 采纳)终态词 ⇒ 专属结局:not-parked 的「wait for it to finish」对一条
|
|
566
596
|
// 已终结的 run 是永远等不到的假话。表外的未知词仍落 not-parked 如实说(不替引擎断言终结)。
|
|
567
597
|
if (CLAIM_RELEASED_STATES.includes(fresh))
|
|
@@ -844,6 +874,14 @@ function activeRunSelfHealBaseRow(outcome, signal, wayOut) {
|
|
|
844
874
|
`why nothing new could start — that approval card was closed without being answered, so sema ` +
|
|
845
875
|
`reopened it. Answer it and send your message again. (sema did NOT cancel the run: cancelling ` +
|
|
846
876
|
`would have thrown the plan away on your behalf.)`);
|
|
877
|
+
case 'ask-decided-without-card':
|
|
878
|
+
return (`The previous turn was parked on an approval that your permission rules decided on their own (no card was ` +
|
|
879
|
+
`needed); run ${outcome.taskId} resumed and has since finished — sema is re-sending your message.`);
|
|
880
|
+
case 'ask-decided-release-unknown':
|
|
881
|
+
return (`The previous turn was parked on an approval that your permission rules decided on their own (no card was ` +
|
|
882
|
+
`needed), so run ${outcome.taskId} should be resuming` +
|
|
883
|
+
(outcome.lastStatus ? ` (last engine status: ${outcome.lastStatus})` : '') +
|
|
884
|
+
`. sema could not yet confirm it finished, so your message was not sent — wait a moment and send it again.`);
|
|
847
885
|
case 'ask-reopen-failed': {
|
|
848
886
|
const viaEngine = outcome.decidePath
|
|
849
887
|
? ` You can also decide it on the engine directly: POST ${outcome.decidePath}.`
|
|
@@ -129,6 +129,22 @@ export declare const TOOL_END_INTERRUPTED_CODES: ReadonlySet<string>;
|
|
|
129
129
|
/** 这条 `tool_end` 是不是「中断留下的合成收口帧」。未知码 ⇒ false(开集:不认得就不认得,
|
|
130
130
|
* 绝不猜)。 */
|
|
131
131
|
export declare function isInterruptedToolEndCode(code: string | undefined): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* 提交面的 selfOrchestration 准入拒绝(`POST /v1/tasks` 与同闸的 stream 提交,**501**)。
|
|
134
|
+
*
|
|
135
|
+
* 语义:引擎侧的 workflow 开关是开着的(`SELF_ORCHESTRATION_ENABLED=true`),但这台部署处在
|
|
136
|
+
* **半配置的多租户形态** —— 要求 principal、却没有中心侧的准入解析器,于是它无法判「这一位
|
|
137
|
+
* 调用者能不能用 workflow」,fail-closed 拒。请求带 `selfOrchestration:true` **或**
|
|
138
|
+
* `settings.ultracode:true`(后者由 server 展开成前者)就撞这一条;把这两个键去掉,**同一条
|
|
139
|
+
* 请求照常受理**。
|
|
140
|
+
*
|
|
141
|
+
* 🔴 **这是复用码**:它与其它 `capability.*` 501 共用体形,而处置**不同** —— 别的能力位
|
|
142
|
+
* 缺席时去掉这两个键也不会变成可受理。⇒ 消费点必须按**恰等**判,绝不放宽成 `capability.`
|
|
143
|
+
* 前缀判(那是 `sessionMemoryStatus` 那条读面上的另一件事,两者刻意不合流)。
|
|
144
|
+
* 判型与「去键重发一次」的判定归 `src/selfOrchestrationDenial.ts`(文案归端)。
|
|
145
|
+
* 🔴 非布尔的 `selfOrchestration` 走的是 **400**,不是本码 —— 那是「值不对」不是「不给用」。
|
|
146
|
+
*/
|
|
147
|
+
export declare const CAPABILITY_SELF_ORCHESTRATION_REQUIRED = "capability.self_orchestration_required";
|
|
132
148
|
/** 409 session-busy 的 canonical 码([2377]C-1,server main `049ff2c`,随 5.0.0 发)。 */
|
|
133
149
|
export declare const ACTIVE_RUN_BUSY_ERROR_CODE = "conflict.session_active_run";
|
|
134
150
|
/** 仲裁店不可达 ⇒ **真相未知**(core 1.397 三分的第三形;塌进 `not_landed` 是诚实缺陷)。 */
|
package/dist/engineErrorCodes.js
CHANGED
|
@@ -182,6 +182,23 @@ export const TOOL_END_INTERRUPTED_CODES = new Set([
|
|
|
182
182
|
export function isInterruptedToolEndCode(code) {
|
|
183
183
|
return typeof code === 'string' && TOOL_END_INTERRUPTED_CODES.has(code);
|
|
184
184
|
}
|
|
185
|
+
// ── 能力准入族(server 7.57.0 S-81;`capability.*` 是**开集前缀**,本行只登记其中一个成员)────
|
|
186
|
+
/**
|
|
187
|
+
* 提交面的 selfOrchestration 准入拒绝(`POST /v1/tasks` 与同闸的 stream 提交,**501**)。
|
|
188
|
+
*
|
|
189
|
+
* 语义:引擎侧的 workflow 开关是开着的(`SELF_ORCHESTRATION_ENABLED=true`),但这台部署处在
|
|
190
|
+
* **半配置的多租户形态** —— 要求 principal、却没有中心侧的准入解析器,于是它无法判「这一位
|
|
191
|
+
* 调用者能不能用 workflow」,fail-closed 拒。请求带 `selfOrchestration:true` **或**
|
|
192
|
+
* `settings.ultracode:true`(后者由 server 展开成前者)就撞这一条;把这两个键去掉,**同一条
|
|
193
|
+
* 请求照常受理**。
|
|
194
|
+
*
|
|
195
|
+
* 🔴 **这是复用码**:它与其它 `capability.*` 501 共用体形,而处置**不同** —— 别的能力位
|
|
196
|
+
* 缺席时去掉这两个键也不会变成可受理。⇒ 消费点必须按**恰等**判,绝不放宽成 `capability.`
|
|
197
|
+
* 前缀判(那是 `sessionMemoryStatus` 那条读面上的另一件事,两者刻意不合流)。
|
|
198
|
+
* 判型与「去键重发一次」的判定归 `src/selfOrchestrationDenial.ts`(文案归端)。
|
|
199
|
+
* 🔴 非布尔的 `selfOrchestration` 走的是 **400**,不是本码 —— 那是「值不对」不是「不给用」。
|
|
200
|
+
*/
|
|
201
|
+
export const CAPABILITY_SELF_ORCHESTRATION_REQUIRED = 'capability.self_orchestration_required';
|
|
185
202
|
// ── 会话冲突族 ────────────────────────────────────────────────────────────────────────────────
|
|
186
203
|
/** 409 session-busy 的 canonical 码([2377]C-1,server main `049ff2c`,随 5.0.0 发)。 */
|
|
187
204
|
export const ACTIVE_RUN_BUSY_ERROR_CODE = 'conflict.session_active_run';
|
|
@@ -74,10 +74,11 @@ import type { AgentEvent } from '@sema-agent/sdk';
|
|
|
74
74
|
import { type AskGateWireDeps } from './frameRouter.js';
|
|
75
75
|
export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
|
|
76
76
|
export { HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE } from './frameRouter.js';
|
|
77
|
-
export type { AskGateWireDeps } from './frameRouter.js';
|
|
77
|
+
export type { AskGateWireDeps, ParkReattachNotice } from './frameRouter.js';
|
|
78
78
|
export type { AskAnsweredOutput } from './gateLedger.js';
|
|
79
79
|
export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
|
|
80
|
-
export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
|
|
80
|
+
export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES, nextHopBudget, MAX_GATE_HOPS, MAX_TOTAL_PARKS } from './parkResolver.js';
|
|
81
|
+
export type { HopBudget, HopRound } from './parkResolver.js';
|
|
81
82
|
export type { GateOutcome, GateFailureCode } from './parkResolver.js';
|
|
82
83
|
/**
|
|
83
84
|
* 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
|
package/dist/hitl/askGateWire.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createGateLedger } from './gateLedger.js';
|
|
2
2
|
import { flushHeldWithInterruptRewrite, isHostProgressFrame, routeFrame, } from './frameRouter.js';
|
|
3
|
-
import { resolvePark } from './parkResolver.js';
|
|
3
|
+
import { resolvePark, nextHopBudget, stalledTerminal, MAX_GATE_HOPS } from './parkResolver.js';
|
|
4
4
|
// ── 宿主面口(搬迁差分 3)────────────────────────────────────────────────────────────────────
|
|
5
5
|
//
|
|
6
6
|
// 壳里这三件都是 `getAppStateStoreRef()` 直写 `AppState` + `require('../utils/autoModeDenials.js')`。
|
|
@@ -28,7 +28,7 @@ export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
|
|
|
28
28
|
// 🔴 `HoldOrigin` 随 #324 换锚删除(2026-08-22):出身不再在入表当拍冻结成两值枚举,改在
|
|
29
29
|
// `flushHeld` 当拍按已登记的 gate 主角身份求值 —— 那个枚举没有对外语义了。干净切:它只在
|
|
30
30
|
// 未发布的 0.40.0 窗里存在过,零已发布消费者([clean-cut-no-legacy-compat])。
|
|
31
|
-
export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
|
|
31
|
+
export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES, nextHopBudget, MAX_GATE_HOPS, MAX_TOTAL_PARKS } from './parkResolver.js';
|
|
32
32
|
/**
|
|
33
33
|
* 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
|
|
34
34
|
* 其它事件原样透传;非 AskUserQuestion 的 gate 保持现状。fail-soft:任何桥内失败回退为
|
|
@@ -53,12 +53,32 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
|
53
53
|
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
54
54
|
};
|
|
55
55
|
let stream = source;
|
|
56
|
-
|
|
56
|
+
// ── L-80 hop 预算(2026-09-03):数「连续非进展轮」,不数 park 次数 ─────────────────────────────
|
|
57
|
+
// 一轮的进展 = 上一次 reattach 打开的那段流里出现过 host 推进帧(`isHostProgressFrame`:
|
|
58
|
+
// text/reasoning/tool_start 且非孙代)。真 park 的帧序 tool_end→message_committed→turn_end→
|
|
59
|
+
// done{suspended} 一个推进帧都不带([2084]① 收窄过的集合),所以「引擎 revive 后立刻原样 re-park」
|
|
60
|
+
// (坐标失配 / reopen 类)天然判非进展;「模型重试同一失败 Edit 每次真被门」每轮都有推进帧 ⇒
|
|
61
|
+
// 判进展、不限次(CC 同款:问 N 次答 N 次)。首段是 sync leg,自带 gated tool_start ⇒ 恒判进展。
|
|
62
|
+
let budget;
|
|
63
|
+
let progressedSinceReattach = false;
|
|
64
|
+
let lastStallReason;
|
|
65
|
+
let lastRoundPresentedCard = false;
|
|
66
|
+
// 对抗复审 r1 [high]①:进展**同时**要求 park 身份推进 —— 同一个 gatedCallId 原样回来,哪怕中间吐了一帧文本
|
|
67
|
+
// (「retrying…」)也不算进展;新 toolCallId(模型重试/下一只门)+ 推进帧才算。tool-less park 无身份,只看帧
|
|
68
|
+
// + MAX_TOTAL_PARKS 硬兜底。
|
|
69
|
+
let lastResolvedGatedCallId;
|
|
70
|
+
/** 上一轮有没有真决断落地(progress:true)。有 ⇒ 这一轮的「首读空」是取件失败不是空转,推进帧照算进展
|
|
71
|
+
* (F15-f:门1 决完引擎真推进了,门2 首击读空);没有(从没解析到过行)⇒ no_pending 一票否决(L12)。 */
|
|
72
|
+
let lastRoundDecided = false;
|
|
73
|
+
/** 本 turn 内解析不出身份的 park 数(MAX_TOTAL_PARKS 只数这一类,r6 [high]:真推进的新门不吃硬兜底)。 */
|
|
74
|
+
let unverifiableParks = 0;
|
|
57
75
|
while (true) {
|
|
58
76
|
/** park 请求:遇 AskUserQuestion / 工具审批 gate 时置位后 break 内环。 */
|
|
59
77
|
let park = null;
|
|
60
78
|
for await (const ev of stream) {
|
|
61
79
|
led.noteSeq(ev);
|
|
80
|
+
if (isHostProgressFrame(ev))
|
|
81
|
+
progressedSinceReattach = true;
|
|
62
82
|
// HOLD 只护 park 窗口:host lane 的模型推进帧一到就放行扣留帧(触发集的来龙去脉见
|
|
63
83
|
// `frameRouter.isHostProgressFrame` 上方长注)。
|
|
64
84
|
// 件 B(异源复审 finding 采纳):这个中途出口**也**要走中断感知的那一个。病形 = 用户按了
|
|
@@ -97,19 +117,75 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
|
|
|
97
117
|
yield* flushHeldWithInterruptRewrite(led, { signal: opts?.signal });
|
|
98
118
|
return;
|
|
99
119
|
}
|
|
100
|
-
|
|
101
|
-
|
|
120
|
+
// 进展判决分两半(对抗复审 r2 [high]①):
|
|
121
|
+
// · **帧**半场在 resolve 之前判:上一段流里有推进帧 ⇒ 先按进展记(同因计数复位、预算清零);
|
|
122
|
+
// · **身份**半场在 resolve 之后追认:这一轮真解析到的 pending 行(`resolution.gatedCallId`)与上一轮
|
|
123
|
+
// **同一只 call** ⇒ 把这一轮追改成非进展(哪怕中间吐了一帧文本)。durable `suspended` 帧常不带
|
|
124
|
+
// toolCallId,所以身份只认解析到的行,帧上缺席不算「变了」,也**不覆盖**上一次已知身份。
|
|
125
|
+
// 帧半场先判是为了不让触顶晚一拍(cap 用的是**本轮之前**的连续非进展数)。
|
|
126
|
+
// 帧半场(resolve 前):只看上一段流有没有 host 推进帧。有 ⇒ 同因账先复位(下一只门的首击要从 1 数起,
|
|
127
|
+
// F15-f),预算**不在这里提交**;触顶输入 = 上一轮累计(有帧时沿用,没帧时 +1)—— 帧上的 park 身份
|
|
128
|
+
// **不参与**预判(对抗复审 r3 [high]①:重放帧可能仍携旧 call A,而 rescan 真解析到的是新 call B)。
|
|
129
|
+
const framesProgressed = progressedSinceReattach;
|
|
130
|
+
const prevStalled = budget?.stalled ?? 0;
|
|
131
|
+
if (framesProgressed) {
|
|
132
|
+
led.resetAlreadyResolvedGate();
|
|
133
|
+
lastStallReason = undefined;
|
|
134
|
+
}
|
|
135
|
+
// 触顶(对抗复审 r4 [high]①):有推进帧时**不在解析前**触顶 —— 先解析、先呈、先决,提交预算后仍是同一只
|
|
136
|
+
// call 才收场(见下);没有推进帧时上一轮累计 +1 已足够判定,在 resolvePark 内按 stalledRounds 收场。
|
|
137
|
+
const ctxBase = {
|
|
102
138
|
deps,
|
|
103
139
|
led,
|
|
104
140
|
taskId: taskId.current,
|
|
105
|
-
hops,
|
|
141
|
+
hops: (budget?.total ?? 0) + 1,
|
|
142
|
+
lastStallReason,
|
|
143
|
+
lastRoundPresentedCard,
|
|
106
144
|
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
145
|
+
};
|
|
146
|
+
const resolution = await resolvePark(park, {
|
|
147
|
+
...ctxBase,
|
|
148
|
+
stalledRounds: framesProgressed ? 0 : prevStalled + 1,
|
|
149
|
+
unverifiableParks,
|
|
107
150
|
});
|
|
108
151
|
if (resolution.kind === 'failsoft') {
|
|
109
152
|
for (const out of resolution.events)
|
|
110
153
|
yield out;
|
|
111
154
|
return;
|
|
112
155
|
}
|
|
156
|
+
// 身份半场(resolve 后,权威):只认这一轮真解析到的 pending 行(`resolution.gatedCallId` **不**回填帧上身份,
|
|
157
|
+
// 对抗复审 r4 [high]②)。同一只 call 原样回来 ⇒ 非进展(哪怕有推进帧);没解析到行(no_pending / 传输败)
|
|
158
|
+
// 的轮次 `progress:false` 一票否决。预算在这里一次提交;提交后超上限 ⇒ 立即收场(这一轮已呈过卡,不再呈)。
|
|
159
|
+
const resolvedId = resolution.gatedCallId;
|
|
160
|
+
if (resolvedId === undefined)
|
|
161
|
+
unverifiableParks++;
|
|
162
|
+
const sameCall = resolvedId !== undefined && resolvedId === lastResolvedGatedCallId;
|
|
163
|
+
const progressed = framesProgressed && !sameCall && (resolution.progress !== false || lastRoundDecided);
|
|
164
|
+
budget = nextHopBudget(budget, progressed ? 'progress' : 'stalled');
|
|
165
|
+
if (!progressed && budget.stalled > MAX_GATE_HOPS) {
|
|
166
|
+
const terminal = stalledTerminal(park, { ...ctxBase, stalledRounds: budget.stalled }, budget.stalled, resolution.progress === false ? resolution.reason : (sameCall ? 'the approval was decided but the engine parked the same call again' : lastStallReason), resolution.progress === true, resolvedId);
|
|
167
|
+
if (terminal.kind === 'failsoft') {
|
|
168
|
+
for (const out of terminal.events)
|
|
169
|
+
yield out;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (progressed) {
|
|
174
|
+
lastStallReason = undefined;
|
|
175
|
+
}
|
|
176
|
+
else if (resolution.progress === false) {
|
|
177
|
+
lastStallReason = resolution.reason;
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
lastStallReason = 'the approval was decided but the engine parked the same call again';
|
|
181
|
+
}
|
|
182
|
+
if (resolvedId !== undefined)
|
|
183
|
+
lastResolvedGatedCallId = resolvedId;
|
|
184
|
+
// r5 [high]①:豁免只授予**新 call** 上落地的决断(与上一轮身份不同)—— 同一只 call 原样回来再决一次不授予,
|
|
185
|
+
// 否则「可见并决 / 暂不可见」交替可无限;而 F15-f 那形(门1 首次决完、门2 首击读空)仍能拿到豁免。
|
|
186
|
+
lastRoundDecided = resolution.progress === true && !sameCall;
|
|
187
|
+
lastRoundPresentedCard = resolution.presented; // 每次调用的真回执:规则直决 / 取件失败 = false ⇒ 触顶时收场卡照呈
|
|
188
|
+
progressedSinceReattach = false;
|
|
113
189
|
const seq = led.lastSeq();
|
|
114
190
|
stream = deps.runsEvents(taskId.current, {
|
|
115
191
|
...(seq !== undefined ? { lastEventId: seq } : {}),
|