@zhushanwen/pi-pending-notifications 0.9.0 → 0.10.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/README.md CHANGED
@@ -24,11 +24,11 @@
24
24
  - `pending:register { id, type, name }` —— 操作启动(`type` ∈ `workflow` / `subagent` / `bash`)
25
25
  - `pending:unregister { id, reason }` —— 操作结束(`reason` 映射为终态 status)
26
26
  - 发送方:subagent-core(subagent 与 workflow run 生命周期:启动注册 / 结束注销 / 崩溃恢复 sweep 补注销)、base-tool-enhance(bash 后台任务)、subagent-workflow(仅崩溃恢复补注销)
27
- - **session entries 唯一状态源**:register/unregister 经 `appendEntry` 落盘,无内存第二份状态;活跃集合 = register − unregister 差集,工具投影与写侧去重对同一份 entries 现算,结构上不可分歧
27
+ - **session entries 唯一状态源**:register/unregister 经 `appendEntry` 落盘,无内存第二份状态;活跃集合 = register − unregister 差集,工具派生视图与写侧去重对同一份 entries 现算,结构上不可分歧
28
28
  - **写侧幂等**:重复 register、未知/已注销 id 的 unregister 均忽略,天然不重复落盘
29
29
  - **无 TTL**:三类操作均按进程存活口径处理,长任务(>1h)仍视为活跃
30
30
  - **跨 session 残留过滤**:查询按当前 session id 过滤 fork 继承的父级注册残留
31
- - **跨扩展导出**:`countActiveFromEntries()` 供 goal(continuation 守卫)、subagent-workflow(后代存活判定)直接复用差集计算;差集本体单点在 `@zhushanwen/extension-protocol`
31
+ - **跨扩展导出**:`countActiveFromEntries()` 供 goal(continuation 检查)、subagent-workflow(后代存活判定)直接复用差集计算;差集本体单点在 `@zhushanwen/extension-protocol`
32
32
 
33
33
  ## 安装
34
34
 
@@ -55,7 +55,7 @@ LLM 在决定「继续推进还是等待异步操作」前主动查询:
55
55
  { "action": "list" }
