@zhushanwen/pi-session-manager 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -5
- package/skills/session-manager-ext-config/SKILL.md +24 -22
- package/src/__tests__/helpers/extension-harness.ts +5 -5
- package/src/__tests__/helpers/logger-mock.ts +5 -5
- package/src/__tests__/notify-content.test.ts +1 -1
- package/src/__tests__/notify-delivery-discipline.test.ts +125 -0
- package/src/__tests__/session-start-recovery.test.ts +25 -16
- package/src/__tests__/watch-orchestration.test.ts +120 -86
- package/src/index.ts +7 -14
- package/src/notify-content.ts +5 -5
- package/src/notify-delivery.ts +87 -0
- package/src/watch-coordinator.ts +40 -17
- package/src/__tests__/notify-ledger-discipline.test.ts +0 -260
- package/src/notify-ledger.ts +0 -121
package/src/watch-coordinator.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// 3. trailing debounce 50ms 两层攒批(D3 裁决4):分拣键 (sessionId, reason) 定处理类别,
|
|
13
13
|
// 批身份 settleSeq/deathSeq 定 record 边界——跨 session 不合批由分组键自然排除;
|
|
14
14
|
// cancelled/orphaned 不入窗(逐笔即时);
|
|
15
|
-
// 4. session_start
|
|
15
|
+
// 4. session_start 重启收尾路径(D7③):活跃 type 'session' register 重开 watch
|
|
16
16
|
// (入参 = entry id 即 notifyId,P4 三键零改动),每 entry 独立 handler 禁串行 await。
|
|
17
17
|
//
|
|
18
18
|
// 合批的 record 幂等键 = 批内首个响应的 notifyId(A6 口径:每 notifyId 至多一条、
|
|
@@ -32,7 +32,7 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
|
32
32
|
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
33
33
|
|
|
34
34
|
import { buildManagedNotifyContent, type ManagedNotifyDetails } from "./notify-content.ts";
|
|
35
|
-
import { recordManagedNotify } from "./notify-
|
|
35
|
+
import { recordManagedNotify } from "./notify-delivery.ts";
|
|
36
36
|
|
|
37
37
|
const logger = getLogger("session-manager");
|
|
38
38
|
|
|
@@ -77,13 +77,15 @@ export interface WatchCoordinatorDeps {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
export interface WatchCoordinator {
|
|
80
|
-
/** 工具结果成功后的 arm:register + 开表(顺序固定——C-5 微窗方向 =
|
|
81
|
-
|
|
80
|
+
/** 工具结果成功后的 arm:register + 开表(顺序固定——C-5 微窗方向 = 查询结果先少计不产幽灵)。
|
|
81
|
+
* kind:"lifetime" = lifetime 死亡债权注册(result-collect 设计 §3.4,emit 申报
|
|
82
|
+
* kind 标记);缺省 = send 债权。 */
|
|
83
|
+
armSessionNotify(ctx: ExtensionContext, notifyId: string, name: string, kind?: "lifetime"): void;
|
|
82
84
|
/** sessionId → label 缓存回填(create 入参 / list 结果;send 的 name 与文案 label 源) */
|
|
83
85
|
noteLabel(sessionId: string, label: string): void;
|
|
84
86
|
/** 读 label 缓存(缺席回退 sessionId——label 降级路径,见交付偏差登记) */
|
|
85
87
|
labelFor(sessionId: string): string;
|
|
86
|
-
/** session_start
|
|
88
|
+
/** session_start 重启收尾路径(D7③):活跃 type 'session' register 逐条重开 watch */
|
|
87
89
|
recoverStaleRegisters(ctx: ExtensionContext): void;
|
|
88
90
|
}
|
|
89
91
|
|
|
@@ -94,7 +96,7 @@ export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordin
|
|
|
94
96
|
* 权威源仍是 pi sessionName(登记表主表 #1),本缓存无回写;写方 = noteLabel 单点
|
|
95
97
|
* 回填(create 入参 / list 结果),生命周期随 coordinator 实例(pi 进程级)。 */
|
|
96
98
|
const labels = new Map<string, string>();
|
|
97
|
-
/** 死亡新闻槽 (sessionId, deathSeq) 去重集合(ADR-0087
|
|
99
|
+
/** 死亡新闻槽 (sessionId, deathSeq) 去重集合(ADR-0087 关键不采用④的槽键消解机制):
|
|
98
100
|
* 技术簿记 W24-EX-C(data-source-registry.md §4⑧),非 GUI 数据——键单调不重放、
|
|
99
101
|
* 槽位消费一次即终局,无消费点清理(集合容量 = 进程生命周期内死亡事件数),生命周期
|
|
100
102
|
* 随 coordinator 实例(pi 进程级)。 */
|
|
@@ -110,9 +112,13 @@ export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordin
|
|
|
110
112
|
return labels.get(sessionId) ?? sessionId;
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
function registerPending(id: string, name: string): void {
|
|
115
|
+
function registerPending(id: string, name: string, kind?: "lifetime"): void {
|
|
114
116
|
try {
|
|
115
|
-
|
|
117
|
+
// kind 债权类别标记(result-collect 设计 §3.4):lifetime 死亡债权申报
|
|
118
|
+
// kind:"lifetime"(collect 缺省等待集据此排除),send 债权不带(无 kind
|
|
119
|
+
// 缺省 = send)——两类注册 emit 此前同形态 {id, type:'session', name}
|
|
120
|
+
// (type 不可辨),标记经 pending:register 落盘 entry 透传给工具侧。
|
|
121
|
+
pi.events.emit("pending:register", { id, type: "session", name, ...(kind ? { kind } : {}) });
|
|
116
122
|
} catch (err) {
|
|
117
123
|
// emit 失败(stale bus)不阻断 arm——watch 照开,应答侧 unregister 幂等兜底;
|
|
118
124
|
// warn 留痕(STANDARDS §11.2 静默丢弃必须登记)
|
|
@@ -136,13 +142,18 @@ export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordin
|
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
function armSessionNotify(
|
|
140
|
-
|
|
145
|
+
function armSessionNotify(
|
|
146
|
+
ctx: ExtensionContext,
|
|
147
|
+
notifyId: string,
|
|
148
|
+
name: string,
|
|
149
|
+
kind?: "lifetime",
|
|
150
|
+
): void {
|
|
151
|
+
registerPending(notifyId, name, kind);
|
|
141
152
|
openWatch(ctx, notifyId, name);
|
|
142
153
|
}
|
|
143
154
|
|
|
144
155
|
/** 开表:fire-and-forget(D2/P1:不传 timeout 长挂不死;被覆盖的悬 promise 已知无害)。
|
|
145
|
-
* 每次调用独立 handler
|
|
156
|
+
* 每次调用独立 handler——收尾路径禁串行 await 链的实现基础(D7③ 编排纪律)。 */
|
|
146
157
|
function openWatch(ctx: ExtensionContext, notifyId: string, label: string): void {
|
|
147
158
|
void deps.callWatch(ctx, notifyId).then(
|
|
148
159
|
(result) => {
|
|
@@ -167,7 +178,7 @@ export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordin
|
|
|
167
178
|
function handleRespond(notifyId: string, label: string, result: MarkerRpcResult): void {
|
|
168
179
|
if (!result.ok) {
|
|
169
180
|
// cancelled/timeout = 应答未达(session 销毁等 select 侧折叠)——不动 pending,
|
|
170
|
-
// 残留由下次 session_start
|
|
181
|
+
// 残留由下次 session_start 收尾路径清(D7③ / 附录 C-9 的清理通道)。
|
|
171
182
|
// non-json/channel-error = 应答已到但不可解析 → 折叠 cancelled 清 stale register。
|
|
172
183
|
if (result.reason === "non-json" || result.reason === "channel-error") {
|
|
173
184
|
logger.warn("[session-manager] watch respond unusable — folding cancelled", {
|
|
@@ -180,8 +191,8 @@ export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordin
|
|
|
180
191
|
}
|
|
181
192
|
// 读侧不信任外部格式(AGENTS 关键规则 5),null 与畸形/词表外拆开处置:
|
|
182
193
|
// ① null(旧 runtime 象限,respond null → JSON "null")是 D6 兼容矩阵的显式
|
|
183
|
-
|
|
184
|
-
|
|
194
|
+
// 静默豁免——折叠 cancelled 收尾、不 warn(非漂移信号,不伪造死亡通知);
|
|
195
|
+
// ② 畸形对象 / 词表外 reason / error envelope {error,...}(runtime params 检查
|
|
185
196
|
// 拒绝态)= 协议漂移信号 → 必须 warn 留痕(同上方 non-json/channel-error
|
|
186
197
|
// 分支形态:notifyId + 漂移形态摘要),再折叠 cancelled 清 stale register。
|
|
187
198
|
let parsed: unknown;
|
|
@@ -210,7 +221,7 @@ export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordin
|
|
|
210
221
|
}
|
|
211
222
|
const reason = parsed.reason;
|
|
212
223
|
if (reason === "cancelled" || reason === "orphaned") {
|
|
213
|
-
// 例外1:静默(主 abort
|
|
224
|
+
// 例外1:静默(主 abort 处理完毕 / 归属失效 / fail-closed / orphan 吸收态)——
|
|
214
225
|
// 逐笔即时 unregister,不入攒批窗、不 record、不动死亡槽。
|
|
215
226
|
unregisterPending(notifyId, reason);
|
|
216
227
|
return;
|
|
@@ -303,8 +314,20 @@ export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordin
|
|
|
303
314
|
fulfills,
|
|
304
315
|
label: first.label,
|
|
305
316
|
};
|
|
306
|
-
//
|
|
307
|
-
|
|
317
|
+
// 默认投递(unified-delivery 波 2:port.deliver);false(port 缺席 / rejected /
|
|
318
|
+
// 幂等拒收)不改 unregister 语义。collectKeys = 批内全部债权键(result-collect
|
|
319
|
+
// 设计 §3.4 managed 行):合批条目载荷幂等键 = 批首债权键,collect 按键集逐键
|
|
320
|
+
// 展开定位——键集申报经 recordManagedNotify 必传参数构造性防漏。三类「注销
|
|
321
|
+
// pending 而无投递日志条目」分支不经此申报点——成员键不入任何条目键集,collect
|
|
322
|
+
// 侧按第四态收取:死亡槽去重跨批与 sessionId 缺失防御在本函数早退(上方两个
|
|
323
|
+
// return 分支);例外1 cancelled/orphaned 在上游 handleRespond 应答分流、不入
|
|
324
|
+
// 攒批窗不进本函数。
|
|
325
|
+
recordManagedNotify(
|
|
326
|
+
first.notifyId,
|
|
327
|
+
content,
|
|
328
|
+
details,
|
|
329
|
+
batch.map((item) => item.notifyId),
|
|
330
|
+
);
|
|
308
331
|
for (const item of batch) unregisterPending(item.notifyId, reason);
|
|
309
332
|
}
|
|
310
333
|
|
|
@@ -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
|
-
});
|
package/src/notify-ledger.ts
DELETED
|
@@ -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
|
-
}
|