@sema-agent/client-core 0.29.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/README.md +19 -3
  3. package/dist/adapt/arms.js +24 -1
  4. package/dist/adapt/wireShapes.d.ts +7 -0
  5. package/dist/adapt/wireShapes.js +7 -0
  6. package/dist/adapter/activeRunSelfHeal.d.ts +285 -48
  7. package/dist/adapter/activeRunSelfHeal.js +553 -19
  8. package/dist/adapter/runStream.js +13 -3
  9. package/dist/engineWireSdk.d.ts +10 -2
  10. package/dist/engineWireSdk.js +7 -3
  11. package/dist/hitl/approvalDecisionNoteAudit.d.ts +58 -0
  12. package/dist/hitl/approvalDecisionNoteAudit.js +91 -0
  13. package/dist/hitl/armedGateRegistry.d.ts +62 -4
  14. package/dist/hitl/armedGateRegistry.js +226 -14
  15. package/dist/hitl/askParkRowRouting.d.ts +150 -0
  16. package/dist/hitl/askParkRowRouting.js +183 -0
  17. package/dist/hitl/gateIdentity.d.ts +8 -0
  18. package/dist/hitl/gateIdentity.js +8 -0
  19. package/dist/hitl/hitlBridge.d.ts +7 -0
  20. package/dist/hitl/hitlBridge.js +11 -2
  21. package/dist/hitl/parkOwnership.d.ts +2 -1
  22. package/dist/hitl/parkOwnership.js +11 -3
  23. package/dist/hitl/parkRowBirthWait.d.ts +63 -0
  24. package/dist/hitl/parkRowBirthWait.js +192 -0
  25. package/dist/hitl/planReviewWire.d.ts +31 -1
  26. package/dist/hitl/planReviewWire.js +69 -30
  27. package/dist/hitl/resumeRunningCard.d.ts +134 -0
  28. package/dist/hitl/resumeRunningCard.js +177 -0
  29. package/dist/hitl/toolApprovalWire.d.ts +49 -9
  30. package/dist/hitl/toolApprovalWire.js +9 -0
  31. package/dist/index.d.ts +5 -0
  32. package/dist/index.js +14 -0
  33. package/dist/seatContract.d.ts +27 -0
  34. package/dist/seatContract.js +42 -0
  35. package/dist/subagent/engineSubagentTail.d.ts +0 -2
  36. package/dist/subagent/engineSubagentTail.js +7 -15
  37. package/dist/subagentContentStore.d.ts +58 -2
  38. package/dist/subagentContentStore.js +95 -6
  39. package/dist/toolResult.d.ts +26 -0
  40. package/dist/toolResult.js +38 -6
  41. package/dist/workflowClient.d.ts +6 -1
  42. package/docs/INTEGRATION-CLIENTS.md +844 -0
  43. package/docs/REFACTOR-LEDGER.md +392 -0
  44. package/package.json +7 -4