56
56
  ```
57
57
 
58
- 典型场景:agent 想确认是否真有后台任务在跑(workflow run / subagent / bash 后台命令),避免在异步操作未收口时提前下结论或重复派发。
58
+ 典型场景:agent 想确认是否真有后台任务在跑(workflow run / subagent / bash 后台命令),避免在异步操作未收尾时提前下结论或重复派发。
59
59
 
60
60
  ## 文件结构
61
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-pending-notifications",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Cross-extension async operation registration/query mechanism for Pi.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,7 +34,8 @@
34
34
  "typebox": "1.3.27"
35
35
  },
36
36
  "dependencies": {
37
- "@zhushanwen/extension-protocol": "0.21.0",
37
+ "@zhushanwen/extension-protocol": "0.22.0",
38
+ "@zhushanwen/pi-delivery-port": "0.2.0",
38
39
  "@zhushanwen/pi-extension-logger": "0.6.2"
39
40
  },
40
41
  "peerDependenciesMeta": {
@@ -0,0 +1,630 @@
1
+ // 测试框架:vitest
2
+ // 运行命令:pnpm -C extensions/universal/pending-notifications test
3
+ //
4
+ // collect_results 工具单测(result-collect 设计 §3.1③ 工具面;设计 §5 拆分依据:
5
+ // 工具面独立可测——mock port 句柄)。
6
+ //
7
+ // 测试策略:
8
+ // - vi.mock("@zhushanwen/pi-delivery-port") 全模块 mock:工具经进程级槽消费投递端口,
9
+ // 测试注入 mock handle(collectArm/consume/collectRelease/onCollectArrival 四方法 +
10
+ // 存量面),与 u-p-port 实装解耦(不加载 delivery-port.ts 真实模块)
11
+ // - mock 手法与 pending-notifications.test.ts 同款:最小 mock ExtensionAPI /
12
+ // ExtensionContext,共享 entries 数组 = session JSONL 的单测层镜像(appendEntry
13
+ // mock 同步入账)
14
+ // - 覆盖(任务书目标 6):lifetime 排除生效 / 跨 session 残留反向断言 / 未知 id
15
+ // errors / 第四态返回形态;另覆盖 sequential 声明、abort 正常返回、timeout、
16
+ // in-push-pipeline note、快照重收、port 缺席降级、kind 落盘透传、等待中同步到达
17
+ // 的 outcome 回填(一致性修复 U1:managed 合批「deliver 先于注销」同步段时序)
18
+
19
+ /* eslint-disable taste/no-unsafe-cast -- 测试 mock 构造(pending-notifications.test.ts:15 同款):最小 mock ExtensionAPI/ExtensionContext 无法逐字段满足真实接口,cast 真值由测试断言兜底(mock 被被测代码真实调用,行为不符即红) */
20
+
21
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
23
+
24
+ import pendingNotificationsExtension from "../index";
25
+ import { computeCollectWaitKeys, countActiveFromEntries } from "../state";
26
+
27
+ // ── delivery-port 全模块 mock(vi.mock 工厂提升,经 vi.hoisted 共享引用) ──
28
+
29
+ const portMocks = vi.hoisted(() => ({
30
+ getDeliveryPort: vi.fn<() => unknown>(),
31
+ }));
32
+
33
+ vi.mock("@zhushanwen/pi-delivery-port", () => portMocks);
34
+
35
+ // ── Mock 工具(与 pending-notifications.test.ts 同款手法) ──────────────
36
+
37
+ interface HandlerRegistry { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
38
+ sessionStart: ((event: unknown, ctx: ExtensionContext) => void | Promise<void>) | undefined;
39
+ pendingRegister: ((data: unknown) => void) | undefined;
40
+ pendingUnregister: ((data: unknown) => void) | undefined;
41
+ }
42
+
43
+ interface MockSessionEntry { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
44
+ customType: string;
45
+ data: Record<string, unknown>;
46
+ }
47
+
48
+ interface MockPort { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
49
+ collectArm: ReturnType<typeof vi.fn>;
50
+ consume: ReturnType<typeof vi.fn>;
51
+ collectRelease: ReturnType<typeof vi.fn>;
52
+ onCollectArrival: ReturnType<typeof vi.fn>;
53
+ deliver: ReturnType<typeof vi.fn>;
54
+ recover: ReturnType<typeof vi.fn>;
55
+ metrics: ReturnType<typeof vi.fn>;
56
+ flush: ReturnType<typeof vi.fn>;
57
+ dispose: ReturnType<typeof vi.fn>;
58
+ }
59
+
60
+ interface MockSetup { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
61
+ pi: ExtensionAPI;
62
+ handlers: HandlerRegistry;
63
+ entries: MockSessionEntry[];
64
+ appendEntryMock: ReturnType<typeof vi.fn>;
65
+ registerToolMock: ReturnType<typeof vi.fn>;
66
+ port: MockPort;
67
+ }
68
+
69
+ function createMockPort(): MockPort {
70
+ return {
71
+ collectArm: vi.fn(() => []),
72
+ consume: vi.fn(() => []),
73
+ collectRelease: vi.fn(),
74
+ onCollectArrival: vi.fn(() => vi.fn()),
75
+ deliver: vi.fn(),
76
+ recover: vi.fn(() => 0),
77
+ metrics: vi.fn(() => ({ settleRejected: 0, recoveryReplays: 0 })),
78
+ flush: vi.fn(),
79
+ dispose: vi.fn(),
80
+ };
81
+ }
82
+
83
+ function createMockSetup(): MockSetup {
84
+ const handlers: HandlerRegistry = {
85
+ sessionStart: undefined,
86
+ pendingRegister: undefined,
87
+ pendingUnregister: undefined,
88
+ };
89
+ // P1 单测层镜像:appendEntry mock 同步入账共享 entries
90
+ const entries: MockSessionEntry[] = [];
91
+ const appendEntryMock = vi.fn((customType: string, data: Record<string, unknown>) => {
92
+ entries.push({ customType, data });
93
+ });
94
+ const registerToolMock = vi.fn();
95
+ const port = createMockPort();
96
+ portMocks.getDeliveryPort.mockReturnValue(port);
97
+
98
+ const pi = {
99
+ appendEntry: appendEntryMock,
100
+ registerTool: registerToolMock,
101
+ sendMessage: vi.fn(),
102
+ on: vi.fn((event: string, handler: (event: unknown, ctx: ExtensionContext) => void | Promise<void>) => {
103
+ if (event === "session_start") handlers.sessionStart = handler;
104
+ }),
105
+ events: {
106
+ emit: vi.fn(),
107
+ on: vi.fn((channel: string, handler: (data: unknown) => void) => {
108
+ if (channel === "pending:register") handlers.pendingRegister = handler;
109
+ if (channel === "pending:unregister") handlers.pendingUnregister = handler;
110
+ }),
111
+ },
112
+ } as unknown as ExtensionAPI;
113
+
114
+ return { pi, handlers, entries, appendEntryMock, registerToolMock, port };
115
+ }
116
+
117
+ function createMockCtx(setup: MockSetup, sessionId = "sess-current"): ExtensionContext {
118
+ return {
119
+ sessionManager: {
120
+ getEntries: () => setup.entries as unknown[],
121
+ getSessionId: () => sessionId,
122
+ },
123
+ } as unknown as ExtensionContext;
124
+ }
125
+
126
+ function fireSessionStart(setup: MockSetup, sessionId?: string): void {
127
+ if (!setup.handlers.sessionStart) throw new Error("session_start handler not registered");
128
+ void setup.handlers.sessionStart({ type: "session_start", reason: "resume" }, createMockCtx(setup, sessionId));
129
+ }
130
+
131
+ interface CollectToolDef { // oe-exempt:20261016:test:测试 mock 类型单实现为测试基建常态(pending-notifications.test.ts 存量同款类型未登记为历史遗留)
132
+ executionMode?: string;
133
+ description?: string;
134
+ execute: (
135
+ toolCallId: string,
136
+ p: unknown,
137
+ signal: AbortSignal | undefined,
138
+ onUpdate: unknown,
139
+ ctx: ExtensionContext,
140
+ ) => Promise<{
141
+ content: Array<{ type: string; text: string }>;
142
+ details: {
143
+ collected: Array<Record<string, unknown>>;
144
+ stillOpen: Array<{ id: string; note?: string }>;
145
+ errors?: Array<{ id: string; reason: string }>;
146
+ };
147
+ }>;
148
+ }
149
+
150
+ function getCollectTool(setup: MockSetup): CollectToolDef {
151
+ const call = setup.registerToolMock.mock.calls.find(
152
+ (c) => (c[0] as { name: string }).name === "collect_results",
153
+ );
154
+ if (!call) throw new Error("collect_results tool not registered");
155
+ return call[0] as CollectToolDef;
156
+ }
157
+
158
+ async function runCollect(
159
+ setup: MockSetup,
160
+ params: Record<string, unknown>,
161
+ signal?: AbortSignal,
162
+ ): ReturnType<CollectToolDef["execute"]> {
163
+ return getCollectTool(setup).execute("collect-call-id", params, signal, undefined, createMockCtx(setup));
164
+ }
165
+
166
+ // ── 共享 fixtures ───────────────────────────────────
167
+
168
+ const NOW = 1_700_000_000_000;
169
+
170
+ function makeRegisterEntry(id: string, extra: Record<string, unknown> = {}): MockSessionEntry {
171
+ return {
172
+ customType: "pending:register",
173
+ data: {
174
+ id,
175
+ type: "workflow",
176
+ name: `op-${id}`,
177
+ registeredAt: NOW,
178
+ sessionId: "sess-current",
179
+ ...extra,
180
+ },
181
+ };
182
+ }
183
+
184
+ // ────────────────────────────────────────────────────
185
+ // state.ts:缺省等待键集 + 曾注册集(collect 工具侧纯函数)
186
+ // ────────────────────────────────────────────────────
187
+
188
+ describe("computeCollectWaitKeys(collect_results 缺省等待集,工具侧减法)", () => {
189
+ it("lifetime 债权排除生效:kind=lifetime 的 session register 不入缺省集,其余照常", () => {
190
+ const entries = [
191
+ makeRegisterEntry("sm-life", { type: "session", kind: "lifetime" }),
192
+ makeRegisterEntry("sm-send", { type: "session" }),
193
+ makeRegisterEntry("bt-1", { type: "bash" }),
194
+ ];
195
+ expect(computeCollectWaitKeys(entries, "sess-current")).toEqual(["sm-send", "bt-1"]);
196
+ });
197
+
198
+ it("升级边沿:无 kind 的旧 session register(send 债权缺省语义)不排除", () => {
199
+ const entries = [makeRegisterEntry("sm-legacy", { type: "session" })];
200
+ expect(computeCollectWaitKeys(entries, "sess-current")).toEqual(["sm-legacy"]);
201
+ });
202
+
203
+ it("[跨 session 残留反向断言] sessionId ≠ 当前的继承注册不入缺省集;本 session 计入", () => {
204
+ // fork 继承的父级注册残留:不滤则子 session 缺省 collect 恒挂继承残留(设计 §3.1③)
205
+ const entries = [
206
+ makeRegisterEntry("parent-bg", { sessionId: "sess-old" }),
207
+ makeRegisterEntry("own-bg"),
208
+ ];
209
+ expect(computeCollectWaitKeys(entries, "sess-current")).toEqual(["own-bg"]);
210
+ });
211
+
212
+ it("entry 缺 sessionId 的旧形态条目视为本 session(不过滤,宁放行不误逐)", () => {
213
+ const legacy: MockSessionEntry = {
214
+ customType: "pending:register",
215
+ data: { id: "old-1", type: "subagent", name: "o", registeredAt: NOW },
216
+ };
217
+ expect(computeCollectWaitKeys([legacy], "sess-current")).toEqual(["old-1"]);
218
+ });
219
+
220
+ it("currentSessionId 空串回退(session_start 未到):不过滤", () => {
221
+ const entries = [makeRegisterEntry("a", { sessionId: "s-other" }), makeRegisterEntry("b")];
222
+ expect(computeCollectWaitKeys(entries, "")).toEqual(["a", "b"]);
223
+ });
224
+
225
+ it("[共享函数缺省语义不动] countActiveFromEntries 仍计 lifetime 为活跃(goal/subagent-workflow 零变化)", () => {
226
+ // 排除只发生在 computeCollectWaitKeys(工具侧减法)——countActiveFromEntries
227
+ // 缺省语义不变是任务书机械判据③与设计 §3.4 排除归属显式约定的锁定面。
228
+ const entries = [makeRegisterEntry("sm-life", { type: "session", kind: "lifetime" })];
229
+ expect(countActiveFromEntries(entries).ids).toEqual(["sm-life"]);
230
+ });
231
+ });
232
+
233
+ // scanPendingFields(终局回填字段视图)的用例已随 MF-2-1 单点收敛移至 protocol:
234
+ // packages/extension-protocol/src/pending-entries.test.ts「字段映射视图」describe
235
+ // (register 首见 type + 注销 status 词表成员判定,同一规则供本包回填与
236
+ // delivery-port 快照基线消费)。
237
+
238
+ // ────────────────────────────────────────────────────
239
+ // collect_results 工具(mock port 注入)
240
+ // ────────────────────────────────────────────────────
241
+
242
+ describe("collect_results tool", () => {
243
+ let setup: MockSetup;
244
+
245
+ beforeEach(() => {
246
+ vi.useFakeTimers();
247
+ vi.setSystemTime(NOW);
248
+ setup = createMockSetup();
249
+ pendingNotificationsExtension(setup.pi);
250
+ fireSessionStart(setup);
251
+ });
252
+
253
+ afterEach(() => {
254
+ portMocks.getDeliveryPort.mockReset();
255
+ vi.useRealTimers();
256
+ });
257
+
258
+ it("工具注册形态:name=collect_results、executionMode=sequential", () => {
259
+ const tool = getCollectTool(setup);
260
+ expect(tool.executionMode).toBe("sequential");
261
+ expect(tool.description).toBeTruthy();
262
+ });
263
+
264
+ it("无 ids 且无活跃 pending → 立即空返回,端口零触达(等待集为空不进入等待)", async () => {
265
+ const res = await runCollect(setup, {});
266
+
267
+ expect(res.details.collected).toEqual([]);
268
+ expect(res.details.stillOpen).toEqual([]);
269
+ expect(res.details.errors).toBeUndefined();
270
+ expect(portMocks.getDeliveryPort).not.toHaveBeenCalled();
271
+ });
272
+
273
+ it("缺省集 = 活跃 − lifetime 债权 − 跨 session 残留:collectArm 只收到本 session 非 lifetime 键", async () => {
274
+ setup.entries.push(
275
+ makeRegisterEntry("sm-life", { type: "session", kind: "lifetime" }),
276
+ makeRegisterEntry("parent-bg", { sessionId: "sess-old" }),
277
+ makeRegisterEntry("own-1"),
278
+ );
279
+
280
+ await runCollect(setup, {});
281
+
282
+ expect(setup.port.collectArm).toHaveBeenCalledWith(["own-1"]);
283
+ });
284
+
285
+ it("只有 lifetime 债权时缺省 collect 立即空返回(排除后等待集为空,不挂满超时)", async () => {
286
+ setup.entries.push(makeRegisterEntry("sm-life", { type: "session", kind: "lifetime" }));
287
+
288
+ const res = await runCollect(setup, {});
289
+
290
+ expect(res.details).toEqual({ collected: [], stillOpen: [] });
291
+ expect(setup.port.collectArm).not.toHaveBeenCalled();
292
+ });
293
+
294
+ it("快照 ready 合并进 collected(已完成收取,读侧快照;无等待则不订阅到达回调)", async () => {
295
+ setup.entries.push(makeRegisterEntry("w-1"));
296
+ setup.port.collectArm.mockReturnValue([
297
+ { id: "w-1", state: "ready", entry: { type: "workflow", outcome: "completed", content: "done!" } },
298
+ ]);
299
+
300
+ const res = await runCollect(setup, {});
301
+
302
+ expect(res.details.collected).toEqual([
303
+ { id: "w-1", type: "workflow", outcome: "completed", content: "done!" },
304
+ ]);
305
+ expect(res.details.stillOpen).toEqual([]);
306
+ expect(setup.port.onCollectArrival).not.toHaveBeenCalled();
307
+ // 快照收取不写新终局(读侧快照),但等待集登记须解散
308
+ expect(setup.port.consume).toHaveBeenCalled();
309
+ expect(setup.port.collectRelease).toHaveBeenCalled();
310
+ });
311
+
312
+ it("in-push-pipeline → stillOpen 附 note(不收取不等待)", async () => {
313
+ setup.entries.push(makeRegisterEntry("w-2"));
314
+ setup.port.collectArm.mockReturnValue([{ id: "w-2", state: "in-push-pipeline" }]);
315
+
316
+ const res = await runCollect(setup, {});
317
+
318
+ expect(res.details.collected).toEqual([]);
319
+ expect(res.details.stillOpen).toEqual([
320
+ { id: "w-2", note: expect.stringContaining("push pipeline") },
321
+ ]);
322
+ expect(setup.port.collectRelease).toHaveBeenCalled();
323
+ });
324
+
325
+ it("第四态 unregistered-no-entry 返回形态:outcome=注销 status + note、无 content、不虚构载荷", async () => {
326
+ setup.entries.push(makeRegisterEntry("sm-1"));
327
+ setup.port.collectArm.mockReturnValue([
328
+ {
329
+ id: "sm-1",
330
+ state: "unregistered-no-entry",
331
+ entry: { type: "session", outcome: "cancelled", note: "settled via unregister entry, no payload" },
332
+ },
333
+ ]);
334
+
335
+ const res = await runCollect(setup, {});
336
+
337
+ expect(res.details.collected).toEqual([
338
+ { id: "sm-1", type: "session", outcome: "cancelled", note: "settled via unregister entry, no payload" },
339
+ ]);
340
+ expect("content" in res.details.collected[0]).toBe(false);
341
+ });
342
+
343
+ it("到达等待路径:collectArm 等待 → 到达回调 consume → 全键到齐返回 collected 并解散等待集", async () => {
344
+ setup.entries.push(makeRegisterEntry("w-1"));
345
+ setup.port.collectArm.mockReturnValue([{ id: "w-1", state: "waiting" }]);
346
+
347
+ const promise = runCollect(setup, {});
348
+ // execute 同步段已跑到 await(collectArm + onCollectArrival 订阅完成)
349
+ const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
350
+ const unsubscribe = setup.port.onCollectArrival.mock.results[0].value as ReturnType<typeof vi.fn>;
351
+ // 端口 consume 的真实返回形态(MF-1-6):只含 {id, content, details}——type/outcome
352
+ // 由工具侧终局回填(register entry 在册 → type;注销不在册 → outcome 缺省)
353
+ setup.port.consume.mockReturnValueOnce([
354
+ { id: "w-1", content: "payload" },
355
+ ]);
356
+
357
+ arrivalCb();
358
+ const res = await promise;
359
+
360
+ expect(res.details.collected).toEqual([
361
+ { id: "w-1", type: "workflow", content: "payload" },
362
+ ]);
363
+ expect(res.details.stillOpen).toEqual([]);
364
+ expect(setup.port.collectRelease).toHaveBeenCalledTimes(1);
365
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
366
+ });
367
+
368
+ it("[同步到达 outcome 回填] 等待中到达的条目:consume 时点注销未落盘 outcome 取空,返回组装前从注销 entry 回填(U1)", async () => {
369
+ // 时序镜像(managed flushBatch,watch-coordinator.ts:323-329):deliver(到达
370
+ // 回调在 deliver 栈内同步触发 pullArrived→consume,此刻注销 entry 未落盘)→
371
+ // 同步段后半 unregisterPending 落盘 → await 恢复后回填生效。
372
+ setup.entries.push(makeRegisterEntry("sm-1", { type: "session" }));
373
+ setup.port.collectArm.mockReturnValue([{ id: "sm-1", state: "waiting" }]);
374
+
375
+ const promise = runCollect(setup, {});
376
+ const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
377
+ // 端口 consume 的真实返回形态:载荷在、type/outcome 取不到(注销尚未落盘——
378
+ // register 已落盘但本用例镜像 deliver 栈内时点,mock 按最简形态不给 type)
379
+ setup.port.consume.mockReturnValueOnce([
380
+ { id: "sm-1", content: "2 queued messages done", details: { fulfills: 2 } },
381
+ ]);
382
+
383
+ arrivalCb(); // deliver 同步栈前半:consume 取走载荷,outcome 缺失
384
+ setup.handlers.pendingUnregister!({ id: "sm-1", reason: "completed" }); // 同步栈后半:注销落盘(listener 走 mapReasonToStatus)
385
+
386
+ const res = await promise;
387
+
388
+ expect(res.details.collected).toEqual([
389
+ { id: "sm-1", type: "session", outcome: "completed", content: "2 queued messages done", details: { fulfills: 2 } },
390
+ ]);
391
+ // 注销 entry 已由真实 listener 落盘(回填源在本 session entries 现算)
392
+ expect(
393
+ setup.entries.some((e) => e.customType === "pending:unregister" && e.data.id === "sm-1"),
394
+ ).toBe(true);
395
+ });
396
+
397
+ it("[outcome 回填反向断言] 注销 entry 不在册的收取条目 outcome 保持 undefined(不虚构终态)", async () => {
398
+ setup.entries.push(makeRegisterEntry("w-1"));
399
+ setup.port.collectArm.mockReturnValue([{ id: "w-1", state: "waiting" }]);
400
+
401
+ const promise = runCollect(setup, {});
402
+ const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
403
+ setup.port.consume.mockReturnValueOnce([
404
+ { id: "w-1", content: "payload without unregister" },
405
+ ]);
406
+
407
+ arrivalCb();
408
+ const res = await promise;
409
+
410
+ expect(res.details.collected).toEqual([
411
+ { id: "w-1", type: "workflow", outcome: undefined, content: "payload without unregister" },
412
+ ]);
413
+ });
414
+
415
+ it("超时路径:已收部分进 collected、未完成进 stillOpen(诚实返回,不抛错)", async () => {
416
+ setup.entries.push(makeRegisterEntry("fast"), makeRegisterEntry("slow"));
417
+ setup.port.collectArm.mockReturnValue([
418
+ { id: "fast", state: "waiting" },
419
+ { id: "slow", state: "waiting" },
420
+ ]);
421
+
422
+ const promise = runCollect(setup, { timeout_ms: 5_000 });
423
+ const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
424
+ setup.port.consume
425
+ .mockReturnValueOnce([{ id: "fast", content: "fast done" }])
426
+ .mockReturnValue([]);
427
+
428
+ arrivalCb(); // fast 到达,slow 仍等
429
+ await vi.advanceTimersByTimeAsync(5_000);
430
+ const res = await promise;
431
+
432
+ expect(res.details.collected).toEqual([{ id: "fast", type: "workflow", content: "fast done" }]);
433
+ expect(res.details.stillOpen).toEqual([{ id: "slow" }]);
434
+ expect(setup.port.collectRelease).toHaveBeenCalledTimes(1);
435
+ });
436
+
437
+ it("timeout_ms 缺省 = 3_600_000(量级决策 §3.3:缺省 1h 前不判超时)", async () => {
438
+ setup.entries.push(makeRegisterEntry("w-1"));
439
+ setup.port.collectArm.mockReturnValue([{ id: "w-1", state: "waiting" }]);
440
+
441
+ const promise = runCollect(setup, {});
442
+ await vi.advanceTimersByTimeAsync(3_599_999);
443
+ // 最后一毫秒之前:缺省超时 timer 仍在(未判超时)
444
+ expect(vi.getTimerCount()).toBe(1);
445
+
446
+ await vi.advanceTimersByTimeAsync(1);
447
+ const res = await promise;
448
+ expect(res.details.stillOpen).toEqual([{ id: "w-1" }]);
449
+ });
450
+
451
+ it("abort 捕获后正常 return(禁抛错路径):已收部分返回、未收进 stillOpen", async () => {
452
+ setup.entries.push(makeRegisterEntry("w-1"), makeRegisterEntry("w-2"));
453
+ setup.port.collectArm.mockReturnValue([
454
+ { id: "w-1", state: "waiting" },
455
+ { id: "w-2", state: "waiting" },
456
+ ]);
457
+ const controller = new AbortController();
458
+
459
+ const promise = runCollect(setup, {}, controller.signal);
460
+ const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
461
+ setup.port.consume
462
+ .mockReturnValueOnce([{ id: "w-1", content: "partial" }])
463
+ .mockReturnValue([]);
464
+
465
+ arrivalCb(); // w-1 到达
466
+ controller.abort(); // 用户打断
467
+ const res = await promise; // 不抛错即通过
468
+
469
+ expect(res.details.collected).toEqual([{ id: "w-1", type: "workflow", content: "partial" }]);
470
+ expect(res.details.stillOpen).toEqual([{ id: "w-2" }]);
471
+ expect(setup.port.collectRelease).toHaveBeenCalledTimes(1);
472
+ });
473
+
474
+ it("调用前已 aborted 的 signal:直接返回、不订阅到达回调", async () => {
475
+ setup.entries.push(makeRegisterEntry("w-1"));
476
+ setup.port.collectArm.mockReturnValue([{ id: "w-1", state: "waiting" }]);
477
+ const controller = new AbortController();
478
+ controller.abort();
479
+
480
+ const res = await runCollect(setup, {}, controller.signal);
481
+
482
+ expect(res.details.stillOpen).toEqual([{ id: "w-1" }]);
483
+ expect(setup.port.onCollectArrival).not.toHaveBeenCalled();
484
+ expect(setup.port.collectRelease).toHaveBeenCalledTimes(1);
485
+ });
486
+
487
+ it("未知 id → errors 逐条列出、不阻断已知子集(collectArm 收全量键,unknown 态由端口定态回流)", async () => {
488
+ setup.entries.push(makeRegisterEntry("w-1"));
489
+ setup.port.collectArm.mockReturnValue([
490
+ { id: "w-1", state: "waiting" },
491
+ { id: "ghost", state: "unknown" },
492
+ ]);
493
+
494
+ const promise = runCollect(setup, { ids: ["w-1", "ghost", "ghost"] });
495
+ const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
496
+ setup.port.consume.mockReturnValueOnce([{ id: "w-1", content: "ok" }]);
497
+
498
+ arrivalCb();
499
+ const res = await promise;
500
+
501
+ // 已知子集照常等待并收取;显式 ids 全量(去重)交端口定态(MF-1-5:未知判定单点在端口)
502
+ expect(setup.port.collectArm).toHaveBeenCalledWith(["w-1", "ghost"]);
503
+ expect(res.details.collected).toEqual([{ id: "w-1", type: "workflow", content: "ok" }]);
504
+ // 未知 id 去重后逐条列出
505
+ expect(res.details.errors).toEqual([
506
+ { id: "ghost", reason: expect.stringContaining("unknown id") },
507
+ ]);
508
+ expect(res.content[0].text).toContain("ghost");
509
+ });
510
+
511
+ it("全部 ids 未知 → 立即返回 errors(collectArm 定态全 unknown,不进入等待)", async () => {
512
+ setup.port.collectArm.mockReturnValue([
513
+ { id: "ghost-a", state: "unknown" },
514
+ { id: "ghost-b", state: "unknown" },
515
+ ]);
516
+
517
+ const res = await runCollect(setup, { ids: ["ghost-a", "ghost-b"] });
518
+
519
+ expect(res.details.collected).toEqual([]);
520
+ expect(res.details.stillOpen).toEqual([]);
521
+ expect(res.details.errors).toHaveLength(2);
522
+ // 未知键不登记等待集:不订阅到达回调、不挂满超时
523
+ expect(setup.port.onCollectArrival).not.toHaveBeenCalled();
524
+ expect(setup.port.collectArm).toHaveBeenCalledWith(["ghost-a", "ghost-b"]);
525
+ });
526
+
527
+ it("已完结 id(register+unregister 在册)不算未知 → collectArm 收键(第四态/快照收取入口)", async () => {
528
+ setup.entries.push(
529
+ makeRegisterEntry("done-1"),
530
+ { customType: "pending:unregister", data: { id: "done-1", reason: "completed", status: "completed" } },
531
+ );
532
+ setup.port.collectArm.mockReturnValue([
533
+ {
534
+ id: "done-1",
535
+ state: "unregistered-no-entry",
536
+ entry: { type: "workflow", outcome: "completed", note: "no payload" },
537
+ },
538
+ ]);
539
+
540
+ const res = await runCollect(setup, { ids: ["done-1"] });
541
+
542
+ expect(setup.port.collectArm).toHaveBeenCalledWith(["done-1"]);
543
+ expect(res.details.errors).toBeUndefined();
544
+ expect(res.details.collected).toEqual([
545
+ { id: "done-1", type: "workflow", outcome: "completed", note: "no payload" },
546
+ ]);
547
+ });
548
+
549
+ it("重复收取 = 快照重收:同 id 二次 collect 再次返回原载荷(工具侧无拒绝分支)", async () => {
550
+ setup.entries.push(makeRegisterEntry("w-1"));
551
+ setup.port.collectArm.mockReturnValue([
552
+ { id: "w-1", state: "ready", entry: { type: "workflow", outcome: "completed", content: "payload" } },
553
+ ]);
554
+
555
+ const first = await runCollect(setup, { ids: ["w-1"] });
556
+ const second = await runCollect(setup, { ids: ["w-1"] });
557
+
558
+ expect(first.details.collected).toEqual(second.details.collected);
559
+ });
560
+
561
+ it("port 缺席(槽未装配)→ 正常返回不可用文案 + 不抛错", async () => {
562
+ portMocks.getDeliveryPort.mockReturnValue(undefined);
563
+ setup.entries.push(makeRegisterEntry("w-1"));
564
+
565
+ const res = await runCollect(setup, {});
566
+
567
+ expect(res.details.collected).toEqual([]);
568
+ expect(res.details.stillOpen).toEqual([]);
569
+ expect(res.content[0].text).toContain("unavailable");
570
+ });
571
+
572
+ it("content 文本承载收取载荷(collected content 随文本到达 agent)", async () => {
573
+ setup.entries.push(makeRegisterEntry("w-1"));
574
+ setup.port.collectArm.mockReturnValue([
575
+ { id: "w-1", state: "ready", entry: { type: "workflow", outcome: "completed", content: "result body" } },
576
+ ]);
577
+
578
+ const res = await runCollect(setup, {});
579
+
580
+ expect(res.content[0].text).toContain("w-1");
581
+ expect(res.content[0].text).toContain("result body");
582
+ });
583
+ });
584
+
585
+ // ────────────────────────────────────────────────────
586
+ // kind 读取链写侧落点(pending:register listener 落盘透传)
587
+ // ────────────────────────────────────────────────────
588
+
589
+ describe("pending:register kind 透传(lifetime 标记落盘)", () => {
590
+ let setup: MockSetup;
591
+
592
+ beforeEach(() => {
593
+ vi.useFakeTimers();
594
+ vi.setSystemTime(NOW);
595
+ setup = createMockSetup();
596
+ pendingNotificationsExtension(setup.pi);
597
+ fireSessionStart(setup);
598
+ });
599
+
600
+ afterEach(() => {
601
+ portMocks.getDeliveryPort.mockReset();
602
+ vi.useRealTimers();
603
+ });
604
+
605
+ it("register event 带 kind=lifetime → 落盘 entry data 透传 kind;读取侧归一化回读", () => {
606
+ setup.handlers.pendingRegister!({ id: "sm-life", type: "session", name: "ms", kind: "lifetime" });
607
+
608
+ const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
609
+ expect(regCall).toBeDefined();
610
+ expect(regCall![1]).toMatchObject({ id: "sm-life", kind: "lifetime" });
611
+
612
+ // 读取链闭环:normalizeRegisterEntry 白名单透传后工具侧可读到 kind
613
+ const res = countActiveFromEntries(setup.entries as unknown[]);
614
+ expect(res.entries[0].kind).toBe("lifetime");
615
+ });
616
+
617
+ it("register event 不带 kind → 落盘 entry 无 kind 键(非 managed 注册零字段增量)", () => {
618
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "op" });
619
+
620
+ const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
621
+ expect("kind" in regCall![1]).toBe(false);
622
+ });
623
+
624
+ it("register event kind 值不在白名单(如未来新值)→ 不透传(缺省 send 债权语义)", () => {
625
+ setup.handlers.pendingRegister!({ id: "w-x", type: "session", name: "ms", kind: "future-kind" });
626
+
627
+ const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
628
+ expect("kind" in regCall![1]).toBe(false);
629
+ });
630
+ });
@@ -157,7 +157,7 @@ describe("state pure functions", () => {
157
157
  });
158
158
  });
159
159
 
160
- describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow / 工具投影复用)", () => {
160
+ describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow / 工具派生视图复用)", () => {
161
161
  const mkRegister = (id: string, type: "subagent" | "workflow" = "subagent", overrides: Record<string, unknown> = {}) => ({
162
162
  type: "custom",
163
163
  customType: "pending:register",
@@ -201,7 +201,7 @@ describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow /
201
201
  expect(countActiveFromEntries(entries, { types: ["subagent", "workflow"] }).count).toBe(2);
202
202
  });
203
203
 
204
- it("TTL 过期仍判活跃(刻意不校验 expiresAt,对齐 goal continuation 守卫语义)", () => {
204
+ it("TTL 过期仍判活跃(刻意不校验 expiresAt,对齐 goal continuation 检查语义)", () => {
205
205
  const expired = mkRegister("bg-1", "subagent", { registeredAt: 0, expiresAt: 1 });
206
206
  expect(countActiveFromEntries([expired]).count).toBe(1);
207
207
  });
@@ -212,12 +212,12 @@ describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow /
212
212
 
213
213
  it("entries 含 null/undefined 元素 → 跳过不抛 TypeError(S-10)", () => {
214
214
  // S-10 回归:外部调用方可能传入含 null/undefined 的 entries(session 文件坏行/脏数据),
215
- // 遍历时访问 raw.customType 前必须守卫,否则 TypeError 炸掉整个差集判定。
215
+ // 遍历时访问 raw.customType 前必须检查,否则 TypeError 炸掉整个差集判定。
216
216
  const withNulls = [null, undefined, mkRegister("bg-1"), mkUnregister("bg-1"), null];
217
217
  expect(countActiveFromEntries(withNulls).count).toBe(0);
218
218
  const mixed = [undefined, null, mkRegister("bg-2")];
219
219
  expect(countActiveFromEntries(mixed).ids).toEqual(["bg-2"]);
220
- // register 循环同样守卫:unregister 遍历前的 null 不污染差集
220
+ // register 循环同样检查:unregister 遍历前的 null 不污染差集
221
221
  expect(countActiveFromEntries([null, undefined]).count).toBe(0);
222
222
  });
223
223
 
@@ -323,8 +323,8 @@ describe("pendingNotificationsExtension factory", () => {
323
323
  expect(await getCount(setup)).toBe(0);
324
324
  });
325
325
 
326
- it("[跨 session 残留] other session 的 register → 不进投影、不补 unregister entry", async () => {
327
- // fork 继承的父级注册残留不进差集(currentSessionId 过滤),且落盘收口归
326
+ it("[跨 session 残留] other session 的 register → 不进派生视图、不补 unregister entry", async () => {
327
+ // fork 继承的父级注册残留不进差集(currentSessionId 过滤),且落盘收尾归
328
328
  // 对账通道——本包不做任何补写。
329
329
  setup.entries.push(makeRegisterEntry("w-1", { sessionId: "sess-old" }));
330
330
  fireSessionStart(setup, "sess-current");
@@ -478,7 +478,7 @@ describe("pendingNotificationsExtension factory", () => {
478
478
  describe("safeAppendEntry error handling", () => {
479
479
  it("appendEntry throwing (stale context) does not break listener; entries and query stay consistent", async () => {
480
480
  // 单一权威源语义:落盘失败 → entries 没有该注册 → 工具同样查不到(不再出现
481
- // 「内存已收、盘上没有」的工具/守卫分裂);listener 不被异常打断,后续注册正常。
481
+ // 「内存已收、盘上没有」的工具/检查分裂);listener 不被异常打断,后续注册正常。
482
482
  fireSessionStart(setup);
483
483
  setup.appendEntryMock.mockImplementationOnce(() => {
484
484
  throw new Error("stale context");
@@ -516,7 +516,7 @@ describe("pendingNotificationsExtension factory", () => {
516
516
  });
517
517
  });
518
518
 
519
- describe("写入侧落盘契约与跨 session 投影", () => {
519
+ describe("写入侧落盘契约与跨 session 派生视图", () => {
520
520
  it("register 落盘契约:data 含 id/type/name/registeredAt/sessionId,无条件无 expiresAt 键(bash)", async () => {
521
521
  fireSessionStart(setup);
522
522
 
@@ -542,10 +542,10 @@ describe("pendingNotificationsExtension factory", () => {
542
542
  expect("expiresAt" in data).toBe(false);
543
543
  });
544
544
 
545
- it("[fork 过滤] 子 session 工具投影不见父 session 注册,父残留不被补注销", async () => {
545
+ it("[fork 过滤] 子 session 工具派生视图不见父 session 注册,父残留不被补注销", async () => {
546
546
  // fork 子 session(继承父 entries):父级注册(sessionId=sess-parent)不进
547
- // 子 session 投影(currentSessionId 过滤),且本包不为残留补写任何 entry
548
- //(落盘收口归 core 对账 sweep / bte 对账通道)。
547
+ // 子 session 派生视图(currentSessionId 过滤),且本包不为残留补写任何 entry
548
+ //(落盘收尾归 core 对账 sweep / bte 对账通道)。
549
549
  setup.entries.push({
550
550
  customType: "pending:register",
551
551
  data: { id: "bg-parent", type: "subagent", name: "p", registeredAt: NOW, sessionId: "sess-parent" },
package/src/index.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * 设计定位:为长耗时异步操作(workflow/subagent/bash 后台任务)提供跨 extension 的
5
5
  * 活跃操作注册/查询机制。workflow/subagent/bash 运行时通过 EventBus(pi.events.emit)
6
6
  * 广播 register/unregister,本扩展监听这些事件、将状态写入 session entries(pi.appendEntry),
7
- * 供 LLM 通过 pending_notifications 工具主动查询当前活跃异步操作
7
+ * 供 LLM 通过 pending_notifications 工具主动查询当前活跃异步操作、通过 collect_results
8
+ * 工具批量阻塞收取结果(result-collect 设计 §3.1③)
8
9
  * (goal 不在 before_agent_start 注入等待消息——避免双信息源,pending 感知由 LLM 自行查询)。
9
10
  *
10
11
  * 文件职责:
@@ -16,7 +17,7 @@
16
17
  * ① subagent-core——notify-host.ts(register + record 终态化 unregister——finalizeRun
17
18
  * 直落后的残留发射面,观测通道 = sweep reconciled WARN)/ orchestration/lifecycle.ts
18
19
  * (register 主链);
19
- * ② base-tool-enhance——bash 投影(notify.ts register/unregister 主链 +
20
+ * ② base-tool-enhance——bash 派生视图(notify.ts register/unregister 主链 +
20
21
  * process-exit-guard.ts 退出边沿尽力补 emit(pending-reconcile 对账走 appendEntry
21
22
  * 权威路径,不经 emit));
22
23
  * ③ subagent-workflow——仅崩溃恢复时 emit pending:unregister(session-lifecycle.ts
@@ -34,10 +35,12 @@
34
35
  * - base-tool-enhance pending-reconcile.ts(session_start 对账,同款)。
35
36
  *
36
37
  * entry 契约(与 goal before-agent-start.ts 对齐,读取端按 e.data.id 算差集):
37
- * - pending:register → { id, type, name, registeredAt, sessionId }
38
+ * - pending:register → { id, type, name, registeredAt, sessionId, kind? }
39
+ * (kind 可选 = managed lifetime 债权标记,session-manager 写侧申报、本 listener
40
+ * 白名单透传落盘——collect_results 缺省等待集排除依据,result-collect 设计 §3.4)
38
41
  * - pending:unregister → { id, reason, status }
39
42
  *
40
- * 状态权威源:session entries 是唯一状态——工具投影与写侧去重/活跃判断全部对
43
+ * 状态权威源:session entries 是唯一状态——工具派生视图与写侧去重/活跃判断全部对
41
44
  * getEntries() 现算(appendEntry 同步入账,pi dist 实证,锚定 pi-semantics
42
45
  * PS-29 extensionAPI appendEntry 直委托 appendCustomEntry + PS-17 _appendEntry
43
46
  * 同步 push 无 debounce),无内存第二份状态;历史的内存 registry、
@@ -48,22 +51,33 @@
48
51
  */
49
52
 
50
53
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
51
- import { mapReasonToStatus as mapReasonToStatusImpl } from "@zhushanwen/extension-protocol";
54
+ import {
55
+ mapReasonToStatus as mapReasonToStatusImpl,
56
+ scanPendingFields,
57
+ } from "@zhushanwen/extension-protocol";
58
+ import {
59
+ getDeliveryPort,
60
+ type CollectedEntry,
61
+ type CollectSnapshot,
62
+ type DeliveryPortHandle,
63
+ } from "@zhushanwen/pi-delivery-port";
52
64
  import { getLogger } from "@zhushanwen/pi-extension-logger";
53
65
  import { Type } from "typebox";
54
66
 
55
67
  import {
68
+ computeCollectWaitKeys,
56
69
  countActiveFromEntries,
57
70
  hasPendingId,
58
71
  isPendingActive,
59
72
  normalizePendingType,
60
73
  type PendingEntry,
74
+ type PendingKind,
61
75
  type PendingStatus,
62
76
  type PendingType,
63
77
  } from "./state.ts";
64
78
  import { renderPendingNotificationsResult } from "./render-result.ts";
65
79
 
66
- // 跨扩展消费 API:goal(continuation 守卫)/ subagent-workflow(agent_end 后代判定)
80
+ // 跨扩展消费 API:goal(continuation 检查)/ subagent-workflow(agent_end 后代判定)
67
81
  // 直接 import 本包导出的 countActiveFromEntries,避免各自复制差集逻辑。导出面只保留
68
82
  // 唯一消费函数 + 其签名所需类型(entries 是唯一状态源,包内不再导出状态机 API)。
69
83
  export {
@@ -94,6 +108,52 @@ interface PendingToolDetails {
94
108
  summary: string;
95
109
  }
96
110
 
111
+ // ── collect_results(批量收取,result-collect 设计 §3.1③)─────────────
112
+
113
+ /** 缺省超时(设计 §3.3 量级决策:任务级对象 → 小时量级。非墙钟判死——超时是等待器的
114
+ * 诚实返回:任务不受影响、stillOpen 列出、恢复 push、可续 collect。偏离「任务级缺省
115
+ * 不限时」的原因与论证登记在设计 §3.3,工具描述引导 agent 按预期时长显式传参)。 */
116
+ const COLLECT_DEFAULT_TIMEOUT_MS = 3_600_000;
117
+
118
+ /** collect_results 工具参数 schema */
119
+ const CollectResultsParams = Type.Object({
120
+ ids: Type.Optional(Type.Array(Type.String())),
121
+ timeout_ms: Type.Optional(Type.Number()),
122
+ });
123
+
124
+ /** stillOpen 条目:未收取的等待键(note 标记特殊态,如已入 push 管线)。 */
125
+ interface StillOpenEntry {
126
+ id: string;
127
+ note?: string;
128
+ }
129
+
130
+ /** errors 条目:未知 id(判定源 = 活跃集 ∪ 投递日志键集 ∪ 曾注册集 的补集),逐条列出不阻断已知子集。 */
131
+ interface CollectErrorEntry {
132
+ id: string;
133
+ reason: string;
134
+ }
135
+
136
+ /** collect_results 工具的 details 形状(与返回契约同构,结构化消费方)。 */
137
+ interface CollectToolDetails {
138
+ collected: CollectedEntry[];
139
+ stillOpen: StillOpenEntry[];
140
+ errors?: CollectErrorEntry[];
141
+ }
142
+
143
+ /** collect_results 工具描述(缺省口径 + lifetime 排除 + managed 全键返回形态 + timeout
144
+ * 缺省值引导显式传参 + 反模式(勿当活性检查用、只关心特定操作勿省略 ids)+
145
+ * 与 pending_notifications 分工声明,result-collect 设计 §3.1③/§3.4 工具描述更新项)。 */
146
+ const COLLECT_RESULTS_DESCRIPTION = [
147
+ "Block and batch-collect results of background async operations (workflow / subagent / bash background tasks / managed session claims), instead of receiving one notification per completion.",
148
+ "Returns collected (entries: {id, type, outcome, content, details}), stillOpen (not collected: {id, note?}) and errors (unknown ids, listed individually without blocking the known subset).",
149
+ "ids: target operation ids (as returned when dispatching background tasks). Omitted = every active pending operation of the current session, EXCLUDING managed-session lifetime claims (alive-markers that only settle when the session dies — waiting on them would hang until timeout) and registrations inherited from a forked parent session.",
150
+ "timeout_ms: max wait in milliseconds, default 3600000 (1 hour). Pass a value matched to the expected task duration (e.g. 120000 for short tasks). On timeout the already-arrived results are returned; the rest stay in stillOpen and notify normally (push) when they complete.",
151
+ "A managed-session batched notification matched by any of its keys returns one entry per key of that batch (same payload shared). Collecting an already-collected id again returns its snapshot (no duplicate injection). Interruption (timeout / user abort) returns normally with the arrived subset.",
152
+ "Do NOT use this tool as a liveness or status check — it blocks the current turn until results arrive or timeout_ms elapses. Immediate status queries go to pending_notifications (list).",
153
+ "DO NOT omit ids when you only care about specific operations — the default wait set blocks on every active non-lifetime pending operation of this session at once.",
154
+ "Division of labor: pending_notifications (list) is the immediate status query; collect_results is the blocking wait — use it only when the next step actually needs those results.",
155
+ ].join("\n");
156
+
97
157
  /**
98
158
  * 模块级 EventBus 监听器 unsubscribe 函数列表。
99
159
  *
@@ -122,7 +182,7 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
122
182
  unsubscribers = [];
123
183
 
124
184
  // ── 闭包内状态 ─────
125
- // entries 是唯一状态源(无内存状态副本):currentSessionId 是查询投影的跨 session
185
+ // entries 是唯一状态源(无内存状态副本):currentSessionId 是查询派生视图的跨 session
126
186
  // 过滤基准;sessionManager 是 listener 写侧前置判断的 entries 读取通道(EventBus
127
187
  // 回调无 ctx 参数,经 session_start 缓存持有)。
128
188
  let currentSessionId: string = "";
@@ -183,6 +243,9 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
183
243
  name: parsed.name,
184
244
  registeredAt: Date.now(),
185
245
  sessionId: currentSessionId,
246
+ // lifetime 债权标记落盘透传(result-collect 设计 §3.4 kind 读取链写侧落点;
247
+ // 无标记条目形状不变——非 managed 注册零字段增量)
248
+ ...(parsed.kind ? { kind: parsed.kind } : {}),
186
249
  });
187
250
 
188
251
  debugLog("debug", "listener: pending:register appended", { id: parsed.id });
@@ -268,6 +331,219 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
268
331
  return renderPendingNotificationsResult(result, options, theme);
269
332
  },
270
333
  });
334
+
335
+ // ── 批量收取 tool(result-collect 设计 §3.1③)─────────────────────
336
+ pi.registerTool({
337
+ name: "collect_results",
338
+ label: "Collect Results",
339
+ description: COLLECT_RESULTS_DESCRIPTION,
340
+ parameters: CollectResultsParams,
341
+ // 任一时刻至多一个在途 collect(设计前提 P2:同批任一工具声明 sequential 即整批
342
+ // 串行)——多在途等待集的跨集判重预案因此不需要。
343
+ executionMode: "sequential",
344
+ execute: async (
345
+ _toolCallId: string,
346
+ params: { ids?: string[]; timeout_ms?: number },
347
+ signal: AbortSignal | undefined,
348
+ _onUpdate: unknown,
349
+ ctx: ExtensionContext,
350
+ ): Promise<{ content: { type: "text"; text: string }[]; details: CollectToolDetails }> =>
351
+ collectResults(params, signal, ctx),
352
+ });
353
+
354
+ /** collect_results 执行体(工厂闭包:读 currentSessionId 缺省集过滤基准)。
355
+ *
356
+ * 执行序(设计 §3.1③):等待键集计算 → collectArm(端口同步段登记 + 五态快照)→
357
+ * 快照合并组装(ready ∪ unregistered-no-entry 进 collected;in-push-pipeline 进
358
+ * stillOpen 附 note;waiting 留守)→ await 到达/超时/abort 三者先到 → consume +
359
+ * collectRelease → 返回。AbortSignal 捕获后正常 return(禁抛错路径——pi 契约下抛错
360
+ * 会把已写 collected 终局的载荷随返回丢失,收取即丢失,设计 §3.1 [abort])。
361
+ */
362
+ async function collectResults(
363
+ params: { ids?: string[]; timeout_ms?: number },
364
+ signal: AbortSignal | undefined,
365
+ ctx: ExtensionContext,
366
+ ): Promise<{ content: { type: "text"; text: string }[]; details: CollectToolDetails }> {
367
+ const entries = ctx.sessionManager.getEntries();
368
+ const timeoutMs = params.timeout_ms ?? COLLECT_DEFAULT_TIMEOUT_MS;
369
+
370
+ // 等待键集:显式 ids 全量交端口五态判决定态(unknown 态键由快照回流进
371
+ // errors,MF-1-5:未知判定单点在端口——工具侧不再重扫 entries 算「活跃集 ∪
372
+ // 曾注册集」补集);缺省 = computeCollectWaitKeys(活跃 − lifetime 死亡债权
373
+ // − 跨 session 残留,currentSessionId 空串回退同款)。
374
+ let waitKeys: string[];
375
+ if (params.ids) {
376
+ waitKeys = [...new Set(params.ids)];
377
+ } else {
378
+ waitKeys = computeCollectWaitKeys(entries, currentSessionId);
379
+ }
380
+
381
+ // 等待集为空:立即返回,不进入等待、不触端口(设计 §3.1③ 边界语义)。显式
382
+ // ids 全量交端口定态后空集只剩两种形态(空数组 / 缺省集空),均不产 errors。
383
+ if (waitKeys.length === 0) {
384
+ const details: CollectToolDetails = { collected: [], stillOpen: [] };
385
+ debugLog("debug", "collect_results: empty wait set, returning immediately");
386
+ return { content: [{ type: "text" as const, text: formatCollectDetails(details) }], details };
387
+ }
388
+
389
+ // 槽缺席降级(base-tool-enhance session_start 装配缺失的异常窗口,生产不可达常态;
390
+ // STANDARDS §11.1 通知类接入点降级非静默吞):warn 留痕 + 正常返回(不抛错,
391
+ // 错误文本指向恢复动作 = 下次 session_start 重试装配后重试)。
392
+ const port = getDeliveryPort();
393
+ if (!port) {
394
+ logger.warn(
395
+ "[pending-notifications] collect_results: delivery port not bound — collect unavailable (base-tool-enhance session_start 装配缺失)",
396
+ { waitKeys },
397
+ );
398
+ return {
399
+ content: [
400
+ {
401
+ type: "text" as const,
402
+ text: "collect_results unavailable: delivery port not bound (assembly missing). Retry after the session finishes starting (session_start rebinds the port).",
403
+ },
404
+ ],
405
+ details: { collected: [], stillOpen: [] },
406
+ };
407
+ }
408
+
409
+ // collectArm:端口同步段登记等待集 + 五态快照(每 key 一条;unknown 态 =
410
+ // 端口未知判定单点回流)。
411
+ const snapshot = port.collectArm(waitKeys);
412
+ const collected: CollectedEntry[] = [];
413
+ const stillOpen: StillOpenEntry[] = [];
414
+ const waitingIds: string[] = [];
415
+ const unknownIds: string[] = [];
416
+ partitionCollectSnapshot(snapshot, { collected, stillOpen, waitingIds, unknownIds });
417
+ const errors: CollectErrorEntry[] = unknownIds.map((id) => ({
418
+ id,
419
+ reason: "unknown id — not an active pending operation and never registered in this session",
420
+ }));
421
+
422
+ // 到达等待:事件驱动(onCollectArrival)不轮询;到达/超时/abort 三者先到。
423
+ // 完成判据 = 全部等待键出现在已收取集合(到达即 consume 取走载荷+终局)。
424
+ await awaitCollectArrivals(port, waitingIds, collected, timeoutMs, signal);
425
+
426
+ // 终局序列(设计 §3.1③):consume 取走剩余已到达挂起条目(超时/abort 路径的
427
+ // 部分收取),collectRelease 解散等待集并把未收挂起条目直通内核 flush(跳过
428
+ // 合批窗口,恢复 push)。空等待路径不会走到这里(提前返回)。
429
+ collected.push(...port.consume());
430
+ port.collectRelease();
431
+
432
+ // type/outcome 缺失回填(字段来源见 backfillCollectFields 注释)。
433
+ backfillCollectFields(ctx, collected);
434
+
435
+ // 未收取的等待键进 stillOpen(无 note = 单纯未完成,完成时恢复 push)。
436
+ const collectedIds = new Set(collected.map((c) => c.id));
437
+ for (const id of waitingIds) {
438
+ if (!collectedIds.has(id)) stillOpen.push({ id });
439
+ }
440
+
441
+ const details: CollectToolDetails = { collected, stillOpen };
442
+ if (errors.length > 0) details.errors = errors;
443
+ debugLog("debug", "collect_results: returning", {
444
+ collected: collected.length,
445
+ stillOpen: stillOpen.length,
446
+ errors: errors.length,
447
+ });
448
+ return { content: [{ type: "text" as const, text: formatCollectDetails(details) }], details };
449
+ }
450
+
451
+ /** 快照五态分流(§3.1③):ready ∪ unregistered-no-entry 进 collected;in-push-pipeline
452
+ * 进 stillOpen 附 note;waiting 留守;unknown 逐条回流(错误组装在调用方)。 */
453
+ function partitionCollectSnapshot(
454
+ snapshot: CollectSnapshot,
455
+ buckets: {
456
+ collected: CollectedEntry[];
457
+ stillOpen: StillOpenEntry[];
458
+ waitingIds: string[];
459
+ unknownIds: string[];
460
+ },
461
+ ): void {
462
+ for (const snap of snapshot) {
463
+ switch (snap.state) {
464
+ case "ready":
465
+ case "unregistered-no-entry":
466
+ // 快照收取:ready = 日志条目已有终局的读侧快照(不写新终局);
467
+ // unregistered-no-entry = 第四态无载荷有限信息形态(outcome 取注销
468
+ // status、note 说明来源,端口侧组装)。两态的 entry 契约在册。
469
+ buckets.collected.push({ id: snap.id, ...(snap.entry ?? {}) });
470
+ break;
471
+ case "in-push-pipeline":
472
+ // 已过分流点、在 push 管线排队待注入:不收取(收取后再注入 = 双重呈现)
473
+ // 不等待(等其终局 = 等待源与阻塞源互等自锁)——附 note 进 stillOpen。
474
+ buckets.stillOpen.push({
475
+ id: snap.id,
476
+ note: "already queued in the push pipeline — will be delivered as a normal notification after this tool returns",
477
+ });
478
+ break;
479
+ case "waiting":
480
+ buckets.waitingIds.push(snap.id);
481
+ break;
482
+ case "unknown":
483
+ // 未知 id(端口五态判据单点判定):逐条列出、不阻断已知子集。
484
+ buckets.unknownIds.push(snap.id);
485
+ break;
486
+ }
487
+ }
488
+ }
489
+
490
+ /** 到达等待段:等待键为空立即返回;否则 consume+到达判据(全部等待键已收取)/
491
+ * 超时 / abort 三者先到解除(abort 捕获后正常 return,不抛错——抛错会把已写
492
+ * collected 终局的载荷随返回丢失,§3.1 [abort])。 */
493
+ async function awaitCollectArrivals(
494
+ port: DeliveryPortHandle,
495
+ waitingIds: readonly string[],
496
+ collected: CollectedEntry[],
497
+ timeoutMs: number,
498
+ signal: AbortSignal | undefined,
499
+ ): Promise<void> {
500
+ if (waitingIds.length === 0) return;
501
+ await new Promise<void>((resolve) => {
502
+ let settled = false;
503
+ let timer: ReturnType<typeof setTimeout> | undefined = undefined;
504
+ let unsubscribeArrival: () => void = () => {};
505
+ const onAbort = () => finish();
506
+ const finish = () => {
507
+ if (settled) return;
508
+ settled = true;
509
+ if (timer !== undefined) clearTimeout(timer);
510
+ signal?.removeEventListener("abort", onAbort);
511
+ unsubscribeArrival();
512
+ resolve();
513
+ };
514
+ const pullArrived = () => {
515
+ collected.push(...port.consume());
516
+ const collectedIds = new Set(collected.map((c) => c.id));
517
+ if (waitingIds.every((id) => collectedIds.has(id))) finish();
518
+ };
519
+ if (signal?.aborted) {
520
+ finish();
521
+ return;
522
+ }
523
+ timer = setTimeout(finish, timeoutMs);
524
+ signal?.addEventListener("abort", onAbort, { once: true });
525
+ unsubscribeArrival = port.onCollectArrival(pullArrived);
526
+ });
527
+ }
528
+
529
+ /** type/outcome 缺失回填(设计 §3.1③ 字段来源表:type 与 pending:register 的
530
+ * type 对齐、outcome 与 pending:unregister 的 status 对齐;MF-1-6:端口 consume
531
+ * 只返回 {id, content, details},终态字段统一由本处单遍扫描回填):「等待中
532
+ * 同步到达」的挂起条目在端口 consume 时点,生产方的注销 entry 尚未落盘
533
+ * (managed flushBatch 的 deliver 与 unregisterPending 同步段相邻、deliver 先
534
+ * 行;端口 consume 由到达回调在 deliver 栈内同步触发),outcome 恒取空。await
535
+ * 恢复后生产方同步栈已完成(注销 entry 已落盘),按本 session entries 现算回填;
536
+ * 注销不在册的条目保持 undefined(诚实缺省,不虚构终态)。快照收取条目
537
+ * (ready / unregistered-no-entry)的 type/outcome 已由端口 collectArm 填充,
538
+ * 回填只补缺不覆盖。 */
539
+ function backfillCollectFields(ctx: ExtensionContext, collected: CollectedEntry[]): void {
540
+ if (!collected.some((c) => c.outcome === undefined || c.type === undefined)) return;
541
+ const fields = scanPendingFields(ctx.sessionManager.getEntries());
542
+ for (const c of collected) {
543
+ if (c.type === undefined) c.type = fields.registerTypes.get(c.id);
544
+ if (c.outcome === undefined) c.outcome = fields.unregisterStatuses.get(c.id);
545
+ }
546
+ }
271
547
  }
272
548
 
273
549
  // ── 事件解析 helper ─────────────────────────────────
@@ -276,6 +552,7 @@ interface ParsedRegister {
276
552
  id: string;
277
553
  type: PendingType;
278
554
  name: string;
555
+ kind?: PendingKind;
279
556
  }
280
557
 
281
558
  /** 解析 pending:register 事件 data(容错缺失/类型错误字段) */
@@ -288,6 +565,9 @@ function parseRegisterEvent(data: unknown): ParsedRegister | null {
288
565
  // bash 类型直通(归一化映射与 state.ts 读取侧共用同一函数,防两侧漂移)
289
566
  type: normalizePendingType(d.type),
290
567
  name: typeof d.name === "string" ? d.name : d.id,
568
+ // lifetime 标记白名单解析(result-collect 设计 §3.4;未知值不透传 = 缺省
569
+ // send 债权语义,与 normalizeRegisterEntry 读取侧白名单同口径防漂移)
570
+ ...(d.kind === "lifetime" ? { kind: "lifetime" as const } : {}),
291
571
  };
292
572
  }
293
573
 
@@ -335,3 +615,27 @@ export function buildPendingNotificationsSummary(details: {
335
615
  ? `${details.count} pending operations`
336
616
  : `${details.count} entries listed`;
337
617
  }
618
+
619
+ /** collect_results 结果的可读文本(content 通道:载荷 content 随文本到达 agent,
620
+ * details 承载结构化同构副本供程序消费)。 */
621
+ function formatCollectDetails(details: CollectToolDetails): string {
622
+ const lines = [
623
+ `collect_results: ${details.collected.length} collected, ${details.stillOpen.length} still open${
624
+ details.errors ? `, ${details.errors.length} unknown id(s)` : ""
625
+ }`,
626
+ ];
627
+ for (const c of details.collected) {
628
+ const typeTag = c.type ? `[${c.type}] ` : "";
629
+ const outcomeTag = c.outcome ? ` (${c.outcome})` : "";
630
+ const body = c.content ? `: ${c.content}` : "";
631
+ const noteTag = c.note ? ` — ${c.note}` : "";
632
+ lines.push(`- collected ${typeTag}${c.id}${outcomeTag}${body}${noteTag}`);
633
+ }
634
+ for (const s of details.stillOpen) {
635
+ lines.push(`- still open: ${s.id}${s.note ? ` (${s.note})` : ""}`);
636
+ }
637
+ for (const e of details.errors ?? []) {
638
+ lines.push(`- unknown id: ${e.id} — ${e.reason}`);
639
+ }
640
+ return lines.join("\n");
641
+ }
package/src/state.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * 职责:
5
5
  * - PendingEntry: 一个异步操作的完整描述(与 pending:register entry data 对齐)
6
- * - countActiveFromEntries: register − unregister 差集 = 活跃集合(goal 守卫 /
7
- * subagent-workflow 后代判定 / 本包 pending_notifications 工具投影共用)
6
+ * - countActiveFromEntries: register − unregister 差集 = 活跃集合(goal 检查 /
7
+ * subagent-workflow 后代判定 / 本包 pending_notifications 工具派生视图共用)
8
8
  * - hasPendingId / isPendingActive: 写侧落盘前置判断(register 去重 / unregister 活跃判定)
9
9
  *
10
10
  * 设计要点:
@@ -17,14 +17,27 @@
17
17
  * 结构上不可分歧
18
18
  * - 纯函数,不依赖 Pi 运行时(ExtensionAPI/appendEntry),可独立单元测试
19
19
  * - 差集刻意不校验 expiresAt(四类型(workflow/subagent/bash/session)全 process 档,长任务 >1h 仍应视为活跃,
20
- * 对齐 goal continuation 守卫语义);历史 session 文件中遗留的带 expiresAt 的
20
+ * 对齐 goal continuation 检查语义);历史 session 文件中遗留的带 expiresAt 的
21
21
  * register entry 无需迁移——差集语义不读该键
22
22
  */
23
- import { applyPendingDiff, scanPendingEntries } from "@zhushanwen/extension-protocol";
23
+ import {
24
+ applyPendingDiff,
25
+ scanPendingEntries,
26
+ } from "@zhushanwen/extension-protocol";
24
27
 
25
28
  /** 异步操作类型(来源:workflow / subagent / bash 后台任务 / session 完成通知债权) */
26
29
  export type PendingType = "workflow" | "subagent" | "bash" | "session";
27
30
 
31
+ /**
32
+ * managed 债权分类标记(pending:register emit 可选字段 kind,session-manager 写侧
33
+ * 申报,result-collect 设计 §3.4):lifetime = 生命周期死亡债权(create_session 返回的
34
+ * lifetimeNotifyId,仅 session exited/deleted 时兑现、存活期恒活跃)——collect_results
35
+ * 缺省等待集据此排除(排除只在工具侧 computeCollectWaitKeys 做减法,countActiveFromEntries
36
+ * 缺省语义不动:goal/subagent-workflow 消费方仍计 lifetime 为活跃)。
37
+ * 无 kind 缺省 = send 债权(消息完成即兑现,缺省主场景键,不排除)。
38
+ */
39
+ export type PendingKind = "lifetime";
40
+
28
41
  /** 异步操作终态/过渡状态。active = 仍在运行;其他都视为已结束 */
29
42
  export type PendingStatus = "active" | "completed" | "failed" | "cancelled" | "expired" | "time_limited" | "aborted";
30
43
 
@@ -42,6 +55,8 @@ export interface PendingEntry {
42
55
  registeredAt: number;
43
56
  /** 注册时的 sessionId(用于跨 session 残留过滤) */
44
57
  sessionId: string;
58
+ /** managed 债权分类标记(可选;无标记 = send 债权缺省语义,见 PendingKind) */
59
+ kind?: PendingKind;
45
60
  }
46
61
 
47
62
  /** countActiveFromEntries 的过滤选项。 */
@@ -51,7 +66,7 @@ export interface CountActiveOptions {
51
66
  /**
52
67
  * [跨 session 残留过滤] 当前 session id(过滤基准):传入时按「register entry 的
53
68
  * sessionId ≠ 当前 session → 跳过」过滤——fork 继承的父级注册残留(永久留存于子
54
- * session 文件,落盘收口归 core 对账 sweep / bte 对账通道)不进差集计数,守卫不幻
69
+ * session 文件,落盘收尾归 core 对账 sweep / bte 对账通道)不进差集计数,检查不幻
55
70
  * defer。缺省(undefined)= 不过滤(向后兼容:既有调用方零改动行为不变)。
56
71
  * entry 缺 sessionId 字段的旧形态条目视为本 session(归一化兜底语义与
57
72
  * normalizeRegisterEntry 一致),不过滤。
@@ -71,13 +86,13 @@ export interface CountActiveResult {
71
86
  * 从持久化 entries 计算活跃 pending 数(register − unregister 差集)。
72
87
  *
73
88
  * 只做「有没有活跃 pending」的只读判断,不产生任何写入;TTL 刻意不校验
74
- * (长任务 subagent >1h 仍应视为活跃,对齐 goal agent-end 的 continuation 守卫语义)。
89
+ * (长任务 subagent >1h 仍应视为活跃,对齐 goal agent-end 的 continuation 检查语义)。
75
90
  * 调用方:goal(agent_end 时判断是否发 continuation)、subagent-workflow
76
91
  * (agent_end 时判断子进程是否有活跃后代,决定是否保持进程等 steer 唤醒)、
77
- * 本包 pending_notifications 工具投影。
92
+ * 本包 pending_notifications 工具派生视图。
78
93
  *
79
94
  * 跨 session 残留过滤:持有当前 session 概念的调用方应传入 opts.currentSessionId,
80
- * 使 fork 继承的父级注册残留不进差集(守卫不幻 defer)。
95
+ * 使 fork 继承的父级注册残留不进差集(检查不幻 defer)。
81
96
  */
82
97
  export function countActiveFromEntries(
83
98
  entries: unknown[],
@@ -94,6 +109,27 @@ export function countActiveFromEntries(
94
109
  };
95
110
  }
96
111
 
112
+ /**
113
+ * collect_results 的缺省等待键集(result-collect 设计 §3.1③):
114
+ * 当前 session 全部活跃 pending 项(countActiveFromEntries 同口径,currentSessionId
115
+ * 过滤含空串回退)− managed lifetime 死亡债权(kind 标记,本函数工具侧减法——
116
+ * countActiveFromEntries 缺省语义不动,goal/subagent-workflow 消费方零变化)。
117
+ */
118
+ export function computeCollectWaitKeys(entries: unknown[], currentSessionId: string): string[] {
119
+ const active = countActiveFromEntries(
120
+ entries,
121
+ currentSessionId ? { currentSessionId } : undefined,
122
+ ).entries;
123
+ return active.filter((entry) => entry.kind !== "lifetime").map((entry) => entry.id);
124
+ }
125
+
126
+ /**
127
+ * collect 终局回填的字段视图单点在 protocol scanPendingFields(register 首见
128
+ * type + 注销 status 的 MAPPED 成员,与 delivery-port 快照基线同一映射规则,
129
+ * MF-2-1;回填时序背景见 index.ts collectResults 回填段)——本文件不持有
130
+ * 字段映射实现。
131
+ */
132
+
97
133
  /**
98
134
  * 写侧前置判断①:entries 中是否已存在该 id 的 register entry(无论注销与否)。
99
135
  * register listener 落盘前去重用(U6 重复注册忽略);id 全局唯一(时间戳+随机)
@@ -128,7 +164,7 @@ function filterActiveRegisters(
128
164
  if (opts?.types && !opts.types.includes(entry.type)) continue;
129
165
  // 跨 session 残留跳过(基准 = opts.currentSessionId)。判据读原始 data.sessionId
130
166
  // 而非归一化值:entry 缺 sessionId 的旧形态条目(归一化兜底为 "")视为本
131
- // session——不过滤,与 normalizeRegisterEntry 的容错语义对齐(守卫漏计的危害
167
+ // session——不过滤,与 normalizeRegisterEntry 的容错语义对齐(检查漏计的危害
132
168
  // 方向是幻 defer,宁放行不误杀活跃计数)。
133
169
  if (
134
170
  opts?.currentSessionId !== undefined &&
@@ -146,13 +182,13 @@ function filterActiveRegisters(
146
182
  * type 归一化:subagent/bash/session 原样保留,其余(含缺失/未知值)归 workflow。
147
183
  * state.ts 与 index.ts 两处归一化共用本函数,防止「写入侧直通、读取侧归并」漂移。
148
184
  *
149
- * [偏好显式化] 缺失/未知 type 默认归 workflow = 宁挂账不失明:未知类型照常进差集
150
- * (守卫不因类型畸形失明),收口通道按类型分流——workflow / 畸形条目由 core 注册
151
- * 对账 sweep 收口(查 WorkflowRun store:终态 ∪ state 文件不存在 → 补注销);bash 无
185
+ * [偏好显式化] 缺失/未知 type 默认归 workflow = 宁挂着不处理也不失明:未知类型照常进差集
186
+ * (检查不因类型畸形失明),收尾通道按类型分流——workflow / 畸形条目由 core 注册
187
+ * 对账 sweep 收尾(查 WorkflowRun store:终态 ∪ state 文件不存在 → 补注销);bash 无
152
188
  * record/store 可查——注册随进程退出注销,进程死亡窗口的丢失无补发通道,属显式
153
189
  * 边界(每孤儿 bash 注册至多 1 条静态虚报,熔断限损);session(notify-once D6
154
190
  * 词表真身在此——否则写侧归一成 workflow 展示错标)由 watch 应答 unregister 与
155
- * extension session_start 重启收口腿(D7③)收口,reconcile-sweep 对该 raw type
191
+ * extension session_start 重启收尾路径(D7③)收尾,reconcile-sweep 对该 raw type
156
192
  * 显式 skip(不入 workflow run-state 判据,防误销活跃 claim)。
157
193
  */
158
194
  export function normalizePendingType(raw: unknown): PendingType {
@@ -163,10 +199,13 @@ export function normalizePendingType(raw: unknown): PendingType {
163
199
  }
164
200
 
165
201
  /** 从 entry data 归一化为 PendingEntry(补默认值,容错缺失字段)。
166
- * id 的 string 合法性已由差集层(applyPendingDiff)判定,此处直接窄化。 */
202
+ * id 的 string 合法性已由差集层(applyPendingDiff)判定,此处直接窄化。
203
+ * kind 白名单构造透传(result-collect 设计 §3.4:现状白名单只取 id/type/name/
204
+ * registeredAt/sessionId,额外字段被丢弃——不改则工具侧拿不到 kind、lifetime
205
+ * 排除静默失效;仅认得 "lifetime" 一个值,未知值不透传)。 */
167
206
  function normalizeRegisterEntry(data: Record<string, unknown>, currentSessionId: string): PendingEntry {
168
207
  const registeredAt = typeof data.registeredAt === "number" ? data.registeredAt : Date.now();
169
- return {
208
+ const entry: PendingEntry = {
170
209
  id: data.id as string,
171
210
  type: normalizePendingType(data.type),
172
211
  name: typeof data.name === "string" ? data.name : (data.id as string),
@@ -174,4 +213,6 @@ function normalizeRegisterEntry(data: Record<string, unknown>, currentSessionId:
174
213
  registeredAt,
175
214
  sessionId: typeof data.sessionId === "string" ? data.sessionId : currentSessionId,
176
215
  };
216
+ if (data.kind === "lifetime") entry.kind = "lifetime";
217
+ return entry;
177
218
  }