@zhushanwen/pi-session-manager 0.6.3 → 0.6.5
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/package.json +4 -4
- package/src/__tests__/notify-delivery-discipline.test.ts +58 -4
- package/src/__tests__/notify-reconcile-resume-edge.test.ts +95 -0
- package/src/__tests__/notify-reconcile.test.ts +502 -0
- package/src/index.ts +84 -0
- package/src/notify-content.ts +8 -1
- package/src/notify-delivery.ts +99 -26
- package/src/notify-reconcile.ts +414 -0
- package/src/watch-coordinator.ts +7 -14
package/src/notify-delivery.ts
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
// 非静默吞)。缺席回退不建第二投递路径(unified-delivery §3.7:两套装配并存违背
|
|
13
13
|
// 装配单一归属——notifier 直发兜底属 notifier 自身形态,本域降级 = 放弃该条 +
|
|
14
14
|
// warn 修复信号)。
|
|
15
|
+
//
|
|
16
|
+
// rejected 处置阶梯(completion-signal-delivery D1/D8①,ADR-0122⑧ 边界精确化):
|
|
17
|
+
// rejected(幂等键未登记、投递日志未落,重放结构性无从覆盖)不再等于丢失——
|
|
18
|
+
// 瞬态成因(disposed / persist 瞬断)当场 getDeliveryPort() 重取当前实例单次重提
|
|
19
|
+
// 原 envelope(一次投递尝试的同步收尾动作,非重试循环非定时器——ADR-0122 红线
|
|
20
|
+
// 不越界,RL10 同锁:重提 rejected 或重取缺席即停,无第三次投递、无定时器句柄);
|
|
21
|
+
// 重提仍失败恢复责任移交对账边沿的事实判据兜底(notify-reconcile 三入口编排,
|
|
22
|
+
// warn 文案带 recovered 指引——RL9/RL11 同族)。
|
|
15
23
|
|
|
16
24
|
import { getDeliveryPort } from "@zhushanwen/pi-delivery-port";
|
|
17
25
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
@@ -24,34 +32,26 @@ import {
|
|
|
24
32
|
|
|
25
33
|
const logger = getLogger("session-manager");
|
|
26
34
|
|
|
35
|
+
/** port.deliver 单次调用的受理裁决(主投与单次重提共用同一收尾语义)。 */
|
|
36
|
+
type DeliverOutcome =
|
|
37
|
+
| { kind: "accepted" }
|
|
38
|
+
| { kind: "swallowed" }
|
|
39
|
+
| { kind: "failed"; reason: string }
|
|
40
|
+
| { kind: "port-absent" };
|
|
41
|
+
|
|
27
42
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* collectKeys(result-collect 设计 §3.4 managed 行):批内全部债权键集申报——合批
|
|
32
|
-
* 条目载荷幂等键 = 批首债权键,其余成员仅注销 pending 无独立日志条目,单值申报下
|
|
33
|
-
* collect 无法按非首键定位载荷;键集申报经端口逐键展开(命中 = 等待集 ∩ 条目键集
|
|
34
|
-
* ≠ ∅)。必传:批恒至少一条应答,无键集的 deliver 在本域 = collect 永远等不到,
|
|
35
|
-
* 属调用方缺陷,构造性防漏(编译期拦截)。
|
|
36
|
-
*
|
|
37
|
-
* port 缺席 → warn + false(降级登记,见文件头)。deliver swallowed = 幂等命中
|
|
38
|
-
* (同 notifyId 已受理或已终局——重放/重复应答的重复提交),按已投递返回 true;
|
|
39
|
-
* deliver rejected(persist-failed 等)→ warn + false(幂等键未登记,watch 重试
|
|
40
|
-
* 合法)。
|
|
43
|
+
* 单次投递(主投与重提共用的投递管道):deliver rejected / 写入异常 / port 缺席
|
|
44
|
+
* 三类失败收敛为 failed(reason 透传)——重提链据此裁决停止位。
|
|
41
45
|
*/
|
|
42
|
-
|
|
46
|
+
function deliverOnce(
|
|
43
47
|
notifyId: string,
|
|
44
48
|
content: string,
|
|
45
49
|
details: ManagedNotifyDetails,
|
|
46
50
|
collectKeys: string[],
|
|
47
|
-
):
|
|
51
|
+
): DeliverOutcome {
|
|
48
52
|
const port = getDeliveryPort();
|
|
49
53
|
if (!port) {
|
|
50
|
-
|
|
51
|
-
"[session-manager] delivery port not bound — notification dropped (base-tool-enhance session_start 装配缺失;下次 session_start 重试装配)",
|
|
52
|
-
{ notifyId },
|
|
53
|
-
);
|
|
54
|
-
return false;
|
|
54
|
+
return { kind: "port-absent" };
|
|
55
55
|
}
|
|
56
56
|
try {
|
|
57
57
|
const result = port.deliver(
|
|
@@ -68,13 +68,9 @@ export function recordManagedNotify(
|
|
|
68
68
|
{ collectKey: collectKeys },
|
|
69
69
|
);
|
|
70
70
|
if (result.kind === "rejected") {
|
|
71
|
-
|
|
72
|
-
notifyId,
|
|
73
|
-
reason: result.reason,
|
|
74
|
-
});
|
|
75
|
-
return false;
|
|
71
|
+
return { kind: "failed", reason: result.reason };
|
|
76
72
|
}
|
|
77
|
-
return
|
|
73
|
+
return { kind: result.kind };
|
|
78
74
|
} catch (err) {
|
|
79
75
|
// 写入异常(stale ctx / appendEntry 失败)按接入点降级:不拖垮 watch 应答编排
|
|
80
76
|
//(unregister 照常),warn 留痕可归因
|
|
@@ -82,6 +78,83 @@ export function recordManagedNotify(
|
|
|
82
78
|
notifyId,
|
|
83
79
|
error: toErrorMessage(err),
|
|
84
80
|
});
|
|
81
|
+
return { kind: "failed", reason: toErrorMessage(err) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 投递一条死亡通知(unified-delivery 波 2:port.deliver 主投——managed-session-notify
|
|
87
|
+
* 外部通道逐条不合批)。
|
|
88
|
+
*
|
|
89
|
+
* collectKeys(result-collect 设计 §3.4 managed 行):批内全部债权键集申报——合批
|
|
90
|
+
* 条目载荷幂等键 = 批首债权键,其余成员仅注销 pending 无独立日志条目,单值申报下
|
|
91
|
+
* collect 无法按非首键定位载荷;键集申报经端口逐键展开(命中 = 等待集 ∩ 条目键集
|
|
92
|
+
* ≠ ∅)。必传:批恒至少一条应答,无键集的 deliver 在本域 = collect 永远等不到,
|
|
93
|
+
* 属调用方缺陷,构造性防漏(编译期拦截)。
|
|
94
|
+
*
|
|
95
|
+
* port 缺席 → warn + false(降级登记,见文件头)。deliver swallowed = 幂等命中
|
|
96
|
+
* (同 notifyId 已受理或已终局——重放/重复应答的重复提交),按已投递返回 true;
|
|
97
|
+
* deliver rejected(disposed / persist-failed 等)→ 处置阶梯①接线(D8①):立即
|
|
98
|
+
* getDeliveryPort() 重取单次重提原 envelope(同幂等键——rejected 条目幂等键未
|
|
99
|
+
* 登记,重提合法),重提 accepted/swallowed 按已投递返回 true,重提仍 rejected
|
|
100
|
+
* 或重取缺席即停 + warn 恢复指引(恢复通道 = 对账边沿事实判据,notify-reconcile)。
|
|
101
|
+
*/
|
|
102
|
+
export function recordManagedNotify(
|
|
103
|
+
notifyId: string,
|
|
104
|
+
content: string,
|
|
105
|
+
details: ManagedNotifyDetails,
|
|
106
|
+
collectKeys: string[],
|
|
107
|
+
): boolean {
|
|
108
|
+
const outcome = deliverOnce(notifyId, content, details, collectKeys);
|
|
109
|
+
if (outcome.kind === "accepted" || outcome.kind === "swallowed") {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
if (outcome.kind === "port-absent") {
|
|
113
|
+
logger.warn(
|
|
114
|
+
"[session-manager] delivery port not bound — notification dropped (base-tool-enhance session_start 装配缺失;下次 session_start 重试装配)",
|
|
115
|
+
{ notifyId },
|
|
116
|
+
);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
// rejected(幂等键未登记):首败留痕(修复信号)+ 处置阶梯①接线。
|
|
120
|
+
logger.warn(
|
|
121
|
+
"[session-manager] delivery port rejected notification; retrying once via re-acquired port, recovered by reconcile fallback if the retry fails too (idempotency key not registered)",
|
|
122
|
+
{ notifyId, reason: outcome.reason },
|
|
123
|
+
);
|
|
124
|
+
return retryManagedNotifyOnce(notifyId, content, details, collectKeys);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 立即单次重提(D8①,与 bash 域 retryDeliverOnce 同款单次形态——RL10 同锁):
|
|
129
|
+
* getDeliveryPort() 重取当前实例(session_start 重绑后即新实例)、重提原 envelope
|
|
130
|
+
* (同幂等键同 collectKey 申报)。单次不循环:重提 rejected 或重取 undefined 均
|
|
131
|
+
* 直接停止(RL10 的两处停止位),无定时器、无重试队列、无第三次投递;重取缺席
|
|
132
|
+
* 不扩直发降级(缺席直发只属主路径缺席形态),恢复通道 = 对账边沿事实判据。
|
|
133
|
+
*/
|
|
134
|
+
function retryManagedNotifyOnce(
|
|
135
|
+
notifyId: string,
|
|
136
|
+
content: string,
|
|
137
|
+
details: ManagedNotifyDetails,
|
|
138
|
+
collectKeys: string[],
|
|
139
|
+
): boolean {
|
|
140
|
+
const retryOutcome = deliverOnce(notifyId, content, details, collectKeys);
|
|
141
|
+
if (retryOutcome.kind === "accepted" || retryOutcome.kind === "swallowed") {
|
|
142
|
+
logger.debug("[session-manager] notify retry accepted via re-acquired port", { notifyId });
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (retryOutcome.kind === "port-absent") {
|
|
146
|
+
logger.warn(
|
|
147
|
+
"[session-manager] notify retry skipped: delivery port absent on re-acquire; recovered by reconcile fallback",
|
|
148
|
+
{ notifyId },
|
|
149
|
+
);
|
|
85
150
|
return false;
|
|
86
151
|
}
|
|
152
|
+
// 单次重提仍 rejected(转换窗旧实例在位 / 持久写失败):即停报警(RL11——
|
|
153
|
+
// 持久成因不静默重试,warn 留痕交环境修复;环境修复后的送达由对账边沿的
|
|
154
|
+
// 事实判据自动承接,判据不区分成因)。
|
|
155
|
+
logger.warn(
|
|
156
|
+
"[session-manager] notify retry rejected after the single re-acquire attempt; single retry spent, recovered by reconcile fallback (session_start / agent_settled edge)",
|
|
157
|
+
{ notifyId, reason: retryOutcome.reason },
|
|
158
|
+
);
|
|
159
|
+
return false;
|
|
87
160
|
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// src/notify-reconcile.ts — managed session 死亡/结果通知的对账补投
|
|
2
|
+
// (completion-signal-delivery D8:session-manager 域对账步与判据事实源接入,
|
|
3
|
+
// 与 bash/subagent 域同构处置阶梯的域侧落位)。
|
|
4
|
+
//
|
|
5
|
+
// 三重事实判据(域事实映射进共享骨架——判据逻辑零新增,真值组合单源锚定
|
|
6
|
+
// @zhushanwen/pi-delivery-port reconcile-judgement.ts 的 isReconcileNotifyCandidate
|
|
7
|
+
// (判据链收编单源,真值表测试随迁该包锁定);本域曾按 subagent-core 先例同构
|
|
8
|
+
// 落位,收编轮起改直依共享包):
|
|
9
|
+
// 候选 = 应投递而未送达(isTerminal)∧ 域排除位未命中 ∧ 送达痕迹未命中 ∧
|
|
10
|
+
// 投递日志无该幂等键承载
|
|
11
|
+
//
|
|
12
|
+
// 判据事实源磁盘化(completion-signal-delivery A1 修复的域侧同构落位,范本 =
|
|
13
|
+
// base-tool-enhance pending-reconcile.ts「判据事实源磁盘化」段):判据全部事实
|
|
14
|
+
// 源——pending 表派生视图(设计 SC10)+ 送达痕迹 / 投递日志 / 死亡新闻对三个辅助
|
|
15
|
+
// 源——读 session JSONL 落盘条目(readSessionFileEntries),不读 pi 内存账。pi
|
|
16
|
+
// SessionManager._appendEntry 先 fileEntries.push 后 _persist、persist 失败
|
|
17
|
+
// (EACCES 等)无回滚,留下「幽灵内存 entry」(内存账在册、JSONL 无行;pi 侧
|
|
18
|
+
// 机制实锚登记 pi-semantics PS-97:dist/core/session-manager.js :815-820
|
|
19
|
+
// _appendEntry / :794-814 _persist);内存账
|
|
20
|
+
// 口径曾把幽灵的送达痕迹/投递日志条目当真 → 「拒收未受理」的键被误判已受理 →
|
|
21
|
+
// 补投结构性静默失效。磁盘事实使 delivered/journaled 语义 = 「JSONL 实际含该键
|
|
22
|
+
// 条目」,与受理落盘契约一物一名;sessionFile 缺席(no-persist 形态——_persist
|
|
23
|
+
// no-op 恒成功、无幽灵态)退化内存 entries(该形态内存账即完整事实)+ warn 留痕。
|
|
24
|
+
// 成本口径:内存账 ⊇ 盘上条目(幽灵态只会多不会少),内存预检「无 register 条目
|
|
25
|
+
// 早退」= 盘上亦无——高频边沿(agent_settled)常态(无 managed session 债权)零
|
|
26
|
+
// 文件读,候选在册才读盘(bash 域范本同款「候选在册才读盘」形态)。
|
|
27
|
+
// 幽灵候选源(register/unregister 条目自身落盘失败)的残余 = 磁盘口径下候选不可
|
|
28
|
+
// 见的保守 miss(漏补投方向,与「漏补投优于重复投」既有取向一致):该形态 =
|
|
29
|
+
// 债权登记持久化断裂,重启后任何恢复通道都无从发现,对账以磁盘事实为准是其正确
|
|
30
|
+
// 语义;盘上无注销条目 = 「债权在途」不虚构补投。
|
|
31
|
+
//
|
|
32
|
+
// 四项读取规则(D8③ 本域判据的落地细则,逐项落实):
|
|
33
|
+
// ① 取数形态 = pending:register/unregister 全量 entries 现算(经 protocol
|
|
34
|
+
// scanPendingEntries 单点,registerEntries 含已注销条目)——非 active 视图
|
|
35
|
+
// (applyPendingDiff 差集会把注销条目滤掉;rejected 场景 flushBatch 尾部
|
|
36
|
+
// 无条件注销后条目从 active 视图消失,按 active 读则候选结构性不可达,
|
|
37
|
+
// 与 D8①「注销语义不动、恢复责任移交对账层」矛盾)。已注销条目恰是判据
|
|
38
|
+
// 对象:注销存在 = 新闻已到达并被处理过 = 「应投递而 rejected」的必要事实。
|
|
39
|
+
// ② 静默注销类排除依据 = pending:unregister entry 的 reason 原词
|
|
40
|
+
// (cancelled/orphaned——例外1 上游分流逐笔即时注销,非补投对象)。经
|
|
41
|
+
// resolveSettlementReason 的解析枚举不可用(stopped/exited/deleted 同被
|
|
42
|
+
// 折叠为 cancelled,四项分流失真)——本文件自扫 unregister 条目 data.reason
|
|
43
|
+
// 原词(PENDING_UNREGISTER_ENTRY_TYPE 词表单点自 protocol)。
|
|
44
|
+
// ③ 三类「注销 reason 为应投递类而无独立主路径投递」形态排除(候选只认
|
|
45
|
+
// flushBatch 正常路径批首的独立投递键):
|
|
46
|
+
// (a) 合批非首成员——成员键随批首条目 collectKey 键集申报承载(批首成功
|
|
47
|
+
// 受理形态),承载反查命中即排除。实现 = 投递日志条目 id 集 ∪ 条目
|
|
48
|
+
// collectKey 键集展开。
|
|
49
|
+
// (b) 死亡槽重复后续批——同死亡新闻已由前批投递送达(flushBatch 例外2
|
|
50
|
+
// 早退分支,unregister-only)。死亡槽 (sessionId, deathSeq) 是运行期
|
|
51
|
+
// 去重内存态、不参与判据(SC10 登记);其判据侧磁盘投影 = 死亡新闻对
|
|
52
|
+
// (label, reason∈{exited,deleted}) 已有送达通知条目(送达通知
|
|
53
|
+
// details.label/details.reason 携带两事实)。后续批键的 register name
|
|
54
|
+
// 与前批 label 同源(同 session 债权的 armSessionNotify 同 name),对
|
|
55
|
+
// 命中即排除。只限死亡类 reason(exited/deleted):结果新闻无槽去重
|
|
56
|
+
// 语义,同 session 两次 send 的两条 completed 新闻是两个独立新闻,按
|
|
57
|
+
// 对排除会误吞待补投候选。残余边界:label 用户可重复(两个 session
|
|
58
|
+
// 同 label)时对命中可能漏补投——「漏补投优于重复投」保守方向,与
|
|
59
|
+
// 送达痕迹判据同款取向。前批亦 rejected 的变体(死亡对无痕迹)后续批
|
|
60
|
+
// 键保留候选 = 伴生误报(三重叠加极边缘),显式登记不建排除位。
|
|
61
|
+
// (c) sessionId 缺失防御批——flushBatch 早退分支(warn 留痕 unregister-only)。
|
|
62
|
+
// 判据源四源(pending 表 / 投递日志 / 送达痕迹 / 死亡对)均无「应答缺
|
|
63
|
+
// sessionId」的任何落盘标记(设计原文自认「无区分标记」):键未被任何
|
|
64
|
+
// 条目承载恰是该形态的定义特征,与 rejected 批首磁盘同形态。排除的
|
|
65
|
+
// 机器化实现不可达(强行区分需新增落盘——撞 D2 零新增落盘否决;或
|
|
66
|
+
// 时间启发式——违反 ADR-0122)。实施期裁决 = 不建排除位,残余呈报:
|
|
67
|
+
// 可达性 = D-4 协议不变量违反(fail-closed 应答 reason 恒 cancelled
|
|
68
|
+
// 走静默注销;应投递类 reason 却缺 sessionId = runtime 双重违例,生产
|
|
69
|
+
// 不可达常态且触发即有 flushBatch warn 留痕的漂移信号);残余危害 =
|
|
70
|
+
// 补投一条骨架简报(label/status 两事实真实,重复刺激级),方向低于
|
|
71
|
+
// 漏补投。(c) 形态按上述可达性论证登记为已接受残余(对账结果不建
|
|
72
|
+
// 计数字段)。
|
|
73
|
+
// ④ 域过滤 = 判据只取 register type === "session" 条目。pending 表混装四域
|
|
74
|
+
// 写方(protocol 写方注释登记),跨域条目不过滤则「无独立主路径投递键 →
|
|
75
|
+
// 三重判据结构性全命中」的误报形态跨域成立(subagent record id ≠ 其通知
|
|
76
|
+
// 键 notifyId、workflow runId ≠ wf-done 投递键、bash 条目与本域判据及
|
|
77
|
+
// base-tool-enhance 域判据重复扫同一域)。域内先例 = recoverStaleRegisters
|
|
78
|
+
// 的 type === "session" 判定(watch-coordinator 同款)。
|
|
79
|
+
//
|
|
80
|
+
// payload 分层(D8⑤ + R-1 条款):本域补投 = 骨架简报形态——判据源不含
|
|
81
|
+
// watch 应答内存 payload(fulfills/exitCode/stderrTail/sessionFilePath 全部
|
|
82
|
+
// 不落盘),主路径 content 构造 buildManagedNotifyContent 的 sessionId 为必填
|
|
83
|
+
// 且取值应为 managed 子会话 id(register entry 落盘的 sessionId 是 listener 侧
|
|
84
|
+
// 父会话 id,两值不同物,取用即谎报)——补投不调主路径构造(R-1),骨架文案
|
|
85
|
+
// 独立构造:label(register entry 的 name,域内既有先例 = 重启收尾路径同源)+
|
|
86
|
+
// status(unregister reason)两事实入文,诊断字段按主路径缺席省略语义全部省略,
|
|
87
|
+
// details.fulfills 取 0(0 = 无债权申报,不虚构笔数——主路径 0/undefined 不出行
|
|
88
|
+
// 语义同源)。省略的都是判据源不存在的事实,未虚构字段值(「不谎报」分层族)。
|
|
89
|
+
//
|
|
90
|
+
// 入口形态(D8② 三入口编排互调本文件唯一对账函数 runNotifyReconcileMaintenance):
|
|
91
|
+
// ① session_start——index.ts 既有 handler(重启收尾路径之后);
|
|
92
|
+
// ② agent_settled——index.ts 装配期订阅(回合收尾兜底边沿);
|
|
93
|
+
// ③ 切回补强(__taiji_bg_reconcile__)——pi 命令名全局唯一且单 handler 分发
|
|
94
|
+
// (pi SDK runner.getCommand 取首个同名命中;runtime 只发 base-tool-enhance
|
|
95
|
+
// 注册的那一条命令,字面量契约),本扩展无切回触发源且与 base-tool-enhance
|
|
96
|
+
// 无依赖边(跨扩展依赖通道不存在)——经三方共同依赖包 pi-delivery-port 的
|
|
97
|
+
// 进程级注册槽订阅切回派发(index.ts 装配期 subscribeResumeReconcile,
|
|
98
|
+
// base-tool-enhance handler dispatchResumeReconcile 转发;subagent 域
|
|
99
|
+
// SubagentService 单例直转的 A5 先例同族,reason="resume")。
|
|
100
|
+
// 幂等:补投受理成功后投递日志条目(id + collectKey)与送达痕迹抵消判据,
|
|
101
|
+
// 下一边沿不再命中;跨边沿重复对账天然收敛。
|
|
102
|
+
|
|
103
|
+
import {
|
|
104
|
+
PENDING_UNREGISTER_ENTRY_TYPE,
|
|
105
|
+
scanPendingEntries,
|
|
106
|
+
} from "@zhushanwen/extension-protocol";
|
|
107
|
+
import {
|
|
108
|
+
collectJournaledDeliveryIds,
|
|
109
|
+
isReconcileNotifyCandidate,
|
|
110
|
+
resolveReconcileFactEntries,
|
|
111
|
+
type ReconcileNotifyFacts,
|
|
112
|
+
} from "@zhushanwen/pi-delivery-port";
|
|
113
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
114
|
+
|
|
115
|
+
import { MANAGED_SESSION_NOTIFY_CUSTOM_TYPE } from "./notify-content.ts";
|
|
116
|
+
|
|
117
|
+
const logger = getLogger("session-manager");
|
|
118
|
+
|
|
119
|
+
/** 本域通知的送达 customType(域内单源 = notify-content.ts 导出的透传)。 */
|
|
120
|
+
const NOTIFY_CUSTOM_TYPE = MANAGED_SESSION_NOTIFY_CUSTOM_TYPE;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 应投递类注销 reason 词表(watch-coordinator 攒批窗入窗词表的同源清单——
|
|
124
|
+
* flushBatch 投递后按原词注销的五类;exception2 早退批与防御批按同词注销)。
|
|
125
|
+
* 在册 = 该键的新闻已被 watch 应答处理(应投递而未送达候选的必要事实)。
|
|
126
|
+
*/
|
|
127
|
+
const DELIVERABLE_UNREGISTER_REASONS: ReadonlySet<string> = new Set([
|
|
128
|
+
"completed",
|
|
129
|
+
"failed",
|
|
130
|
+
"stopped",
|
|
131
|
+
"exited",
|
|
132
|
+
"deleted",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
/** 死亡新闻类 reason(死亡槽去重的范围词表——死亡对排除只作用于这两类)。 */
|
|
136
|
+
const DEATH_NEWS_REASONS: ReadonlySet<string> = new Set(["exited", "deleted"]);
|
|
137
|
+
|
|
138
|
+
/** 静默注销类 reason(例外1 上游分流的注销词——AS20 域内边界)。 */
|
|
139
|
+
const SILENT_UNREGISTER_REASONS: ReadonlySet<string> = new Set(["cancelled", "orphaned"]);
|
|
140
|
+
|
|
141
|
+
// ─── 三重事实判据骨架(收编单源 = @zhushanwen/pi-delivery-port
|
|
142
|
+
// reconcile-judgement.ts,本文件经 barrel import 消费;域侧只留「域事实 → 布尔」
|
|
143
|
+
// 映射,见 reconcileOneManagedCandidate 与预检段)───
|
|
144
|
+
|
|
145
|
+
// ─── 域事实收集器(当前 session entries 的单次遍历事实源)───
|
|
146
|
+
|
|
147
|
+
/** unknown 值上指定键的 string 字段(非对象/缺键/非字符串一律 undefined)。 */
|
|
148
|
+
function stringField(value: unknown, key: string): string | undefined {
|
|
149
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
150
|
+
const field = (value as Record<string, unknown>)[key];
|
|
151
|
+
return typeof field === "string" ? field : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* pending:unregister 条目的原词 reason 视图(id → reason 末次值)。四项规则②
|
|
156
|
+
* 的落位:scanPendingEntries.unregisterSettlements 是解析枚举(stopped/exited/
|
|
157
|
+
* deleted 同折叠 cancelled),四项分流失真——本收集器读 data.reason 原词
|
|
158
|
+
* (落盘契约 { id, reason, status } 的 reason 位,词表单点 = protocol
|
|
159
|
+
* PENDING_UNREGISTER_ENTRY_TYPE)。
|
|
160
|
+
*/
|
|
161
|
+
export function collectUnregisterReasons(entries: readonly unknown[]): Map<string, string> {
|
|
162
|
+
const reasons = new Map<string, string>();
|
|
163
|
+
for (const raw of entries) {
|
|
164
|
+
if (!raw || typeof raw !== "object") continue;
|
|
165
|
+
const entry = raw as Record<string, unknown>;
|
|
166
|
+
if (entry.customType !== PENDING_UNREGISTER_ENTRY_TYPE) continue;
|
|
167
|
+
const data = entry.data;
|
|
168
|
+
if (data === null || typeof data !== "object") continue;
|
|
169
|
+
const id = stringField(data, "id");
|
|
170
|
+
const reason = stringField(data, "reason");
|
|
171
|
+
if (id === undefined || reason === undefined) continue;
|
|
172
|
+
reasons.set(id, reason); // 同 id 多条注销取末次(与 protocol 视图同口径)
|
|
173
|
+
}
|
|
174
|
+
return reasons;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 从会话 entries 提取本域已送达通知的幂等键集合(送达痕迹判据事实源)。
|
|
179
|
+
* 判定通道两条任一命中:details.notifyId(主路径/骨架补投 payload 携带)/
|
|
180
|
+
* details.deliveryId(port 受理时统一写入 details 的送达凭据键)。刻意不收
|
|
181
|
+
* content 文本兜底:content 内的 label 片段会让跨键条目误命中(宁滥勿缺的
|
|
182
|
+
* 反向才是双投——痕迹收集按结构化字段精确收,与 subagent 域同款纪律)。
|
|
183
|
+
*/
|
|
184
|
+
export function collectDeliveredManagedNotifyIds(entries: readonly unknown[]): Set<string> {
|
|
185
|
+
const ids = new Set<string>();
|
|
186
|
+
for (const raw of entries) {
|
|
187
|
+
if (!raw || typeof raw !== "object") continue;
|
|
188
|
+
const entry = raw as Record<string, unknown>;
|
|
189
|
+
if (entry.customType !== NOTIFY_CUSTOM_TYPE) continue;
|
|
190
|
+
const details = entry.details;
|
|
191
|
+
const notifyId = stringField(details, "notifyId");
|
|
192
|
+
if (notifyId !== undefined) ids.add(notifyId);
|
|
193
|
+
const deliveryId = stringField(details, "deliveryId");
|
|
194
|
+
if (deliveryId !== undefined) ids.add(deliveryId);
|
|
195
|
+
}
|
|
196
|
+
return ids;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 从会话 entries 提取已送达死亡新闻的对集合((label, reason)——死亡槽去重的
|
|
201
|
+
* 判据侧磁盘投影,(b) 排除位事实源)。只收死亡类 reason(exited/deleted);
|
|
202
|
+
* label 取 details.label(register name 同源事实)。
|
|
203
|
+
*/
|
|
204
|
+
export function collectDeliveredDeathPairs(entries: readonly unknown[]): Set<string> {
|
|
205
|
+
const pairs = new Set<string>();
|
|
206
|
+
for (const raw of entries) {
|
|
207
|
+
if (!raw || typeof raw !== "object") continue;
|
|
208
|
+
const entry = raw as Record<string, unknown>;
|
|
209
|
+
if (entry.customType !== NOTIFY_CUSTOM_TYPE) continue;
|
|
210
|
+
const details = entry.details;
|
|
211
|
+
const label = stringField(details, "label");
|
|
212
|
+
const reason = stringField(details, "reason");
|
|
213
|
+
if (label === undefined || reason === undefined) continue;
|
|
214
|
+
if (!DEATH_NEWS_REASONS.has(reason)) continue;
|
|
215
|
+
pairs.add(`${label}\u0000${reason}`);
|
|
216
|
+
}
|
|
217
|
+
return pairs;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── 判据事实源读侧(session JSONL 落盘条目)───
|
|
221
|
+
|
|
222
|
+
// readSessionFileEntries 已收编 @zhushanwen/pi-delivery-port 单源
|
|
223
|
+
// (session-journal-read.ts,barrel import)——三域物理副本随判据链收编退役;
|
|
224
|
+
// 容错口径(ENOENT 常态空 / 读错误 warn + [] / 脏行单行跳过)与退化语义登记见
|
|
225
|
+
// 该函数头注与文件头「判据事实源磁盘化」段(PS-97)。
|
|
226
|
+
|
|
227
|
+
// ─── 骨架简报 payload(D8⑤ + R-1:独立构造,不调主路径 buildManagedNotifyContent)───
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 补投 details 形态(骨架简报):ManagedNotifyDetails 的子集——sessionId 缺席
|
|
231
|
+
* (判据源无 managed 子会话 id 事实,父会话 id 取用即谎报),fulfills 恒 0
|
|
232
|
+
* (无债权申报)。core extractNotifyRecords 读侧只消费 notifyId 与 reason,
|
|
233
|
+
* sessionId 缺席不影响消费(notify-content.ts 既有注释登记)。
|
|
234
|
+
*/
|
|
235
|
+
export interface ManagedReconcileNotifyDetails { // oe-exempt:20261101:framework:跨包不可达的同形结构副本(权威源 packages/subagent-core notify-reconcile.ts,载体关系登记文件头),域侧映射随本设计交付
|
|
236
|
+
|
|
237
|
+
notifyId: string;
|
|
238
|
+
reason: string;
|
|
239
|
+
fulfills: number;
|
|
240
|
+
label: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 骨架简报构造(D8⑤ 裁决的独立构造位——R-1:禁止调 buildManagedNotifyContent
|
|
245
|
+
* 主路径构造,其 sessionId 为必填且只能取父会话 id 即谎报)。文案只承载判据源
|
|
246
|
+
* 可构造的两事实:label(register entry 的 name)与 status(unregister reason
|
|
247
|
+
* 原词);诊断字段按主路径缺席省略语义省略(判据源不存在的事实不虚构)。
|
|
248
|
+
*/
|
|
249
|
+
export function buildManagedReconcileBriefing(
|
|
250
|
+
notifyId: string,
|
|
251
|
+
label: string,
|
|
252
|
+
reason: string,
|
|
253
|
+
): { content: string; details: ManagedReconcileNotifyDetails } {
|
|
254
|
+
return {
|
|
255
|
+
content: `Managed session "${label}" finished with status "${reason}".`,
|
|
256
|
+
details: { notifyId, reason, fulfills: 0, label },
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── 对账编排(三入口唯一入口)───
|
|
261
|
+
|
|
262
|
+
/** 单次对账结果(日志 + 测试断言面)。 */
|
|
263
|
+
export interface NotifyReconcileResult { // oe-exempt:20261101:framework:跨包不可达的同形结构副本(权威源 packages/subagent-core notify-reconcile.ts,载体关系登记文件头),域侧映射随本设计交付
|
|
264
|
+
|
|
265
|
+
/** 扫描的 register 条目总数(全量 entries 现算口径)。 */
|
|
266
|
+
scanned: number;
|
|
267
|
+
/** 预检候选数(应投递 ∧ 排除位未命中——含 (a)(b) 承载/死亡对两事实待核)。 */
|
|
268
|
+
candidates: number;
|
|
269
|
+
/** 补投已提交(submitNotify 已调)的 notifyId。 */
|
|
270
|
+
redelivered: string[];
|
|
271
|
+
/** 送达痕迹/死亡对命中而跳过的 notifyId。 */
|
|
272
|
+
skippedDelivered: string[];
|
|
273
|
+
/** 投递日志承载命中而跳过的 notifyId。 */
|
|
274
|
+
skippedJournaled: string[];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 对账编排(session_start / agent_settled / 切回(resume)三入口互调的单一函数;
|
|
279
|
+
* 同步——entries 现算 + deliver 同步受理,毫秒级)。幂等:重复执行时已补投键有
|
|
280
|
+
* 投递日志承载/送达痕迹抵消,判据不再命中。失败吞掉记 warn(对账/补投失败无害:候选判据
|
|
281
|
+
* 仍命中,下一对账边沿幂等重查)——调用方不需自行 try-catch。
|
|
282
|
+
*
|
|
283
|
+
* sessionFile = 当前 session JSONL 路径(pi sessionManager.getSessionFile())——
|
|
284
|
+
* 判据全部事实源读它的落盘条目(磁盘事实,机制见文件头「判据事实源磁盘化」段);
|
|
285
|
+
* undefined(no-persist 形态)退化内存 entries(现状行为)+ warn。
|
|
286
|
+
*/
|
|
287
|
+
// resolveFactEntries 已收编 @zhushanwen/pi-delivery-port 单源(session-journal-read.ts
|
|
288
|
+
// resolveReconcileFactEntries,barrel import)——no-persist 退化内存账 + warn 的域侧
|
|
289
|
+
// 形态两域一致,随判据链收编退役本地副本;domain 标签 = "managed notify reconcile"。
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 预检过滤(复杂度拆出——四项规则①-④):全量落盘条目现算(scanPendingEntries 单点
|
|
293
|
+
* ——registerEntries 含已注销条目,非 active 差集视图)后的四项规则:
|
|
294
|
+
* ① 域过滤:只取 type === "session" 条目(registerTypes 首见视图;跨域条目互斥
|
|
295
|
+
* 排除——防四域混装的跨域三重全命中误报);
|
|
296
|
+
* ② 静默注销类排除(unregister reason 原词分流)+ 应投递类词表过滤(非在册词 =
|
|
297
|
+
* 词表外/防御形态,不虚构);无注销条目 = 债权仍在途(新闻未到达)。
|
|
298
|
+
*/
|
|
299
|
+
function prefilterManagedCandidates(
|
|
300
|
+
scan: ReturnType<typeof scanPendingEntries>,
|
|
301
|
+
unregisterReasons: ReadonlyMap<string, string>,
|
|
302
|
+
): Array<{ notifyId: string; label: string; reason: string }> {
|
|
303
|
+
const prefiltered: Array<{ notifyId: string; label: string; reason: string }> = [];
|
|
304
|
+
for (const { data } of scan.registerEntries) {
|
|
305
|
+
const notifyId = stringField(data, "id");
|
|
306
|
+
if (notifyId === undefined) continue;
|
|
307
|
+
if (scan.registerTypes.get(notifyId) !== "session") continue;
|
|
308
|
+
const reason = unregisterReasons.get(notifyId);
|
|
309
|
+
if (reason === undefined) continue;
|
|
310
|
+
if (SILENT_UNREGISTER_REASONS.has(reason)) continue;
|
|
311
|
+
if (!DELIVERABLE_UNREGISTER_REASONS.has(reason)) continue;
|
|
312
|
+
const name = stringField(data, "name");
|
|
313
|
+
prefiltered.push({ notifyId, label: name ?? notifyId, reason });
|
|
314
|
+
}
|
|
315
|
+
return prefiltered;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 单候选补投终判 + 补投出口(复杂度拆出——终判循环体):facts 三事实(isTerminal /
|
|
320
|
+
* excludedByOrigin 预检已保证)+ delivered 双源((a) 通知幂等键已送达 ∧ (b) 死亡对
|
|
321
|
+
* 排除:死亡类 reason 且同 (label, reason) 新闻已送达)+ journaled(载荷键已落盘)。
|
|
322
|
+
* submitNotify false(port 缺席/重提链失败)不留痕跳过:判据仍命中,下一对账边沿
|
|
323
|
+
* 幂等重查;true(accepted/swallowed)→ 已投递。
|
|
324
|
+
*/
|
|
325
|
+
function reconcileOneManagedCandidate(deps: {
|
|
326
|
+
reason: string;
|
|
327
|
+
submitNotify: (notifyId: string, content: string, details: ManagedReconcileNotifyDetails) => boolean;
|
|
328
|
+
}, candidate: { notifyId: string; label: string; reason: string }, deliveredIds: ReadonlySet<string>, deathPairs: ReadonlySet<string>, journaledIds: ReadonlySet<string>, result: NotifyReconcileResult): void {
|
|
329
|
+
const facts: ReconcileNotifyFacts = {
|
|
330
|
+
isTerminal: true, // 预检已保证:已注销 ∧ reason 应投递类
|
|
331
|
+
excludedByOrigin: false, // 预检已保证:域过滤命中 ∧ 非静默注销类
|
|
332
|
+
delivered:
|
|
333
|
+
deliveredIds.has(candidate.notifyId) ||
|
|
334
|
+
(DEATH_NEWS_REASONS.has(candidate.reason) &&
|
|
335
|
+
deathPairs.has(`${candidate.label}\u0000${candidate.reason}`)),
|
|
336
|
+
journaled: journaledIds.has(candidate.notifyId),
|
|
337
|
+
};
|
|
338
|
+
if (!isReconcileNotifyCandidate(facts)) {
|
|
339
|
+
if (facts.delivered) result.skippedDelivered.push(candidate.notifyId);
|
|
340
|
+
if (facts.journaled) result.skippedJournaled.push(candidate.notifyId);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const briefing = buildManagedReconcileBriefing(
|
|
344
|
+
candidate.notifyId,
|
|
345
|
+
candidate.label,
|
|
346
|
+
candidate.reason,
|
|
347
|
+
);
|
|
348
|
+
const accepted = deps.submitNotify(
|
|
349
|
+
candidate.notifyId,
|
|
350
|
+
briefing.content,
|
|
351
|
+
briefing.details,
|
|
352
|
+
);
|
|
353
|
+
if (accepted) {
|
|
354
|
+
result.redelivered.push(candidate.notifyId);
|
|
355
|
+
logger.debug("managed notify reconciled via delivery port", {
|
|
356
|
+
detail: { reason: deps.reason, notifyId: candidate.notifyId },
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function runNotifyReconcileMaintenance(deps: {
|
|
362
|
+
/** 入口来源(session_start / agent_settled / resume 切回)——入口来源日志可辨。 */
|
|
363
|
+
reason: string;
|
|
364
|
+
/** 当前 session entries(内存账——预检短路源 + no-persist 退化事实源)。 */
|
|
365
|
+
entries: readonly unknown[];
|
|
366
|
+
/**
|
|
367
|
+
* 当前 session JSONL 路径(装配 = ctx.sessionManager.getSessionFile() 直通)——
|
|
368
|
+
* 判据事实源磁盘读侧;undefined = no-persist 形态退化内存账 + warn。
|
|
369
|
+
*/
|
|
370
|
+
sessionFile?: string;
|
|
371
|
+
/** 补投出口(装配注入 = recordManagedNotify 直通——补投与主路径共用投递管道)。 */
|
|
372
|
+
submitNotify: (notifyId: string, content: string, details: ManagedReconcileNotifyDetails) => boolean;
|
|
373
|
+
}): NotifyReconcileResult {
|
|
374
|
+
const result: NotifyReconcileResult = {
|
|
375
|
+
scanned: 0,
|
|
376
|
+
candidates: 0,
|
|
377
|
+
redelivered: [],
|
|
378
|
+
skippedDelivered: [],
|
|
379
|
+
skippedJournaled: [],
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
// 内存预检短路(bash 域「候选在册才读盘」同款成本下界):内存账 ⊇ 盘上条目
|
|
383
|
+
// (幽灵态只会多不会少),无 register 条目早退 = 盘上亦无,零文件读。
|
|
384
|
+
const memScan = scanPendingEntries(deps.entries);
|
|
385
|
+
if (memScan.registerEntries.length === 0) return result;
|
|
386
|
+
|
|
387
|
+
const factEntries = resolveReconcileFactEntries({
|
|
388
|
+
domain: "managed notify reconcile",
|
|
389
|
+
reason: deps.reason,
|
|
390
|
+
entries: deps.entries,
|
|
391
|
+
sessionFile: deps.sessionFile,
|
|
392
|
+
});
|
|
393
|
+
// 四项规则①:全量落盘条目现算(scanPendingEntries 单点——registerEntries
|
|
394
|
+
// 含已注销条目,非 active 差集视图);四源辅助事实同盘单次遍历全产
|
|
395
|
+
const scan = scanPendingEntries(factEntries);
|
|
396
|
+
const unregisterReasons = collectUnregisterReasons(factEntries);
|
|
397
|
+
const deliveredIds = collectDeliveredManagedNotifyIds(factEntries);
|
|
398
|
+
const deathPairs = collectDeliveredDeathPairs(factEntries);
|
|
399
|
+
const journaledIds = collectJournaledDeliveryIds(factEntries);
|
|
400
|
+
|
|
401
|
+
result.scanned = scan.registerEntries.length;
|
|
402
|
+
const prefiltered = prefilterManagedCandidates(scan, unregisterReasons);
|
|
403
|
+
|
|
404
|
+
for (const candidate of prefiltered) {
|
|
405
|
+
result.candidates += 1;
|
|
406
|
+
reconcileOneManagedCandidate(deps, candidate, deliveredIds, deathPairs, journaledIds, result);
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
logger.warn("managed notify reconcile failed; candidates retried at next reconcile edge", {
|
|
410
|
+
detail: { reason: deps.reason, err: err instanceof Error ? err.message : String(err) },
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return result;
|
|
414
|
+
}
|
package/src/watch-coordinator.ts
CHANGED
|
@@ -195,18 +195,10 @@ export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordin
|
|
|
195
195
|
// ② 畸形对象 / 词表外 reason / error envelope {error,...}(runtime params 检查
|
|
196
196
|
// 拒绝态)= 协议漂移信号 → 必须 warn 留痕(同上方 non-json/channel-error
|
|
197
197
|
// 分支形态:notifyId + 漂移形态摘要),再折叠 cancelled 清 stale register。
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// unreachable 防御(callMarkerRpc ok:true 已过 JSON 合法性检测)——真到即漂移
|
|
203
|
-
logger.warn(
|
|
204
|
-
"[session-manager] watch respond unparsable — folding cancelled (protocol drift)",
|
|
205
|
-
{ notifyId, drift: "unparsable-json" },
|
|
206
|
-
);
|
|
207
|
-
unregisterPending(notifyId, "cancelled");
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
198
|
+
// callMarkerRpc 返回 ok:true 前已对同一字符串做过无 reviver 的 JSON.parse
|
|
199
|
+
// 合法性校验(select-rpc.ts),确定性重放恒成功;若真抛错属协议漂移,由外层
|
|
200
|
+
// openWatch 的 error catch 留痕,残留 pending 交下次 session_start 收尾。
|
|
201
|
+
const parsed: unknown = JSON.parse(result.value);
|
|
210
202
|
if (parsed === null) {
|
|
211
203
|
unregisterPending(notifyId, "cancelled");
|
|
212
204
|
return;
|
|
@@ -314,8 +306,9 @@ export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordin
|
|
|
314
306
|
fulfills,
|
|
315
307
|
label: first.label,
|
|
316
308
|
};
|
|
317
|
-
// 默认投递(unified-delivery 波 2:port.deliver);false(port 缺席 / rejected
|
|
318
|
-
//
|
|
309
|
+
// 默认投递(unified-delivery 波 2:port.deliver);false(port 缺席 / rejected
|
|
310
|
+
// 含单次重提链失败——幂等命中 swallowed 按已投递返回 true,不产 false)不改
|
|
311
|
+
// unregister 语义。collectKeys = 批内全部债权键(result-collect
|
|
319
312
|
// 设计 §3.4 managed 行):合批条目载荷幂等键 = 批首债权键,collect 按键集逐键
|
|
320
313
|
// 展开定位——键集申报经 recordManagedNotify 必传参数构造性防漏。三类「注销
|
|
321
314
|
// pending 而无投递日志条目」分支不经此申报点——成员键不入任何条目键集,collect
|