@zhushanwen/pi-pending-notifications 0.3.0 → 0.3.2
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 +91 -1
- package/src/index.ts +20 -2
- package/src/state.ts +62 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-pending-notifications",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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,7 @@ 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 { createRegistry, getActive, rebuildFromEntries, register, unregister } from "../state";
|
|
19
|
+
import { countActiveFromEntries, createRegistry, getActive, rebuildFromEntries, register, unregister } from "../state";
|
|
20
20
|
|
|
21
21
|
// ── Mock 工具 ───────────────────────────────────────
|
|
22
22
|
|
|
@@ -215,6 +215,17 @@ describe("state pure functions", () => {
|
|
|
215
215
|
expect(comp.activeIds).toEqual([]);
|
|
216
216
|
expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
it("entries 含 null/undefined 元素 → 跳过不抛 TypeError(S-10)", () => {
|
|
220
|
+
const comp = rebuildFromEntries(
|
|
221
|
+
createRegistry(),
|
|
222
|
+
[null, makeRegisterEntry("w-1"), undefined],
|
|
223
|
+
"sess-current",
|
|
224
|
+
NOW,
|
|
225
|
+
);
|
|
226
|
+
expect(comp.activeIds).toEqual(["w-1"]);
|
|
227
|
+
expect(comp.expiredToFlush).toEqual([]);
|
|
228
|
+
});
|
|
218
229
|
});
|
|
219
230
|
|
|
220
231
|
describe("normalizeRegisterEntry defaults (via rebuild)", () => {
|
|
@@ -421,3 +432,82 @@ describe("pendingNotificationsExtension factory", () => {
|
|
|
421
432
|
});
|
|
422
433
|
});
|
|
423
434
|
});
|
|
435
|
+
|
|
436
|
+
describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow 复用)", () => {
|
|
437
|
+
const mkRegister = (id: string, type: "subagent" | "workflow" = "subagent", overrides: Record<string, unknown> = {}) => ({
|
|
438
|
+
type: "custom",
|
|
439
|
+
customType: "pending:register",
|
|
440
|
+
data: { id, type, name: id, registeredAt: 1000, expiresAt: 1000 + 3_600_000, sessionId: "s-1", ...overrides },
|
|
441
|
+
});
|
|
442
|
+
const mkUnregister = (id: string, reason = "completed") => ({
|
|
443
|
+
type: "custom",
|
|
444
|
+
customType: "pending:unregister",
|
|
445
|
+
data: { id, reason },
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("空 entries → 0 活跃", () => {
|
|
449
|
+
expect(countActiveFromEntries([])).toEqual({ count: 0, ids: [], entries: [] });
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("纯 register → count = register 数,返回完整 entry", () => {
|
|
453
|
+
const res = countActiveFromEntries([mkRegister("bg-1"), mkRegister("bg-2", "workflow")]);
|
|
454
|
+
expect(res.count).toBe(2);
|
|
455
|
+
expect(res.ids).toEqual(["bg-1", "bg-2"]);
|
|
456
|
+
expect(res.entries[0]).toMatchObject({ id: "bg-1", type: "subagent" });
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("register + unregister 同 id → 差集抵消", () => {
|
|
460
|
+
expect(countActiveFromEntries([mkRegister("bg-1"), mkUnregister("bg-1")]).count).toBe(0);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("混合:部分注销 → 只统计仍活跃的", () => {
|
|
464
|
+
const res = countActiveFromEntries([
|
|
465
|
+
mkRegister("bg-1"),
|
|
466
|
+
mkRegister("bg-2"),
|
|
467
|
+
mkUnregister("bg-1"),
|
|
468
|
+
]);
|
|
469
|
+
expect(res.count).toBe(1);
|
|
470
|
+
expect(res.ids).toEqual(["bg-2"]);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("types 过滤:只统计指定类型的活跃 pending", () => {
|
|
474
|
+
const entries = [mkRegister("bg-1", "subagent"), mkRegister("wf-1", "workflow")];
|
|
475
|
+
expect(countActiveFromEntries(entries, { types: ["subagent"] }).ids).toEqual(["bg-1"]);
|
|
476
|
+
expect(countActiveFromEntries(entries, { types: ["workflow"] }).ids).toEqual(["wf-1"]);
|
|
477
|
+
expect(countActiveFromEntries(entries, { types: ["subagent", "workflow"] }).count).toBe(2);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("TTL 过期仍判活跃(刻意不校验 expiresAt,对齐 goal continuation 守卫语义)", () => {
|
|
481
|
+
const expired = mkRegister("bg-1", "subagent", { registeredAt: 0, expiresAt: 1 });
|
|
482
|
+
expect(countActiveFromEntries([expired]).count).toBe(1);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("同 id 重复 register → 只算一次", () => {
|
|
486
|
+
expect(countActiveFromEntries([mkRegister("bg-1"), mkRegister("bg-1")]).count).toBe(1);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("entries 含 null/undefined 元素 → 跳过不抛 TypeError(S-10)", () => {
|
|
490
|
+
// S-10 回归:外部调用方可能传入含 null/undefined 的 entries(session 文件坏行/脏数据),
|
|
491
|
+
// 遍历时访问 raw.customType 前必须守卫,否则 TypeError 炸掉整个差集判定。
|
|
492
|
+
const withNulls = [null, undefined, mkRegister("bg-1"), mkUnregister("bg-1"), null];
|
|
493
|
+
expect(countActiveFromEntries(withNulls).count).toBe(0);
|
|
494
|
+
const mixed = [undefined, null, mkRegister("bg-2")];
|
|
495
|
+
expect(countActiveFromEntries(mixed).ids).toEqual(["bg-2"]);
|
|
496
|
+
// register 循环同样守卫:unregister 遍历前的 null 不污染差集
|
|
497
|
+
expect(countActiveFromEntries([null, undefined]).count).toBe(0);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("malformed register(id 非 string)→ 跳过", () => {
|
|
501
|
+
const bad = { type: "custom", customType: "pending:register", data: { id: 42 } };
|
|
502
|
+
const good = mkRegister("bg-1");
|
|
503
|
+
expect(countActiveFromEntries([bad, good]).ids).toEqual(["bg-1"]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("跨 session 残留(fork 继承的 register)不校验 sessionId——由 session_start 重建流程补 expired unregister 抵消", () => {
|
|
507
|
+
// 模拟 P fork 主 session:继承来主 session 的 register(sessionId=s-0),已被 rebuild 补 unregister(expired)
|
|
508
|
+
const inherited = mkRegister("parent-bg", "subagent", { sessionId: "s-0" });
|
|
509
|
+
const flushed = mkUnregister("parent-bg", "expired");
|
|
510
|
+
const own = mkRegister("my-bg");
|
|
511
|
+
expect(countActiveFromEntries([inherited, flushed, own]).ids).toEqual(["my-bg"]);
|
|
512
|
+
});
|
|
513
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,24 @@ import {
|
|
|
38
38
|
unregister,
|
|
39
39
|
} from "./state.ts";
|
|
40
40
|
|
|
41
|
+
// 跨扩展消费 API:goal(continuation 守卫)/ subagent-workflow(agent_end 后代判定)
|
|
42
|
+
// 直接 import 本包的导出,避免各自复制差集逻辑。
|
|
43
|
+
export {
|
|
44
|
+
countActiveFromEntries,
|
|
45
|
+
createRegistry,
|
|
46
|
+
getActive,
|
|
47
|
+
PENDING_TTL_MS,
|
|
48
|
+
type CountActiveOptions,
|
|
49
|
+
type CountActiveResult,
|
|
50
|
+
type PendingEntry,
|
|
51
|
+
type PendingRegistry,
|
|
52
|
+
type PendingStatus,
|
|
53
|
+
type PendingType,
|
|
54
|
+
rebuildFromEntries,
|
|
55
|
+
register,
|
|
56
|
+
unregister,
|
|
57
|
+
} from "./state.ts";
|
|
58
|
+
|
|
41
59
|
/** 工具参数 schema */
|
|
42
60
|
const PendingNotificationsParams = Type.Object({
|
|
43
61
|
action: Type.Union([
|
|
@@ -97,11 +115,11 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
97
115
|
}
|
|
98
116
|
}
|
|
99
117
|
|
|
100
|
-
// debug 日志:环境变量
|
|
118
|
+
// debug 日志:环境变量 XYZ_AGENT_DEBUG=1 时输出到 console.debug。
|
|
101
119
|
// 不再写入 session entry(pending:log)——session entries 是 append-only 无法 GC,
|
|
102
120
|
// 12 处 debug 日志会让长 session 的 entries 线性膨胀,而 goal before-agent-start
|
|
103
121
|
// 每 turn 全量扫描 getEntries()。状态数据(pending:register/unregister)仍写 entry。
|
|
104
|
-
const debugEnabled = process.env.
|
|
122
|
+
const debugEnabled = process.env.XYZ_AGENT_DEBUG === "1";
|
|
105
123
|
function debugLog(level: string, message: string, data?: unknown): void {
|
|
106
124
|
if (!debugEnabled) return;
|
|
107
125
|
console.debug(`[pending-notifications:${level}] ${message}`, data ?? "");
|
package/src/state.ts
CHANGED
|
@@ -108,6 +108,66 @@ export function getActive(registry: PendingRegistry): PendingEntry[] {
|
|
|
108
108
|
return Array.from(registry.operations.values()).filter((op) => op.status === "active");
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/** countActiveFromEntries 的过滤选项。 */
|
|
112
|
+
export interface CountActiveOptions {
|
|
113
|
+
/** 只统计指定类型的活跃 pending;缺省 = 全部类型(subagent + workflow) */
|
|
114
|
+
types?: PendingType[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** countActiveFromEntries 的结果。 */
|
|
118
|
+
export interface CountActiveResult {
|
|
119
|
+
count: number;
|
|
120
|
+
ids: string[];
|
|
121
|
+
/** 活跃的完整 entry(含类型/名称/TTL,供调用方展示或后续判断) */
|
|
122
|
+
entries: PendingEntry[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 从持久化 entries 计算活跃 pending 数(register − unregister 差集)。
|
|
127
|
+
*
|
|
128
|
+
* 与 rebuildFromEntries 的分工:本函数只做「有没有活跃 pending」的只读判断,
|
|
129
|
+
* 不写 registry、不判 sessionId/expiresAt(TTL 刻意不校验——长任务 subagent >1h
|
|
130
|
+
* 仍应视为活跃,对齐 goal agent-end 的 continuation 守卫语义)。
|
|
131
|
+
* 调用方:goal(agent_end 时判断是否发 continuation)、subagent-workflow
|
|
132
|
+
* (agent_end 时判断子进程是否有活跃后代,决定是否保持进程等 steer 唤醒)。
|
|
133
|
+
*
|
|
134
|
+
* 注:跨 session 残留(fork 继承的 register)由 index.ts 的 session_start 重建
|
|
135
|
+
* 流程补 unregister(expired) 抵消;本函数只做纯差集,不重复处理。
|
|
136
|
+
*/
|
|
137
|
+
export function countActiveFromEntries(
|
|
138
|
+
entries: unknown[],
|
|
139
|
+
opts?: CountActiveOptions,
|
|
140
|
+
): CountActiveResult {
|
|
141
|
+
const unregistered = new Set<string>();
|
|
142
|
+
for (const raw of entries as EntryLike[]) {
|
|
143
|
+
// S-10:entries 元素可能是 null/undefined(外部调用方/坏数据),
|
|
144
|
+
// EntryLike 断言前先守卫,避免访问 raw.customType 抛 TypeError 炸掉差集判定。
|
|
145
|
+
if (!raw || typeof raw !== "object") continue;
|
|
146
|
+
if (raw.customType !== "pending:unregister") continue;
|
|
147
|
+
const data = (raw.data ?? {}) as UnregisterEntryData;
|
|
148
|
+
if (typeof data.id === "string") unregistered.add(data.id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const active: PendingEntry[] = [];
|
|
152
|
+
const seen = new Set<string>();
|
|
153
|
+
for (const raw of entries as EntryLike[]) {
|
|
154
|
+
if (!raw || typeof raw !== "object") continue;
|
|
155
|
+
if (raw.customType !== "pending:register") continue;
|
|
156
|
+
const data = (raw.data ?? {}) as RegisterEntryData;
|
|
157
|
+
if (typeof data.id !== "string" || unregistered.has(data.id) || seen.has(data.id)) continue;
|
|
158
|
+
seen.add(data.id);
|
|
159
|
+
const entry = normalizeRegisterEntry(data, "");
|
|
160
|
+
if (opts?.types && !opts.types.includes(entry.type)) continue;
|
|
161
|
+
active.push(entry);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
count: active.length,
|
|
166
|
+
ids: active.map((e) => e.id),
|
|
167
|
+
entries: active,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
111
171
|
/** rebuildFromEntries 的结果:重建后的活跃列表 + 需要补注销的 entry */
|
|
112
172
|
export interface RebuildResult {
|
|
113
173
|
/** 重建后识别为 active 的 id 列表(已写入 registry) */
|
|
@@ -140,6 +200,8 @@ export function rebuildFromEntries(
|
|
|
140
200
|
const unregisteredIds = new Set<string>();
|
|
141
201
|
|
|
142
202
|
for (const raw of entries as EntryLike[]) {
|
|
203
|
+
// S-10:同 countActiveFromEntries——null/undefined 元素先守卫再访问字段。
|
|
204
|
+
if (!raw || typeof raw !== "object") continue;
|
|
143
205
|
if (raw.customType === "pending:register") {
|
|
144
206
|
registerEntries.push({ data: (raw.data ?? {}) as RegisterEntryData });
|
|
145
207
|
} else if (raw.customType === "pending:unregister") {
|