@zhushanwen/pi-session-manager 0.6.4 → 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.
@@ -0,0 +1,502 @@
1
+ // notify-reconcile.test.ts — session-manager 域通知对账步的判据与补投形态
2
+ // (completion-signal-delivery D8:SC10 pending 表派生视图四项读取规则 + 域过滤
3
+ // + 骨架简报补投形态锁)。
4
+ //
5
+ // 全部同步逻辑层(TEST-STRATEGY 三档判据①:entries 注入 + submitNotify spy——
6
+ // 零真盘零真进程零 timer);entries 形态锚定落盘契约(pending:register 三键 +
7
+ // type、pending:unregister { id, reason, status }、managed-session-notify 通知条目
8
+ // details、delivery-port outbox/ack v2 条目 data)。
9
+ //
10
+ // 覆盖面(验收条款 3「域过滤与四项读取规则判据单测」+ 验收条款 4 检查点机械项):
11
+ // 真值表(骨架同构对拍 u1 四态);① 全量 entries 现算(已注销条目仍是判据对象、
12
+ // active 条目非候选);② 静默注销类原词排除(cancelled/orphaned);③(a) 合批非首
13
+ // 成员承载反查(批首 collectKey 键集 ∪ 条目 id);③(b) 死亡新闻对排除(同
14
+ // (label, reason∈{exited,deleted}) 已送达——跨 label 不误伤);④ 域过滤(四域
15
+ // 混装互斥排除);补投形态(骨架简报:label/status 两事实入文、不含 sessionId、
16
+ // fulfills=0——D8⑤/R-1 锁);幂等收敛(补投受理后 journal 承载抵消);死亡槽
17
+ // 内存态缺席不阻断补投(判据不读死亡槽——SC10 登记)。
18
+
19
+ import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ import { describe, expect, it, vi } from "vitest";
24
+
25
+ import { PENDING_REGISTER_ENTRY_TYPE, PENDING_UNREGISTER_ENTRY_TYPE } from "@zhushanwen/extension-protocol";
26
+ import { ACK_CUSTOM_TYPE, OUTBOX_CUSTOM_TYPE } from "@zhushanwen/pi-delivery-port";
27
+
28
+ const { loggerMock } = vi.hoisted(() => ({
29
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
30
+ }));
31
+
32
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
33
+ getLogger: () => loggerMock,
34
+ }));
35
+
36
+ import { MANAGED_SESSION_NOTIFY_CUSTOM_TYPE } from "../notify-content.ts";
37
+ import {
38
+ collectDeliveredDeathPairs,
39
+ collectDeliveredManagedNotifyIds,
40
+ collectUnregisterReasons,
41
+ buildManagedReconcileBriefing,
42
+ runNotifyReconcileMaintenance,
43
+ } from "../notify-reconcile.ts";
44
+
45
+ // ── entries 构造(落盘契约锚定形态)──
46
+
47
+ function registerEntry(id: string, type = "session", name = `label-${id}`): unknown {
48
+ return {
49
+ type: "custom",
50
+ customType: PENDING_REGISTER_ENTRY_TYPE,
51
+ data: { id, type, name, registeredAt: 1, sessionId: "parent-s" },
52
+ };
53
+ }
54
+
55
+ function unregisterEntry(id: string, reason: string): unknown {
56
+ return {
57
+ type: "custom",
58
+ customType: PENDING_UNREGISTER_ENTRY_TYPE,
59
+ data: { id, reason, status: "completed" },
60
+ };
61
+ }
62
+
63
+ /** 本域已送达通知条目(主路径/补投 payload 落盘形态:details 携 notifyId)。 */
64
+ function notifyEntry(notifyId: string, label: string, reason: string): unknown {
65
+ return {
66
+ type: "custom",
67
+ customType: MANAGED_SESSION_NOTIFY_CUSTOM_TYPE,
68
+ content: `Managed session "${label}" (child-s) finished with status "${reason}".`,
69
+ display: true,
70
+ details: { notifyId, sessionId: "child-s", reason, fulfills: 2, label },
71
+ };
72
+ }
73
+
74
+ /** 投递日志 outbox v2 条目(受理落盘;collectKey = 债权键集申报)。 */
75
+ function outboxEntry(id: string, collectKey?: string[]): unknown {
76
+ return {
77
+ type: "custom",
78
+ customType: OUTBOX_CUSTOM_TYPE,
79
+ data: {
80
+ v: 2,
81
+ id,
82
+ timing: "steer",
83
+ payload: { kind: "custom", customType: MANAGED_SESSION_NOTIFY_CUSTOM_TYPE, content: "x", display: true },
84
+ recordedAt: 1,
85
+ ...(collectKey !== undefined ? { collectKey } : {}),
86
+ },
87
+ };
88
+ }
89
+
90
+ function ackEntry(id: string): unknown {
91
+ return { type: "custom", customType: ACK_CUSTOM_TYPE, data: { v: 2, id, outcome: "delivered" } };
92
+ }
93
+
94
+ /** 对账编排输入的最小形态(submitNotify 默认受理成功)。 */
95
+ function makeDeps(entries: unknown[], submitNotify = vi.fn(() => true)) {
96
+ return { reason: "test", entries, submitNotify };
97
+ }
98
+
99
+ // ── 骨架真值表已随判据链收编迁 @zhushanwen/pi-delivery-port
100
+ // reconcile-judgement.test.ts 单点锁定(三域物理对拍副本退役);本文件只锁
101
+ // 域侧「域事实 → 布尔」映射与编排行为 ──
102
+
103
+ // ── 四项读取规则①:全量 entries 现算(注销条目是判据对象)──
104
+
105
+ describe("四项读取规则① 取数形态(pending 表派生视图)", () => {
106
+ it("正类:应投递而 rejected(register+unregister 全量对 + 无痕迹无承载)→ 恰 1 补投", () => {
107
+ const submit = vi.fn(() => true);
108
+ const result = runNotifyReconcileMaintenance(
109
+ makeDeps([registerEntry("sm-1"), unregisterEntry("sm-1", "completed")], submit),
110
+ );
111
+ expect(result.candidates).toBe(1);
112
+ expect(result.redelivered).toEqual(["sm-1"]);
113
+ expect(submit).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it("注销条目仍是判据对象(非 active 视图):已注销键出候选——与 active 差集读法结构性区分", () => {
117
+ // 同一 entries 若按 active 差集读(register−unregister),sm-1 消失、
118
+ // 候选不可达;全量现算读法下 sm-1 是候选——本用例锁「候选非空」即锁读法。
119
+ const submit = vi.fn(() => true);
120
+ const result = runNotifyReconcileMaintenance(
121
+ makeDeps([registerEntry("sm-1"), unregisterEntry("sm-1", "exited")], submit),
122
+ );
123
+ expect(result.candidates).toBe(1);
124
+ });
125
+
126
+ it("active 条目(无 unregister)非候选:新闻未到达,不虚构补投", () => {
127
+ const submit = vi.fn(() => true);
128
+ const result = runNotifyReconcileMaintenance(makeDeps([registerEntry("sm-live")], submit));
129
+ expect(result.candidates).toBe(0);
130
+ expect(submit).not.toHaveBeenCalled();
131
+ });
132
+ });
133
+
134
+ // ── 四项读取规则②:静默注销类原词排除 ──
135
+
136
+ describe("四项读取规则② 静默注销类排除(unregister reason 原词分流)", () => {
137
+ it.each(["cancelled", "orphaned"] as const)(
138
+ "reason=%s(例外1 静默注销族)→ 零补投",
139
+ (reason) => {
140
+ const submit = vi.fn(() => true);
141
+ const result = runNotifyReconcileMaintenance(
142
+ makeDeps([registerEntry("sm-q"), unregisterEntry("sm-q", reason)], submit),
143
+ );
144
+ expect(result.candidates).toBe(0);
145
+ expect(submit).not.toHaveBeenCalled();
146
+ },
147
+ );
148
+
149
+ it("collectUnregisterReasons 读 reason 原词(非解析枚举——stopped/exited/deleted 不被折叠)", () => {
150
+ const reasons = collectUnregisterReasons([
151
+ unregisterEntry("sm-a", "stopped"),
152
+ unregisterEntry("sm-b", "exited"),
153
+ unregisterEntry("sm-c", "cancelled"),
154
+ { type: "custom", customType: PENDING_UNREGISTER_ENTRY_TYPE, data: { id: "sm-bad" } }, // 缺 reason
155
+ { type: "custom", customType: "other", data: { id: "sm-x", reason: "completed" } }, // 词表外条目
156
+ ]);
157
+ expect(reasons.get("sm-a")).toBe("stopped");
158
+ expect(reasons.get("sm-b")).toBe("exited");
159
+ expect(reasons.get("sm-c")).toBe("cancelled");
160
+ expect(reasons.has("sm-bad")).toBe(false);
161
+ expect(reasons.has("sm-x")).toBe(false);
162
+ });
163
+ });
164
+
165
+ // ── 四项读取规则③:三类「无独立主路径投递」形态排除 ──
166
+
167
+ describe("四项读取规则③(a) 合批非首成员排除(承载反查:journal id ∪ collectKey 键集)", () => {
168
+ it("批首成功受理形态:成员键被批首条目 collectKey 承载 → 成员零补投;批首自身有条目 id → 整批零补投", () => {
169
+ const submit = vi.fn(() => true);
170
+ const result = runNotifyReconcileMaintenance(
171
+ makeDeps(
172
+ [
173
+ registerEntry("sm-head"),
174
+ registerEntry("sm-member"),
175
+ outboxEntry("sm-head", ["sm-head", "sm-member"]), // 批首条目承载两键
176
+ unregisterEntry("sm-head", "completed"),
177
+ unregisterEntry("sm-member", "completed"),
178
+ ],
179
+ submit,
180
+ ),
181
+ );
182
+ expect(result.candidates).toBe(2);
183
+ expect(result.redelivered).toEqual([]); // 双双被承载反查排除
184
+ expect(submit).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it("批首 rejected 形态(无条目无承载):成员键反查不命中也不虚构——成员与批首磁盘同形态时保持判据单义(残余边界登记见实现注释)", () => {
188
+ // 批首 rejected 是正类(补投对象);成员键在同批 rejected 形态下无任何承载
189
+ // 事实可辨——本用例锁定该形态下判据的行为面(成员与批首同样出候选),
190
+ // 残余边界(重复刺激级)已在实现注释呈报。
191
+ const submit = vi.fn(() => true);
192
+ const result = runNotifyReconcileMaintenance(
193
+ makeDeps(
194
+ [registerEntry("sm-head"), registerEntry("sm-member"), unregisterEntry("sm-head", "completed"), unregisterEntry("sm-member", "completed")],
195
+ submit,
196
+ ),
197
+ );
198
+ expect(result.candidates).toBe(2);
199
+ expect(result.redelivered).toEqual(["sm-head", "sm-member"]);
200
+ });
201
+
202
+ // collectJournaledDeliveryIds 行为锁(collectKey 展开 / ack 并入 / 畸形跳过)
203
+ // 已随判据链收编迁 @zhushanwen/pi-delivery-port reconcile-judgement.test.ts
204
+ // 单点锁定;域侧经编排用例(批首承载反查)覆盖组合行为。
205
+ });
206
+
207
+ describe("四项读取规则③(b) 死亡新闻对排除(死亡槽去重的判据侧磁盘投影)", () => {
208
+ it("同 (label, exited) 新闻已送达 → 后续批键(同 label 同 reason)零补投", () => {
209
+ const submit = vi.fn(() => true);
210
+ const result = runNotifyReconcileMaintenance(
211
+ makeDeps(
212
+ [
213
+ registerEntry("sm-later", "session", "research"), // 后续批键,label 同前批
214
+ notifyEntry("sm-earlier", "research", "exited"), // 前批死亡新闻已送达
215
+ unregisterEntry("sm-later", "exited"),
216
+ ],
217
+ submit,
218
+ ),
219
+ );
220
+ expect(result.candidates).toBe(1);
221
+ expect(result.redelivered).toEqual([]); // 死亡对命中排除
222
+ expect(result.skippedDelivered).toEqual(["sm-later"]);
223
+ });
224
+
225
+ it("同 (label, deleted) 同款排除;死亡对只作用于死亡类 reason——completed 后续批不误吞", () => {
226
+ const submit = vi.fn(() => true);
227
+ const deleted = runNotifyReconcileMaintenance(
228
+ makeDeps(
229
+ [registerEntry("sm-d", "session", "research"), notifyEntry("sm-d0", "research", "deleted"), unregisterEntry("sm-d", "deleted")],
230
+ submit,
231
+ ),
232
+ );
233
+ expect(deleted.redelivered).toEqual([]);
234
+
235
+ const completed = runNotifyReconcileMaintenance(
236
+ makeDeps(
237
+ [registerEntry("sm-c", "session", "research"), notifyEntry("sm-c0", "research", "completed"), unregisterEntry("sm-c", "completed")],
238
+ submit,
239
+ ),
240
+ );
241
+ // 结果新闻无槽去重语义:同 label 的 completed 已送达不构成对——rejected 待补投候选保留
242
+ expect(completed.redelivered).toEqual(["sm-c"]);
243
+ });
244
+
245
+ it("不同 label 不误伤:label B 的死亡新闻已送达不阻断 label A 的待补投死亡新闻", () => {
246
+ const submit = vi.fn(() => true);
247
+ const result = runNotifyReconcileMaintenance(
248
+ makeDeps(
249
+ [registerEntry("sm-a", "session", "alpha"), notifyEntry("sm-b0", "beta", "exited"), unregisterEntry("sm-a", "exited")],
250
+ submit,
251
+ ),
252
+ );
253
+ expect(result.redelivered).toEqual(["sm-a"]);
254
+ });
255
+
256
+ it("collectDeliveredDeathPairs 只收死亡类 reason(exited/deleted)", () => {
257
+ const pairs = collectDeliveredDeathPairs([
258
+ notifyEntry("sm-1", "alpha", "exited"),
259
+ notifyEntry("sm-2", "beta", "deleted"),
260
+ notifyEntry("sm-3", "gamma", "completed"),
261
+ ]);
262
+ expect([...pairs]).toEqual(["alpha\u0000exited", "beta\u0000deleted"]); // 键分隔符 NUL(转义形态断言)
263
+ expect(pairs.has("gamma\u0000completed")).toBe(false); // 结果新闻不进死亡对
264
+ });
265
+ });
266
+
267
+ // ── 四项读取规则④:域过滤(pending 表四域混装互斥排除)──
268
+
269
+ describe("四项读取规则④ 域过滤(只取 type==='session')", () => {
270
+ it.each([
271
+ ["bash", "bt-1"],
272
+ ["subagent", "rec-1"],
273
+ ["workflow", "wf-1"],
274
+ ] as const)("跨域条目 type=%s → 零补投(跨域三重全命中误报的互斥排除)", (type, id) => {
275
+ const submit = vi.fn(() => true);
276
+ const result = runNotifyReconcileMaintenance(
277
+ makeDeps([registerEntry(id, type, `name-${id}`), unregisterEntry(id, "completed")], submit),
278
+ );
279
+ expect(result.candidates).toBe(0);
280
+ expect(submit).not.toHaveBeenCalled();
281
+ });
282
+ });
283
+
284
+ // ── 幂等收敛与补投出口语义 ──
285
+
286
+ describe("对账幂等与补投出口", () => {
287
+ it("补投受理后的下一对账边沿零补投:outbox 条目承载抵消判据(跨边沿幂等)", () => {
288
+ const entries = [registerEntry("sm-1"), unregisterEntry("sm-1", "completed")];
289
+ const first = runNotifyReconcileMaintenance(makeDeps(entries, vi.fn(() => true)));
290
+ expect(first.redelivered).toEqual(["sm-1"]);
291
+ // 补投受理 → outbox 条目落盘(id = notifyId)——下一边沿的 entries 已含承载事实
292
+ const entriesAfter = [...entries, outboxEntry("sm-1")];
293
+ const second = runNotifyReconcileMaintenance(makeDeps(entriesAfter, vi.fn(() => true)));
294
+ expect(second.redelivered).toEqual([]);
295
+ expect(second.skippedJournaled).toEqual(["sm-1"]);
296
+ });
297
+
298
+ it("送达痕迹抵消(details.notifyId / details.deliveryId 双通道)", () => {
299
+ const ids = collectDeliveredManagedNotifyIds([
300
+ notifyEntry("sm-1", "alpha", "completed"), // details.notifyId
301
+ {
302
+ type: "custom",
303
+ customType: MANAGED_SESSION_NOTIFY_CUSTOM_TYPE,
304
+ details: { deliveryId: "sm-2" }, // port 受理凭据键
305
+ },
306
+ ]);
307
+ expect(ids.has("sm-1")).toBe(true);
308
+ expect(ids.has("sm-2")).toBe(true);
309
+ });
310
+
311
+ it("submitNotify false(port 缺席/重提链失败)→ 不计 redelivered,候选判据保留(下一边沿幂等重查)", () => {
312
+ const submit = vi.fn(() => false);
313
+ const result = runNotifyReconcileMaintenance(
314
+ makeDeps([registerEntry("sm-1"), unregisterEntry("sm-1", "completed")], submit),
315
+ );
316
+ expect(result.candidates).toBe(1);
317
+ expect(result.redelivered).toEqual([]);
318
+ });
319
+
320
+ it("畸形 entries 元素容错(null/词表外条目跳过——容错形态对齐 protocol 扫描纪律)", () => {
321
+ const submit = vi.fn(() => true);
322
+ const result = runNotifyReconcileMaintenance(
323
+ makeDeps([null, 42, registerEntry("sm-1"), unregisterEntry("sm-1", "failed")], submit),
324
+ );
325
+ expect(result.redelivered).toEqual(["sm-1"]);
326
+ });
327
+ });
328
+
329
+ // ── 补投形态:骨架简报(D8⑤ + R-1)──
330
+
331
+ describe("补投形态(D8⑤ 骨架简报 + R-1 不调主路径构造)", () => {
332
+ it("骨架简报:label/status 两事实入文、不含 sessionId 段、details.fulfills 取 0、notifyId = 候选键", () => {
333
+ const submit = vi.fn(() => true);
334
+ const result = runNotifyReconcileMaintenance(
335
+ makeDeps([registerEntry("sm-9", "session", "research"), unregisterEntry("sm-9", "exited")], submit),
336
+ );
337
+ expect(result.redelivered).toEqual(["sm-9"]);
338
+ expect(submit).toHaveBeenCalledWith(
339
+ "sm-9",
340
+ 'Managed session "research" finished with status "exited".',
341
+ { notifyId: "sm-9", reason: "exited", fulfills: 0, label: "research" },
342
+ );
343
+ });
344
+
345
+ it("buildManagedReconcileBriefing 独立构造(R-1):不产 sessionId 字段、fulfills 恒 0(无债权申报不虚构笔数)", () => {
346
+ const briefing = buildManagedReconcileBriefing("sm-9", "research", "failed");
347
+ expect(Object.keys(briefing.details).sort()).toEqual(["fulfills", "label", "notifyId", "reason"]);
348
+ expect(briefing.details.fulfills).toBe(0);
349
+ expect(briefing.content).toContain('"research"');
350
+ expect(briefing.content).toContain('"failed"');
351
+ expect(briefing.content).not.toContain("("); // 无 (sessionId) 段——主路径形态的缺席省略
352
+ });
353
+ });
354
+
355
+ // ── 死亡槽内存态不参与判据(SC10 登记)──
356
+
357
+ describe("死亡槽 (sessionId, deathSeq) 内存态缺席不阻断补投", () => {
358
+ it("判据不读死亡槽:重启后死亡槽清空的形态下,rejected 死亡新闻仍出候选并补投", () => {
359
+ // entries 不含任何死亡槽信息(运行期内存态)——判据照常出候选
360
+ const submit = vi.fn(() => true);
361
+ const result = runNotifyReconcileMaintenance(
362
+ makeDeps([registerEntry("sm-d", "session", "research"), unregisterEntry("sm-d", "exited")], submit),
363
+ );
364
+ expect(result.redelivered).toEqual(["sm-d"]);
365
+ });
366
+ });
367
+
368
+ // ── A1 产品缺陷修复回归(域侧同构):判据事实源磁盘化 ──
369
+ // 缺陷链(与 bash 域范本同构,范本 = base-tool-enhance pending-reconcile.test.ts
370
+ // 「disk-fact journal source」describe):pi SessionManager._appendEntry 先
371
+ // fileEntries.push 后 _persist、persist 失败(真机注入形态 = session JSONL
372
+ // chmod 0444,EACCES 挡写不挡读)无回滚 → 幽灵内存 entry(内存账在册、JSONL 无
373
+ // 行);判据曾消费 getEntries() 内存账 → delivered/journaled 误真 → 补投静默失效。
374
+ // 修复后事实源 = session JSONL 落盘条目(deps.sessionFile,index.ts 经
375
+ // getSessionFile() 传入)。单测模拟形态 = 内存 entries 与磁盘文件不一致(幽灵态
376
+ // 的定义特征),与真机 chmod 注入同族(同是「内存有、盘上无」的产物)。
377
+ describe("判据事实源磁盘化(幽灵内存 entry 回归,A1 域侧同构修复)", () => {
378
+ const DATA_DIR = mkdtempSync(join(tmpdir(), "sm-reconcile-"));
379
+ const SESSION_FILE = join(DATA_DIR, "session.jsonl");
380
+
381
+ /** 写 session JSONL(pi _persist 落盘形态:每条 entry 一行 JSON.stringify)。 */
382
+ function writeSessionFile(lines: unknown[]): void {
383
+ mkdirSync(DATA_DIR, { recursive: true });
384
+ writeFileSync(SESSION_FILE, lines.map((l) => JSON.stringify(l)).join("\n") + (lines.length > 0 ? "\n" : ""));
385
+ }
386
+
387
+ it("幽灵 in-memory outbox entry(内存有、盘上无)→ 补投发生(A1 回归本体)", () => {
388
+ // 磁盘事实:JSONL 只含 register + unregister(EACCES 实况——outbox persist
389
+ // 被挡,盘上无该键任何受理行);内存账多一条幽灵 outbox entry
390
+ writeSessionFile([registerEntry("sm-1"), unregisterEntry("sm-1", "completed")]);
391
+ const ghostEntries = [
392
+ registerEntry("sm-1"),
393
+ unregisterEntry("sm-1", "completed"),
394
+ outboxEntry("sm-1"), // 幽灵:rejected 首败受理即留一条,内存账在册、盘上无
395
+ ];
396
+ const submit = vi.fn(() => true);
397
+ const result = runNotifyReconcileMaintenance({
398
+ reason: "test",
399
+ entries: ghostEntries,
400
+ sessionFile: SESSION_FILE,
401
+ submitNotify: submit,
402
+ });
403
+ // 幽灵 journaled 不再误真:补投放行(内存账口径曾沿幽灵态静默吞补投)
404
+ expect(result.redelivered).toEqual(["sm-1"]);
405
+ expect(submit).toHaveBeenCalledTimes(1);
406
+ });
407
+
408
+ it("幽灵 delivered trace(内存通知条目、盘上无)→ 补投发生(delivered 事实读盘)", () => {
409
+ writeSessionFile([registerEntry("sm-1"), unregisterEntry("sm-1", "completed")]);
410
+ const ghostEntries = [
411
+ registerEntry("sm-1"),
412
+ unregisterEntry("sm-1", "completed"),
413
+ notifyEntry("sm-1", "research", "completed"), // 幽灵送达痕迹
414
+ ];
415
+ const submit = vi.fn(() => true);
416
+ const result = runNotifyReconcileMaintenance({
417
+ reason: "test",
418
+ entries: ghostEntries,
419
+ sessionFile: SESSION_FILE,
420
+ submitNotify: submit,
421
+ });
422
+ expect(result.redelivered).toEqual(["sm-1"]);
423
+ });
424
+
425
+ it("outbox entry 在盘上 → 零补投(journaled 读盘正命中,磁盘事实正向抵消)", () => {
426
+ // entries 形态 = 重启后内存账从盘重建的一致形态(内存账 ⊇ 盘上条目是运行时
427
+ // 构造性前提——人工构造「内存空、盘上有」会撞内存预检短路早退,非真实形态)
428
+ const diskAndMemory = [registerEntry("sm-1"), unregisterEntry("sm-1", "completed"), outboxEntry("sm-1")];
429
+ writeSessionFile(diskAndMemory);
430
+ const submit = vi.fn(() => true);
431
+ const result = runNotifyReconcileMaintenance({
432
+ reason: "test",
433
+ entries: diskAndMemory,
434
+ sessionFile: SESSION_FILE,
435
+ submitNotify: submit,
436
+ });
437
+ expect(result.redelivered).toEqual([]);
438
+ expect(result.skippedJournaled).toEqual(["sm-1"]);
439
+ expect(submit).not.toHaveBeenCalled();
440
+ });
441
+
442
+ it("幽灵 unregister reason(register 落盘、注销条目盘上无)→ 零补投(磁盘口径:盘上无注销 = 债权在途,保守 miss)", () => {
443
+ // 口径锁(文件头「判据事实源磁盘化」段残余登记):register 落盘 + unregister
444
+ // 幽灵 → 盘上无注销 = 「新闻未到达」——不虚构补投(漏补投方向,与
445
+ // 「漏补投优于重复投」既有取向一致)
446
+ writeSessionFile([registerEntry("sm-1")]);
447
+ const ghostEntries = [registerEntry("sm-1"), unregisterEntry("sm-1", "completed")];
448
+ const submit = vi.fn(() => true);
449
+ const result = runNotifyReconcileMaintenance({
450
+ reason: "test",
451
+ entries: ghostEntries,
452
+ sessionFile: SESSION_FILE,
453
+ submitNotify: submit,
454
+ });
455
+ expect(result.candidates).toBe(0);
456
+ expect(submit).not.toHaveBeenCalled();
457
+ });
458
+
459
+ it("sessionFile 缺席(no-persist 形态)→ 退化内存账 + warn(现状行为;该形态无幽灵态,内存账即完整事实)", () => {
460
+ const inMemoryEntries = [registerEntry("sm-1"), unregisterEntry("sm-1", "completed"), outboxEntry("sm-1")];
461
+ const submit = vi.fn(() => true);
462
+ const result = runNotifyReconcileMaintenance({
463
+ reason: "test",
464
+ entries: inMemoryEntries, // 不传 sessionFile
465
+ submitNotify: submit,
466
+ });
467
+ // no-persist 形态 _persist no-op 恒成功:内存 outbox = 真受理 → 不补投
468
+ expect(result.redelivered).toEqual([]);
469
+ expect(result.skippedJournaled).toEqual(["sm-1"]);
470
+ expect(loggerMock.warn).toHaveBeenCalledWith(
471
+ expect.stringContaining("fell back to in-memory entries"),
472
+ expect.anything(),
473
+ );
474
+ });
475
+
476
+ it("session file 不可读(chmod 0000,读侧 EACCES)→ 盘事实按空 + warn(保守 miss,候选源与抵消事实同按空)", () => {
477
+ writeSessionFile([registerEntry("sm-1"), unregisterEntry("sm-1", "completed")]);
478
+ chmodSync(SESSION_FILE, 0o000);
479
+ const submit = vi.fn(() => true);
480
+ try {
481
+ const result = runNotifyReconcileMaintenance({
482
+ reason: "test",
483
+ entries: [registerEntry("sm-1"), unregisterEntry("sm-1", "completed")],
484
+ sessionFile: SESSION_FILE,
485
+ submitNotify: submit,
486
+ });
487
+ // 读失败宽容口径(warn 即修复信号):候选源与抵消事实同按空 → 零补投;
488
+ // 下一对账边沿环境修复后幂等重查
489
+ expect(result.redelivered).toEqual([]);
490
+ expect(loggerMock.warn).toHaveBeenCalledWith(
491
+ expect.stringContaining("disk entries treated as empty"),
492
+ expect.anything(),
493
+ );
494
+ } finally {
495
+ chmodSync(SESSION_FILE, 0o644);
496
+ }
497
+ });
498
+ });
499
+
500
+ // ── readSessionFileEntries 容错段已随判据链收编迁 @zhushanwen/pi-delivery-port
501
+ // reconcile-judgement.test.ts 单点锁定(三域物理副本退役);域侧经「session file
502
+ // 不可读 → 盘事实按空 + warn」编排用例覆盖组合行为。
package/src/index.ts CHANGED
@@ -16,10 +16,13 @@ import {
16
16
  type MarkerRpcResult,
17
17
  type SessionManagerAction,
18
18
  } from "@zhushanwen/extension-protocol";
19
+ import { subscribeResumeReconcile } from "@zhushanwen/pi-delivery-port";
19
20
  import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
20
21
  import { Type, type Static, type TObject } from "typebox";
21
22
 
22
23
  import { renderSessionManagerResult } from "./render.ts";
24
+ import { runNotifyReconcileMaintenance } from "./notify-reconcile.ts";
25
+ import { recordManagedNotify } from "./notify-delivery.ts";
23
26
  import { createWatchCoordinator } from "./watch-coordinator.ts";
24
27
 
25
28
  // 模块级 logger(default export 首行 setPiHandle 注入后自动走 appendEntry 持久化,
@@ -182,6 +185,54 @@ async function executeTool(
182
185
 
183
186
  // ── Extension 入口 ──
184
187
 
188
+ /** 对账步 deps 的 ctx 侧最小输入(session_start / agent_settled 两入口的
189
+ * ExtensionContext 与切回槽的 ResumeReconcileContext 结构兼容本子集——对账编排
190
+ * 只读这两个成员,见 runNotifyReconcileSafely 的消费点)。 */
191
+ interface ReconcileSessionContext {
192
+ sessionManager: {
193
+ getEntries(): readonly unknown[];
194
+ getSessionFile(): string | undefined;
195
+ };
196
+ }
197
+
198
+ /**
199
+ * 通知对账步的安全包装(completion-signal-delivery D8② 三入口编排的接线形态):
200
+ * entries 读取异常吞掉记 warn(对账失败无害:候选判据仍在,下一边沿幂等重查),
201
+ * 对账编排本体(runNotifyReconcileMaintenance)内部已有同款纪律——本包装再挡
202
+ * entries 读取(ctx.sessionManager.getEntries)一层。补投出口 = recordManagedNotify
203
+ * 直通(与主路径同一投递管道与重提链——补投 payload 走骨架简报,幂等键与主路径
204
+ * 同构造 notifyId,collectKey 申报 = 自身键)。
205
+ *
206
+ * sessionFile = ctx.sessionManager.getSessionFile()(补投判据事实源磁盘化,A1
207
+ * 缺陷修复的域侧同构落点):判据事实读 session JSONL 落盘条目而非内存账——pi
208
+ * _appendEntry persist 失败无回滚的幽灵内存 entry(pi-semantics PS-97:dist/core/
209
+ * session-manager.js :815-820 _appendEntry / :794-814 _persist)曾使
210
+ * delivered/journaled 误真、补投静默失效(机制见 notify-reconcile.ts 文件头
211
+ * 「判据事实源磁盘化」段);
212
+ * no-persist 形态 getSessionFile() 返回 undefined,对账侧退化内存账(现状行为)。
213
+ */
214
+ function runNotifyReconcileSafely(
215
+ pi: ExtensionAPI,
216
+ ctx: ReconcileSessionContext,
217
+ reason: "session_start" | "agent_settled" | "resume",
218
+ ): void {
219
+ logger.debug("notify reconcile dispatch", { detail: { reason } });
220
+ try {
221
+ const entries = ctx.sessionManager.getEntries();
222
+ runNotifyReconcileMaintenance({
223
+ reason,
224
+ entries,
225
+ sessionFile: ctx.sessionManager.getSessionFile(),
226
+ submitNotify: (notifyId, content, details) =>
227
+ recordManagedNotify(notifyId, content, details, [notifyId]),
228
+ });
229
+ } catch (err) {
230
+ logger.warn("notify reconcile dispatch failed; retried at next reconcile edge", {
231
+ detail: { reason, err: err instanceof Error ? err.message : String(err) },
232
+ });
233
+ }
234
+ }
235
+
185
236
  /** 单个 session 工具的声明式配置(registerSessionTool 的输入)。 */
186
237
  interface SessionToolConfig<S extends TObject> {
187
238
  name: string
@@ -271,6 +322,39 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
271
322
  // 活跃集回归基线;每 entry 独立 handler(禁串行 await)在 coordinator 内保证。
272
323
  pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
273
324
  coordinator.recoverStaleRegisters(ctx);
325
+ // 通知对账步(completion-signal-delivery D8② 入口①):rejected 丢失形态的
326
+ // 事实判据补投(session_start 重绑后的确定性边沿——旧 pi 实例的 rejected
327
+ // 通知在此恢复)。与 agent_settled 入口(下方)互调同一对账函数
328
+ // (runNotifyReconcileMaintenance,RL12 判据单一);幂等由判据的投递日志
329
+ // 承载/送达痕迹两事实抵消保证(补投受理成功即有 outbox 条目落盘)。
330
+ runNotifyReconcileSafely(pi, ctx, "session_start");
331
+ });
332
+
333
+ // 通知对账步入口②(completion-signal-delivery D8②):agent_settled 回合收尾
334
+ // 边沿——进程存活形态(重提失败后不重启不切回)的兜底恢复入口(ADR-0122 保留
335
+ // 白名单同族:事件驱动、判据现算磁盘事实,无定时器)。订阅生命周期(RL13
336
+ // 同款):本注册点 = extension load 装配期,每 pi 实例恰一次;同进程 session
337
+ // 替换会重建 eventBus 并重新 load extension,旧实例连同其订阅一并消亡、新
338
+ // 实例重新注册——无死 handler 累积(构造性成立)。高频边沿的常态零成本由
339
+ // 判据预检短路兜住(无 register 条目不进终判循环)。
340
+ pi.on("agent_settled", (_event: unknown, ctx: ExtensionContext) => {
341
+ runNotifyReconcileSafely(pi, ctx, "agent_settled");
342
+ });
343
+
344
+ // 通知对账步入口③(completion-signal-delivery D8② 三入口同构的切回补强边沿,
345
+ // U2 修复):同进程「切走会话再切回」不重发 session_start(真机实证),本域
346
+ // 入口①② 在该场景不可达——切回边沿唯一触发源是 base-tool-enhance 注册的
347
+ // `__taiji_bg_reconcile__` 内部命令(runtime 在 getCommands 时 fire-and-forget
348
+ // 触发,无节流——原 60s per-session 节流窗已随 ADR-0122 防御机制清查退役,
349
+ // 高频防护由本域对账链预检判据早退承担),本
350
+ // 扩展与 base-tool-enhance 无依赖边(跨扩展依赖通道不存在),经三方共同依赖
351
+ // 包 pi-delivery-port 的进程级注册槽订阅其派发(subagent 域 SubagentService
352
+ // 单例直转的 A5 先例同族接线)。订阅生命周期(RL13 同款论证):订阅点 =
353
+ // extension load 装配期,同进程 session 替换重新 load extension,新闭包经同域
354
+ // key 覆盖旧订阅,无死 handler 累积;域侧围栏 = runNotifyReconcileSafely 内
355
+ // catch,异常原则上不向派发方传播。
356
+ subscribeResumeReconcile("session-manager", (ctx) => {
357
+ runNotifyReconcileSafely(pi, ctx, "resume");
274
358
  });
275
359
 
276
360
  registerSessionTool(pi, {
@@ -69,7 +69,14 @@ export function buildManagedNotifyContent(params: ManagedNotifyContentParams): s
69
69
  export interface ManagedNotifyDetails {
70
70
  /** 本批 record 的幂等键 = 批内首个响应的 notifyId(≤1 record/批由合批边界保证) */
71
71
  notifyId: string;
72
- sessionId: string;
72
+ /**
73
+ * managed 子会话 id(主路径 = watch 应答 payload 回带)。completion-signal-delivery
74
+ * D8⑤:对账补投的骨架简报 details 缺席该字段——判据源(pending 表)落盘的
75
+ * sessionId 是 listener 侧父会话 id,非本字段所指的子会话 id,取用即谎报
76
+ * (R-1 条款);core extractNotifyRecords 读侧只消费 notifyId 与 reason,
77
+ * sessionId 为诊断冗余,缺席不影响消费。
78
+ */
79
+ sessionId?: string;
73
80
  reason: string;
74
81
  /** 批内处理完毕笔数(runtime fulfillsN 直供,缺省回退批长——见 watch-coordinator) */
75
82
  fulfills: number;