@sema-agent/client-core 0.29.0 → 0.30.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 +400 -0
- package/README.md +19 -3
- package/dist/adapt/arms.js +24 -1
- package/dist/adapt/wireShapes.d.ts +7 -0
- package/dist/adapt/wireShapes.js +7 -0
- package/dist/adapter/activeRunSelfHeal.d.ts +264 -48
- package/dist/adapter/activeRunSelfHeal.js +501 -17
- package/dist/adapter/runStream.js +13 -3
- package/dist/engineWireSdk.d.ts +10 -2
- package/dist/engineWireSdk.js +7 -3
- package/dist/hitl/approvalDecisionNoteAudit.d.ts +58 -0
- package/dist/hitl/approvalDecisionNoteAudit.js +91 -0
- package/dist/hitl/askParkRowRouting.d.ts +150 -0
- package/dist/hitl/askParkRowRouting.js +183 -0
- package/dist/hitl/gateIdentity.d.ts +8 -0
- package/dist/hitl/gateIdentity.js +8 -0
- package/dist/hitl/hitlBridge.d.ts +7 -0
- package/dist/hitl/hitlBridge.js +11 -2
- package/dist/hitl/parkRowBirthWait.d.ts +63 -0
- package/dist/hitl/parkRowBirthWait.js +192 -0
- package/dist/hitl/resumeRunningCard.d.ts +134 -0
- package/dist/hitl/resumeRunningCard.js +177 -0
- package/dist/hitl/toolApprovalWire.d.ts +46 -9
- package/dist/hitl/toolApprovalWire.js +9 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +14 -0
- package/dist/seatContract.d.ts +27 -0
- package/dist/seatContract.js +42 -0
- package/dist/subagent/engineSubagentTail.d.ts +0 -2
- package/dist/subagent/engineSubagentTail.js +7 -15
- package/dist/subagentContentStore.d.ts +58 -2
- package/dist/subagentContentStore.js +95 -6
- package/dist/toolResult.d.ts +26 -0
- package/dist/toolResult.js +38 -6
- package/dist/workflowClient.d.ts +6 -1
- package/docs/INTEGRATION-CLIENTS.md +844 -0
- package/docs/REFACTOR-LEDGER.md +392 -0
- package/package.json +7 -4
package/dist/engineWireSdk.d.ts
CHANGED
|
@@ -67,8 +67,16 @@ export type EngineProbeOpts = {
|
|
|
67
67
|
};
|
|
68
68
|
export type EngineWireClientConfig = {
|
|
69
69
|
baseUrl: string;
|
|
70
|
-
/** 真 token 串;缺省经 wireAuthTokenFor 三态解析(loopback-unauthed / 'anon')。
|
|
71
|
-
|
|
70
|
+
/** 真 token 串;缺省经 wireAuthTokenFor 三态解析(loopback-unauthed / 'anon')。
|
|
71
|
+
*
|
|
72
|
+
* `{ mode: 'same-origin-relay' }`([C175],0.29.0)= **浏览器同源宿主的显式声明形**:凭证由
|
|
73
|
+
* 同源反代(cookie/session)承载,出站零 Authorization 头;它是 SDK 浏览器守卫的**唯一**豁免形
|
|
74
|
+
* (AgentClient 在浏览器宿主拒绝 token/loopback-unauthed/'anon' 三态构造),且该形下 `baseUrl`
|
|
75
|
+
* 允许相对/同源路径(`''`、`'/api'`)。🔴 这个形**只能显式传入**(作者声明「我部署在同源反代后」),
|
|
76
|
+
* 绝不由 `resolveWireAuth` 三态解析推导出来 —— 解析口的三态语义一字不动,relay 形直传 SDK。 */
|
|
77
|
+
token?: string | {
|
|
78
|
+
mode: 'same-origin-relay';
|
|
79
|
+
};
|
|
72
80
|
/** 缺席/undefined=不发 x-agent-principal 头(owner-null;F-011 停发,replEntry live 车道同闸口
|
|
73
81
|
* 语义;显式 `| undefined` 让 EngineWireTarget.principal 直传合法——exactOptionalPropertyTypes)。 */
|
|
74
82
|
principal?: string | undefined;
|
package/dist/engineWireSdk.js
CHANGED
|
@@ -72,15 +72,19 @@ export function wireAuthTokenFor(baseUrl, token) {
|
|
|
72
72
|
*/
|
|
73
73
|
export function makeEngineWireClient(cfg) {
|
|
74
74
|
try {
|
|
75
|
-
|
|
75
|
+
const base = {
|
|
76
76
|
baseUrl: cfg.baseUrl,
|
|
77
|
-
authToken: resolveWireAuth(cfg.baseUrl, cfg.token),
|
|
78
77
|
// F-011 停发:缺席/空串=不给键(SDK 6.11 缺席=不发 x-agent-principal 头,owner-null)。
|
|
79
78
|
...(cfg.principal !== undefined && cfg.principal !== '' ? { principal: cfg.principal } : {}),
|
|
80
79
|
...(cfg.timeoutMs !== undefined ? { timeoutMs: cfg.timeoutMs } : {}),
|
|
81
80
|
maxRetries: cfg.maxRetries ?? 0,
|
|
82
81
|
...(cfg.fetchImpl ? { fetch: cfg.fetchImpl } : {}),
|
|
83
|
-
}
|
|
82
|
+
};
|
|
83
|
+
// relay 形直传(显式声明,不过三态解析);串/缺席走 resolveWireAuth 三态,语义与 0.28.x 字节
|
|
84
|
+
// 不变。分支构造而非三元合流:SDK AgentClientConfig 按 authToken 判别联合,联合值不可直赋。
|
|
85
|
+
return typeof cfg.token === 'object'
|
|
86
|
+
? new AgentClient({ ...base, authToken: cfg.token })
|
|
87
|
+
: new AgentClient({ ...base, authToken: resolveWireAuth(cfg.baseUrl, cfg.token) });
|
|
84
88
|
}
|
|
85
89
|
catch {
|
|
86
90
|
return null;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hitl/approvalDecisionNoteAudit.ts — 审批**回决备注**(`decisionNote` / `noteRecorded`)的读面
|
|
3
|
+
* 判定 + 人话行(#265 上收件,2026-08-14;源形 = cli `src/sema/approvalDecisionNoteAudit.ts`,
|
|
4
|
+
* 供给半场 = server 7.15.0 的 `AskDecisionAck.decisionNote` / `ToolApprovalRespondAck.noteRecorded`)。
|
|
5
|
+
*
|
|
6
|
+
* ── 病 ────────────────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
* 送出去的那条 deny 理由到底有没有落进审计档,客户端这边**一个字都不知道**:决断腿此前是
|
|
8
|
+
* `await decide…` 裸调,回体整个被丢掉。于是「我写了拒绝理由」与「那条理由真的进了审计行」之间
|
|
9
|
+
* 没有任何证据 —— deny 归因链在用户这一端是断的。
|
|
10
|
+
*
|
|
11
|
+
* ── 三态,不是两态 ────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
* server 对这一位的契约(SDK `ToolApprovalRespondAck.noteRecorded` / `AskDecisionAck.decisionNote`
|
|
13
|
+
* 头注逐字)是**三态**,把它压成布尔就会造谎:
|
|
14
|
+
* · `recorded` —— `noteRecorded === true`,**或** durable ack 回显了 `decisionNote` 正文
|
|
15
|
+
* (契约:「行上有才发」⇒ 回显本身就是落行的证据);
|
|
16
|
+
* · `not-recorded` —— `noteRecorded === false`。这是店的**真实结果**(并发歧义臂的输家如实 false),
|
|
17
|
+
* 它说的是「理由没落档」,**不是**「审批失败」—— 决断照旧成立,渲染面必须
|
|
18
|
+
* 在同一行里把这句说出来,否则用户会以为自己按的那个拒绝没生效;
|
|
19
|
+
* · `unknown` —— 两个位都不在场(旧 server / 这台部署没有 durable ask 账本 / 本次压根没送 note)。
|
|
20
|
+
* ⇒ **整行不渲**。缺席≠false —— 渲一行「未记录」等于替引擎回答一个它没回答
|
|
21
|
+
* 的问题。
|
|
22
|
+
*
|
|
23
|
+
* 🔴 `noteRecorded === false` **压过**正文回显:显式的 per-call 真相优先。两者同时在场的形
|
|
24
|
+
* (并发歧义臂:行上留的是赢家那条备注,我这条没落)只有这样才说得准 —— 正文仍带出来当归因
|
|
25
|
+
* 材料(用户看得见「行上现在是哪条」),但状态词按 false 走。
|
|
26
|
+
*
|
|
27
|
+
* ── 🔴 归层 ───────────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
* 本文件 = **纯判定 + 文案**,`import` 列表为空(常驻门 ④ 段逐次对账)。呈现口不在这里 ——
|
|
29
|
+
* 装配层把行交给宿主的通知/转录面。三端(cli / desktop / web)撞的是同一件事,读的是同一份 ack。
|
|
30
|
+
*
|
|
31
|
+
* ── UNTRUSTED ─────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
* `decisionNote` 的正文是**卡口自由文本**原样回声(用户自己打的拒绝理由,也可能是别的客户端打的)。
|
|
33
|
+
* 读面就地有界截断 + 清洗控制字符 —— 只渲染,永不回喂模型,也绝不整段进日志。
|
|
34
|
+
*/
|
|
35
|
+
/** 审计面通知的独占 key —— 与审批流告警面分格(两件事不抢同一格)。 */
|
|
36
|
+
export declare const DECISION_NOTE_NOTICE_KEY = "approval-decision-note";
|
|
37
|
+
/** 备注面的三态判决。`note` = 引擎回显的正文(缺席 = 引擎没回显,**不是**「没有备注」)。 */
|
|
38
|
+
export interface DecisionNoteAudit {
|
|
39
|
+
state: 'recorded' | 'not-recorded' | 'unknown';
|
|
40
|
+
note?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 读一份回体(durable `AskDecisionAck` / legacy `ToolApprovalRespondAck` / 409
|
|
44
|
+
* `conflict.ask_decided` 体)里的备注面判决。
|
|
45
|
+
*
|
|
46
|
+
* 🔴 **在场即读、不在场即 unknown**:两个位在 SDK 类型上都是可选的,而「类型说 optional」与
|
|
47
|
+
* 「这台引擎真的发了」是两件事 —— 一律结构窄读,读不出就是缺席,绝不合成。
|
|
48
|
+
*/
|
|
49
|
+
export declare function readDecisionNoteAudit(ack: unknown): DecisionNoteAudit;
|
|
50
|
+
/**
|
|
51
|
+
* 人话行。**`unknown` ⇒ `null`(整行不渲)** —— 这条纪律不可削。
|
|
52
|
+
*
|
|
53
|
+
* @param opts.settledElsewhere 这份材料来自 409 `conflict.ask_decided`(别的客户端/legacy 腿先结算)
|
|
54
|
+
* ⇒ 行里要点明归因来源,否则用户会把首决那条备注当成自己刚写的那条。
|
|
55
|
+
*/
|
|
56
|
+
export declare function decisionNoteAuditLine(audit: DecisionNoteAudit, opts?: {
|
|
57
|
+
settledElsewhere?: boolean;
|
|
58
|
+
}): string | null;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hitl/approvalDecisionNoteAudit.ts — 审批**回决备注**(`decisionNote` / `noteRecorded`)的读面
|
|
3
|
+
* 判定 + 人话行(#265 上收件,2026-08-14;源形 = cli `src/sema/approvalDecisionNoteAudit.ts`,
|
|
4
|
+
* 供给半场 = server 7.15.0 的 `AskDecisionAck.decisionNote` / `ToolApprovalRespondAck.noteRecorded`)。
|
|
5
|
+
*
|
|
6
|
+
* ── 病 ────────────────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
* 送出去的那条 deny 理由到底有没有落进审计档,客户端这边**一个字都不知道**:决断腿此前是
|
|
8
|
+
* `await decide…` 裸调,回体整个被丢掉。于是「我写了拒绝理由」与「那条理由真的进了审计行」之间
|
|
9
|
+
* 没有任何证据 —— deny 归因链在用户这一端是断的。
|
|
10
|
+
*
|
|
11
|
+
* ── 三态,不是两态 ────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
* server 对这一位的契约(SDK `ToolApprovalRespondAck.noteRecorded` / `AskDecisionAck.decisionNote`
|
|
13
|
+
* 头注逐字)是**三态**,把它压成布尔就会造谎:
|
|
14
|
+
* · `recorded` —— `noteRecorded === true`,**或** durable ack 回显了 `decisionNote` 正文
|
|
15
|
+
* (契约:「行上有才发」⇒ 回显本身就是落行的证据);
|
|
16
|
+
* · `not-recorded` —— `noteRecorded === false`。这是店的**真实结果**(并发歧义臂的输家如实 false),
|
|
17
|
+
* 它说的是「理由没落档」,**不是**「审批失败」—— 决断照旧成立,渲染面必须
|
|
18
|
+
* 在同一行里把这句说出来,否则用户会以为自己按的那个拒绝没生效;
|
|
19
|
+
* · `unknown` —— 两个位都不在场(旧 server / 这台部署没有 durable ask 账本 / 本次压根没送 note)。
|
|
20
|
+
* ⇒ **整行不渲**。缺席≠false —— 渲一行「未记录」等于替引擎回答一个它没回答
|
|
21
|
+
* 的问题。
|
|
22
|
+
*
|
|
23
|
+
* 🔴 `noteRecorded === false` **压过**正文回显:显式的 per-call 真相优先。两者同时在场的形
|
|
24
|
+
* (并发歧义臂:行上留的是赢家那条备注,我这条没落)只有这样才说得准 —— 正文仍带出来当归因
|
|
25
|
+
* 材料(用户看得见「行上现在是哪条」),但状态词按 false 走。
|
|
26
|
+
*
|
|
27
|
+
* ── 🔴 归层 ───────────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
* 本文件 = **纯判定 + 文案**,`import` 列表为空(常驻门 ④ 段逐次对账)。呈现口不在这里 ——
|
|
29
|
+
* 装配层把行交给宿主的通知/转录面。三端(cli / desktop / web)撞的是同一件事,读的是同一份 ack。
|
|
30
|
+
*
|
|
31
|
+
* ── UNTRUSTED ─────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
* `decisionNote` 的正文是**卡口自由文本**原样回声(用户自己打的拒绝理由,也可能是别的客户端打的)。
|
|
33
|
+
* 读面就地有界截断 + 清洗控制字符 —— 只渲染,永不回喂模型,也绝不整段进日志。
|
|
34
|
+
*/
|
|
35
|
+
/** 备注面在渲染时的正文上界(一行 footer 通知,不是转录面正文)。 */
|
|
36
|
+
const NOTE_DISPLAY_MAX = 200;
|
|
37
|
+
/** 审计面通知的独占 key —— 与审批流告警面分格(两件事不抢同一格)。 */
|
|
38
|
+
export const DECISION_NOTE_NOTICE_KEY = 'approval-decision-note';
|
|
39
|
+
/**
|
|
40
|
+
* 控制字符/换行清洗 + 有界截断(UNTRUSTED 正文只走这一个口)。
|
|
41
|
+
* 🔴 换行必须折平:footer 通知是**单行**面,一条带换行的备注会把整段排版撕开。
|
|
42
|
+
*/
|
|
43
|
+
function cleanNote(raw) {
|
|
44
|
+
const flat = raw
|
|
45
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
|
|
46
|
+
.replace(/ {2,}/g, ' ')
|
|
47
|
+
.trim();
|
|
48
|
+
return flat.length <= NOTE_DISPLAY_MAX ? flat : `${flat.slice(0, NOTE_DISPLAY_MAX - 1)}…`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 读一份回体(durable `AskDecisionAck` / legacy `ToolApprovalRespondAck` / 409
|
|
52
|
+
* `conflict.ask_decided` 体)里的备注面判决。
|
|
53
|
+
*
|
|
54
|
+
* 🔴 **在场即读、不在场即 unknown**:两个位在 SDK 类型上都是可选的,而「类型说 optional」与
|
|
55
|
+
* 「这台引擎真的发了」是两件事 —— 一律结构窄读,读不出就是缺席,绝不合成。
|
|
56
|
+
*/
|
|
57
|
+
export function readDecisionNoteAudit(ack) {
|
|
58
|
+
if (ack === null || typeof ack !== 'object')
|
|
59
|
+
return { state: 'unknown' };
|
|
60
|
+
const o = ack;
|
|
61
|
+
const note = typeof o.decisionNote === 'string' && o.decisionNote.trim() !== '' ? cleanNote(o.decisionNote) : undefined;
|
|
62
|
+
// 显式 per-call 真相优先(见头注)。
|
|
63
|
+
if (o.noteRecorded === false)
|
|
64
|
+
return { state: 'not-recorded', ...(note !== undefined ? { note } : {}) };
|
|
65
|
+
if (o.noteRecorded === true)
|
|
66
|
+
return { state: 'recorded', ...(note !== undefined ? { note } : {}) };
|
|
67
|
+
// `noteRecorded` 是非布尔垃圾值(旧 server / 代理改写)⇒ 当它不在场,只看正文回显。
|
|
68
|
+
if (note !== undefined)
|
|
69
|
+
return { state: 'recorded', note };
|
|
70
|
+
return { state: 'unknown' };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 人话行。**`unknown` ⇒ `null`(整行不渲)** —— 这条纪律不可削。
|
|
74
|
+
*
|
|
75
|
+
* @param opts.settledElsewhere 这份材料来自 409 `conflict.ask_decided`(别的客户端/legacy 腿先结算)
|
|
76
|
+
* ⇒ 行里要点明归因来源,否则用户会把首决那条备注当成自己刚写的那条。
|
|
77
|
+
*/
|
|
78
|
+
export function decisionNoteAuditLine(audit, opts) {
|
|
79
|
+
if (audit.state === 'unknown')
|
|
80
|
+
return null;
|
|
81
|
+
const quoted = audit.note !== undefined ? `: "${audit.note}"` : '';
|
|
82
|
+
if (opts?.settledElsewhere === true) {
|
|
83
|
+
// 首决备注只在 recorded 侧有意义(false = 我这条没落,但行上仍是别人那条)。
|
|
84
|
+
return audit.state === 'recorded'
|
|
85
|
+
? `this approval was already decided elsewhere — the reason recorded on the audit trail${quoted}`
|
|
86
|
+
: `this approval was already decided elsewhere; your reason was not saved to the audit trail — that decision stands${quoted}`;
|
|
87
|
+
}
|
|
88
|
+
return audit.state === 'recorded'
|
|
89
|
+
? `decision reason recorded on the audit trail${quoted}`
|
|
90
|
+
: `your decision stands — the engine did not save its reason to the audit trail${quoted}`;
|
|
91
|
+
}
|
|
@@ -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;
|
package/dist/hitl/hitlBridge.js
CHANGED
|
@@ -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
|
|
83
|
-
|
|
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 };
|