@zhushanwen/pi-pending-notifications 0.5.2 → 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 +2 -2
- package/src/__tests__/pending-notifications.test.ts +83 -37
- package/src/index.ts +16 -1
- package/src/state.ts +98 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-pending-notifications",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"typebox": "*"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zhushanwen/pi-extension-logger": "0.4.
|
|
36
|
+
"@zhushanwen/pi-extension-logger": "0.4.1"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"typecheck": "npx tsc --noEmit",
|
|
@@ -205,24 +205,31 @@ describe("state pure functions", () => {
|
|
|
205
205
|
expect(comp.expiredToFlush).toEqual([]);
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
-
it("U3
|
|
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([
|
|
218
|
+
expect(comp.activeIds).toEqual(["w-1"]);
|
|
219
|
+
expect(comp.expiredToFlush).toEqual([]);
|
|
216
220
|
});
|
|
217
221
|
|
|
218
|
-
it("U4
|
|
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([
|
|
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
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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"
|
|
308
|
+
expect.objectContaining({ id: "w-1" }),
|
|
300
309
|
);
|
|
301
310
|
});
|
|
302
311
|
|
|
303
|
-
it("
|
|
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"
|
|
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
|
|
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(
|
|
397
|
-
|
|
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
|
|
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
|
|
571
|
+
it("[W4 翻档] PENDING_LIFECYCLE:subagent/workflow/bash 全 process 档(session 档机器留存待未来类型)", () => {
|
|
545
572
|
expect(PENDING_LIFECYCLE).toEqual({
|
|
546
|
-
subagent: "
|
|
547
|
-
workflow: "
|
|
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
|
|
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([
|
|
614
|
+
expect(comp.activeIds).toEqual([]);
|
|
585
615
|
expect(comp.expiredToFlush).toEqual([]);
|
|
586
616
|
});
|
|
587
617
|
|
|
588
|
-
it("
|
|
589
|
-
//
|
|
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([
|
|
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("
|
|
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
|
|
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
|
|
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(
|
|
681
|
-
expect((
|
|
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
|
-
|
|
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: "
|
|
30
|
-
workflow: "
|
|
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
|
-
/**
|
|
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,46 +164,58 @@ export interface CountActiveResult {
|
|
|
136
164
|
* 从持久化 entries 计算活跃 pending 数(register − unregister 差集)。
|
|
137
165
|
*
|
|
138
166
|
* 与 rebuildFromEntries 的分工:本函数只做「有没有活跃 pending」的只读判断,
|
|
139
|
-
* 不写 registry、不判
|
|
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
|
-
*
|
|
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[],
|
|
149
179
|
opts?: CountActiveOptions,
|
|
150
180
|
): CountActiveResult {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// EntryLike 断言前先守卫,避免访问 raw.customType 抛 TypeError 炸掉差集判定。
|
|
155
|
-
if (!raw || typeof raw !== "object") continue;
|
|
156
|
-
if (raw.customType !== "pending:unregister") continue;
|
|
157
|
-
const data = (raw.data ?? {}) as UnregisterEntryData;
|
|
158
|
-
if (typeof data.id === "string") unregistered.add(data.id);
|
|
159
|
-
}
|
|
181
|
+
// 与 rebuildFromEntries 共用单趟扫描(S-10 守卫一致),分流 register/unregister
|
|
182
|
+
const { registerEntries, unregisteredIds } = scanPendingEntries(entries);
|
|
183
|
+
const active = filterActiveRegisters(registerEntries, unregisteredIds, opts);
|
|
160
184
|
|
|
185
|
+
return {
|
|
186
|
+
count: active.length,
|
|
187
|
+
ids: active.map((e) => e.id),
|
|
188
|
+
entries: active,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 差集过滤:跳过 id 非法 / 已注销 / 重复 register / 跨 session 残留的 entry,按 opts.types 过滤类型。 */
|
|
193
|
+
function filterActiveRegisters(
|
|
194
|
+
registerEntries: Array<{ data: RegisterEntryData }>,
|
|
195
|
+
unregisteredIds: Set<string>,
|
|
196
|
+
opts?: CountActiveOptions,
|
|
197
|
+
): PendingEntry[] {
|
|
161
198
|
const active: PendingEntry[] = [];
|
|
162
199
|
const seen = new Set<string>();
|
|
163
|
-
for (const
|
|
164
|
-
if (
|
|
165
|
-
if (raw.customType !== "pending:register") continue;
|
|
166
|
-
const data = (raw.data ?? {}) as RegisterEntryData;
|
|
167
|
-
if (typeof data.id !== "string" || unregistered.has(data.id) || seen.has(data.id)) continue;
|
|
200
|
+
for (const { data } of registerEntries) {
|
|
201
|
+
if (typeof data.id !== "string" || unregisteredIds.has(data.id) || seen.has(data.id)) continue;
|
|
168
202
|
seen.add(data.id);
|
|
169
203
|
const entry = normalizeRegisterEntry(data, "");
|
|
170
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
|
+
}
|
|
171
216
|
active.push(entry);
|
|
172
217
|
}
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
count: active.length,
|
|
176
|
-
ids: active.map((e) => e.id),
|
|
177
|
-
entries: active,
|
|
178
|
-
};
|
|
218
|
+
return active;
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
/** rebuildFromEntries 的结果:重建后的活跃列表 + 需要补注销的 entry */
|
|
@@ -233,6 +273,16 @@ function scanPendingEntries(entries: unknown[]): PendingEntryScan {
|
|
|
233
273
|
* - TTL 过期(U3)→ expired
|
|
234
274
|
*
|
|
235
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 档类型,勿误删。
|
|
236
286
|
*/
|
|
237
287
|
function isExpiredEntry(entry: PendingEntry, currentSessionId: string, now: number): boolean {
|
|
238
288
|
// 跨 session 残留(U4)——process 档跳过(D16:进程级生命周期跨 session 续存,
|
|
@@ -264,6 +314,20 @@ export function rebuildFromEntries(
|
|
|
264
314
|
if (typeof data.id !== "string") continue;
|
|
265
315
|
if (unregisteredIds.has(data.id)) continue;
|
|
266
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
|
+
|
|
267
331
|
const entry = normalizeRegisterEntry(data, currentSessionId);
|
|
268
332
|
if (isExpiredEntry(entry, currentSessionId, now)) {
|
|
269
333
|
expiredToFlush.push({ id: entry.id, status: "expired" });
|
|
@@ -280,6 +344,14 @@ export function rebuildFromEntries(
|
|
|
280
344
|
/**
|
|
281
345
|
* type 归一化:subagent/bash 原样保留,其余(含缺失/未知值)归 workflow。
|
|
282
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 条静态虚报,无空转驱动源,熔断限损)。
|
|
283
355
|
*/
|
|
284
356
|
export function normalizePendingType(raw: unknown): PendingType {
|
|
285
357
|
if (raw === "subagent") return "subagent";
|