@zhushanwen/pi-pending-notifications 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-pending-notifications",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Cross-extension async operation registration/query mechanism for Pi.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -205,24 +205,31 @@ describe("state pure functions", () => {
205
205
  expect(comp.expiredToFlush).toEqual([]);
206
206
  });
207
207
 
208
- it("U3: register expiredflush unregister with status=expired", () => {
208
+ it("[W4 翻档] U3 机器对现存类型不可达:workflow(process 档)超 TTL active、无 flush", () => {
209
+ // 翻档后三类型全 process 档:expiresAt 判定短路(isExpiredEntry 恒 false)。
210
+ // 旧行为(session 档 1h TTL 过期 → flush expired unregister)已被移除——
211
+ // 长任务注册不再被 TTL 静默清除(事故环 4 放大器),收口归注销发射点枚举 +
212
+ // core 注册对账 sweep。
209
213
  const comp = rebuild(
210
214
  [makeRegisterEntry("w-1", { expiresAt: NOW - 1 })],
211
215
  "sess-current",
212
216
  NOW,
213
217
  );
214
- expect(comp.activeIds).toEqual([]);
215
- expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
218
+ expect(comp.activeIds).toEqual(["w-1"]);
219
+ expect(comp.expiredToFlush).toEqual([]);
216
220
  });
217
221
 
218
- it("U4: register with different sessionId flush unregister with status=expired", () => {
222
+ it("[W4 读侧过滤③] U4 session 残留不入 registry、不补注销(读侧过滤替代 U4 中性化)", () => {
223
+ // 翻档后 fork 继承的父级注册残留由 rebuild 读侧过滤兜住(≠ 当前 session →
224
+ // 跳过),残留 entry 留在 session 文件(差集消费方各自过滤),落盘收口归
225
+ // core 注册对账 sweep——不再补 expired unregister。
219
226
  const comp = rebuild(
220
227
  [makeRegisterEntry("w-1", { sessionId: "sess-other" })],
221
228
  "sess-current",
222
229
  NOW,
223
230
  );
224
231
  expect(comp.activeIds).toEqual([]);
225
- expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
232
+ expect(comp.expiredToFlush).toEqual([]);
226
233
  });
227
234
 
228
235
  it("entries 含 null/undefined 元素 → 跳过不抛 TypeError(S-10)", () => {
@@ -238,7 +245,7 @@ describe("state pure functions", () => {
238
245
  });
239
246
 
240
247
  describe("normalizeRegisterEntry defaults (via rebuild)", () => {
241
- it("entry with only {id} → type=workflow, name=id, sessionId=current, expiresAt=registeredAt+TTL", () => {
248
+ it("[W4 翻档] entry with only {id} → type=workflow(process 档), name=id, sessionId=current, expiresAt=undefined", () => {
242
249
  const r = createRegistry();
243
250
  const result = rebuildFromEntries(
244
251
  r,
@@ -252,7 +259,9 @@ describe("state pure functions", () => {
252
259
  expect(entry.name).toBe("w-min");
253
260
  expect(entry.sessionId).toBe("sess-current");
254
261
  expect(entry.status).toBe("active");
255
- expect(entry.expiresAt).toBe(entry.registeredAt + 3_600_000);
262
+ // 翻档后 workflow = process 档:无 TTL(normalizePendingType 默认归 workflow
263
+ // 的偏好 = 缺失/未知 type 宁挂账不失明,清理通道 = core 对账 sweep)。
264
+ expect(entry.expiresAt).toBeUndefined();
256
265
  });
257
266
  });
258
267
  });
@@ -290,22 +299,22 @@ describe("pendingNotificationsExtension factory", () => {
290
299
  expect(await getCount(setup)).toBe(0);
291
300
  });
292
301
 
293
- it("U3: expired register flush pending:unregister entry", async () => {
302
+ it("[W4 翻档] expired register(process 档)→ active、不补 unregister entry", async () => {
294
303
  vi.setSystemTime(NOW + 3_700_000);
295
304
  fireSessionStart(setup, createMockCtx([makeRegisterEntry("w-1", { expiresAt: NOW })]));
296
- expect(await getCount(setup)).toBe(0);
297
- expect(setup.appendEntryMock).toHaveBeenCalledWith(
305
+ expect(await getCount(setup)).toBe(1);
306
+ expect(setup.appendEntryMock).not.toHaveBeenCalledWith(
298
307
  "pending:unregister",
299
- expect.objectContaining({ id: "w-1", status: "expired" }),
308
+ expect.objectContaining({ id: "w-1" }),
300
309
  );
301
310
  });
302
311
 
303
- it("U4: different sessionId → flush pending:unregister entry", async () => {
312
+ it("[W4 读侧过滤③] different sessionId → 不入 registry(active=0)、不补 unregister entry", async () => {
304
313
  fireSessionStart(setup, createMockCtx([makeRegisterEntry("w-1", { sessionId: "sess-old" })], "sess-current"));
305
314
  expect(await getCount(setup)).toBe(0);
306
- expect(setup.appendEntryMock).toHaveBeenCalledWith(
315
+ expect(setup.appendEntryMock).not.toHaveBeenCalledWith(
307
316
  "pending:unregister",
308
- expect.objectContaining({ id: "w-1", status: "expired" }),
317
+ expect.objectContaining({ id: "w-1" }),
309
318
  );
310
319
  });
311
320
  });
@@ -383,7 +392,10 @@ describe("pendingNotificationsExtension factory", () => {
383
392
  });
384
393
 
385
394
  describe("session_shutdown (U11)", () => {
386
- it("U11: marks all active as cancelled + flushes unregister entries", () => {
395
+ it("[W4 翻档] U11 对全部现存类型(process 档)不再发生:shutdown 不标 cancelled、不补 unregister", async () => {
396
+ // process 档语义跨 shutdown 存活:任务收尾归任务自身/reaper/监督器,不由
397
+ // session 退出裁定(设计 D4 连带面 3 显式接受);清理痕迹由 core 注册对账
398
+ // sweep 兜底。U11 机器留存待未来 session 档类型,勿误认清理仍在工作。
387
399
  fireSessionStart(setup, createMockCtx([]));
388
400
  setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "a" });
389
401
  setup.handlers.pendingRegister!({ id: "s-1", type: "subagent", name: "b" });
@@ -393,12 +405,8 @@ describe("pendingNotificationsExtension factory", () => {
393
405
  void setup.handlers.sessionShutdown({ type: "session_shutdown" }, createMockCtx([]));
394
406
 
395
407
  const unregisterCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:unregister");
396
- expect(unregisterCalls).toHaveLength(2);
397
- const flushedIds = unregisterCalls.map((c) => (c[1] as { id: string }).id).sort();
398
- expect(flushedIds).toEqual(["s-1", "w-1"]);
399
- for (const c of unregisterCalls) {
400
- expect((c[1] as { status: string }).status).toBe("cancelled");
401
- }
408
+ expect(unregisterCalls).toHaveLength(0);
409
+ expect(await getCount(setup)).toBe(2);
402
410
  });
403
411
  });
404
412
 
@@ -512,13 +520,32 @@ describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow 复
512
520
  expect(countActiveFromEntries([bad, good]).ids).toEqual(["bg-1"]);
513
521
  });
514
522
 
515
- it("跨 session 残留(fork 继承的 register)不校验 sessionId——由 session_start 重建流程补 expired unregister 抵消", () => {
523
+ it("跨 session 残留(fork 继承的 register)缺省不校验 sessionId——传入 currentSessionId 时按 [W4 读侧过滤①] 跳过", () => {
524
+ // 差集口向后兼容:不传 currentSessionId = 不过滤(既有调用方零改动)。
516
525
  // 模拟 P fork 主 session:继承来主 session 的 register(sessionId=s-0),已被 rebuild 补 unregister(expired)
517
526
  const inherited = mkRegister("parent-bg", "subagent", { sessionId: "s-0" });
518
527
  const flushed = mkUnregister("parent-bg", "expired");
519
528
  const own = mkRegister("my-bg");
520
529
  expect(countActiveFromEntries([inherited, flushed, own]).ids).toEqual(["my-bg"]);
521
530
  });
531
+
532
+ it("[W4 读侧过滤①] currentSessionId 过滤:跨 session entry 跳过、本 session 计入", () => {
533
+ const inherited = mkRegister("parent-bg", "subagent", { sessionId: "s-0" });
534
+ const own = mkRegister("my-bg", "subagent", { sessionId: "s-1" });
535
+ const bashOwn = mkRegister("bt-1", "bash", { sessionId: "s-1" });
536
+ expect(
537
+ countActiveFromEntries([inherited, own, bashOwn], { currentSessionId: "s-1" }).ids,
538
+ ).toEqual(["my-bg", "bt-1"]);
539
+ // 同一输入不传基准 → 全量计入(向后兼容面不变)
540
+ expect(countActiveFromEntries([inherited, own, bashOwn]).count).toBe(3);
541
+ });
542
+
543
+ it("[W4 读侧过滤①] entry 缺 sessionId 的旧形态条目视为本 session(不过滤,宁放行不误杀)", () => {
544
+ const legacy = { type: "custom", customType: "pending:register", data: { id: "old-1", type: "subagent", name: "o" } };
545
+ expect(
546
+ countActiveFromEntries([legacy], { currentSessionId: "s-1" }).ids,
547
+ ).toEqual(["old-1"]);
548
+ });
522
549
  });
523
550
 
524
551
  // ────────────────────────────────────────────────────
@@ -541,10 +568,10 @@ function makeBashRegisterEntry(id: string, overrides: Record<string, unknown> =
541
568
  }
542
569
 
543
570
  describe("D16 lifecycle 分档:常量与 type 归一化", () => {
544
- it("PENDING_LIFECYCLE:subagent/workflow=session,bash=process", () => {
571
+ it("[W4 翻档] PENDING_LIFECYCLE:subagent/workflow/bashprocess 档(session 档机器留存待未来类型)", () => {
545
572
  expect(PENDING_LIFECYCLE).toEqual({
546
- subagent: "session",
547
- workflow: "session",
573
+ subagent: "process",
574
+ workflow: "process",
548
575
  bash: "process",
549
576
  });
550
577
  });
@@ -575,25 +602,29 @@ describe("D16 process 档(type=bash)纯函数行为", () => {
575
602
  expect(comp.expiredToFlush).toEqual([]);
576
603
  });
577
604
 
578
- it("U4 跨 session 不标 expired:bash entry sessionId 不符当前 session → 仍 active(进程级生命周期跨 session 续存)", () => {
605
+ it("[W4 读侧过滤③] U4 跨 session 残留(含 bash)→ 不入 registry(读侧过滤一刀对全部类型生效)", () => {
606
+ // 读侧过滤③不分类型:跨 session 残留在 rebuild 入口即被跳过(W6 的 bash 跨
607
+ // session 可见性断言钉成的显式选择——子 session goal 不再为父 session bash
608
+ // 任务 defer)。残留 entry 的落盘收口归 core 注册对账 sweep。
579
609
  const comp = rebuild(
580
610
  [makeBashRegisterEntry("bt-1", { sessionId: "sess-other" })],
581
611
  "sess-current",
582
612
  NOW,
583
613
  );
584
- expect(comp.activeIds).toEqual(["bt-1"]);
614
+ expect(comp.activeIds).toEqual([]);
585
615
  expect(comp.expiredToFlush).toEqual([]);
586
616
  });
587
617
 
588
- it("session 档对照回归:同形状(无 expiresAt 键)的 workflow entry → 回填 TTL 且过期照旧判 expired", () => {
589
- // 保证分档判定没有把 session 档的 TTL 路径一并豁免
618
+ it("[W4 翻档] 同形状(无 expiresAt 键)的 workflow entry → 不回填 TTL、照常 active(process 档)", () => {
619
+ // 翻档后 workflow = process 档:读取侧不回填 TTL(normalizeRegisterEntry
620
+ // process 档恒 undefined),注册不再被 1h TTL 静默清除(守卫不失明)。
590
621
  const legacy = {
591
622
  customType: "pending:register",
592
623
  data: { id: "w-1", type: "workflow", name: "w", registeredAt: NOW - 3_700_000, sessionId: "sess-current" },
593
624
  };
594
625
  const comp = rebuild([legacy], "sess-current", NOW);
595
- expect(comp.activeIds).toEqual([]);
596
- expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
626
+ expect(comp.activeIds).toEqual(["w-1"]);
627
+ expect(comp.expiredToFlush).toEqual([]);
597
628
  });
598
629
 
599
630
  it("差集路径识别 bash entry:register 计入且 type 保留,unregister 抵消", () => {
@@ -646,14 +677,14 @@ describe("D16 process 档(type=bash)工厂行为", () => {
646
677
  expect("expiresAt" in data).toBe(false);
647
678
  });
648
679
 
649
- it("session 档对照回归:workflow register → 落盘 data.expiresAt = NOW + TTL", async () => {
680
+ it("[W4 翻档] workflow register → 落盘 data expiresAt 键(process 档写入侧豁免)", async () => {
650
681
  fireSessionStart(setup, createMockCtx([]));
651
682
 
652
683
  setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "run" });
653
684
 
654
685
  const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
655
686
  const data = regCall![1] as Record<string, unknown>;
656
- expect(data.expiresAt).toBe(NOW + 3_600_000);
687
+ expect("expiresAt" in data).toBe(false);
657
688
  });
658
689
 
659
690
  it("U3 工厂级:bash entry 超 1h 后 session_start rebuild → 仍 active、不补 unregister", async () => {
@@ -666,10 +697,9 @@ describe("D16 process 档(type=bash)工厂行为", () => {
666
697
  expect(flushCalls).toHaveLength(0);
667
698
  });
668
699
 
669
- it("session_shutdown:bash 不标 cancelled、不补 unregister entry、内存仍 active", async () => {
700
+ it("[W4 翻档] session_shutdown:bash workflow(全 process 档)都不标 cancelled、内存仍 active", async () => {
670
701
  fireSessionStart(setup, createMockCtx([]));
671
702
  setup.handlers.pendingRegister!({ id: "bt-1", type: "bash", name: "run tests" });
672
- // 同时注册一个 session 档条目,对照证明分档只豁免 process 档
673
703
  setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "run" });
674
704
  setup.appendEntryMock.mockClear();
675
705
 
@@ -677,8 +707,24 @@ describe("D16 process 档(type=bash)工厂行为", () => {
677
707
  void setup.handlers.sessionShutdown({ type: "session_shutdown" }, createMockCtx([]));
678
708
 
679
709
  const unregisterCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:unregister");
680
- expect(unregisterCalls).toHaveLength(1);
681
- expect((unregisterCalls[0][1] as { id: string }).id).toBe("w-1");
710
+ expect(unregisterCalls).toHaveLength(0);
711
+ expect(await getCount(setup)).toBe(2);
712
+ });
713
+
714
+ it("[W4 读侧过滤③ 投影] fork 继承残留不入 registry:session 替换后 pending_notifications 工具不虚报跨 session 活跃", async () => {
715
+ // A9② 场景:父 session(sess-parent)注册 bg-parent → fork 出子 session
716
+ //(sess-child,继承父 entries)→ 子 session_start rebuild 按 sessionId 过滤
717
+ // 残留 → 工具 count/list 只见子 session 自己的注册。
718
+ const parentEntries = [
719
+ { customType: "pending:register", data: { id: "bg-parent", type: "subagent", name: "p", registeredAt: NOW, sessionId: "sess-parent" } },
720
+ ];
721
+ fireSessionStart(setup, createMockCtx(parentEntries, "sess-child"));
722
+ // 子 session 自己的新注册(listener 写入,sessionId=sess-child)
723
+ setup.handlers.pendingRegister!({ id: "bg-own", type: "subagent", name: "own" });
724
+
682
725
  expect(await getCount(setup)).toBe(1);
726
+ const res = await runTool(setup, { action: "list" });
727
+ const ids = (res.details as { items: PendingEntry[] }).items.map((i) => i.id);
728
+ expect(ids).toEqual(["bg-own"]);
683
729
  });
684
730
  });
package/src/index.ts CHANGED
@@ -236,6 +236,13 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
236
236
  });
237
237
 
238
238
  // ── session_shutdown:所有 active → cancelled + 补 entry(U11) ──
239
+ // [W4 翻档登记] U11(shutdown 标 cancelled)随翻档对全部现存类型(process 档)
240
+ // 不再发生——process 档语义本就跨 shutdown 存活,任务收尾归任务自身/reaper/
241
+ // 监督器,不由 session 退出裁定;清理痕迹由 core 注册对账 sweep 兜底(覆盖
242
+ // subagent/workflow;bash 无 record/store 可查,死亡窗口丢失无补发通道——显式
243
+ // 边界,impl-plan §5 偏差登记)。已按设计
244
+ // 接受(设计 D4 连带面 3),非缺陷。本 handler 与 U3/U4 机器同为 session 档
245
+ // 留存件,待未来 session 档类型。
239
246
  pi.on("session_shutdown", (_event, _ctx: ExtensionContext) => {
240
247
  const active = getActive(registry);
241
248
  for (const op of active) {
@@ -261,7 +268,15 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
261
268
  "查询当前活跃的异步操作(workflow/subagent/bash 后台任务)。action=count 返回数量;action=list 返回列表。状态由 EventBus + session entries 维护,无需手动注册。",
262
269
  parameters: PendingNotificationsParams,
263
270
  execute: async (_toolCallId: string, params: { action: "count" | "list" }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext): Promise<{ content: { type: "text"; text: string }[]; details: PendingToolDetails }> => {
264
- const active = getActive(registry);
271
+ // [W4 读侧过滤③ 工具读侧半口] 防御性按当前 session 过滤(rebuild 入口已按
272
+ // sessionId 过滤,此处是第二道):listener 注册路径写入的 entry sessionId =
273
+ // 注册时的 currentSessionId,session 替换(/new /fork 重建闭包)前残留的
274
+ // 旧 session entry 若经任何路径进入 registry,投影面会虚报跨 session 活跃。
275
+ // currentSessionId 为空串(session_start 未到)时不过滤(同 rebuild 的
276
+ // 旧形态容错方向:宁放行不误逐)。
277
+ const active = getActive(registry).filter(
278
+ (op) => currentSessionId === "" || op.sessionId === currentSessionId,
279
+ );
265
280
 
266
281
  debugLog("debug", `tool ${params.action} requested`, { action: params.action, activeCount: active.length });
267
282
 
package/src/state.ts CHANGED
@@ -24,10 +24,22 @@ export type PendingType = "workflow" | "subagent" | "bash";
24
24
  * shutdown 标 cancelled 全套生效。
25
25
  * - "process":随进程存活——无 TTL(不计算/不回填 expiresAt)、跨 session 续存
26
26
  * (fork/switch 后任务仍在跑)、shutdown 不标 cancelled(收尾归任务自身/reaper)。
27
+ *
28
+ * [W4 翻档 · 设计 chat-domain-v1x-liveness-governance D4 分档对齐] subagent/workflow
29
+ * 从 session 档翻 process 档:后台子代理与 workflow run 的真实生命周期跨 session 存活、
30
+ * 可跑超 1h——session 档的 1h TTL(U3)与跨 session 补注销(U4)会把长任务/重启后的
31
+ * 注册静默清除,goal 守卫随之失明(2026-09-08 事故环 4 的放大器)。翻档后守卫依赖的
32
+ * 收口链 = 注销合法发射点枚举(5 处,含 core 注册对账 sweep)+ idle-gc startedAt 锚
33
+ * 兜底归档(只归档不补注销,注销统一交 sweep)。
34
+ *
35
+ * [W4 session 档机器死代码处置] 翻档后三类型全 process 档——PENDING_TTL_MS / U3 / U4 /
36
+ * U11 全体暂无消费类型。机制本体保留(registry 通用能力,session 档暂无消费类型,
37
+ * 留存待未来类型),**勿误认清理仍在工作**:本文件不再有任何类型的 TTL 清理或跨
38
+ * session 注销在运行。
27
39
  */
28
40
  export const PENDING_LIFECYCLE: Record<PendingType, "session" | "process"> = {
29
- subagent: "session",
30
- workflow: "session",
41
+ subagent: "process",
42
+ workflow: "process",
31
43
  bash: "process",
32
44
  };
33
45
 
@@ -74,7 +86,13 @@ interface EntryLike {
74
86
  data?: unknown;
75
87
  }
76
88
 
77
- /** pending:register entry 的 TTL(1 小时) */
89
+ /**
90
+ * pending:register entry 的 TTL(1 小时)。
91
+ *
92
+ * [W4 死代码登记] 翻档后三类型全 process 档,本常量仅剩 session 档机器的兼容读侧
93
+ * 回填路径(normalizeRegisterEntry 对 session 档缺失 expiresAt 的旧 entry 回填)——
94
+ * 现无任何类型声明 session 档,回填分支不可达。留存待未来 session 档类型,勿删。
95
+ */
78
96
  export const PENDING_TTL_MS = 3_600_000;
79
97
 
80
98
  /** 注册表:内存中的活跃操作(session 隔离,由 index.ts 在闭包内持有) */
@@ -122,6 +140,16 @@ export function getActive(registry: PendingRegistry): PendingEntry[] {
122
140
  export interface CountActiveOptions {
123
141
  /** 只统计指定类型的活跃 pending;缺省 = 全部类型(subagent + workflow + bash) */
124
142
  types?: PendingType[];
143
+ /**
144
+ * [W4 读侧过滤①] 当前 session id(跨 session 残留过滤基准):传入时按
145
+ * 「register entry 的 sessionId ≠ 当前 session → 跳过」过滤——fork 继承的
146
+ * 父级注册残留(翻 process 档后不再被 U4 补注销中性化,永久留存于子 session
147
+ * 文件)不进差集计数,守卫不幻 defer。
148
+ * 缺省(undefined)= 不过滤(向后兼容:既有调用方零改动行为不变)。
149
+ * entry 缺 sessionId 字段的旧形态条目视为本 session(归一化兜底语义与
150
+ * normalizeRegisterEntry 一致),不过滤。
151
+ */
152
+ currentSessionId?: string;
125
153
  }
126
154
 
127
155
  /** countActiveFromEntries 的结果。 */
@@ -136,13 +164,15 @@ export interface CountActiveResult {
136
164
  * 从持久化 entries 计算活跃 pending 数(register − unregister 差集)。
137
165
  *
138
166
  * 与 rebuildFromEntries 的分工:本函数只做「有没有活跃 pending」的只读判断,
139
- * 不写 registry、不判 sessionId/expiresAt(TTL 刻意不校验——长任务 subagent >1h
167
+ * 不写 registry、不判 expiresAt(TTL 刻意不校验——长任务 subagent >1h
140
168
  * 仍应视为活跃,对齐 goal agent-end 的 continuation 守卫语义)。
141
169
  * 调用方:goal(agent_end 时判断是否发 continuation)、subagent-workflow
142
170
  * (agent_end 时判断子进程是否有活跃后代,决定是否保持进程等 steer 唤醒)。
143
171
  *
144
- * 注:跨 session 残留(fork 继承的 register)由 index.ts 的 session_start 重建
145
- * 流程补 unregister(expired) 抵消;本函数只做纯差集,不重复处理。
172
+ * [W4 读侧过滤①] session 残留的过滤职责已从「index.ts 的 session_start 重建
173
+ * 流程补 unregister(expired) 抵消」(session 档 U4 机制,翻档后对 process 档不再
174
+ * 触发)移交给本函数的 opts.currentSessionId 口——调用方持有当前 session 概念的
175
+ * 应传入,使 fork 继承的父级注册残留不进差集(守卫不幻 defer)。
146
176
  */
147
177
  export function countActiveFromEntries(
148
178
  entries: unknown[],
@@ -159,7 +189,7 @@ export function countActiveFromEntries(
159
189
  };
160
190
  }
161
191
 
162
- /** 差集过滤:跳过 id 非法 / 已注销 / 重复 register entry,按 opts.types 过滤类型。 */
192
+ /** 差集过滤:跳过 id 非法 / 已注销 / 重复 register / 跨 session 残留的 entry,按 opts.types 过滤类型。 */
163
193
  function filterActiveRegisters(
164
194
  registerEntries: Array<{ data: RegisterEntryData }>,
165
195
  unregisteredIds: Set<string>,
@@ -172,6 +202,17 @@ function filterActiveRegisters(
172
202
  seen.add(data.id);
173
203
  const entry = normalizeRegisterEntry(data, "");
174
204
  if (opts?.types && !opts.types.includes(entry.type)) continue;
205
+ // [W4 读侧过滤①] 跨 session 残留跳过(基准 = opts.currentSessionId)。判据读
206
+ // 原始 data.sessionId 而非归一化值:entry 缺 sessionId 的旧形态条目(归一化
207
+ // 兜底为 "")视为本 session——不过滤,与 normalizeRegisterEntry 的容错语义
208
+ // 对齐(守卫漏计的危害方向是幻 defer,宁放行不误杀活跃计数)。
209
+ if (
210
+ opts?.currentSessionId !== undefined &&
211
+ typeof data.sessionId === "string" &&
212
+ data.sessionId !== opts.currentSessionId
213
+ ) {
214
+ continue;
215
+ }
175
216
  active.push(entry);
176
217
  }
177
218
  return active;
@@ -232,6 +273,16 @@ function scanPendingEntries(entries: unknown[]): PendingEntryScan {
232
273
  * - TTL 过期(U3)→ expired
233
274
  *
234
275
  * process 档两检全跳过(D16:进程级生命周期跨 session 续存且无 TTL)。
276
+ *
277
+ * [W4 翻档语义核对登记] 翻档后三类型全 process 档,本函数对现存类型恒 false——
278
+ * U3/U4 清理对翻档类型不再触发,这是翻档的**预期语义**而非缺陷:process 档的
279
+ * 生命周期本就跨 shutdown/fork 存活,跨 session 检测(U4)的职能已移交读侧过滤
280
+ * (countActiveFromEntries 的 currentSessionId 口 + rebuildFromEntries 的
281
+ * currentSessionId 入 registry 过滤),清理职能移交注销发射点枚举 + core 注册对账
282
+ * sweep(判据 = record 终态 ∪ 已归档/不存在;覆盖 subagent/workflow——bash 无
283
+ * record/store 可查,死亡窗口丢失无补发通道,见 normalizePendingType 注的显式
284
+ * 边界登记)。本函数与 PENDING_TTL_MS 同为
285
+ * session 档机器留存件,待未来 session 档类型,勿误删。
235
286
  */
236
287
  function isExpiredEntry(entry: PendingEntry, currentSessionId: string, now: number): boolean {
237
288
  // 跨 session 残留(U4)——process 档跳过(D16:进程级生命周期跨 session 续存,
@@ -263,6 +314,20 @@ export function rebuildFromEntries(
263
314
  if (typeof data.id !== "string") continue;
264
315
  if (unregisteredIds.has(data.id)) continue;
265
316
 
317
+ // [W4 读侧过滤③ rebuild 半口] 跨 session 残留不入 registry(翻 process 档后
318
+ // U4 补注销对现存类型不再触发,fork 继承的父级注册残留若不过滤会永久虚报
319
+ // pending_notifications 工具投影)。与 U4 的差异:**跳过不补注销**——残留
320
+ // entry 留在 session 文件(读侧过滤③①口各自兜住差集消费方),落盘收口归
321
+ // core 注册对账 sweep(判据含 record 查不到 → 视同终态补注销)。判据读原始
322
+ // data.sessionId(缺 sessionId 的旧形态条目视为本 session,不过滤——宁放行
323
+ // 不误逐)。
324
+ if (
325
+ typeof data.sessionId === "string" &&
326
+ data.sessionId !== currentSessionId
327
+ ) {
328
+ continue;
329
+ }
330
+
266
331
  const entry = normalizeRegisterEntry(data, currentSessionId);
267
332
  if (isExpiredEntry(entry, currentSessionId, now)) {
268
333
  expiredToFlush.push({ id: entry.id, status: "expired" });
@@ -279,6 +344,14 @@ export function rebuildFromEntries(
279
344
  /**
280
345
  * type 归一化:subagent/bash 原样保留,其余(含缺失/未知值)归 workflow。
281
346
  * state.ts 与 index.ts 两处归一化共用本函数,防止「写入侧直通、读取侧归并」漂移。
347
+ *
348
+ * [W4 偏好显式化] 缺失/未知 type 默认归 workflow = process 档 = 永不 TTL 清理。
349
+ * 该偏好是刻意选择:畸形条目**宁挂账不失明**——误归 session 档会让未知类型被
350
+ * 1h TTL / 跨 session 清理静默抹掉(守卫失明方向)。挂账的收口通道按类型分流
351
+ * [F2 如实口径]:workflow / 畸形条目由 core 注册对账 sweep 收口(查 WorkflowRun
352
+ * store:终态 ∪ state 文件不存在 → 补注销);bash 无 record/store 可查——bash
353
+ * 注册随进程退出注销,进程死亡窗口的丢失无补发通道,属显式边界(impl-plan §5
354
+ * 偏差登记:每孤儿 bash 注册 1 条静态虚报,无空转驱动源,熔断限损)。
282
355
  */
283
356
  export function normalizePendingType(raw: unknown): PendingType {
284
357
  if (raw === "subagent") return "subagent";