@@ -0,0 +1,150 @@
1
+ /**
2
+ * hitl/askParkRowRouting.ts — ask park(`suspended`)重开链的**纯判定层**(#265 上收件,
3
+ * 2026-08-14;源形 = cli `src/sema/askParkReopen.ts` 的判据半场,该文件的呈现/决断编排留在端上)。
4
+ *
5
+ * 三端(cli / desktop / web)重开一张 ask park 的卡时,要按同一套判据回答四个问题;本文件是这
6
+ * 四问的唯一真源,**一个渲染面都不碰**(帧/卡口/HitlBridge 全在调用方):
7
+ * ① 「此刻队列里有没有一行**该由本臂决**的行?」 → {@link classifyAskParkRows}(三态)
8
+ * ② 「这一行的身份键是什么?」 → {@link askParkRowIdentity}
9
+ * ③ 「这一行该走问答臂还是工具门臂?」 → {@link askParkRowArm}
10
+ * ④ 「链跑砸了、而行已不在表 —— 这是已决还是没轮到?」 → {@link classifyAskParkChainFailure}
11
+ * 外加一件构造保证:同一行的并发重开**合流**成一次({@link createRowArmSingleFlight})。
12
+ *
13
+ * ── ① 三态与证据纪律(cli #269 的整个要点)────────────────────────────────────────────────────
14
+ * `/v1/approvals` 是 **scope 级全量单队列**:child 委派的 park 行 `taskId` = child 自己的 run id,
15
+ * 所以「按 busy 信号点名的 taskId 单键过滤」会结构性漏看。发现面 = 全量取回 → 先找点名行 →
16
+ * 没有时**在队列无歧义(恰一行)且该行归属可正向证明**的前提下兜底。
17
+ * 🔴 分态只有一条纪律:**只有正面证据才配 `settled`**。
18
+ * · 空表 / 多行无点名行 / 唯一行归属证明不了 ⇒ 一律 `unborn`(缺席证明不了任何事;这三种形都
19
+ * 可能在下一拍变成「我的那一行出生了」——引擎正在跑那次决定 Write 参数的模型调用);
20
+ * · 行在、但门种不属 ask 门族 ⇒ 也只给 `unborn`。「看得见的这一行不属本臂」与「这里已经没有本臂
21
+ * 该决的事」是两件事,而门换代窗恰恰会短暂两者同形(plan_review 行在 decide 落地后还能被读到
22
+ * 一小会儿,接替它的 human/tool_approval 行尚未出生)。把这一拍读成终判 = 把失败形换个姿势
23
+ * 原样复现。代价(如实记):真·走错臂时要等满有界窗才说那句诚实话。
24
+ * ⇒ 本函数**今天一条 `settled` 都不产**,这是成文结论不是遗漏:队列读面给得出的一切都只是缺席类
25
+ * 证据。`settled` 留在 {@link ParkRowProbe} 契约里给**读面更强**的宿主(注入 gate 实例态/wire
26
+ * 直供门身份)用 —— 那时「已了结」才是拿得出正面证据的判决。
27
+ *
28
+ * ── ③ 行路由:锚在**决定量**上,不锚门种的名字 ───────────────────────────────────────────────
29
+ * 🔴 `gateKind === 'human'` **不是**「这是 AskUserQuestion」的证据(engine 7.18.0 真 wire 直证):
30
+ * plan 批准之后铸出的 **Write** 审批行带的就是 `gateKind:"human" · toolName:"Write" ·
31
+ * input:{file_path,content}`。`human` 在引擎侧是**泛化的「要人来决定」**(本包 `hitlBridge.observe`
32
+ * 对 `gate:null` 的补形同样是 `{kind:'human'}`),它与 `tool_approval` 是「粗粒度 / 细粒度」的关系,
33
+ * 不是「问答 / 工具」的二分。把 `human` 单义化成问答臂,那一行就会被送进没有问句 payload 的臂 ⇒
34
+ * 静默失败 ⇒ write 卡永不呈现。问答臂要的东西是**问句**,那就问「这行到底有没有问句」。
35
+ *
36
+ * ── 归层 ────────────────────────────────────────────────────────────────────────────────────
37
+ * 本文件值级 import 全在包内(gateIdentity / parkOwnership / frameRouter / activeRunSelfHeal 词表),
38
+ * SDK 只 type-only;零宿主端口、零模块级状态(单活闸是**工厂**,寿命归调用方 —— 包不替宿主决定
39
+ * 那张表活多久)。
40
+ */
41
+ import type { PendingCheckpoint } from '@sema-agent/sdk';
42
+ import type { ParkRowProbe } from './parkRowBirthWait.js';
43
+ import { type ParkOwnershipDeps } from './parkOwnership.js';
44
+ /**
45
+ * 待决行有界等待窗的**建议上限**(端可覆盖;`waitForParkRowBirth` 自己不带缺省)。
46
+ * 实测锚:plan 批准之后引擎要跑**一次真实模型调用**才算得出 Write 的参数、才把审批行落库 ——
47
+ * t+2500ms 读到的仍是空表,t+3000ms 行才出生。15s = 实测 3s 的 5 倍余量(慢模型/负载下的头部
48
+ * 延迟);超限如实收口 —— 到那一刻降级话是真话,这正是有界的意义。
49
+ * 🔴 代价如实记:真孤儿 park(座位占着、确实没有任何待决行)要等满这个窗才说那句诚实的失败话。
50
+ * 方向取舍 = 让**常见且可修**的那一态(行未出生)真的被接住,代价是**罕见且本就已坏**的那一态
51
+ * 多等一个有界窗;等待全程可被中断(signal 直通重查环)。
52
+ */
53
+ export declare const ASK_PARK_ROW_WAIT_MS = 15000;
54
+ /** 重查节奏建议值。500ms 级:比行出生的量级(秒)细一个数量级,又不至于把本地引擎打成轮询风暴。 */
55
+ export declare const ASK_PARK_ROW_POLL_MS = 500;
56
+ /** {@link classifyAskParkRows} 的注入口(缺省 = 包内归属判据)。 */
57
+ export interface AskParkRowScanDeps {
58
+ /** 归属判据(缺省 = `pendingRowIsOwnedByThisSession`)。fail-CLOSED:证不出 = 不采信。 */
59
+ isOwned?: (row: PendingCheckpoint) => boolean;
60
+ /** 缺省归属判据的注入位(多会话宿主传 sessionKey / 自己的 currentSessionId)。 */
61
+ ownership?: ParkOwnershipDeps;
62
+ }
63
+ /**
64
+ * ① 「本臂此刻有没有一行可决断的行」——三态判决(见文件头)。**纯函数**:行由调用方读面取回,
65
+ * 本函数只判;绝不抛。
66
+ *
67
+ * @param rows `/v1/approvals` 全量 pending 行(scope 级单队列原样)
68
+ * @param taskId busy 信号点名的 run id(优先匹配它;匹配不到才谈兜底)
69
+ */
70
+ export declare function classifyAskParkRows(rows: readonly PendingCheckpoint[], taskId: string, deps?: AskParkRowScanDeps): ParkRowProbe<PendingCheckpoint>;
71
+ /** ② 行身份三件套(重开链的一切键都从这里出;两处各算一遍必漂)。 */
72
+ export interface AskParkRowIdentity {
73
+ /** 🔴 行内身份绑定:decide 腿一律按**该行自己的** taskId 走,绝不绑请求者的 —— 兜底腿把别的行
74
+ * 解到请求者名下时,按请求者的 id 去决断就是决了另一件事。 */
75
+ rowTaskId: string;
76
+ gatedCallId: string | undefined;
77
+ /** 首见/复见 + 呈现回执的台账键(`gateIdentity.approvalCallKey` 单源,端零手抄字面)。 */
78
+ armedKey: string;
79
+ }
80
+ /** ② 算一行的身份三件套。`taskId` = 请求者点名的 id(行自己没带 taskId 时的回退)。 */
81
+ export declare function askParkRowIdentity(taskId: string, row: PendingCheckpoint): AskParkRowIdentity;
82
+ /**
83
+ * ③ 这一行该走哪条臂。判据锚在**决定量**(这行到底有没有问句)上,不锚门种的名字 —— 理由见文件头。
84
+ * · kind 缺席(pre-3.0.0 旧行)⇒ 维持 toolName 判据(旧行没有别的信号);
85
+ * · kind 是 ask 门族里 `human` 之外的词(tool_approval / irreversible_ask / policy_ask)⇒ 工具臂;
86
+ * · kind = `human` ⇒ 三选一命中即问答臂:toolName 明说是 ask 工具 / 行真带问句 payload /
87
+ * 行**连 toolName 都没有**(没有工具可渲,问答臂是唯一可能的形,且它自己会对空 payload 诚实收口)。
88
+ * 🔴 `toolName` 是 UNTRUSTED wire 位且 `/v1/approvals` 行不经工具名归一 —— 用包的规范判据
89
+ * {@link isAskTool}(空白/下划线/大小写不敏感),严格串比会把小写形误路由进工具臂。
90
+ */
91
+ export declare function askParkRowArm(row: PendingCheckpoint): 'question' | 'tool-gate';
92
+ /**
93
+ * ④ 「这一行还在不在待决表里」——回读判据(键取法与 {@link askParkRowIdentity} 同源)。
94
+ * 🔴 调用方读面失败时**必须**当「还在」(保守:照走重试/上屏;重复 decide 由 server 侧 checkpoint
95
+ * 一次性消费挡住,二发只会得到 stale/no_pending 拒绝,不会 double-act)。
96
+ */
97
+ export declare function askParkRowStillPending(rows: readonly PendingCheckpoint[], identity: AskParkRowIdentity): boolean;
98
+ /**
99
+ * ④ 链失败之后「行已不在 pending 表」的**两义分臂**判决。
100
+ * · `retry` —— 行还在表上 ⇒ 这就是一次真失败:照走重试 / 如实上屏;
101
+ * · `row-gone-after-card` —— 行没了 **且卡呈现过** ⇒ **静默收口**:零重试、零失败话、零成功断言。
102
+ * 🔴 名字刻意只说**可观察事实**(二次评审两轮处置):它不叫「已在别处决了」,因为呈现只证明卡
103
+ * 渲出来过,证明不了有人做过决定 —— 一个带决断语义的判决值会诱导宿主替引擎宣布成功。它成立的
104
+ * 判据是两件可观察事实的合取:队列回读说这一行已不在待决表(一次真读数,不是缺席推断),且本链
105
+ * 确实把卡交到过渲染面。处置的理由也只由这两件事推出:重试 = 对一个不在待决表的行再决一次
106
+ * (server 侧 checkpoint 一次性消费会拒),上屏「the session is still held」= 对一个已不在队列的
107
+ * 行说它还占着。🔴 宿主**不许**据此对用户断言「你的决定已生效」。残余(如实记):行在呈现窗里
108
+ * 被换代/过期而恰好没人决过时,这一格会把它按已收口处理 —— 代价是这一轮不再重开(用户下一条
109
+ * 消息会再撞 409,自愈腿重跑);要真的分出「已决」只能靠决断回执 / typed stale·conflict 应答那类
110
+ * **正面证据**,而那属于决断腿的供给面,不在本读面。
111
+ * 🔴 为什么不干脆退回 `row-unborn` 继续重查(二次评审 R3 [medium] 提议,**未采纳**,理由对称):
112
+ * 那会把**最常见的良性时序**(决断真落地 → 座位释放 → 队列从此空表)变成「白等满一个窗,然后
113
+ * 对用户说『没能重开那张卡』」—— 一句同样为假的话,而且它出现在事情**本来就成了**的那一格。
114
+ * 两个方向各有一次谎报风险,取舍按代价:本格的代价是这一轮不重开(下一条消息自愈腿重跑,可收敛),
115
+ * 对面的代价是每一次成功决断都附赠一句假失败 + 一个满窗停顿(每次都发生,不可收敛)。
116
+ * · `row-unborn` —— 行没了 **且一张卡都没呈上** ⇒ 没有任何人决过任何事,行消失只可能是
117
+ * 「刚才那一行换代了 / 下一只门的行还没铸出来」。此时按成功收口 = 对一件从没发生过的决断下
118
+ * 断言,而呈现回执永远等不到 ⇒ 用户拿到降级话,三秒后真的出生的那张卡再没人去接。这一格
119
+ * **不是失败**(不上失败话、不重试),交回有界重查环。
120
+ *
121
+ * 🔴 `cardPresented` 必须是**无窗的事实**(整条链寿命里的呈现事件订阅),不能拿带看门狗的呈现回执
122
+ * 近似:迟到卡会让带窗的那只回 false,于是一次真呈现被误判成「行从未出生」,重查环再铸一张卡 ⇒
123
+ * 同一个待决项两张可按的卡。
124
+ */
125
+ export type AskParkChainFailureDisposition = 'retry' | 'row-gone-after-card' | 'row-unborn';
126
+ export declare function classifyAskParkChainFailure(i: {
127
+ rowStillPending: boolean;
128
+ cardPresented: boolean;
129
+ }): AskParkChainFailureDisposition;
130
+ /**
131
+ * **行级重开单活闸**:同一行(键 = {@link AskParkRowIdentity.armedKey})的第二个调用方**合流**到
132
+ * 在飞的那一次(拿逐字相同的判决),不另起一条链 —— 「一个待决项至多一张卡」由此是构造保证。
133
+ *
134
+ * 🔴 为什么按**行键**而不是请求者的 taskId:发现面的兜底腿会把**不同** taskId 的两个调用方解到
135
+ * **同一行**,那时两条链会各铸一张身份不同、都能按的卡 —— 同一个待决项两张卡,先按的决断、后按的
136
+ * 变成 stale 误导面。而重开臂的寿命一旦从毫秒级拉到一个秒级的有界重查窗,「结构上不容易撞上」就
137
+ * 不再是防线([paired-mechanisms]:两半各持「对方会兜底」的假设 = 零结果),必须在源头收掉。
138
+ *
139
+ * 🔴 **工厂,不是模块级台账**:这张表的寿命 = 宿主一个会话的重开面,由装配点持有;包不替宿主决定
140
+ * 它活多久,多会话宿主天然一会话一只(单例化反而会让两个会话互相合流)。
141
+ */
142
+ export interface RowArmSingleFlight<T> {
143
+ /** 有在飞的同键链 ⇒ 返回它(第二个调用方零副作用);否则起一条并登记(结束后自动清账)。 */
144
+ join: (key: string, start: () => Promise<T>) => Promise<T>;
145
+ /** 当前在飞条数(诊断/测试用)。 */
146
+ size: () => number;
147
+ /** 清空(套件之间不许互相串态)。 */
148
+ clear: () => void;
149
+ }
150
+ export declare function createRowArmSingleFlight<T>(): RowArmSingleFlight<T>;
@@ -0,0 +1,183 @@
1
+ import { askParkForeignGateKind } from '../adapter/activeRunSelfHeal.js';
2
+ import { isAskTool } from './frameRouter.js';
3
+ import { approvalCallKey } from './gateIdentity.js';
4
+ import { askQuestionsFromPending, pendingRowIsOwnedByThisSession } from './parkOwnership.js';
5
+ /**
6
+ * 待决行有界等待窗的**建议上限**(端可覆盖;`waitForParkRowBirth` 自己不带缺省)。
7
+ * 实测锚:plan 批准之后引擎要跑**一次真实模型调用**才算得出 Write 的参数、才把审批行落库 ——
8
+ * t+2500ms 读到的仍是空表,t+3000ms 行才出生。15s = 实测 3s 的 5 倍余量(慢模型/负载下的头部
9
+ * 延迟);超限如实收口 —— 到那一刻降级话是真话,这正是有界的意义。
10
+ * 🔴 代价如实记:真孤儿 park(座位占着、确实没有任何待决行)要等满这个窗才说那句诚实的失败话。
11
+ * 方向取舍 = 让**常见且可修**的那一态(行未出生)真的被接住,代价是**罕见且本就已坏**的那一态
12
+ * 多等一个有界窗;等待全程可被中断(signal 直通重查环)。
13
+ */
14
+ export const ASK_PARK_ROW_WAIT_MS = 15000;
15
+ /** 重查节奏建议值。500ms 级:比行出生的量级(秒)细一个数量级,又不至于把本地引擎打成轮询风暴。 */
16
+ export const ASK_PARK_ROW_POLL_MS = 500;
17
+ /**
18
+ * ① 「本臂此刻有没有一行可决断的行」——三态判决(见文件头)。**纯函数**:行由调用方读面取回,
19
+ * 本函数只判;绝不抛。
20
+ *
21
+ * @param rows `/v1/approvals` 全量 pending 行(scope 级单队列原样)
22
+ * @param taskId busy 信号点名的 run id(优先匹配它;匹配不到才谈兜底)
23
+ */
24
+ export function classifyAskParkRows(rows, taskId, deps) {
25
+ const isOwned = deps?.isOwned ?? ((row) => pendingRowIsOwnedByThisSession(row, deps?.ownership));
26
+ const own = rows.find(r => r.taskId === taskId);
27
+ // 🔴 兜底纪律:只在**队列无歧义**(恰一行 pending)时才兜底 —— scope 级单队列上可能还躺着别的
28
+ // 会话/别的后台 lane 的行,多行并存时挑任何一行都证明不了归属,展示并决它 = 把无关审批递到这个
29
+ // 用户手上。歧义 ⇒ 本拍无行(交回有界重查环,窗尽才诚实失败)。
30
+ const sole = rows.length === 1 ? rows[0] : undefined;
31
+ const soleUnowned = sole !== undefined && !isOwned(sole);
32
+ const pending = own ?? (soleUnowned ? undefined : sole);
33
+ if (pending === undefined) {
34
+ return {
35
+ kind: 'unborn',
36
+ reason: rows.length === 0
37
+ ? // 空表两义:要么真孤儿 park(座位占着却没有待决行 = 引擎态异常),要么下一只门的行还没
38
+ // 铸出来。两义分不开 ⇒ 按可修的那一义有界重查,窗尽才按前者如实收口。
39
+ `no pending row at all for task ${taskId} — either an orphan park (a seat held with no decision outstanding) or the next gate's row is not minted yet`
40
+ : soleUnowned
41
+ ? `the only pending row (task ${String(sole?.taskId)} / session ${String(sole?.sessionId)}) could not be proven to belong to this client (not in its own-run ledger, session id absent or different) — refusing to surface an approval that may be another session's`
42
+ : `${String(rows.length)} pending rows but none for task ${taskId} — ambiguous queue, refusing to surface an unrelated row`,
43
+ };
44
+ }
45
+ // 行路由的门种闸:kind 在场但不在 ask 门族(plan_review / resource_limit / 未来新词)⇒ 拒绝入臂
46
+ // —— 对语义不属本臂的门驱动 `/decide` 比不重开更坏。判为 `unborn` 而非 `settled`,理由见文件头。
47
+ // 判据 = 包内单源 {@link askParkForeignGateKind}(`findPendingForTask` 的行过滤同一只,P-30)。
48
+ const foreignKind = askParkForeignGateKind(pending);
49
+ if (foreignKind !== null) {
50
+ return {
51
+ kind: 'unborn',
52
+ reason: `the visible pending row (task ${String(pending.taskId)}) is parked on a '${foreignKind}' gate — not an ask/approval gate; this arm will not surface or decide it, and it may also be the previous gate's row still visible during a transition`,
53
+ };
54
+ }
55
+ return { kind: 'row', row: pending };
56
+ }
57
+ /** 行上的门种(空串按缺席归一;缺席 = pre-`gate_kind` 的历史行,别当 `"human"`)。 */
58
+ function askParkRowGateKind(row) {
59
+ return typeof row.gateKind === 'string' && row.gateKind.length > 0 ? row.gateKind : null;
60
+ }
61
+ /** ② 算一行的身份三件套。`taskId` = 请求者点名的 id(行自己没带 taskId 时的回退)。 */
62
+ export function askParkRowIdentity(taskId, row) {
63
+ const rowTaskId = typeof row.taskId === 'string' && row.taskId.length > 0 ? row.taskId : taskId;
64
+ const gatedCallId = row.toolCallId ?? row.boundCallId ?? undefined;
65
+ return { rowTaskId, gatedCallId, armedKey: approvalCallKey(gatedCallId, rowTaskId) };
66
+ }
67
+ /**
68
+ * ③ 这一行该走哪条臂。判据锚在**决定量**(这行到底有没有问句)上,不锚门种的名字 —— 理由见文件头。
69
+ * · kind 缺席(pre-3.0.0 旧行)⇒ 维持 toolName 判据(旧行没有别的信号);
70
+ * · kind 是 ask 门族里 `human` 之外的词(tool_approval / irreversible_ask / policy_ask)⇒ 工具臂;
71
+ * · kind = `human` ⇒ 三选一命中即问答臂:toolName 明说是 ask 工具 / 行真带问句 payload /
72
+ * 行**连 toolName 都没有**(没有工具可渲,问答臂是唯一可能的形,且它自己会对空 payload 诚实收口)。
73
+ * 🔴 `toolName` 是 UNTRUSTED wire 位且 `/v1/approvals` 行不经工具名归一 —— 用包的规范判据
74
+ * {@link isAskTool}(空白/下划线/大小写不敏感),严格串比会把小写形误路由进工具臂。
75
+ */
76
+ export function askParkRowArm(row) {
77
+ const rowGateKind = askParkRowGateKind(row);
78
+ const toolNameIsAsk = isAskTool(row.toolName ?? undefined);
79
+ if (rowGateKind === null)
80
+ return toolNameIsAsk ? 'question' : 'tool-gate';
81
+ if (!GENERIC_DECISION_GATE_KINDS.includes(rowGateKind))
82
+ return 'tool-gate';
83
+ const rowHasToolName = typeof row.toolName === 'string' && row.toolName.trim().length > 0;
84
+ return toolNameIsAsk || askQuestionsFromPending(row) !== null || !rowHasToolName ? 'question' : 'tool-gate';
85
+ }
86
+ /**
87
+ * 「泛化的**要人来决定**」那一族 kind —— 它们**不自带**「这是工具 / 这是问句」的答案,所以必须按
88
+ * 决定量(这行到底有没有问句)判臂;别的 ask 族 kind 自带答案,直接进工具臂。
89
+ *
90
+ * 逐条证据(engine 侧类型直读,不靠名字猜):
91
+ * · `human` —— core `CheckpointGate` 的 `{kind:'human', reason, toolName}`,而实测真 wire 上它同时
92
+ * 承载「Write 审批行」与「AskUserQuestion 行」两形(#269 的第二病就是把它单义化成问句)。
93
+ * · `policy_ask` —— 它是**决断信封**那一侧的名字:core `ResumeOutcome` 的
94
+ * `{gate:'policy_ask', boundCallId, boundInputHash, decision:'allow'|'deny', answer?: QuestionAnswer}`
95
+ * —— 那个 `answer?` 位就是**问句门也走这个信封**的直证。所以一行报 `policy_ask` 时同样两形皆可能,
96
+ * 按名字硬判工具臂 = `human` 那一病换个 kind 复发(二次评审 R3 [high] 命中)。
97
+ * · `irreversible_ask` 的 core 形是 `{kind, reason, toolName}`(toolName **必填**)、`tool_approval`
98
+ * 按定义就是工具审批 ⇒ 两者不进这张表,直接工具臂。
99
+ */
100
+ const GENERIC_DECISION_GATE_KINDS = ['human', 'policy_ask'];
101
+ /**
102
+ * ④ 「这一行还在不在待决表里」——回读判据(键取法与 {@link askParkRowIdentity} 同源)。
103
+ * 🔴 调用方读面失败时**必须**当「还在」(保守:照走重试/上屏;重复 decide 由 server 侧 checkpoint
104
+ * 一次性消费挡住,二发只会得到 stale/no_pending 拒绝,不会 double-act)。
105
+ */
106
+ export function askParkRowStillPending(rows, identity) {
107
+ return rows.some(r => identity.gatedCallId !== undefined
108
+ ? (r.toolCallId ?? r.boundCallId ?? undefined) === identity.gatedCallId
109
+ : r.taskId === identity.rowTaskId);
110
+ }
111
+ export function classifyAskParkChainFailure(i) {
112
+ if (i.rowStillPending)
113
+ return 'retry';
114
+ return i.cardPresented ? 'row-gone-after-card' : 'row-unborn';
115
+ }
116
+ export function createRowArmSingleFlight() {
117
+ const inFlight = new Map();
118
+ /**
119
+ * 正在跑 `start()` 的键(只覆盖 start 的**同步执行窗**)。
120
+ * 🔴 它封的是最常见的那一形自依赖:`async () => { const v = await join(同键, …) ; … }` —— async
121
+ * 函数体在第一个 await 之前是**同步**跑的,那次 `join(同键)` 恰好落在这个窗里。不封它的话,外层
122
+ * run 会 settle 到一个反过来等 run 的 promise ⇒ 永不 settle,而这一行之后每一次重开都合流到它。
123
+ */
124
+ const starting = new Set();
125
+ return {
126
+ join: (key, start) => {
127
+ if (starting.has(key)) {
128
+ // 前置条件被破(`start` 不许 join 自己的键)⇒ 当场拒绝,绝不交出一只会永久 pending 的 promise。
129
+ return Promise.reject(new Error(`row-arm single flight: start() re-entered join() for its own key while starting (${key})`));
130
+ }
131
+ const joined = inFlight.get(key);
132
+ if (joined !== undefined)
133
+ return joined;
134
+ // 🔴 **占位先于 start()**:直接 `const run = start()` 会让键在 start 跑完之前是空的,start
135
+ // 内部(或它同步触发的任何回调)再入同键就会另起第二条链 —— 同一个待决项两张卡,正是本闸
136
+ // 要封的那件事。先铸一只空 promise 占键,再把 start 的结果接进去。
137
+ let settle;
138
+ let fail;
139
+ const run = new Promise((resolve, reject) => {
140
+ settle = resolve;
141
+ fail = reject;
142
+ });
143
+ inFlight.set(key, run);
144
+ // 🔴 清账**认身份不认键**:`clear()` 之后同键可能已经起了新的一条,而旧链落地时那句无条件
145
+ // `delete(key)` 会把新链的登记抹掉 ⇒ 第三个调用方又能起一条,闸形同虚设。
146
+ const release = () => {
147
+ if (inFlight.get(key) === run)
148
+ inFlight.delete(key);
149
+ };
150
+ void run.then(release, release);
151
+ starting.add(key);
152
+ try {
153
+ const started = start();
154
+ if (started === run) {
155
+ // 🔴 自依赖 fail-fast(二次评审 R2 [high]):`start` 把占位 promise 原样交回来(典型形 = 它
156
+ // 自己又 join 了同一个键)⇒ run 会 resolve 到它自己,**永不 settle**,而这一行的后续每一次
157
+ // 重开都会合流到那只永久 pending 的 promise —— 死锁比多铸一张卡坏得多。
158
+ // 与它配对的另一半在 join 入口:`starting` 集合封住 `start` **同步执行窗**内的同键再入
159
+ // (async 函数体第一个 await 之前那段,正是 `await join(同键)` 的落点)。
160
+ // 残余(如实记):`start` 在**更晚的 tick**(已经 await 过别的东西之后)才 join 自己的键,
161
+ // 两道闸都判不出来 —— 那条只能靠调用协议:**`start` 不许 join 自己的键**。
162
+ fail?.(new Error('row-arm single flight: start() returned the in-flight placeholder for the same key'));
163
+ }
164
+ else {
165
+ settle?.(started);
166
+ }
167
+ }
168
+ catch (e) {
169
+ // start 同步抛 ⇒ 判决走**拒绝的 promise**(签名承诺的就是 Promise<T>),不是同步抛给调用方
170
+ // ——后者会让合流者与首发者拿到两种不同的失败形。
171
+ fail?.(e);
172
+ }
173
+ finally {
174
+ starting.delete(key);
175
+ }
176
+ return run;
177
+ },
178
+ size: () => inFlight.size,
179
+ clear: () => {
180
+ inFlight.clear();
181
+ },
182
+ };
183
+ }
@@ -10,6 +10,14 @@
10
10
  * 活着 —— 漂一个字节,首见/复见判决就静默失真。本文件把全部字面收成单源:铸口 import 这里,
