@zhushanwen/pi-pending-notifications 0.3.5 → 0.4.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 +1 -1
- package/src/__tests__/pending-notifications.test.ts +172 -1
- package/src/index.ts +20 -6
- package/src/state.ts +87 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-pending-notifications",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Cross-extension async operation registration/query mechanism for Pi — prevents message injection during long-running operations.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -16,7 +16,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
16
16
|
|
|
17
17
|
import pendingNotificationsExtension from "../index";
|
|
18
18
|
import type { PendingEntry } from "../state";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
countActiveFromEntries,
|
|
21
|
+
createRegistry,
|
|
22
|
+
getActive,
|
|
23
|
+
PENDING_LIFECYCLE,
|
|
24
|
+
normalizePendingType,
|
|
25
|
+
rebuildFromEntries,
|
|
26
|
+
register,
|
|
27
|
+
unregister,
|
|
28
|
+
} from "../state";
|
|
20
29
|
|
|
21
30
|
// ── Mock 工具 ───────────────────────────────────────
|
|
22
31
|
|
|
@@ -511,3 +520,165 @@ describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow 复
|
|
|
511
520
|
expect(countActiveFromEntries([inherited, flushed, own]).ids).toEqual(["my-bg"]);
|
|
512
521
|
});
|
|
513
522
|
});
|
|
523
|
+
|
|
524
|
+
// ────────────────────────────────────────────────────
|
|
525
|
+
// D16 lifecycle 分档(M3):process 档(type=bash)纯函数行为
|
|
526
|
+
// ────────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
/** bash register entry 的真实落盘形状:无 expiresAt 键(写入侧省略,D16) */
|
|
529
|
+
function makeBashRegisterEntry(id: string, overrides: Record<string, unknown> = {}): MockSessionEntry {
|
|
530
|
+
return {
|
|
531
|
+
customType: "pending:register",
|
|
532
|
+
data: {
|
|
533
|
+
id,
|
|
534
|
+
type: "bash",
|
|
535
|
+
name: `task-${id}`,
|
|
536
|
+
registeredAt: NOW,
|
|
537
|
+
sessionId: "sess-current",
|
|
538
|
+
...overrides,
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
describe("D16 lifecycle 分档:常量与 type 归一化", () => {
|
|
544
|
+
it("PENDING_LIFECYCLE:subagent/workflow=session,bash=process", () => {
|
|
545
|
+
expect(PENDING_LIFECYCLE).toEqual({
|
|
546
|
+
subagent: "session",
|
|
547
|
+
workflow: "session",
|
|
548
|
+
bash: "process",
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("normalizePendingType:subagent/bash 直通,其余(缺失/未知/大小写不符)归 workflow", () => {
|
|
553
|
+
expect(normalizePendingType("subagent")).toBe("subagent");
|
|
554
|
+
expect(normalizePendingType("bash")).toBe("bash");
|
|
555
|
+
expect(normalizePendingType("workflow")).toBe("workflow");
|
|
556
|
+
expect(normalizePendingType(undefined)).toBe("workflow");
|
|
557
|
+
expect(normalizePendingType("scheduler")).toBe("workflow");
|
|
558
|
+
expect(normalizePendingType("Bash")).toBe("workflow");
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
describe("D16 process 档(type=bash)纯函数行为", () => {
|
|
563
|
+
it("读取侧不回填 TTL:无 expiresAt 的 bash entry → registry entry.expiresAt=undefined 且 active", () => {
|
|
564
|
+
const r = createRegistry();
|
|
565
|
+
const result = rebuildFromEntries(r, [makeBashRegisterEntry("bt-1")], "sess-current", NOW);
|
|
566
|
+
expect(result.activeIds).toEqual(["bt-1"]);
|
|
567
|
+
expect(result.expiredToFlush).toEqual([]);
|
|
568
|
+
expect(r.operations.get("bt-1")!.expiresAt).toBeUndefined();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("U3 不判过期:bash entry 注册超 1h(TTL 之外)→ 仍 active、无 expiredToFlush", () => {
|
|
572
|
+
const entry = makeBashRegisterEntry("bt-1", { registeredAt: NOW - 3_700_000 });
|
|
573
|
+
const comp = rebuild([entry], "sess-current", NOW);
|
|
574
|
+
expect(comp.activeIds).toEqual(["bt-1"]);
|
|
575
|
+
expect(comp.expiredToFlush).toEqual([]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("U4 跨 session 不标 expired:bash entry sessionId 不符当前 session → 仍 active(进程级生命周期跨 session 续存)", () => {
|
|
579
|
+
const comp = rebuild(
|
|
580
|
+
[makeBashRegisterEntry("bt-1", { sessionId: "sess-other" })],
|
|
581
|
+
"sess-current",
|
|
582
|
+
NOW,
|
|
583
|
+
);
|
|
584
|
+
expect(comp.activeIds).toEqual(["bt-1"]);
|
|
585
|
+
expect(comp.expiredToFlush).toEqual([]);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("session 档对照回归:同形状(无 expiresAt 键)的 workflow entry → 回填 TTL 且过期照旧判 expired", () => {
|
|
589
|
+
// 保证分档判定没有把 session 档的 TTL 路径一并豁免
|
|
590
|
+
const legacy = {
|
|
591
|
+
customType: "pending:register",
|
|
592
|
+
data: { id: "w-1", type: "workflow", name: "w", registeredAt: NOW - 3_700_000, sessionId: "sess-current" },
|
|
593
|
+
};
|
|
594
|
+
const comp = rebuild([legacy], "sess-current", NOW);
|
|
595
|
+
expect(comp.activeIds).toEqual([]);
|
|
596
|
+
expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("差集路径识别 bash entry:register 计入且 type 保留,unregister 抵消", () => {
|
|
600
|
+
const bashReg = makeBashRegisterEntry("bt-1");
|
|
601
|
+
const res = countActiveFromEntries([bashReg] as unknown[]);
|
|
602
|
+
expect(res.count).toBe(1);
|
|
603
|
+
expect(res.entries[0].type).toBe("bash");
|
|
604
|
+
expect(res.entries[0].expiresAt).toBeUndefined();
|
|
605
|
+
|
|
606
|
+
expect(
|
|
607
|
+
countActiveFromEntries([bashReg, makeUnregisterEntry("bt-1")] as unknown[]).count,
|
|
608
|
+
).toBe(0);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("types 过滤:bash 可作为过滤类型", () => {
|
|
612
|
+
const entries = [makeBashRegisterEntry("bt-1"), makeRegisterEntry("w-1")] as unknown[];
|
|
613
|
+
expect(countActiveFromEntries(entries, { types: ["bash"] }).ids).toEqual(["bt-1"]);
|
|
614
|
+
expect(countActiveFromEntries(entries, { types: ["workflow"] }).ids).toEqual(["w-1"]);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// ────────────────────────────────────────────────────
|
|
619
|
+
// D16 lifecycle 分档(M3):process 档(type=bash)工厂行为
|
|
620
|
+
// ────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
describe("D16 process 档(type=bash)工厂行为", () => {
|
|
623
|
+
let setup: MockSetup;
|
|
624
|
+
|
|
625
|
+
beforeEach(() => {
|
|
626
|
+
vi.useFakeTimers();
|
|
627
|
+
vi.setSystemTime(NOW);
|
|
628
|
+
setup = createMockPi();
|
|
629
|
+
pendingNotificationsExtension(setup.pi);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
afterEach(() => {
|
|
633
|
+
vi.useRealTimers();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("register 写入:bash → 落盘 data 无 expiresAt 键且 type=bash 直通(不被归并为 workflow)", async () => {
|
|
637
|
+
fireSessionStart(setup, createMockCtx([]));
|
|
638
|
+
|
|
639
|
+
setup.handlers.pendingRegister!({ id: "bt-1", type: "bash", name: "run tests" });
|
|
640
|
+
|
|
641
|
+
expect(await getCount(setup)).toBe(1);
|
|
642
|
+
const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
|
|
643
|
+
expect(regCall).toBeDefined();
|
|
644
|
+
const data = regCall![1] as Record<string, unknown>;
|
|
645
|
+
expect(data.type).toBe("bash");
|
|
646
|
+
expect("expiresAt" in data).toBe(false);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("session 档对照回归:workflow register → 落盘 data.expiresAt = NOW + TTL", async () => {
|
|
650
|
+
fireSessionStart(setup, createMockCtx([]));
|
|
651
|
+
|
|
652
|
+
setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "run" });
|
|
653
|
+
|
|
654
|
+
const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
|
|
655
|
+
const data = regCall![1] as Record<string, unknown>;
|
|
656
|
+
expect(data.expiresAt).toBe(NOW + 3_600_000);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("U3 工厂级:bash entry 超 1h 后 session_start rebuild → 仍 active、不补 unregister", async () => {
|
|
660
|
+
vi.setSystemTime(NOW + 3_700_000);
|
|
661
|
+
const stale = makeBashRegisterEntry("bt-1", { registeredAt: NOW });
|
|
662
|
+
fireSessionStart(setup, createMockCtx([stale]));
|
|
663
|
+
|
|
664
|
+
expect(await getCount(setup)).toBe(1);
|
|
665
|
+
const flushCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:unregister");
|
|
666
|
+
expect(flushCalls).toHaveLength(0);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("session_shutdown:bash 不标 cancelled、不补 unregister entry、内存仍 active", async () => {
|
|
670
|
+
fireSessionStart(setup, createMockCtx([]));
|
|
671
|
+
setup.handlers.pendingRegister!({ id: "bt-1", type: "bash", name: "run tests" });
|
|
672
|
+
// 同时注册一个 session 档条目,对照证明分档只豁免 process 档
|
|
673
|
+
setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "run" });
|
|
674
|
+
setup.appendEntryMock.mockClear();
|
|
675
|
+
|
|
676
|
+
if (!setup.handlers.sessionShutdown) throw new Error("session_shutdown not registered");
|
|
677
|
+
void setup.handlers.sessionShutdown({ type: "session_shutdown" }, createMockCtx([]));
|
|
678
|
+
|
|
679
|
+
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");
|
|
682
|
+
expect(await getCount(setup)).toBe(1);
|
|
683
|
+
});
|
|
684
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* - emit("pending:unregister", { id, reason })
|
|
16
16
|
*
|
|
17
17
|
* entry 契约(与 goal before-agent-start.ts 对齐,读取端按 e.data.id 算差集):
|
|
18
|
-
* - pending:register → { id, type, name, registeredAt, expiresAt
|
|
18
|
+
* - pending:register → { id, type, name, registeredAt, expiresAt?, sessionId }
|
|
19
|
+
* (expiresAt 仅 session 档写入;process 档(D16)省略该字段)
|
|
19
20
|
* - pending:unregister → { id, reason, status }
|
|
20
21
|
*
|
|
21
22
|
* 监听方式:pi.events.on(Pi 的 EventBus,真实 SDK 为 EventBus.on,非 optional)。
|
|
@@ -29,22 +30,26 @@ import { Type } from "typebox";
|
|
|
29
30
|
import {
|
|
30
31
|
createRegistry,
|
|
31
32
|
getActive,
|
|
33
|
+
PENDING_LIFECYCLE,
|
|
32
34
|
PENDING_TTL_MS,
|
|
33
35
|
type PendingEntry,
|
|
34
36
|
type PendingRegistry,
|
|
35
37
|
type PendingStatus,
|
|
36
38
|
type PendingType,
|
|
39
|
+
normalizePendingType,
|
|
37
40
|
rebuildFromEntries,
|
|
38
41
|
register,
|
|
39
42
|
unregister,
|
|
40
43
|
} from "./state.ts";
|
|
41
44
|
|
|
42
45
|
// 跨扩展消费 API:goal(continuation 守卫)/ subagent-workflow(agent_end 后代判定)
|
|
43
|
-
// 直接 import 本包的导出,避免各自复制差集逻辑。
|
|
46
|
+
// 直接 import 本包的导出,避免各自复制差集逻辑。PENDING_LIFECYCLE 供消费方(如
|
|
47
|
+
// base-tool-enhance 启动时的 peer 版本检查)读取分档声明。
|
|
44
48
|
export {
|
|
45
49
|
countActiveFromEntries,
|
|
46
50
|
createRegistry,
|
|
47
51
|
getActive,
|
|
52
|
+
PENDING_LIFECYCLE,
|
|
48
53
|
PENDING_TTL_MS,
|
|
49
54
|
type CountActiveOptions,
|
|
50
55
|
type CountActiveResult,
|
|
@@ -141,13 +146,15 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
141
146
|
debugLog("debug", "listener: pending:register parsed", parsed);
|
|
142
147
|
|
|
143
148
|
const now = Date.now();
|
|
149
|
+
// D16 分档:process 档(bash 后台任务)不计算 expiresAt——进程级生命周期
|
|
150
|
+
// 无 TTL 概念,任务寿命由其自身超时/reaper 管理,session 档保持 TTL 不变。
|
|
144
151
|
const entry: PendingEntry = {
|
|
145
152
|
id: parsed.id,
|
|
146
153
|
type: parsed.type,
|
|
147
154
|
name: parsed.name,
|
|
148
155
|
status: "active",
|
|
149
156
|
registeredAt: now,
|
|
150
|
-
expiresAt: now + PENDING_TTL_MS,
|
|
157
|
+
expiresAt: PENDING_LIFECYCLE[parsed.type] === "session" ? now + PENDING_TTL_MS : undefined,
|
|
151
158
|
sessionId: currentSessionId,
|
|
152
159
|
};
|
|
153
160
|
|
|
@@ -158,12 +165,14 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
158
165
|
return;
|
|
159
166
|
}
|
|
160
167
|
|
|
168
|
+
// 落盘与内存 entry 对称:process 档省略 expiresAt 字段(而非写 undefined),
|
|
169
|
+
// 读取侧 normalizeRegisterEntry 对 process 档同样不回填,两侧共同兑现 TTL 豁免。
|
|
161
170
|
safeAppendEntry("pending:register", {
|
|
162
171
|
id: entry.id,
|
|
163
172
|
type: entry.type,
|
|
164
173
|
name: entry.name,
|
|
165
174
|
registeredAt: entry.registeredAt,
|
|
166
|
-
expiresAt: entry.expiresAt,
|
|
175
|
+
...(entry.expiresAt !== undefined ? { expiresAt: entry.expiresAt } : {}),
|
|
167
176
|
sessionId: entry.sessionId,
|
|
168
177
|
});
|
|
169
178
|
|
|
@@ -226,6 +235,10 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
226
235
|
pi.on("session_shutdown", (_event, _ctx: ExtensionContext) => {
|
|
227
236
|
const active = getActive(registry);
|
|
228
237
|
for (const op of active) {
|
|
238
|
+
// D16 分档:process 档跳过 cancelled 标注——进程级生命周期的任务跨
|
|
239
|
+
// session 替换继续运行(fork/switch),收尾归任务自身/reaper,
|
|
240
|
+
// 不由 session 退出裁定。
|
|
241
|
+
if (PENDING_LIFECYCLE[op.type] === "process") continue;
|
|
229
242
|
const changed = unregister(registry, op.id, "cancelled");
|
|
230
243
|
if (changed) {
|
|
231
244
|
safeAppendEntry("pending:unregister", {
|
|
@@ -241,7 +254,7 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
241
254
|
name: "pending_notifications",
|
|
242
255
|
label: "Pending Notifications",
|
|
243
256
|
description:
|
|
244
|
-
"查询当前活跃的异步操作(workflow/subagent
|
|
257
|
+
"查询当前活跃的异步操作(workflow/subagent/bash 后台任务)。action=count 返回数量;action=list 返回列表。状态由 EventBus + session entries 维护,无需手动注册。",
|
|
245
258
|
parameters: PendingNotificationsParams,
|
|
246
259
|
execute: async (_toolCallId: string, params: { action: "count" | "list" }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext): Promise<{ content: { type: "text"; text: string }[]; details: PendingToolDetails }> => {
|
|
247
260
|
const active = getActive(registry);
|
|
@@ -279,7 +292,8 @@ function parseRegisterEvent(data: unknown): ParsedRegister | null {
|
|
|
279
292
|
if (typeof d.id !== "string") return null;
|
|
280
293
|
return {
|
|
281
294
|
id: d.id,
|
|
282
|
-
|
|
295
|
+
// D16:bash 类型直通(归一化映射与 state.ts 读取侧共用同一函数,防两侧漂移)
|
|
296
|
+
type: normalizePendingType(d.type),
|
|
283
297
|
name: typeof d.name === "string" ? d.name : d.id,
|
|
284
298
|
};
|
|
285
299
|
}
|
package/src/state.ts
CHANGED
|
@@ -14,8 +14,22 @@
|
|
|
14
14
|
* 不直接写 entry —— 写 entry 是副作用,由 index.ts 负责
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
/** 异步操作类型(来源:workflow / subagent
|
|
18
|
-
export type PendingType = "workflow" | "subagent";
|
|
17
|
+
/** 异步操作类型(来源:workflow / subagent / bash 后台任务) */
|
|
18
|
+
export type PendingType = "workflow" | "subagent" | "bash";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 生命周期分档(D16):type 级声明,行为按档判定(不做 type 特判)——
|
|
22
|
+
* 未来 scheduler 等长任务类型声明 process 档即零改动获得同语义。
|
|
23
|
+
* - "session":随 session entry 存活——TTL 过期(U3)、跨 session 清理(U4)、
|
|
24
|
+
* shutdown 标 cancelled 全套生效。
|
|
25
|
+
* - "process":随进程存活——无 TTL(不计算/不回填 expiresAt)、跨 session 续存
|
|
26
|
+
* (fork/switch 后任务仍在跑)、shutdown 不标 cancelled(收尾归任务自身/reaper)。
|
|
27
|
+
*/
|
|
28
|
+
export const PENDING_LIFECYCLE: Record<PendingType, "session" | "process"> = {
|
|
29
|
+
subagent: "session",
|
|
30
|
+
workflow: "session",
|
|
31
|
+
bash: "process",
|
|
32
|
+
};
|
|
19
33
|
|
|
20
34
|
/** 异步操作终态/过渡状态。active = 仍在运行;其他都视为已结束 */
|
|
21
35
|
export type PendingStatus = "active" | "completed" | "failed" | "cancelled" | "expired" | "time_limited" | "aborted";
|
|
@@ -32,8 +46,8 @@ export interface PendingEntry {
|
|
|
32
46
|
status: PendingStatus;
|
|
33
47
|
/** 注册时间戳 ms */
|
|
34
48
|
registeredAt: number;
|
|
35
|
-
/** 过期时间戳 ms(registeredAt + TTL) */
|
|
36
|
-
expiresAt: number;
|
|
49
|
+
/** 过期时间戳 ms(registeredAt + TTL);process 档恒 undefined(D16:进程级生命周期无 TTL) */
|
|
50
|
+
expiresAt: number | undefined;
|
|
37
51
|
/** 注册时的 sessionId(用于跨 session 残留检测) */
|
|
38
52
|
sessionId: string;
|
|
39
53
|
}
|
|
@@ -110,7 +124,7 @@ export function getActive(registry: PendingRegistry): PendingEntry[] {
|
|
|
110
124
|
|
|
111
125
|
/** countActiveFromEntries 的过滤选项。 */
|
|
112
126
|
export interface CountActiveOptions {
|
|
113
|
-
/** 只统计指定类型的活跃 pending;缺省 = 全部类型(subagent + workflow) */
|
|
127
|
+
/** 只统计指定类型的活跃 pending;缺省 = 全部类型(subagent + workflow + bash) */
|
|
114
128
|
types?: PendingType[];
|
|
115
129
|
}
|
|
116
130
|
|
|
@@ -181,7 +195,8 @@ export interface RebuildResult {
|
|
|
181
195
|
*
|
|
182
196
|
* 算法(对齐 goal before-agent-start.ts 的读取契约):
|
|
183
197
|
* 1. 收集所有 pending:register entry,按 id 算差集(减去 pending:unregister 的 id)
|
|
184
|
-
* 前提:id 全局唯一(workflow runId=`wf-<ts>-<rand>`、subagent id=`bg-/run-<tag>-<seq>-<ts
|
|
198
|
+
* 前提:id 全局唯一(workflow runId=`wf-<ts>-<rand>`、subagent id=`bg-/run-<tag>-<seq>-<ts>`、
|
|
199
|
+
* bash 后台任务 id=`bt-<ts>-<rand>`)。
|
|
185
200
|
* 若未来 id 复用(register→unregister→register 同 id),全局 Set 差集会误跳第二次 register。
|
|
186
201
|
* 2. 对每个活跃的 register entry 检查:
|
|
187
202
|
* - sessionId 不符当前 session → expired(U4 跨 session 残留)
|
|
@@ -190,28 +205,62 @@ export interface RebuildResult {
|
|
|
190
205
|
*
|
|
191
206
|
* 注意:本函数只重建 registry + 计算需补的 entry,不写 entry(副作用归 index.ts)。
|
|
192
207
|
*/
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
/** rebuildFromEntries 的扫描阶段结果:register 原始 data 列表 + 已注销 id 集合 */
|
|
209
|
+
interface PendingEntryScan {
|
|
210
|
+
registerEntries: Array<{ data: RegisterEntryData }>;
|
|
211
|
+
unregisteredIds: Set<string>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 单趟扫描 entries 按 customType 分流(pending:register / pending:unregister)。 */
|
|
215
|
+
function scanPendingEntries(entries: unknown[]): PendingEntryScan {
|
|
216
|
+
const scan: PendingEntryScan = { registerEntries: [], unregisteredIds: new Set() };
|
|
201
217
|
|
|
202
218
|
for (const raw of entries as EntryLike[]) {
|
|
203
219
|
// S-10:同 countActiveFromEntries——null/undefined 元素先守卫再访问字段。
|
|
204
220
|
if (!raw || typeof raw !== "object") continue;
|
|
205
221
|
if (raw.customType === "pending:register") {
|
|
206
|
-
registerEntries.push({ data: (raw.data ?? {}) as RegisterEntryData });
|
|
222
|
+
scan.registerEntries.push({ data: (raw.data ?? {}) as RegisterEntryData });
|
|
207
223
|
} else if (raw.customType === "pending:unregister") {
|
|
208
224
|
const data = (raw.data ?? {}) as UnregisterEntryData;
|
|
209
225
|
if (typeof data.id === "string") {
|
|
210
|
-
unregisteredIds.add(data.id);
|
|
226
|
+
scan.unregisteredIds.add(data.id);
|
|
211
227
|
}
|
|
212
228
|
}
|
|
213
229
|
}
|
|
214
230
|
|
|
231
|
+
return scan;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 判定单个 register entry 重建时是否应标 expired:
|
|
236
|
+
* - 跨 session 残留(U4)→ expired
|
|
237
|
+
* - TTL 过期(U3)→ expired
|
|
238
|
+
*
|
|
239
|
+
* process 档两检全跳过(D16:进程级生命周期跨 session 续存且无 TTL)。
|
|
240
|
+
*/
|
|
241
|
+
function isExpiredEntry(entry: PendingEntry, currentSessionId: string, now: number): boolean {
|
|
242
|
+
// 跨 session 残留(U4)——process 档跳过(D16:进程级生命周期跨 session 续存,
|
|
243
|
+
// fork/switch 后任务仍在跑;标 expired 补 unregister 会让差集消费方误判「无活跃任务」)
|
|
244
|
+
if (PENDING_LIFECYCLE[entry.type] === "session" && entry.sessionId !== currentSessionId) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
// 过期(U3)——process 档跳过(D16:无 TTL,expiresAt 恒 undefined);
|
|
248
|
+
// session 档理论上恒有值,防御 undefined 不过期(缺失 = 该条目不过期)
|
|
249
|
+
return (
|
|
250
|
+
PENDING_LIFECYCLE[entry.type] === "session" &&
|
|
251
|
+
entry.expiresAt !== undefined &&
|
|
252
|
+
entry.expiresAt <= now
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function rebuildFromEntries(
|
|
257
|
+
registry: PendingRegistry,
|
|
258
|
+
entries: unknown[],
|
|
259
|
+
currentSessionId: string,
|
|
260
|
+
now: number,
|
|
261
|
+
): RebuildResult {
|
|
262
|
+
const { registerEntries, unregisteredIds } = scanPendingEntries(entries);
|
|
263
|
+
|
|
215
264
|
const activeIds: string[] = [];
|
|
216
265
|
const expiredToFlush: Array<{ id: string; status: PendingStatus }> = [];
|
|
217
266
|
|
|
@@ -220,13 +269,7 @@ export function rebuildFromEntries(
|
|
|
220
269
|
if (unregisteredIds.has(data.id)) continue;
|
|
221
270
|
|
|
222
271
|
const entry = normalizeRegisterEntry(data, currentSessionId);
|
|
223
|
-
|
|
224
|
-
if (entry.sessionId !== currentSessionId) {
|
|
225
|
-
expiredToFlush.push({ id: entry.id, status: "expired" });
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
// 过期(U3)
|
|
229
|
-
if (entry.expiresAt <= now) {
|
|
272
|
+
if (isExpiredEntry(entry, currentSessionId, now)) {
|
|
230
273
|
expiredToFlush.push({ id: entry.id, status: "expired" });
|
|
231
274
|
continue;
|
|
232
275
|
}
|
|
@@ -238,16 +281,35 @@ export function rebuildFromEntries(
|
|
|
238
281
|
return { activeIds, expiredToFlush };
|
|
239
282
|
}
|
|
240
283
|
|
|
284
|
+
/**
|
|
285
|
+
* type 归一化:subagent/bash 原样保留,其余(含缺失/未知值)归 workflow。
|
|
286
|
+
* state.ts 与 index.ts 两处归一化共用本函数,防止「写入侧直通、读取侧归并」漂移。
|
|
287
|
+
*/
|
|
288
|
+
export function normalizePendingType(raw: unknown): PendingType {
|
|
289
|
+
if (raw === "subagent") return "subagent";
|
|
290
|
+
if (raw === "bash") return "bash";
|
|
291
|
+
return "workflow";
|
|
292
|
+
}
|
|
293
|
+
|
|
241
294
|
/** 从 entry data 归一化为 PendingEntry(补默认值,容错缺失字段) */
|
|
242
295
|
function normalizeRegisterEntry(data: RegisterEntryData, currentSessionId: string): PendingEntry {
|
|
243
296
|
const registeredAt = typeof data.registeredAt === "number" ? data.registeredAt : Date.now();
|
|
297
|
+
const type = normalizePendingType(data.type);
|
|
244
298
|
return {
|
|
245
299
|
id: data.id as string,
|
|
246
|
-
type
|
|
300
|
+
type,
|
|
247
301
|
name: typeof data.name === "string" ? data.name : (data.id as string),
|
|
248
302
|
status: "active",
|
|
249
303
|
registeredAt,
|
|
250
|
-
|
|
304
|
+
// D16:process 档不回填 TTL——写入侧本就省略 expiresAt,读取侧若回填
|
|
305
|
+
// registeredAt + TTL 会抵消写入侧的豁免(分档必须两侧同改)。session 档
|
|
306
|
+
// 缺失时回填 TTL 兼容旧 entry。
|
|
307
|
+
expiresAt:
|
|
308
|
+
PENDING_LIFECYCLE[type] === "process"
|
|
309
|
+
? undefined
|
|
310
|
+
: typeof data.expiresAt === "number"
|
|
311
|
+
? data.expiresAt
|
|
312
|
+
: registeredAt + PENDING_TTL_MS,
|
|
251
313
|
sessionId: typeof data.sessionId === "string" ? data.sessionId : currentSessionId,
|
|
252
314
|
};
|
|
253
315
|
}
|