@zhushanwen/pi-session-manager 0.1.16 → 0.2.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 +10 -3
- package/skills/session-manager-ext-config/SKILL.md +82 -0
- package/src/__tests__/helpers/extension-harness.ts +137 -0
- package/src/__tests__/helpers/logger-mock.ts +39 -0
- package/src/__tests__/notify-content.test.ts +108 -0
- package/src/__tests__/notify-ledger-discipline.test.ts +260 -0
- package/src/__tests__/session-start-recovery.test.ts +145 -0
- package/src/__tests__/tool-error-handling.test.ts +57 -63
- package/src/__tests__/tool-non-json-response.test.ts +34 -49
- package/src/__tests__/watch-orchestration.test.ts +563 -0
- package/src/index.ts +120 -25
- package/src/notify-content.ts +77 -0
- package/src/notify-ledger.ts +121 -0
- package/src/watch-coordinator.ts +336 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// src/watch-coordinator.ts — watch 桥的 extension 侧编排(notify-once D2/D3/D6/D7③/U4)。
|
|
2
|
+
//
|
|
3
|
+
// 职责:
|
|
4
|
+
// 1. arm:send/create 工具结果成功且 willNotify → pending:register(三键 {id,type:'session',name},
|
|
5
|
+
// P4 既有 emit 写侧零改动)+ 开表(watch fire-and-forget,不传 timeout——D2/P1)
|
|
6
|
+
// 2. watch 应答处理:unregister(reason 原词 emit——pending-notifications 写侧经
|
|
7
|
+
// mapReasonToStatus 族映射落 status,4 个新 reason 的 default 误标防线就在那一跳,
|
|
8
|
+
// 本侧不再预映射)+ 默认 record + 两例外(D3):
|
|
9
|
+
// 例外1 cancelled/orphaned → 静默(逐笔即时 unregister,不 record、不动死亡槽);
|
|
10
|
+
// 例外2 死亡新闻槽 (sessionId, deathSeq) 键去重 → 同键首条 record 死亡新闻 + fulfills N,
|
|
11
|
+
// 同键后续仅 unregister;
|
|
12
|
+
// 3. trailing debounce 50ms 两层攒批(D3 裁决4):分拣键 (sessionId, reason) 定处理类别,
|
|
13
|
+
// 批身份 settleSeq/deathSeq 定 record 边界——跨 session 不合批由分组键自然排除;
|
|
14
|
+
// cancelled/orphaned 不入窗(逐笔即时);
|
|
15
|
+
// 4. session_start 重启收口腿(D7③):活跃 type 'session' register 重开 watch
|
|
16
|
+
// (入参 = entry id 即 notifyId,P4 三键零改动),每 entry 独立 handler 禁串行 await。
|
|
17
|
+
//
|
|
18
|
+
// 合批的 record 幂等键 = 批内首个响应的 notifyId(A6 口径:每 notifyId 至多一条、
|
|
19
|
+
// fulfills 总数守恒;death 槽同语义——同槽只出一条)。fulfillsN = runtime 直供本批
|
|
20
|
+
// 兑现总笔数(同批应答同值,设计 D3 合批段;缺席回退批内应答笔数)。
|
|
21
|
+
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import {
|
|
24
|
+
applyPendingDiff,
|
|
25
|
+
isSessionManagerWatchRespondPayload,
|
|
26
|
+
scanPendingEntries,
|
|
27
|
+
type MarkerRpcResult,
|
|
28
|
+
type SessionManagerWatchRespondPayload,
|
|
29
|
+
type SessionManagerWatchReason,
|
|
30
|
+
} from "@zhushanwen/extension-protocol";
|
|
31
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
32
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
33
|
+
|
|
34
|
+
import { buildManagedNotifyContent, type ManagedNotifyDetails } from "./notify-content.ts";
|
|
35
|
+
import { recordManagedNotify } from "./notify-ledger.ts";
|
|
36
|
+
|
|
37
|
+
const logger = getLogger("session-manager");
|
|
38
|
+
|
|
39
|
+
/** trailing debounce 窗口(D3 裁决4:每次应答到达重置计时;下一 seq 到达即封批) */
|
|
40
|
+
const WATCH_FLUSH_DEBOUNCE_MS = 50;
|
|
41
|
+
|
|
42
|
+
/** 已占的死亡新闻槽(例外2):键 = (sessionId, deathSeq)。factory 闭包状态
|
|
43
|
+
* (extension 进程内 session 隔离约定——槽键含 sessionId,跨子会话不串)。 */
|
|
44
|
+
function deathSlotKey(sessionId: string, deathSeq: number): string {
|
|
45
|
+
return `${sessionId} ${deathSeq}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 畸形/词表外应答的漂移形态摘要里键清单的截断上限(防大 payload 刷屏留痕) */
|
|
49
|
+
const DRIFT_KEY_PREVIEW_LIMIT = 8;
|
|
50
|
+
|
|
51
|
+
/** 畸形/词表外应答的漂移形态摘要(warn 留痕用,同 non-json 分支的 responseHead
|
|
52
|
+
* 形态——只描述形状不做归因):对象 → reason 原词 + 键清单(截断防大 payload 刷屏),
|
|
53
|
+
* 非对象 → typeof。 */
|
|
54
|
+
function describeRespondDrift(parsed: unknown): string {
|
|
55
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
56
|
+
const rec = parsed as Record<string, unknown>;
|
|
57
|
+
const reason = rec["reason"];
|
|
58
|
+
const reasonPart = typeof reason === "string" ? reason : typeof reason;
|
|
59
|
+
const keys = Object.keys(rec).slice(0, DRIFT_KEY_PREVIEW_LIMIT).join(",");
|
|
60
|
+
return `reason=${reasonPart} keys=[${keys}]`;
|
|
61
|
+
}
|
|
62
|
+
return `typeof=${Array.isArray(parsed) ? "array" : typeof parsed}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 入窗待攒批的应答(cancelled/orphaned 例外1 不入窗) */
|
|
66
|
+
interface QueuedRespond {
|
|
67
|
+
notifyId: string;
|
|
68
|
+
label: string;
|
|
69
|
+
reason: SessionManagerWatchReason;
|
|
70
|
+
payload: SessionManagerWatchRespondPayload;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface WatchCoordinatorDeps {
|
|
74
|
+
pi: ExtensionAPI;
|
|
75
|
+
/** 开表通道(index.ts 注入 callSessionManager('watch')——不传 timeout、fire-and-forget) */
|
|
76
|
+
callWatch(ctx: ExtensionContext, notifyId: string): Promise<MarkerRpcResult>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface WatchCoordinator {
|
|
80
|
+
/** 工具结果成功后的 arm:register + 开表(顺序固定——C-5 微窗方向 = 查询面先少计不产幽灵) */
|
|
81
|
+
armSessionNotify(ctx: ExtensionContext, notifyId: string, name: string): void;
|
|
82
|
+
/** sessionId → label 缓存回填(create 入参 / list 结果;send 的 name 与文案 label 源) */
|
|
83
|
+
noteLabel(sessionId: string, label: string): void;
|
|
84
|
+
/** 读 label 缓存(缺席回退 sessionId——label 降级路径,见交付偏差登记) */
|
|
85
|
+
labelFor(sessionId: string): string;
|
|
86
|
+
/** session_start 重启收口腿(D7③):活跃 type 'session' register 逐条重开 watch */
|
|
87
|
+
recoverStaleRegisters(ctx: ExtensionContext): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createWatchCoordinator(deps: WatchCoordinatorDeps): WatchCoordinator {
|
|
91
|
+
const { pi } = deps;
|
|
92
|
+
/** label 缓存(sessionId → label):正常路径文案 label 的数据源(D9 label 两路径之一)。
|
|
93
|
+
* @data-owner #1(data-source-registry.md):label 数据在扩展进程的只读消费副本,
|
|
94
|
+
* 权威源仍是 pi sessionName(登记表主表 #1),本缓存无回写;写方 = noteLabel 单点
|
|
95
|
+
* 回填(create 入参 / list 结果),生命周期随 coordinator 实例(pi 进程级)。 */
|
|
96
|
+
const labels = new Map<string, string>();
|
|
97
|
+
/** 死亡新闻槽 (sessionId, deathSeq) 去重集合(ADR-0087 关键被否④的槽键消解机制):
|
|
98
|
+
* 技术簿记 W24-EX-C(data-source-registry.md §4⑧),非 GUI 数据——键单调不重放、
|
|
99
|
+
* 槽位消费一次即终局,无消费点清理(集合容量 = 进程生命周期内死亡事件数),生命周期
|
|
100
|
+
* 随 coordinator 实例(pi 进程级)。 */
|
|
101
|
+
const deathSlots = new Set<string>();
|
|
102
|
+
let queue: QueuedRespond[] = [];
|
|
103
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
|
|
105
|
+
function noteLabel(sessionId: string, label: string): void {
|
|
106
|
+
labels.set(sessionId, label);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function labelFor(sessionId: string): string {
|
|
110
|
+
return labels.get(sessionId) ?? sessionId;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function registerPending(id: string, name: string): void {
|
|
114
|
+
try {
|
|
115
|
+
pi.events.emit("pending:register", { id, type: "session", name });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// emit 失败(stale bus)不阻断 arm——watch 照开,应答侧 unregister 幂等兜底;
|
|
118
|
+
// warn 留痕(STANDARDS §11.2 静默丢弃必须登记)
|
|
119
|
+
logger.warn("[session-manager] pending:register emit failed (stale bus?)", {
|
|
120
|
+
id,
|
|
121
|
+
error: toErrorMessage(err),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** unregister:reason 传 watch 应答原词——写侧(pending-notifications listener)经
|
|
127
|
+
* protocol mapReasonToStatus 族映射落 status,新 reason 不落 default 误标。 */
|
|
128
|
+
function unregisterPending(id: string, reason: string): void {
|
|
129
|
+
try {
|
|
130
|
+
pi.events.emit("pending:unregister", { id, reason });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
logger.warn("[session-manager] pending:unregister emit failed (stale bus?)", {
|
|
133
|
+
id,
|
|
134
|
+
error: toErrorMessage(err),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function armSessionNotify(ctx: ExtensionContext, notifyId: string, name: string): void {
|
|
140
|
+
registerPending(notifyId, name);
|
|
141
|
+
openWatch(ctx, notifyId, name);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 开表:fire-and-forget(D2/P1:不传 timeout 长挂不死;被覆盖的悬 promise 已知无害)。
|
|
145
|
+
* 每次调用独立 handler——收口腿禁串行 await 链的实现基础(D7③ 编排纪律)。 */
|
|
146
|
+
function openWatch(ctx: ExtensionContext, notifyId: string, label: string): void {
|
|
147
|
+
void deps.callWatch(ctx, notifyId).then(
|
|
148
|
+
(result) => {
|
|
149
|
+
try {
|
|
150
|
+
handleRespond(notifyId, label, result);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
logger.error("[session-manager] watch respond handling failed", {
|
|
153
|
+
notifyId,
|
|
154
|
+
error: toErrorMessage(err),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
(err: unknown) => {
|
|
159
|
+
logger.warn("[session-manager] watch select rejected", {
|
|
160
|
+
notifyId,
|
|
161
|
+
error: toErrorMessage(err),
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function handleRespond(notifyId: string, label: string, result: MarkerRpcResult): void {
|
|
168
|
+
if (!result.ok) {
|
|
169
|
+
// cancelled/timeout = 应答未达(session 销毁等 select 侧折叠)——不动 pending,
|
|
170
|
+
// 残留由下次 session_start 收口腿清(D7③ / 附录 C-9 的清理通道)。
|
|
171
|
+
// non-json/channel-error = 应答已到但不可解析 → 折叠 cancelled 清 stale register。
|
|
172
|
+
if (result.reason === "non-json" || result.reason === "channel-error") {
|
|
173
|
+
logger.warn("[session-manager] watch respond unusable — folding cancelled", {
|
|
174
|
+
notifyId,
|
|
175
|
+
reason: result.reason,
|
|
176
|
+
});
|
|
177
|
+
unregisterPending(notifyId, "cancelled");
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// 读侧不信任外部格式(AGENTS 关键规则 5),null 与畸形/词表外拆开处置:
|
|
182
|
+
// ① null(旧 runtime 象限,respond null → JSON "null")是 D6 兼容矩阵的显式
|
|
183
|
+
// 静默豁免——折叠 cancelled 收口、不 warn(非漂移信号,不伪造死亡通知);
|
|
184
|
+
// ② 畸形对象 / 词表外 reason / error envelope {error,...}(runtime params 守卫
|
|
185
|
+
// 拒绝态)= 协议漂移信号 → 必须 warn 留痕(同上方 non-json/channel-error
|
|
186
|
+
// 分支形态:notifyId + 漂移形态摘要),再折叠 cancelled 清 stale register。
|
|
187
|
+
let parsed: unknown;
|
|
188
|
+
try {
|
|
189
|
+
parsed = JSON.parse(result.value);
|
|
190
|
+
} catch {
|
|
191
|
+
// unreachable 防御(callMarkerRpc ok:true 已过 JSON 合法性检测)——真到即漂移
|
|
192
|
+
logger.warn(
|
|
193
|
+
"[session-manager] watch respond unparsable — folding cancelled (protocol drift)",
|
|
194
|
+
{ notifyId, drift: "unparsable-json" },
|
|
195
|
+
);
|
|
196
|
+
unregisterPending(notifyId, "cancelled");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (parsed === null) {
|
|
200
|
+
unregisterPending(notifyId, "cancelled");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!isSessionManagerWatchRespondPayload(parsed)) {
|
|
204
|
+
logger.warn(
|
|
205
|
+
"[session-manager] watch respond malformed/out-of-vocab — folding cancelled (protocol drift)",
|
|
206
|
+
{ notifyId, drift: describeRespondDrift(parsed) },
|
|
207
|
+
);
|
|
208
|
+
unregisterPending(notifyId, "cancelled");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const reason = parsed.reason;
|
|
212
|
+
if (reason === "cancelled" || reason === "orphaned") {
|
|
213
|
+
// 例外1:静默(主 abort 销账 / 归属失效 / fail-closed / orphan 吸收态)——
|
|
214
|
+
// 逐笔即时 unregister,不入攒批窗、不 record、不动死亡槽。
|
|
215
|
+
unregisterPending(notifyId, reason);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// completed/failed/stopped(结果新闻)与 exited/deleted(死亡新闻)入窗攒批
|
|
219
|
+
queue.push({ notifyId, label, reason, payload: parsed });
|
|
220
|
+
scheduleFlush();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function scheduleFlush(): void {
|
|
224
|
+
if (flushTimer !== undefined) clearTimeout(flushTimer);
|
|
225
|
+
flushTimer = setTimeout(() => {
|
|
226
|
+
flushTimer = undefined;
|
|
227
|
+
flush();
|
|
228
|
+
}, WATCH_FLUSH_DEBOUNCE_MS);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function flush(): void {
|
|
232
|
+
const items = queue;
|
|
233
|
+
queue = [];
|
|
234
|
+
if (items.length === 0) return;
|
|
235
|
+
// 两层模型(D3):① 分拣键 (sessionId, reason) 定处理类别;② 批身份 settleSeq/
|
|
236
|
+
// deathSeq 定 record 边界(同批身份才合一条 record——跨轮同 status 合流由 seq
|
|
237
|
+
// 边界排除,防违背 A7)。Map 保序:先到的分拣组先占死亡槽(例外2 的「首条」语义)。
|
|
238
|
+
const sortGroups = new Map<string, QueuedRespond[]>();
|
|
239
|
+
for (const item of items) {
|
|
240
|
+
const sortKey = `${item.payload.sessionId ?? ""} ${item.reason}`;
|
|
241
|
+
const group = sortGroups.get(sortKey);
|
|
242
|
+
if (group) group.push(item);
|
|
243
|
+
else sortGroups.set(sortKey, [item]);
|
|
244
|
+
}
|
|
245
|
+
for (const group of sortGroups.values()) {
|
|
246
|
+
const reason = group[0].reason;
|
|
247
|
+
const isDeath = reason === "exited" || reason === "deleted";
|
|
248
|
+
const batchGroups = new Map<number, QueuedRespond[]>();
|
|
249
|
+
for (const item of group) {
|
|
250
|
+
const seq = (isDeath ? item.payload.deathSeq : item.payload.settleSeq) ?? -1;
|
|
251
|
+
const batch = batchGroups.get(seq);
|
|
252
|
+
if (batch) batch.push(item);
|
|
253
|
+
else batchGroups.set(seq, [item]);
|
|
254
|
+
}
|
|
255
|
+
for (const batch of batchGroups.values()) {
|
|
256
|
+
flushBatch(batch, isDeath);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function flushBatch(batch: QueuedRespond[], isDeath: boolean): void {
|
|
262
|
+
const first = batch[0];
|
|
263
|
+
const reason = first.reason;
|
|
264
|
+
if (isDeath) {
|
|
265
|
+
// 例外2:死亡新闻槽 (sessionId, deathSeq) 键去重——同键首条 record 死亡新闻,
|
|
266
|
+
// 后续仅 unregister(content 确定性:kind 不再决定文案,消除到达序 nondeterminism;
|
|
267
|
+
// arm→watch 微窗竞态下迟到 watch 不二次发声)。
|
|
268
|
+
const slot = deathSlotKey(
|
|
269
|
+
first.payload.sessionId ?? "",
|
|
270
|
+
first.payload.deathSeq ?? -1,
|
|
271
|
+
);
|
|
272
|
+
if (deathSlots.has(slot)) {
|
|
273
|
+
for (const item of batch) unregisterPending(item.notifyId, reason);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
deathSlots.add(slot);
|
|
277
|
+
}
|
|
278
|
+
const sessionId = first.payload.sessionId;
|
|
279
|
+
if (sessionId === undefined) {
|
|
280
|
+
// 协议不变量(D-4:非 cancelled 应答恒带 sessionId)的防御分支——违反即降级
|
|
281
|
+
// unregister-only + warn,不产出 (undefined) 文案。
|
|
282
|
+
logger.warn("[session-manager] watch respond missing sessionId — record skipped", {
|
|
283
|
+
notifyId: first.notifyId,
|
|
284
|
+
reason,
|
|
285
|
+
});
|
|
286
|
+
for (const item of batch) unregisterPending(item.notifyId, reason);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const fulfills = first.payload.fulfillsN ?? batch.length;
|
|
290
|
+
const content = buildManagedNotifyContent({
|
|
291
|
+
label: first.label,
|
|
292
|
+
sessionId,
|
|
293
|
+
status: reason,
|
|
294
|
+
fulfills,
|
|
295
|
+
sessionFilePath: first.payload.sessionFilePath,
|
|
296
|
+
exitCode: first.payload.exitCode,
|
|
297
|
+
stderrTail: first.payload.stderrTail,
|
|
298
|
+
});
|
|
299
|
+
const details: ManagedNotifyDetails = {
|
|
300
|
+
notifyId: first.notifyId,
|
|
301
|
+
sessionId,
|
|
302
|
+
reason,
|
|
303
|
+
fulfills,
|
|
304
|
+
label: first.label,
|
|
305
|
+
};
|
|
306
|
+
// 默认 record(B-ledger ①);record false(幂等拒收 / 槽空降级)不改 unregister 语义
|
|
307
|
+
recordManagedNotify(first.notifyId, content, details);
|
|
308
|
+
for (const item of batch) unregisterPending(item.notifyId, reason);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function recoverStaleRegisters(ctx: ExtensionContext): void {
|
|
312
|
+
let entries: readonly unknown[];
|
|
313
|
+
try {
|
|
314
|
+
entries = ctx.sessionManager.getEntries();
|
|
315
|
+
} catch (err) {
|
|
316
|
+
logger.warn("[session-manager] session_start recovery failed to read entries", {
|
|
317
|
+
error: toErrorMessage(err),
|
|
318
|
+
});
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// 活跃判定与 pending-notifications/goal 消费同一 protocol 差集单点(防两份实现漂移)
|
|
322
|
+
const active = applyPendingDiff(scanPendingEntries([...entries]));
|
|
323
|
+
for (const { data } of active) {
|
|
324
|
+
if (data["type"] !== "session") continue;
|
|
325
|
+
const id = data["id"];
|
|
326
|
+
if (typeof id !== "string") continue;
|
|
327
|
+
const rawName = data["name"];
|
|
328
|
+
const name = typeof rawName === "string" && rawName !== "" ? rawName : id;
|
|
329
|
+
// 每 entry 独立 fire-and-forget handler(openWatch 内部各自 .then),无任何
|
|
330
|
+
// await 链——W1 悬置(被 W2 覆盖 / 永不应答)不阻塞后续 entry 的开表(D7③)。
|
|
331
|
+
openWatch(ctx, id, name);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { armSessionNotify, noteLabel, labelFor, recoverStaleRegisters };
|
|
336
|
+
}
|