11
11
  * 台账的键推导也 import 这里,两边在结构上不可能再漂。
12
12
  *
13
+ * 🔴 **声明边界(web [C1] d3 拦截跟修,2026-08-12)**:上句「全部字面」只指 **HITL 决断卡的
14
+ * questionId / callKey 键空间**(本文件四常量两函数)。`seatContract.ts` 的
15
+ * `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`live:`/`durable:`/`question:`/`elicit:`/`plan:`)是
16
+ * **另一个键空间** —— 座位 `ToolPermissionRequest.requestId` 的路由域词表,出站校验器按它
17
+ * fail-close。两空间零重叠、各自单源,**绝不合并**:把 `plan-review:`(本文件)与 `plan:`
18
+ * (seat 域)字面归一会让座位校验器静默拒掉全部 plan-review 卡(web 按 [C161]-d3 原文施工时
19
+ * 当场拦下的真回归形)。
20
+ *
13
21
  * 🔴 零 import 纯函数叶(portability:不进任何闭包新边;index 闭包 +1 文件已登记)。
14
22
  */
15
23
  /** overlay 问答帧的 ask-gate 命名空间前缀(`parkResolver.surfaceGateAndDecide` 合成帧 id 用)。 */
@@ -10,6 +10,14 @@
10
10
  * 活着 —— 漂一个字节,首见/复见判决就静默失真。本文件把全部字面收成单源:铸口 import 这里,
11
11
  * 台账的键推导也 import 这里,两边在结构上不可能再漂。
12
12
  *
13
+ * 🔴 **声明边界(web [C1] d3 拦截跟修,2026-08-12)**:上句「全部字面」只指 **HITL 决断卡的
14
+ * questionId / callKey 键空间**(本文件四常量两函数)。`seatContract.ts` 的
15
+ * `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`live:`/`durable:`/`question:`/`elicit:`/`plan:`)是
16
+ * **另一个键空间** —— 座位 `ToolPermissionRequest.requestId` 的路由域词表,出站校验器按它
17
+ * fail-close。两空间零重叠、各自单源,**绝不合并**:把 `plan-review:`(本文件)与 `plan:`
18
+ * (seat 域)字面归一会让座位校验器静默拒掉全部 plan-review 卡(web 按 [C161]-d3 原文施工时
19
+ * 当场拦下的真回归形)。
20
+ *
13
21
  * 🔴 零 import 纯函数叶(portability:不进任何闭包新边;index 闭包 +1 文件已登记)。
