@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.
- package/CHANGELOG.md +455 -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 +285 -48
- package/dist/adapter/activeRunSelfHeal.js +553 -19
- 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/armedGateRegistry.d.ts +62 -4
- package/dist/hitl/armedGateRegistry.js +226 -14
- 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/parkOwnership.d.ts +2 -1
- package/dist/hitl/parkOwnership.js +11 -3
- package/dist/hitl/parkRowBirthWait.d.ts +63 -0
- package/dist/hitl/parkRowBirthWait.js +192 -0
- package/dist/hitl/planReviewWire.d.ts +31 -1
- package/dist/hitl/planReviewWire.js +69 -30
- package/dist/hitl/resumeRunningCard.d.ts +134 -0
- package/dist/hitl/resumeRunningCard.js +177 -0
- package/dist/hitl/toolApprovalWire.d.ts +49 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { eventSeq, } from './types.js';
|
|
2
2
|
import { eventToSdkMessage, turnEndUsage } from './downstream/eventToSdkMessage.js';
|
|
3
3
|
import { terminalToSdkResult } from './downstream/terminalToSdkResult.js';
|
|
4
|
-
import { publishSubagentContentEvent } from '../subagentContentStore.js';
|
|
4
|
+
import { coerceOutput, publishSubagentContentEvent } from '../subagentContentStore.js';
|
|
5
5
|
/**
|
|
6
6
|
* 409 session-busy 拒绝的 **canonical errorCode**([2377]C-1,server main `049ff2c`,随 5.0.0 发)。
|
|
7
7
|
* 引擎把它 stamp 在 `done{status:'failed'}` / `failed` 终帧上,壳据此**结构判读**,不再读人话。
|
|
@@ -226,14 +226,24 @@ async function* runStreamInner(events, ctx, handle = {}) {
|
|
|
226
226
|
const sub = ev;
|
|
227
227
|
publishSubagentContentEvent({
|
|
228
228
|
type: sub.type,
|
|
229
|
-
//
|
|
229
|
+
// 🔴 EventIdentity 上**没有** `taskId`(sdk events.d.ts 的 `interface EventIdentity` 只有
|
|
230
|
+
// eventId / parentToolCallId,加 LIVE 白名单四臂的 sourceTaskId / bgAgentId;`taskId` 只长在
|
|
231
|
+
// `meta` 首帧)⇒ 生产上这里恒走右臂,归账键由 store 的 `parentToTask` 补(canonicalKey)。
|
|
232
|
+
// `??` 保留是**容将来**:哪天上游真在内容帧上发 taskId,这条直接认,不必改形。
|
|
233
|
+
// (0.30.0 发包扫描订正:此前这行注释写「taskId is on every subagent event」,与 store 侧
|
|
234
|
+
// `canonicalKey` 的注释互相矛盾,且被 .d.ts 直接证伪。)
|
|
230
235
|
taskId: sub.taskId ?? sub.parentToolCallId,
|
|
231
236
|
parentToolCallId: sub.parentToolCallId,
|
|
232
237
|
delta: sub.delta,
|
|
233
238
|
toolCallId: sub.toolCallId,
|
|
234
239
|
toolName: sub.toolName,
|
|
235
240
|
args: sub.args,
|
|
236
|
-
|
|
241
|
+
// #158 移交①([3674](d) 姊妹病,2026-08-12):此前是 `typeof sub.output === 'string' ?
|
|
242
|
+
// sub.output : undefined` —— 而 wire 的 `tool_end.output` 是非均匀的(块数组形合法),
|
|
243
|
+
// 于是子代 lane 的块数组 output 经本臂进内容账本**恒空**(查看态卡有工具、结果栏永远空白)。
|
|
244
|
+
// 换用 store 自己的那个唯一字符串化口(tail 腿 engineSubagentTail 用的同一份):两条腿喂
|
|
245
|
+
// 同一个账本,字符串化口就不能有第二份。
|
|
246
|
+
output: coerceOutput(sub.output),
|
|
237
247
|
isError: sub.isError,
|
|
238
248
|
});
|
|
239
249
|
continue;
|
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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链)。
|
|
1
|
+
/** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链)。
|
|
2
|
+
* 每次调用都发事件(复见重开的第二次呈现也要有回执;Set 成员级当不了事件源)。 */
|
|
2
3
|
export declare function registerArmedGate(key: string | null | undefined): void;
|
|
3
4
|
/** W1 带 key 变体(多会话宿主每会话一键,互不串账)。 */
|
|
4
5
|
export declare function registerArmedGateFor(sessionKey: string, key: string | null | undefined): void;
|
|
@@ -6,14 +7,71 @@ export declare function registerArmedGateFor(sessionKey: string, key: string | n
|
|
|
6
7
|
export declare function wasGateArmed(key: string | null | undefined): boolean;
|
|
7
8
|
/** W1 带 key 变体。 */
|
|
8
9
|
export declare function wasGateArmedFor(sessionKey: string, key: string | null | undefined): boolean;
|
|
9
|
-
/** 消费一个身份键(
|
|
10
|
+
/** 消费一个身份键(plan 族的决断消费请优先走 {@link notePlanReviewAnswered} 族 —— 它同时推代;
|
|
11
|
+
* 本口保留给「只清账不推代」的宿主场景与 0.30.0 存量消费方,语义不变)。 */
|
|
10
12
|
export declare function clearArmedGate(key: string | null | undefined): void;
|
|
11
13
|
/** W1 带 key 变体。 */
|
|
12
14
|
export declare function clearArmedGateFor(sessionKey: string, key: string | null | undefined): void;
|
|
15
|
+
/**
|
|
16
|
+
* 订阅 arm 事件(#269 上收):返回退订钩,调用方**必须**在自己的生命周期末调它(监听器挂在
|
|
17
|
+
* 模块级长存表上,漏退 = 闭包泄漏)。
|
|
18
|
+
*
|
|
19
|
+
* 🔴 为什么不复用 {@link waitForGateArmed}:那是**带窗**的一次性回执(超时即 resolve false),
|
|
20
|
+
* 它答的是「看门狗窗内呈现了没有」;#269 的 tool 门臂另外要问一个**无窗**的事实 ——「这条链的
|
|
21
|
+
* 整个寿命里,这张卡到底有没有呈现过」。用带窗的那只当事实源,会在「迟到卡」(窗后才入队)上
|
|
22
|
+
* 给出 false ⇒ 把一次真呈现误判成「行从未出生」⇒ 回环再铸一张卡,同一个待决项两张可按的卡。
|
|
23
|
+
*/
|
|
24
|
+
export declare function onGateArmed(listener: (key: string) => void): () => void;
|
|
25
|
+
/** W1 带 key 变体。 */
|
|
26
|
+
export declare function onGateArmedFor(sessionKey: string, listener: (key: string) => void): () => void;
|
|
27
|
+
/** 测试用:缩短回执看门狗(null 复位)。 */
|
|
28
|
+
export declare function _setGateArmedWaitMsForTest(ms: number | null): void;
|
|
29
|
+
/** 回执看门狗现值(重开臂的缺省等待窗)。 */
|
|
30
|
+
export declare function gateArmedWaitMs(): number;
|
|
31
|
+
/**
|
|
32
|
+
* 等「这几个键里任意一个被 arm」的**事件**(#250 件1 呈现回执)。resolve true = 回执到手;
|
|
33
|
+
* false = 超时(呈现链没走到真入队 —— hook 自动应答/去重丢帧/enqueue 失败/处理器僵死)。
|
|
34
|
+
*
|
|
35
|
+
* 🔴 timer **不 unref**(包 `abortableSleep` 同判据):调用方(重开臂)正 await 本 promise 收
|
|
36
|
+
* verdict —— 回执窗里这只 timer 可能是事件循环里唯一的活,unref 会让 `-p` 车道进程在 await
|
|
37
|
+
* 中途直接退出,诚实的 reopen-failed 行一并蒸发。代价上限 = 一次 timeoutMs 的进程存活延长,
|
|
38
|
+
* 换判决必达。(浏览器宿主无 unref 概念,裸 setTimeout 两端行为一致 —— portability 零分支。)
|
|
39
|
+
*/
|
|
40
|
+
export declare function waitForGateArmed(keys: readonly string[], timeoutMs: number): Promise<boolean>;
|
|
41
|
+
/** W1 带 key 变体。 */
|
|
42
|
+
export declare function waitForGateArmedFor(sessionKey: string, keys: readonly string[], timeoutMs: number): Promise<boolean>;
|
|
13
43
|
/** 宿主呈现面登记口:收到可渲染 question 帧即记(键归一见 gateIdentity;坏形静默忽略 ——
|
|
14
|
-
* id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。
|
|
44
|
+
* id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。
|
|
45
|
+
*
|
|
46
|
+
* 尝试级回执(#250 codex 轮二 [medium]):原始帧 id 自带 `#reopen-*` 尝试序号,是现成的
|
|
47
|
+
* attempt token —— 归一键之外**事件级**再发一枪原始 id(不入 Set,台账词汇保持归一键)。
|
|
48
|
+
* 重开臂锚它,同 gate 两次在飞重开时一次真入队只唤对应那次尝试,不再同键互唤。 */
|
|
15
49
|
export declare function registerArmedGateFromQuestionId(questionId: unknown): void;
|
|
16
50
|
/** W1 带 key 变体。 */
|
|
17
51
|
export declare function registerArmedGateFromQuestionIdFor(sessionKey: string, questionId: unknown): void;
|
|
18
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* plan_review 的台账键(当代)。0 代 = `planReviewQuestionId(taskId)`(canonical 原形 ——
|
|
54
|
+
* overlay 对**原卡**(done 帧首扎)的登记落进同一键);≥1 代 = 同形 + `#g<N>` 尾。
|
|
55
|
+
*/
|
|
56
|
+
export declare function planReviewArmedKey(taskId: string): string;
|
|
57
|
+
/** W1 带 key 变体。 */
|
|
58
|
+
export declare function planReviewArmedKeyFor(sessionKey: string, taskId: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* 记「一张 plan 问答卡被答掉了」⇒ 当代门已被消费,同 run 的**下一只** plan 门是新实例,落新键。
|
|
61
|
+
*
|
|
62
|
+
* ── 为什么锚在「答卡」而不是「决断送达」(局限成文,A-024.4)────────────────────────────────
|
|
63
|
+
* wire 上没有 gate 实例位,宿主能看见的最晚可靠时刻是作答(送达成败在 responder 返回之后才
|
|
64
|
+
* 揭晓)。所以按「答卡」推代。方向代价:决断若终败(重试后仍没送达),门 1 其实还 pending,
|
|
65
|
+
* 下一次重开会判成首见 ——「呈上了一张卡」零历史断言,诚实方向安全;反向(不推代)才会编造
|
|
66
|
+
* 「被关过」。当代键同时从台账清掉(`clearArmedGate` 的 0.30.0 语义在 0 代逐字保持:决断递交后
|
|
67
|
+
* `wasGateArmed(canonical)` 读回 false)。
|
|
68
|
+
*
|
|
69
|
+
* 🔴 决断性门控请走 {@link notePlanReviewAnsweredIfDecisive}(`planReviewWire`):Esc/空答是
|
|
70
|
+
* dismissal 不是决断 —— 那时推代会把「被关过」翻成「首见」,恰是 A-024.4 在最常见路径(Esc
|
|
71
|
+
* 关卡)上要保住的区分。本口只做记账,不做分类。
|
|
72
|
+
*/
|
|
73
|
+
export declare function notePlanReviewAnswered(questionId: unknown): void;
|
|
74
|
+
/** W1 带 key 变体。 */
|
|
75
|
+
export declare function notePlanReviewAnsweredFor(sessionKey: string, questionId: unknown): void;
|
|
76
|
+
/** 测试用:清全部会话的台账(含监听器/代数/答卡记账 —— 半清会让跨用例判决互相污染)。 */
|
|
19
77
|
export declare function _resetArmedGateRegistryForTest(): void;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* armedGateRegistry.ts — per-session 呈现台账:哪些决断卡在**这个宿主进程的哪个会话**里真的
|
|
3
3
|
* 呈现过(A-028.3,#244 族A 包半场,2026-08-12;源形 = cli `src/sema/armedGateRegistry.ts`,
|
|
4
|
-
* 上收时由模块级单 Set 改为 sessionSlot per-session
|
|
4
|
+
* 上收时由模块级单 Set 改为 sessionSlot per-session 键,支持多会话宿主。二段(#244 F1 换装批,
|
|
5
|
+
* 2026-08-14):壳侧剩余两位 —— **arm 事件源**(呈现回执)与 **plan_review 呈现分代** —— 一并
|
|
6
|
+
* 上收,壳侧台账本体退役)。
|
|
5
7
|
*
|
|
6
8
|
* ── 为什么需要它 ────────────────────────────────────────────────────────────────────────────
|
|
7
9
|
* 409 自愈重开臂(`adapter/activeRunSelfHeal.ts` 的分诊消费方)此前对每一张 pending 卡都说
|
|
@@ -20,25 +22,49 @@
|
|
|
20
22
|
* {@link registerArmedGateFromQuestionId} 剥前缀取键(= callKey,与卡键同域)。
|
|
21
23
|
* · plan_review:`planReviewQuestionId(taskId)` 整串作键(保留前缀,与 taskId 直接作 ask 键
|
|
22
24
|
* 永不撞域);重开腿铸 `…#reopen-*`,归一化剥尾 —— 首呈与重开落同一键。
|
|
25
|
+
* 🔴 A-024.4(#250,#244 F1 起分代在包):wire 上没有 gate 实例位([3664] pendingGate 只有
|
|
26
|
+
* kind/decidePath),同 run 推进到第二只 plan 门时,恒定键会让 firstSight 恒 false ⇒ 对一张
|
|
27
|
+
* 从未呈现过的新卡编造「被关过」历史。plan 键因此按**呈现代数**分代(见
|
|
28
|
+
* {@link planReviewArmedKeyFor} / {@link notePlanReviewAnsweredFor}):0 代键与 canonical
|
|
29
|
+
* 字节同形,≥1 代接 `#g<N>` 尾;决断性作答推代(消费当代门),下一只门读回首见。
|
|
23
30
|
*
|
|
24
31
|
* ── 登记点(「没呈现就不算 arm」)───────────────────────────────────────────────────────────
|
|
25
32
|
* ① 宿主呈现面:overlay 收到可渲染 question 帧时 / 卡口真把卡 enqueue 进渲染队列时,宿主调
|
|
26
33
|
* {@link registerArmedGateFromQuestionId} / {@link registerArmedGate}(auto-allow/deny/
|
|
27
34
|
* 失败臂不登记)。包的 publish 点**不**代登记 —— publish 无 overlay 时是 no-op,代登记 =
|
|
28
35
|
* 把「发过帧」谎报成「呈现过」。
|
|
29
|
-
* ② 重开腿:先读(firstSight 判决)
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
36
|
+
* ② 重开腿:先读(firstSight 判决)后写。🔴 回执模式(`planReviewWire` 的
|
|
37
|
+
* `presentationReceiptMs`,#250 件1 语义)下重开腿**零登记**:登记时点 = 宿主呈现面的真
|
|
38
|
+
* 入队点(即 ① 的调用),零回执还登记 = 下次谎称复见。
|
|
39
|
+
* ③ 消费点:plan_review 的**决断性**作答(approve/reject)经 {@link notePlanReviewAnswered}
|
|
40
|
+
* 族推代(当代键同时清掉,`clearArmedGate` 的旧语义在 0 代保持逐字兼容);dismissal
|
|
41
|
+
* (Esc/空答)不推代 —— 门没被消费,下次重开要照实说「reopened」。ask 族键粒度是
|
|
42
|
+
* callId(每 gate 唯一),无此问题,不消费。
|
|
43
|
+
*
|
|
44
|
+
* ── arm 事件源(#250 件1「呈现回执」;#244 F1 上收)────────────────────────────────────────
|
|
45
|
+
* `registerArmedGate` 是「卡真呈现了」的唯一汇聚点,所以重开腿的回执 = 等它的**调用事件**
|
|
46
|
+
* ({@link waitForGateArmed})。事件按每次调用发(不是集合成员测试)—— 复见重开的第二次呈现
|
|
47
|
+
* 同样要有回执;集合本身 arm 一次恒真,当不了事件源。{@link onGateArmed} 是**无窗**订阅位
|
|
48
|
+
* (#269 tool 门臂问的是「这条链的整个寿命里这张卡到底呈现过没有」,带窗回执答不了它)。
|
|
49
|
+
* 尝试级回执:{@link registerArmedGateFromQuestionId} 对**原始帧 id**(带 `#reopen-*` 尾)在
|
|
50
|
+
* 归一键之外事件级加发一枪(不入 Set)—— 同 gate 两次在飞重开时,一次真入队只唤对应那次尝试。
|
|
51
|
+
* 🔴 承重前提(server [3664]③):local 部署形 attach 重连恒零卡帧补发 —— 回执必须宿主进程内闭环。
|
|
34
52
|
*
|
|
35
53
|
* per-session 语义:sessionSlot 注册表(desktop session-host 多引擎会话互不串账);零参 API =
|
|
36
54
|
* DEFAULT_SESSION_KEY 兼容层,单会话宿主(cli)装配一行不动。callId 全局唯一,跨 /clear 不撞键。
|
|
37
55
|
*/
|
|
38
56
|
import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
|
|
39
|
-
import { armedKeyFromQuestionId } from './gateIdentity.js';
|
|
57
|
+
import { armedKeyFromQuestionId, PLAN_REVIEW_QUESTION_ID_PREFIX, planReviewQuestionId, REOPEN_ID_TAIL } from './gateIdentity.js';
|
|
40
58
|
// 🔴 模块级单例(singleton-manifest 登记):per-session 键 → 已呈现身份键集。
|
|
41
59
|
const armedGatesByKey = createSessionSlot();
|
|
60
|
+
// 🔴 同为模块级单例:per-session 键 → 监听器集(两份实例 ⇒ 登记面与等待面各持一半,回执恒 miss)。
|
|
61
|
+
const armedListenersByKey = createSessionSlot();
|
|
62
|
+
// ── plan_review 呈现分代(A-024.4;#244 F1 上收,语义照壳现实现)──────────────────────────────
|
|
63
|
+
/** per-session:taskId → 已消费的 plan 门数(= 当前代数;0 代键保持与 canonical 字节同形)。 */
|
|
64
|
+
const planReviewGenByKey = createSessionSlot();
|
|
65
|
+
/** per-session:已记过账的 plan 问答卡 id(答卡两条通路 —— overlay answerAndRelease 与 responder
|
|
66
|
+
* —— 会对同一张卡各报一次,按完整 questionId 去重,恰推一代)。 */
|
|
67
|
+
const notedPlanReviewIdsByKey = createSessionSlot();
|
|
42
68
|
function setFor(sessionKey) {
|
|
43
69
|
let s = armedGatesByKey.get(sessionKey);
|
|
44
70
|
if (!s) {
|
|
@@ -47,14 +73,52 @@ function setFor(sessionKey) {
|
|
|
47
73
|
}
|
|
48
74
|
return s;
|
|
49
75
|
}
|
|
50
|
-
|
|
76
|
+
function listenersFor(sessionKey) {
|
|
77
|
+
let s = armedListenersByKey.get(sessionKey);
|
|
78
|
+
if (!s) {
|
|
79
|
+
s = new Set();
|
|
80
|
+
armedListenersByKey.set(sessionKey, s);
|
|
81
|
+
}
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
function genMapFor(sessionKey) {
|
|
85
|
+
let m = planReviewGenByKey.get(sessionKey);
|
|
86
|
+
if (!m) {
|
|
87
|
+
m = new Map();
|
|
88
|
+
planReviewGenByKey.set(sessionKey, m);
|
|
89
|
+
}
|
|
90
|
+
return m;
|
|
91
|
+
}
|
|
92
|
+
function notedIdsFor(sessionKey) {
|
|
93
|
+
let s = notedPlanReviewIdsByKey.get(sessionKey);
|
|
94
|
+
if (!s) {
|
|
95
|
+
s = new Set();
|
|
96
|
+
notedPlanReviewIdsByKey.set(sessionKey, s);
|
|
97
|
+
}
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
100
|
+
/** 事件级发一枪(不动 Set)。监听器抛错不许炸登记面 —— 回执面是诊断/判决辅助面。 */
|
|
101
|
+
function emitGateArmedEventFor(sessionKey, key) {
|
|
102
|
+
for (const listener of [...listenersFor(sessionKey)]) {
|
|
103
|
+
try {
|
|
104
|
+
listener(key);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* 绝不反噬呈现链 */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** 登记一个已呈现的决断卡身份键(空/非法输入静默忽略 —— 登记面绝不炸渲染链)。
|
|
112
|
+
* 每次调用都发事件(复见重开的第二次呈现也要有回执;Set 成员级当不了事件源)。 */
|
|
51
113
|
export function registerArmedGate(key) {
|
|
52
114
|
registerArmedGateFor(DEFAULT_SESSION_KEY, key);
|
|
53
115
|
}
|
|
54
116
|
/** W1 带 key 变体(多会话宿主每会话一键,互不串账)。 */
|
|
55
117
|
export function registerArmedGateFor(sessionKey, key) {
|
|
56
|
-
if (typeof key
|
|
57
|
-
|
|
118
|
+
if (typeof key !== 'string' || key.length === 0)
|
|
119
|
+
return;
|
|
120
|
+
setFor(sessionKey).add(key);
|
|
121
|
+
emitGateArmedEventFor(sessionKey, key);
|
|
58
122
|
}
|
|
59
123
|
/** 该身份键的卡在本进程本会话呈现过吗?(false = 首见;文案分形的唯一判据) */
|
|
60
124
|
export function wasGateArmed(key) {
|
|
@@ -66,7 +130,8 @@ export function wasGateArmedFor(sessionKey, key) {
|
|
|
66
130
|
return false;
|
|
67
131
|
return armedGatesByKey.get(sessionKey)?.has(key) === true;
|
|
68
132
|
}
|
|
69
|
-
/** 消费一个身份键(
|
|
133
|
+
/** 消费一个身份键(plan 族的决断消费请优先走 {@link notePlanReviewAnswered} 族 —— 它同时推代;
|
|
134
|
+
* 本口保留给「只清账不推代」的宿主场景与 0.30.0 存量消费方,语义不变)。 */
|
|
70
135
|
export function clearArmedGate(key) {
|
|
71
136
|
clearArmedGateFor(DEFAULT_SESSION_KEY, key);
|
|
72
137
|
}
|
|
@@ -75,8 +140,92 @@ export function clearArmedGateFor(sessionKey, key) {
|
|
|
75
140
|
if (typeof key === 'string' && key.length > 0)
|
|
76
141
|
armedGatesByKey.get(sessionKey)?.delete(key);
|
|
77
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* 订阅 arm 事件(#269 上收):返回退订钩,调用方**必须**在自己的生命周期末调它(监听器挂在
|
|
145
|
+
* 模块级长存表上,漏退 = 闭包泄漏)。
|
|
146
|
+
*
|
|
147
|
+
* 🔴 为什么不复用 {@link waitForGateArmed}:那是**带窗**的一次性回执(超时即 resolve false),
|
|
148
|
+
* 它答的是「看门狗窗内呈现了没有」;#269 的 tool 门臂另外要问一个**无窗**的事实 ——「这条链的
|
|
149
|
+
* 整个寿命里,这张卡到底有没有呈现过」。用带窗的那只当事实源,会在「迟到卡」(窗后才入队)上
|
|
150
|
+
* 给出 false ⇒ 把一次真呈现误判成「行从未出生」⇒ 回环再铸一张卡,同一个待决项两张可按的卡。
|
|
151
|
+
*/
|
|
152
|
+
export function onGateArmed(listener) {
|
|
153
|
+
return onGateArmedFor(DEFAULT_SESSION_KEY, listener);
|
|
154
|
+
}
|
|
155
|
+
/** W1 带 key 变体。 */
|
|
156
|
+
export function onGateArmedFor(sessionKey, listener) {
|
|
157
|
+
const listeners = listenersFor(sessionKey);
|
|
158
|
+
listeners.add(listener);
|
|
159
|
+
return () => {
|
|
160
|
+
listeners.delete(listener);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 呈现回执的缺省看门狗窗(#250 件1;重开臂共用单源,免得每臂各持一个会漂的数)。要盖住的最慢
|
|
165
|
+
* 真实成功路:tool 门臂整链重跑一次(attempt→2s 退避→attempt,各含一跳 approvals.list)与
|
|
166
|
+
* question 臂 overlay 的 PermissionRequest hooks 窗。
|
|
167
|
+
*/
|
|
168
|
+
const GATE_ARMED_WAIT_MS = 5000;
|
|
169
|
+
let gateArmedWaitOverrideMs = null;
|
|
170
|
+
/** 测试用:缩短回执看门狗(null 复位)。 */
|
|
171
|
+
export function _setGateArmedWaitMsForTest(ms) {
|
|
172
|
+
gateArmedWaitOverrideMs = ms;
|
|
173
|
+
}
|
|
174
|
+
/** 回执看门狗现值(重开臂的缺省等待窗)。 */
|
|
175
|
+
export function gateArmedWaitMs() {
|
|
176
|
+
return gateArmedWaitOverrideMs ?? GATE_ARMED_WAIT_MS;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 等「这几个键里任意一个被 arm」的**事件**(#250 件1 呈现回执)。resolve true = 回执到手;
|
|
180
|
+
* false = 超时(呈现链没走到真入队 —— hook 自动应答/去重丢帧/enqueue 失败/处理器僵死)。
|
|
181
|
+
*
|
|
182
|
+
* 🔴 timer **不 unref**(包 `abortableSleep` 同判据):调用方(重开臂)正 await 本 promise 收
|
|
183
|
+
* verdict —— 回执窗里这只 timer 可能是事件循环里唯一的活,unref 会让 `-p` 车道进程在 await
|
|
184
|
+
* 中途直接退出,诚实的 reopen-failed 行一并蒸发。代价上限 = 一次 timeoutMs 的进程存活延长,
|
|
185
|
+
* 换判决必达。(浏览器宿主无 unref 概念,裸 setTimeout 两端行为一致 —— portability 零分支。)
|
|
186
|
+
*/
|
|
187
|
+
export function waitForGateArmed(keys, timeoutMs) {
|
|
188
|
+
return waitForGateArmedFor(DEFAULT_SESSION_KEY, keys, timeoutMs);
|
|
189
|
+
}
|
|
190
|
+
/** W1 带 key 变体。 */
|
|
191
|
+
export function waitForGateArmedFor(sessionKey, keys, timeoutMs) {
|
|
192
|
+
const wanted = new Set(keys.filter((k) => typeof k === 'string' && k.length > 0));
|
|
193
|
+
if (wanted.size === 0)
|
|
194
|
+
return Promise.resolve(false);
|
|
195
|
+
const listeners = listenersFor(sessionKey);
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
const listener = (key) => {
|
|
198
|
+
if (!wanted.has(key))
|
|
199
|
+
return;
|
|
200
|
+
listeners.delete(listener);
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
resolve(true);
|
|
203
|
+
};
|
|
204
|
+
listeners.add(listener);
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
listeners.delete(listener);
|
|
207
|
+
resolve(false);
|
|
208
|
+
}, timeoutMs);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* question 帧 id → 台账键(登记口用):基础归一(剥 ask 前缀/剥重开尾)= `gateIdentity.
|
|
213
|
+
* armedKeyFromQuestionId`;plan_review 形在其上叠**呈现代数**(A-024.4)—— 登记与查询都落在
|
|
214
|
+
* **当代**键上。不认识的形原样入册(未来新 id 形至多多占一个键,不误伤既有词汇)。
|
|
215
|
+
*/
|
|
216
|
+
function armedKeyForQuestionIdFor(sessionKey, questionId) {
|
|
217
|
+
const base = armedKeyFromQuestionId(questionId);
|
|
218
|
+
if (base.startsWith(PLAN_REVIEW_QUESTION_ID_PREFIX)) {
|
|
219
|
+
return planReviewArmedKeyFor(sessionKey, base.slice(PLAN_REVIEW_QUESTION_ID_PREFIX.length));
|
|
220
|
+
}
|
|
221
|
+
return base;
|
|
222
|
+
}
|
|
78
223
|
/** 宿主呈现面登记口:收到可渲染 question 帧即记(键归一见 gateIdentity;坏形静默忽略 ——
|
|
79
|
-
* id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。
|
|
224
|
+
* id 是 wire/合成位,入参按边界收 unknown,本函数就是窄化动作本身)。
|
|
225
|
+
*
|
|
226
|
+
* 尝试级回执(#250 codex 轮二 [medium]):原始帧 id 自带 `#reopen-*` 尝试序号,是现成的
|
|
227
|
+
* attempt token —— 归一键之外**事件级**再发一枪原始 id(不入 Set,台账词汇保持归一键)。
|
|
228
|
+
* 重开臂锚它,同 gate 两次在飞重开时一次真入队只唤对应那次尝试,不再同键互唤。 */
|
|
80
229
|
export function registerArmedGateFromQuestionId(questionId) {
|
|
81
230
|
registerArmedGateFromQuestionIdFor(DEFAULT_SESSION_KEY, questionId);
|
|
82
231
|
}
|
|
@@ -84,9 +233,72 @@ export function registerArmedGateFromQuestionId(questionId) {
|
|
|
84
233
|
export function registerArmedGateFromQuestionIdFor(sessionKey, questionId) {
|
|
85
234
|
if (typeof questionId !== 'string' || questionId.length === 0)
|
|
86
235
|
return;
|
|
87
|
-
|
|
236
|
+
// 🔴 canonical 复用的去重记号过期(codex #244 F1 轮一 [medium]):arm 臂对同 run 每只 plan gate
|
|
237
|
+
// 都复用 `plan-review:<taskId>` 这个 canonical id —— 决断记账按完整 questionId 去重,若记号不随
|
|
238
|
+
// 卡换代过期,第二只 canonical 卡的决断会撞上第一只留下的记号 ⇒ 不推代 ⇒ 第三只门的重开又谎报
|
|
239
|
+
// 「被关过」。**新 canonical 卡的呈现**就是「上一张同 id 卡已消解、这是新实例」的宿主侧可见时刻,
|
|
240
|
+
// 在此把旧记号过期;reopen 尾 id 进程内唯一,记号永不相撞,不用过期。方向:同一张卡「两路各报
|
|
241
|
+
// 一次」之间夹进一次同 id 重呈会双推代 —— 误差落首见话术(零历史断言),诚实安全侧。
|
|
242
|
+
if (questionId.startsWith(PLAN_REVIEW_QUESTION_ID_PREFIX) && !questionId.includes(REOPEN_ID_TAIL)) {
|
|
243
|
+
notedPlanReviewIdsByKey.get(sessionKey)?.delete(questionId);
|
|
244
|
+
}
|
|
245
|
+
const normalized = armedKeyForQuestionIdFor(sessionKey, questionId);
|
|
246
|
+
registerArmedGateFor(sessionKey, normalized);
|
|
247
|
+
if (questionId !== normalized)
|
|
248
|
+
emitGateArmedEventFor(sessionKey, questionId);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* plan_review 的台账键(当代)。0 代 = `planReviewQuestionId(taskId)`(canonical 原形 ——
|
|
252
|
+
* overlay 对**原卡**(done 帧首扎)的登记落进同一键);≥1 代 = 同形 + `#g<N>` 尾。
|
|
253
|
+
*/
|
|
254
|
+
export function planReviewArmedKey(taskId) {
|
|
255
|
+
return planReviewArmedKeyFor(DEFAULT_SESSION_KEY, taskId);
|
|
256
|
+
}
|
|
257
|
+
/** W1 带 key 变体。 */
|
|
258
|
+
export function planReviewArmedKeyFor(sessionKey, taskId) {
|
|
259
|
+
const gen = planReviewGenByKey.get(sessionKey)?.get(taskId) ?? 0;
|
|
260
|
+
const canonical = planReviewQuestionId(taskId);
|
|
261
|
+
return gen > 0 ? `${canonical}#g${String(gen)}` : canonical;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* 记「一张 plan 问答卡被答掉了」⇒ 当代门已被消费,同 run 的**下一只** plan 门是新实例,落新键。
|
|
265
|
+
*
|
|
266
|
+
* ── 为什么锚在「答卡」而不是「决断送达」(局限成文,A-024.4)────────────────────────────────
|
|
267
|
+
* wire 上没有 gate 实例位,宿主能看见的最晚可靠时刻是作答(送达成败在 responder 返回之后才
|
|
268
|
+
* 揭晓)。所以按「答卡」推代。方向代价:决断若终败(重试后仍没送达),门 1 其实还 pending,
|
|
269
|
+
* 下一次重开会判成首见 ——「呈上了一张卡」零历史断言,诚实方向安全;反向(不推代)才会编造
|
|
270
|
+
* 「被关过」。当代键同时从台账清掉(`clearArmedGate` 的 0.30.0 语义在 0 代逐字保持:决断递交后
|
|
271
|
+
* `wasGateArmed(canonical)` 读回 false)。
|
|
272
|
+
*
|
|
273
|
+
* 🔴 决断性门控请走 {@link notePlanReviewAnsweredIfDecisive}(`planReviewWire`):Esc/空答是
|
|
274
|
+
* dismissal 不是决断 —— 那时推代会把「被关过」翻成「首见」,恰是 A-024.4 在最常见路径(Esc
|
|
275
|
+
* 关卡)上要保住的区分。本口只做记账,不做分类。
|
|
276
|
+
*/
|
|
277
|
+
export function notePlanReviewAnswered(questionId) {
|
|
278
|
+
notePlanReviewAnsweredFor(DEFAULT_SESSION_KEY, questionId);
|
|
279
|
+
}
|
|
280
|
+
/** W1 带 key 变体。 */
|
|
281
|
+
export function notePlanReviewAnsweredFor(sessionKey, questionId) {
|
|
282
|
+
if (typeof questionId !== 'string' || !questionId.startsWith(PLAN_REVIEW_QUESTION_ID_PREFIX))
|
|
283
|
+
return;
|
|
284
|
+
const noted = notedIdsFor(sessionKey);
|
|
285
|
+
if (noted.has(questionId))
|
|
286
|
+
return;
|
|
287
|
+
noted.add(questionId);
|
|
288
|
+
const base = questionId.slice(PLAN_REVIEW_QUESTION_ID_PREFIX.length);
|
|
289
|
+
const cut = base.indexOf(REOPEN_ID_TAIL);
|
|
290
|
+
const taskId = cut >= 0 ? base.slice(0, cut) : base;
|
|
291
|
+
if (taskId.length === 0)
|
|
292
|
+
return;
|
|
293
|
+
// 当代键先清(0 代 = canonical,保持 0.30.0 clear-on-decide 的读面语义)再推代。
|
|
294
|
+
clearArmedGateFor(sessionKey, planReviewArmedKeyFor(sessionKey, taskId));
|
|
295
|
+
const gens = genMapFor(sessionKey);
|
|
296
|
+
gens.set(taskId, (gens.get(taskId) ?? 0) + 1);
|
|
88
297
|
}
|
|
89
|
-
/**
|
|
298
|
+
/** 测试用:清全部会话的台账(含监听器/代数/答卡记账 —— 半清会让跨用例判决互相污染)。 */
|
|
90
299
|
export function _resetArmedGateRegistryForTest() {
|
|
91
300
|
armedGatesByKey.clear();
|
|
301
|
+
armedListenersByKey.clear();
|
|
302
|
+
planReviewGenByKey.clear();
|
|
303
|
+
notedPlanReviewIdsByKey.clear();
|
|
92
304
|
}
|