@zhushanwen/pi-session-manager 0.4.0 → 0.6.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.
@@ -1,260 +0,0 @@
1
- // notify-ledger-discipline.test.ts — ledger 装配纪律(notify-once D3 ①-⑤ / U4)。
2
- // 包名 import @zhushanwen/subagent-core(D-10 领地补录后的 canonical 形态,相对路径 hack 已删)。
3
- // 覆盖:
4
- // ① ensureLedgerBound 无条件重 bind(唯一装配方形态,与 subagent-workflow
5
- // bindLedgerHostAndRecover 同款——槽上不残留捕获旧 ctx 的 host);
6
- // ② 槽空 → bind + recoverFromSession 成对(recover 吸收 session 文件既有条目——用未销账
7
- // ledger entry 种子断言 pendingCount,证明 recover 真跑过);
8
- // ③ 双 bind 收敛——「先 bind 方在后 bind 之后 record 仍返回 true」(动态查槽恒取当前实例;
9
- // 缓存前实例引用则 record 恒 false 零日志——D3⑤ 事故形态反证);
10
- // ④ compactionCheck 直通当前实例、抛错不外抛(接入点降级,STANDARDS §11.1);
11
- // ⑤ 消费点 record 动态查槽 + deliveryCustomType = managed-session-notify;
12
- // record 先 appendEntry 落盘后内存更新(时序钉:append 快照 = 该次 record 前计数);
13
- // sendDelivery stale ctx 守卫:stale 抛错静默降级 + warn 归因(条目标 sentAt 不挂回),
14
- // 非 stale 错误原样上抛走 settleRejected 留账重试;
15
- // 槽空降级(ensure 未跑/失败时 record → warn + false,用例登记)。
16
-
17
- import { afterEach, describe, expect, it, vi } from "vitest";
18
- // logger mock helper 必须先于任何会加载 pi-extension-logger 的 import
19
- //(subagent-core barrel / ../notify-ledger.ts),见 logger-mock.ts 文件头
20
- import { createLoggerModuleMock, getLoggerMock } from "./helpers/logger-mock.ts";
21
- import {
22
- bindNotifyLedgerHost,
23
- getBoundNotifyLedger,
24
- type NotifyLedgerHost,
25
- } from "@zhushanwen/subagent-core";
26
- import { STALE_CTX_MARKER } from "@zhushanwen/pi-ext-guards";
27
-
28
- vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
29
-
30
- const loggerMock = getLoggerMock();
31
-
32
- import {
33
- ensureLedgerBound,
34
- recordManagedNotify,
35
- runLedgerCompactionCheck,
36
- } from "../notify-ledger.ts";
37
- import type { ManagedNotifyDetails } from "../notify-content.ts";
38
-
39
- /** recordManagedNotify 的最小合法 details(ManagedNotifyDetails 收紧后的测试桩形状) */
40
- function makeDetails(notifyId: string): ManagedNotifyDetails {
41
- return { notifyId, sessionId: "s-test", reason: "completed", fulfills: 1, label: "test" };
42
- }
43
-
44
- interface HostProbe {
45
- host: NotifyLedgerHost;
46
- appendLedgerEntries: Array<{ customType: string; data: Record<string, unknown> }>;
47
- /** 每次 append 发生时账本 pendingCount 快照——时序钉:恒等于该次 record 前的计数 */
48
- pendingCountsAtAppend: number[];
49
- attach(pendingCount: () => number): void;
50
- }
51
-
52
- function makeHost(): HostProbe {
53
- const appendLedgerEntries: HostProbe["appendLedgerEntries"] = [];
54
- const pendingCountsAtAppend: number[] = [];
55
- let pendingCount: (() => number) | undefined;
56
- const host: NotifyLedgerHost = {
57
- appendLedgerEntry(customType, data) {
58
- // 时序钉(D3 ③):record 先 appendEntry 落盘、后内存更新——此刻本条尚未入账
59
- if (pendingCount) pendingCountsAtAppend.push(pendingCount());
60
- appendLedgerEntries.push({ customType, data: data as Record<string, unknown> });
61
- },
62
- readSessionEntries: () => [],
63
- isIdle: () => false,
64
- onAgentSettled: () => {},
65
- sendDelivery: () => {},
66
- };
67
- return {
68
- host,
69
- appendLedgerEntries,
70
- pendingCountsAtAppend,
71
- attach: (fn) => {
72
- pendingCount = fn;
73
- },
74
- };
75
- }
76
-
77
- /** ensureLedgerBound 的最小 pi/ctx 桩(appendEntry/on/sendMessage + getEntries/isIdle) */
78
- function makePiCtx(
79
- entries: unknown[],
80
- opts?: { isIdle?: boolean; sendMessage?: () => void },
81
- ) {
82
- const pi = {
83
- appendEntry: vi.fn((customType: string, data: unknown) => {
84
- entries.push({ type: "custom", customType, data });
85
- }),
86
- on: vi.fn(),
87
- sendMessage: vi.fn(opts?.sendMessage ?? (() => {})),
88
- };
89
- const ctx = {
90
- sessionManager: { getEntries: vi.fn(() => entries) },
91
- isIdle: () => opts?.isIdle ?? false,
92
- };
93
- return { pi, ctx };
94
- }
95
-
96
- afterEach(() => {
97
- // dispose 摘除模块级绑定(bind 路径实例 current===api → setBoundLedger(undefined))
98
- getBoundNotifyLedger()?.dispose();
99
- });
100
-
101
- describe("ledger 装配纪律(D3 ①-⑤,canonical import)", () => {
102
- it("槽空 → recordManagedNotify 降级 false(ensure 未跑/失败时的登记降级,warn 留痕)", () => {
103
- expect(getBoundNotifyLedger()).toBeUndefined();
104
- expect(recordManagedNotify("sm-x", "content", makeDetails("sm-x"))).toBe(false);
105
- });
106
-
107
- it("⑤ bind 后 record 经动态查槽入账、deliveryCustomType = managed-session-notify;时序钉:append 先于内存更新", () => {
108
- const probe = makeHost();
109
- const ledger = bindNotifyLedgerHost(probe.host);
110
- probe.attach(() => ledger.pendingCount());
111
- expect(getBoundNotifyLedger()).toBe(ledger);
112
- expect(recordManagedNotify("sm-a", "hello", makeDetails("sm-a"))).toBe(true);
113
- expect(probe.appendLedgerEntries).toHaveLength(1);
114
- expect(probe.appendLedgerEntries[0].customType).toBe("subagent-bg-notify-ledger");
115
- expect(probe.appendLedgerEntries[0].data.deliveryCustomType).toBe("managed-session-notify");
116
- expect(probe.appendLedgerEntries[0].data.content).toBe("hello");
117
- expect(probe.pendingCountsAtAppend).toEqual([0]);
118
- expect(ledger.pendingCount()).toBe(1);
119
- });
120
-
121
- it("③ 先 bind 方在后 bind 之后 record 仍返回 true(后 bind 者收敛单实例;缓存前实例引用则恒 false——D3⑤ 反证)", () => {
122
- const probeA = makeHost();
123
- const ledgerA = bindNotifyLedgerHost(probeA.host);
124
- probeA.attach(() => ledgerA.pendingCount());
125
- expect(recordManagedNotify("sm-1", "n1", makeDetails("sm-1"))).toBe(true);
126
- expect(probeA.appendLedgerEntries).toHaveLength(1);
127
-
128
- // 后 bind 方(模拟 subagent-workflow session_start 的 re-bind + recover 成对——
129
- // bindNotifyLedgerHost 内部先 dispose 旧实例再 set,收敛为单实例全量态)
130
- const probeB = makeHost();
131
- const ledgerB = bindNotifyLedgerHost(probeB.host);
132
- probeB.attach(() => ledgerB.pendingCount());
133
-
134
- // 消费点动态查槽恒取当前实例:先 bind 方的后续 record 落后 bind 实例且返回 true
135
- expect(getBoundNotifyLedger()).toBe(ledgerB);
136
- expect(recordManagedNotify("sm-2", "n2", makeDetails("sm-2"))).toBe(true);
137
- expect(probeB.appendLedgerEntries.map((e) => e.data.notifyId)).toEqual(["sm-2"]);
138
- expect(probeA.appendLedgerEntries).toHaveLength(1); // 前实例不再收账
139
- // 时序钉跨 bind 仍成立:sm-2 时 B 账面 0(append 快照 = 该次 record 前计数)
140
- expect(probeB.pendingCountsAtAppend).toEqual([0]);
141
-
142
- // 反证:若消费点缓存了前实例引用,record 恒 false 且零日志(对半概率丢全部通知的事故形态)
143
- expect(ledgerA.record("sm-3", "n3", {})).toBe(false);
144
- // 而动态查槽(本包消费面)依旧 true
145
- expect(recordManagedNotify("sm-4", "n4", makeDetails("sm-4"))).toBe(true);
146
- expect(probeB.appendLedgerEntries).toHaveLength(2);
147
- expect(probeB.pendingCountsAtAppend).toEqual([0, 1]);
148
- });
149
-
150
- it("② 槽空 + ensureLedgerBound → bind + recoverFromSession 成对(既有未销账 ledger entry 被 recover 吸收)", () => {
151
- const entries: unknown[] = [
152
- {
153
- type: "custom",
154
- customType: "subagent-bg-notify-ledger",
155
- data: {
156
- v: 1,
157
- notifyId: "sm-recovered",
158
- content: "prior notification",
159
- record: { notifyId: "sm-recovered" },
160
- },
161
- },
162
- ];
163
- const { pi, ctx } = makePiCtx(entries);
164
- expect(getBoundNotifyLedger()).toBeUndefined();
165
-
166
- ensureLedgerBound(pi as never, ctx as never);
167
- const ledger = getBoundNotifyLedger();
168
- expect(ledger).toBeDefined();
169
- // recover 真跑过:session 文件的未销账条目被吸收进内存态
170
- expect(ctx.sessionManager.getEntries).toHaveBeenCalled();
171
- expect(ledger!.pendingCount()).toBe(1);
172
- // 装配后消费面就绪
173
- expect(recordManagedNotify("sm-new", "n", makeDetails("sm-new"))).toBe(true);
174
- expect(pi.appendEntry).toHaveBeenCalledWith(
175
- "subagent-bg-notify-ledger",
176
- expect.objectContaining({ notifyId: "sm-new", deliveryCustomType: "managed-session-notify" }),
177
- );
178
- });
179
-
180
- it("① 槽已有实例 + ensureLedgerBound → 无条件重 bind(后 bind 收敛接管 + recover 重跑)", () => {
181
- const probe = makeHost();
182
- const ledger = bindNotifyLedgerHost(probe.host);
183
- probe.attach(() => ledger.pendingCount());
184
-
185
- const { pi, ctx } = makePiCtx([]);
186
- ensureLedgerBound(pi as never, ctx as never);
187
- // 后 bind 收敛:旧实例被 dispose,槽切到新实例(槽上不残留捕获旧 ctx 的 host)
188
- const rebound = getBoundNotifyLedger();
189
- expect(rebound).toBeDefined();
190
- expect(rebound).not.toBe(ledger);
191
- // recover 重跑:新 host 的 readSessionEntries 被消费
192
- expect(ctx.sessionManager.getEntries).toHaveBeenCalled();
193
- // 消费面动态查槽:record 落新实例(经新 host 的 appendLedgerEntry = pi.appendEntry)
194
- expect(recordManagedNotify("sm-1", "n", makeDetails("sm-1"))).toBe(true);
195
- expect(pi.appendEntry).toHaveBeenCalled();
196
-
197
- // 重复调用同款:每次 session_start 都重装配(不查槽短路)
198
- ensureLedgerBound(pi as never, ctx as never);
199
- expect(getBoundNotifyLedger()).not.toBe(rebound);
200
- });
201
-
202
- it("sendDelivery stale ctx 守卫:stale 抛错静默降级 + warn 归因(条目标 sentAt,不外抛不挂回)", () => {
203
- const { pi, ctx } = makePiCtx([], {
204
- isIdle: true,
205
- sendMessage: () => {
206
- throw new Error(`ctx ${STALE_CTX_MARKER}`);
207
- },
208
- });
209
- ensureLedgerBound(pi as never, ctx as never);
210
- const ledger = getBoundNotifyLedger();
211
- expect(ledger).toBeDefined();
212
- expect(recordManagedNotify("sm-stale", "n", makeDetails("sm-stale"))).toBe(true);
213
- expect(() => ledger!.attemptDeliver()).not.toThrow();
214
- // stale 降级语义:本条不投递且按已受理标 sentAt(attemptDeliver 摘除,不挂回反复撞)
215
- expect(ledger!.pendingCount()).toBe(0);
216
- expect(loggerMock.warn).toHaveBeenCalledWith(
217
- "[session-manager] notify delivery skipped (stale ctx)",
218
- expect.objectContaining({ error: expect.stringContaining("stale") }),
219
- );
220
- });
221
-
222
- it("sendDelivery 非 stale 错误原样上抛 → settleRejected 留账重试(pending 不摘、无 stale 归因)", () => {
223
- // 清上一用例的 warn 记录——断言只认本用例内的调用
224
- loggerMock.warn.mockClear();
225
- const { pi, ctx } = makePiCtx([], {
226
- isIdle: true,
227
- sendMessage: () => {
228
- throw new Error("boom");
229
- },
230
- });
231
- ensureLedgerBound(pi as never, ctx as never);
232
- const ledger = getBoundNotifyLedger();
233
- expect(ledger).toBeDefined();
234
- expect(recordManagedNotify("sm-err", "n", makeDetails("sm-err"))).toBe(true);
235
- expect(() => ledger!.attemptDeliver()).not.toThrow();
236
- // 非 stale 走 attemptDeliver 既有 catch:settleRejected 留账等下一边沿重试
237
- expect(ledger!.pendingCount()).toBe(1);
238
- expect(loggerMock.warn).not.toHaveBeenCalledWith(
239
- "[session-manager] notify delivery skipped (stale ctx)",
240
- expect.anything(),
241
- );
242
- });
243
-
244
- it("④ compactionCheck 经动态查槽直通当前实例", () => {
245
- const probe = makeHost();
246
- const ledger = bindNotifyLedgerHost(probe.host);
247
- probe.attach(() => ledger.pendingCount());
248
- const spy = vi.spyOn(getBoundNotifyLedger()!, "compactionCheck").mockReturnValue(0);
249
- runLedgerCompactionCheck();
250
- expect(spy).toHaveBeenCalledTimes(1);
251
- });
252
-
253
- it("④ compactionCheck 抛错 → warn 吞掉不外抛(辅助面接入点降级,STANDARDS §11.1)", () => {
254
- bindNotifyLedgerHost(makeHost().host);
255
- vi.spyOn(getBoundNotifyLedger()!, "compactionCheck").mockImplementation(() => {
256
- throw new Error("boom");
257
- });
258
- expect(() => runLedgerCompactionCheck()).not.toThrow();
259
- });
260
- });
@@ -1,121 +0,0 @@
1
- // src/notify-ledger.ts — notify-ledger(B-ledger)装配与 record 消费(notify-once D3 装配纪律)。
2
- //
3
- // canonical 消费面(D-10 领地补录后落地):@zhushanwen/subagent-core 的
4
- // getBoundNotifyLedger / bindNotifyLedgerHost——Symbol.for 进程级单例槽跨扩展 bundle
5
- // 共享同一 ledger 实体(C-ext-06),本包不再持有任何槽镜像。
6
- //
7
- // 装配纪律(D3)对照(session_start 无条件重装配方,与 subagent-workflow
8
- // bindLedgerHostAndRecover 同款;旧「查槽短路」形态已废):
9
- // ① session_start 无条件 bindNotifyLedgerHost(host).recoverFromSession() 成对——host
10
- // 捕获当次 session 的 pi/ctx,不查槽短路(与 subagent-core 每次 session_start 重
11
- // bind 的设计假设对齐:槽上永不残留捕获旧 ctx 的 host);
12
- // ② recover 吸收 session 文件全部既有条目(G3 重启重放即 recoverFromSession 语义);
13
- // ③ 双 bind 收敛:后 bind 者 dispose 前实例 + recover → 单实例全量态,前者的已落盘
14
- // 条目无损(record 先 appendEntry 落盘后内存更新——notify-ledger-discipline 用例钉住)。
15
- // 收敛由 subagent-core bindNotifyLedgerHost 的 dispose 旧实例语义保证,装配方无需
16
- // 自查槽(subagent-core 侧 bind 用模块级单例 handler + boundLedger 引用切换,
17
- // 重 bind 不累积物理监听);
18
- // ④ compactionCheck 同批接线(session_compact handler,P-B4 降级——
19
- // subagent-workflow 同款先例;两侧对同一槽实例重复调用为幂等 no-op);
20
- // ⑤ 消费点恒经 getBoundNotifyLedger() 动态查槽,禁缓存实例引用——后 bind 方 dispose
21
- // 旧实例后缓存引用的 record() 静默 return false 零日志,对半概率丢全部通知。
22
- //
23
- // 唯一装配方形态(拓扑假设):taiji runtime 每 session 重 spawn pi 进程,进程内无
24
- // session 替换——session_start 触发时本包是该 session ledger 的唯一装配方(与
25
- // subagent-workflow 共存时按 ③ 收敛)。stale ctx 面因此收窄,sendDelivery 的
26
- // guardStaleCtx 为防御性兜底(异常形态留痕归因,非正常路径承重)。
27
- //
28
- // 槽空降级(record 时点仍无实例:session_start 未跑 / bind 失败):warn 留痕 + false
29
- //(STANDARDS §11.1 通知类接入点降级,非静默吞)。
30
-
31
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
32
- import { bindNotifyLedgerHost, getBoundNotifyLedger } from "@zhushanwen/subagent-core";
33
- import { getLogger } from "@zhushanwen/pi-extension-logger";
34
- import { createPiNotifyLedgerHost } from "@zhushanwen/pi-notify-ledger-host";
35
- import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
36
-
37
- import {
38
- MANAGED_SESSION_NOTIFY_CUSTOM_TYPE,
39
- type ManagedNotifyDetails,
40
- } from "./notify-content.ts";
41
-
42
- const logger = getLogger("session-manager");
43
-
44
- /**
45
- * 装配纪律 ①②(session_start 调用):无条件 bind + recover 成对。
46
- * host 五端口的 pi 接线(appendEntry / getEntries / isIdle / agent_settled /
47
- * sendMessage + 送达 stale 防御)收敛在共享工厂 @zhushanwen/pi-notify-ledger-host
48
- * ——与 subagent-workflow 同一工厂(同一槽的两处装配方,host 接口一致才能互换
49
- * 消费),本包以 component/logger/warn 前缀参数化(label = session-manager:*、
50
- * stale warn 文案带 "[session-manager] " 前缀,归因不串包)。
51
- * 装配失败不阻断 session_start(record 侧走槽空降级)。
52
- */
53
- export function ensureLedgerBound(pi: ExtensionAPI, ctx: ExtensionContext): void {
54
- try {
55
- // ①② bind + recover 成对(G3 重启重放 = recoverFromSession 既有语义;
56
- // 双 bind 收敛 = subagent-core dispose 旧实例语义,见文件头 ③)
57
- bindNotifyLedgerHost(
58
- createPiNotifyLedgerHost(pi, ctx, {
59
- component: "session-manager",
60
- logger,
61
- staleWarnPrefix: "[session-manager] ",
62
- }),
63
- ).recoverFromSession();
64
- } catch (err) {
65
- logger.warn("[session-manager] notify ledger bind failed (record degrades until next session_start)", {
66
- error: toErrorMessage(err),
67
- });
68
- }
69
- }
70
-
71
- /**
72
- * 记账一条通知(B-ledger ① record,deliveryCustomType 走 managed-session-notify 外部通道,
73
- * 逐条投递不合批——合批已在 watch-coordinator 微窗内重建批身份,D3 裁决4)。
74
- * 装配纪律⑤:每次调用即到即读查槽,禁存实例引用;槽空 → warn 降级 + false。
75
- */
76
- export function recordManagedNotify(
77
- notifyId: string,
78
- content: string,
79
- details: ManagedNotifyDetails,
80
- ): boolean {
81
- const ledger = getBoundNotifyLedger();
82
- if (!ledger) {
83
- logger.warn(
84
- "[session-manager] notify ledger not bound — notification dropped (ensureLedgerBound 未跑或 bind 失败;下次 session_start 重试装配)",
85
- { notifyId },
86
- );
87
- return false;
88
- }
89
- try {
90
- return ledger.record(notifyId, content, details, {
91
- deliveryCustomType: MANAGED_SESSION_NOTIFY_CUSTOM_TYPE,
92
- });
93
- } catch (err) {
94
- // 写入异常(stale ctx / appendEntry 失败)按接入点降级:不拖垮 watch 应答编排
95
- //(unregister 照常),warn 留痕可归因
96
- logger.warn("[session-manager] notify ledger record failed", {
97
- notifyId,
98
- error: toErrorMessage(err),
99
- });
100
- return false;
101
- }
102
- }
103
-
104
- /**
105
- * compactionCheck 接线(装配纪律④,P-B4 降级——subagent-workflow session_compact 同款):
106
- * 检测 ledger/ack entry 被 compaction 清除时按内存态补写(未清除即 0,幂等 no-op)。
107
- */
108
- export function runLedgerCompactionCheck(): void {
109
- try {
110
- const rewritten = getBoundNotifyLedger()?.compactionCheck() ?? 0;
111
- if (rewritten > 0) {
112
- logger.warn(
113
- `[session-manager] notify ledger entries lost to compaction; rewrote ${rewritten} from memory`,
114
- );
115
- }
116
- } catch (err) {
117
- logger.warn("[session-manager] notify ledger compactionCheck failed", {
118
- error: toErrorMessage(err),
119
- });
120
- }
121
- }