14
22
  */
15
23
  /** overlay 问答帧的 ask-gate 命名空间前缀(`parkResolver.surfaceGateAndDecide` 合成帧 id 用)。 */
@@ -192,6 +192,13 @@ export type FindPendingOutcome = {
192
192
  * cares about, e.g. fs-write vs AskUserQuestion) first; fall back to ANY row for this `taskId` (a gate
193
193
  * whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
194
194
  * — a typed failure when nothing for this taskId is queued.
195
+ *
196
+ * P-30(2026-08-14):both legs only consider rows whose `gateKind` belongs to the ask/approval family
197
+ * ({@link askParkForeignGateKind} — the same single-source guard `classifyAskParkRows` uses). Without it
198
+ * the ANY-row fallback would hand a `plan_review`/`resource_limit` row to the tool-approval wire — an
199
+ * empty-toolName card whose decide then 409s (`gate_not_tool_approval`). A foreign-kind row is filtered
200
+ * out, not a rejection of the whole queue: a genuine approval row for the same task still resolves.
201
+ * Rows with no `gateKind` (pre-`gate_kind` history) keep the old behaviour — no stronger signal exists.
195
202
  */
196
203
  export declare function findPendingForTask(client: HitlClientLike, taskId: string, matches: (toolName: string | undefined) => boolean, opts?: {
197
204
  signal?: AbortSignal;
@@ -1,3 +1,4 @@
1
+ import { askParkForeignGateKind } from '../adapter/activeRunSelfHeal.js';
1
2
  import { eventSeq } from '../adapter/types.js';
2
3
  import { hostLog } from '../host.js';
3
4
  // ── deny/plan-review 归因的 wire 窄化(0.28.0 发版扫描 F1/F2/F3 收编;单源,三条腿共用)──────
@@ -70,6 +71,13 @@ export class HitlSafetyError extends Error {
70
71
  * cares about, e.g. fs-write vs AskUserQuestion) first; fall back to ANY row for this `taskId` (a gate
71
72
  * whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
72
73
  * — a typed failure when nothing for this taskId is queued.
74
+ *
75
+ * P-30(2026-08-14):both legs only consider rows whose `gateKind` belongs to the ask/approval family
76
+ * ({@link askParkForeignGateKind} — the same single-source guard `classifyAskParkRows` uses). Without it
77
+ * the ANY-row fallback would hand a `plan_review`/`resource_limit` row to the tool-approval wire — an
78
+ * empty-toolName card whose decide then 409s (`gate_not_tool_approval`). A foreign-kind row is filtered
79
+ * out, not a rejection of the whole queue: a genuine approval row for the same task still resolves.
80
+ * Rows with no `gateKind` (pre-`gate_kind` history) keep the old behaviour — no stronger signal exists.
73
81
  */
74
82
  export async function findPendingForTask(client, taskId, matches, opts) {
75
83
  let rows;
@@ -79,8 +87,9 @@ export async function findPendingForTask(client, taskId, matches, opts) {
79
87
  catch (e) {
80
88
  return { ok: false, reason: `approvals.list failed: ${String(e)}` };
81
89
  }
82
- const pending = rows.find((r) => r.taskId === taskId && matches(typeof r.toolName === 'string' ? r.toolName : undefined)) ??
83
- rows.find((r) => r.taskId === taskId);
90
+ const decidable = rows.filter((r) => askParkForeignGateKind(r) === null);
91
+ const pending = decidable.find((r) => r.taskId === taskId && matches(typeof r.toolName === 'string' ? r.toolName : undefined)) ??
92
+ decidable.find((r) => r.taskId === taskId);
84
93
  if (!pending)
85
94
  return { ok: false, reason: 'no pending checkpoint for this run (resolved/expired?)', code: 'no_pending' };
86
95
  return { ok: true, pending, gatedCallId: pending.toolCallId ?? pending.boundCallId ?? undefined };
@@ -36,7 +36,8 @@ export interface ParkOwnershipDeps {
36
36
  /** 会话腿真源(缺省 = `hostSessionFor(sessionKey).currentSessionId()`;空串按缺席归一,
37
37
  * 判不出 ≠ 命中)。 */
38
38
  currentSessionId?: () => string | undefined;
39
- /** own-run 台账腿(缺省 = 包 `isOwnEngineRun`,**仅默认会话键**下启用,理由见 sessionKey 注)。
39
+ /** own-run 台账腿(**并联**:默认会话键下注入腿与包 `isOwnEngineRun` 缺省腿任一命中即 owned
40
+ * —— 注入是**补腿不是换腿**,cli 1.0.76 扫码 P1 跟修;非默认键下缺省腿整条跳过,见 sessionKey 注)。
40
41
  * 🔴 键域 = `rowIdTail(行 taskId)`(fleet 台账登记时就过 rowIdTail,本判据同域取键后才调本口)。
41
42
  * 🔴 成文局限(复审二轮裁定,不改行为):默认键下的缺省腿是**进程级**证据,不区分同一宿主
42
43
  * 进程内的会话代际 —— `/clear` 前登记的 run 在新会话语境下仍判 owned。这是单会话宿主的
@@ -11,9 +11,17 @@ export function pendingRowIsOwnedByThisSession(row, deps) {
11
11
  // ① 本宿主亲手驱动的 run / 由它闭包进来的自家子代 —— 不依赖会话 id。
12
12
  // 进程级缺省腿只在默认会话键下启用(多会话宿主必须注入会话粒度的口,见 deps.sessionKey 注)。
13
13
  const rowTaskId = typeof row.taskId === 'string' ? row.taskId : '';
14
- const ownRun = deps?.isOwnRun ?? (sessionKey === DEFAULT_SESSION_KEY ? isOwnEngineRun : undefined);
15
- if (ownRun !== undefined && rowTaskId.length > 0 && ownRun(rowIdTail(rowTaskId)))
16
- return true;
14
+ // 🔴 注入腿与缺省台账腿是**并联**不是顶替(cli 1.0.76 扫码 P1 的包 API 半场,主板 [3925]):
15
+ // 此前 `deps?.isOwnRun ?? isOwnEngineRun` 让「端为补一条自己的正向腿」变成「顺手关掉进程内
16
+ // 台账腿」——补一条断一条,消费端得记得自 OR 才不踩(cli 复核读口实翻)。并联只多不少地
17
+ // 要求正向证明,fail-closed 方向不变;非默认键下缺省腿照旧整条跳过(sessionKey 注)。
18
+ if (rowTaskId.length > 0) {
19
+ const tail = rowIdTail(rowTaskId);
20
+ if (deps?.isOwnRun !== undefined && deps.isOwnRun(tail))
21
+ return true;
22
+ if (sessionKey === DEFAULT_SESSION_KEY && isOwnEngineRun(tail))
23
+ return true;
24
+ }
17
25
  // ② 行自带 sessionId === 本会话当前引擎会话。任一侧缺席/空串 = 判不出,不是命中。
18
26
  const currentSessionId = deps?.currentSessionId ?? (() => hostSessionFor(sessionKey)?.currentSessionId());
19
27
  const sessionId = currentSessionId();
@@ -0,0 +1,63 @@
1
+ /** 一次读面探测的判决(三态;见文件头「这个模块是什么」)。 */
2
+ export type ParkRowProbe<Row> = {
3
+ kind: 'row';
4
+ row: Row;
5
+ }
6
+ /** 此刻没有可决断的行,且拿不出「已了结」的正面证据 ⇒ 可能只是还没出生。 */
7
+ | {
8
+ kind: 'unborn';
9
+ reason: string;
10
+ }
11
+ /** 有正面证据:这里没有本臂该决的事 ⇒ 再等也不会变(如行在但门种不属本臂)。 */
12
+ | {
13
+ kind: 'settled';
14
+ reason: string;
15
+ };
16
+ /** 有界重查环的判决。`waitedMs`/`probes` 是留证位(debug 档),不参与任何分支。 */
17
+ export type ParkRowWaitVerdict<Row> = {
18
+ kind: 'row';
19
+ row: Row;
20
+ waitedMs: number;
21
+ probes: number;
22
+ } | {
23
+ kind: 'unborn';
24
+ reason: string;
25
+ waitedMs: number;
26
+ probes: number;
27
+ } | {
28
+ kind: 'settled';
29
+ reason: string;
30
+ waitedMs: number;
31
+ probes: number;
32
+ } | {
33
+ kind: 'aborted';
34
+ waitedMs: number;
35
+ probes: number;
36
+ };
37
+ export interface ParkRowBirthWaitDeps<Row> {
38
+ /**
39
+ * 读一次真状态(第 N 次探测,N 从 1 起)。
40
+ *
41
+ * 🔴 `signal` **必须**被透传进真实读面(HTTP 请求等):它是本环对 `budgetMs` 的**执行手段** ——
42
+ * 环在窗尽时 abort 它,读面才真的被掐断。不接这个 signal 的 probe 会让「有界」退化成一句
43
+ * 口号:一次挂死的读面能把总窗拖到任意长。
44
+ */
45
+ probe: (attempt: number, signal?: AbortSignal) => Promise<ParkRowProbe<Row>>;
46
+ /** 总窗上限(ms)。`<=0` ⇒ 恰一次 probe,不等(那一拍不设内部 deadline,与修前逐字同拍)。 */
47
+ budgetMs: number;
48
+ /** 轮询间隔(ms);实际 sleep = min(intervalMs, 窗内剩余)。 */
49
+ intervalMs: number;
50
+ /** 时钟(测试注入;缺省 Date.now)。 */
51
+ now?: () => number;
52
+ /** sleep(测试注入;缺省 = 可中断等待,见 {@link parkRowPollDelay})。 */
53
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
54
+ /** 中断口(Esc/turn abort);每轮读,真停。 */
55
+ signal?: AbortSignal;
56
+ }
57
+ /**
58
+ * 有界、可中断地等一条 park 的待决行出生。语义与边界见文件头;绝不抛。
59
+ *
60
+ * 🔴 判决只有 `row` 一态可以往下走 —— `unborn`(窗尽)与 `aborted` 都必须由调用方翻成**如实的
61
+ * 失败**,绝不许翻成「已决/已成功」(那正是 cli #269 的病)。
62
+ */
63
+ export declare function waitForParkRowBirth<Row>(deps: ParkRowBirthWaitDeps<Row>): Promise<ParkRowWaitVerdict<Row>>;