@zhushanwen/pi-pending-notifications 0.7.0 → 0.7.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 +2 -1
- package/src/__tests__/pending-notifications.test.ts +52 -5
- package/src/index.ts +11 -13
- package/src/state.ts +19 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-pending-notifications",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Cross-extension async operation registration/query mechanism for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"typebox": "*"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@xyz-agent/extension-protocol": "0.11.0",
|
|
36
37
|
"@zhushanwen/pi-extension-logger": "0.6.0"
|
|
37
38
|
},
|
|
38
39
|
"scripts": {
|
|
@@ -384,6 +384,53 @@ describe("pendingNotificationsExtension factory", () => {
|
|
|
384
384
|
);
|
|
385
385
|
expect(stateChangeCalls).toHaveLength(0);
|
|
386
386
|
});
|
|
387
|
+
|
|
388
|
+
it("U7b: reason → status 词表映射(表驱动——防新枚举值漏映射静默落兜底,cancelled 同族事故先例)", () => {
|
|
389
|
+
fireSessionStart(setup);
|
|
390
|
+
const cases: Array<[string, string]> = [
|
|
391
|
+
["completed", "completed"],
|
|
392
|
+
["failed", "failed"],
|
|
393
|
+
["cancelled", "cancelled"],
|
|
394
|
+
["expired", "expired"],
|
|
395
|
+
["time_limited", "time_limited"],
|
|
396
|
+
["budget_limited", "failed"],
|
|
397
|
+
["aborted", "aborted"],
|
|
398
|
+
// U5 新词表:subagent-core stopReason 展示值经注销 reason 通道到达
|
|
399
|
+
["interrupted", "aborted"],
|
|
400
|
+
["interrupted-by-restart", "aborted"],
|
|
401
|
+
["interrupted-by-parent", "aborted"],
|
|
402
|
+
["reopened", "completed"],
|
|
403
|
+
];
|
|
404
|
+
for (const [i] of cases.entries()) {
|
|
405
|
+
setup.handlers.pendingRegister!({ id: `w-map-${i}`, type: "workflow", name: `op-${i}` });
|
|
406
|
+
}
|
|
407
|
+
setup.appendEntryMock.mockClear();
|
|
408
|
+
for (const [i, [reason]] of cases.entries()) {
|
|
409
|
+
setup.handlers.pendingUnregister!({ id: `w-map-${i}`, reason });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const unregisterCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:unregister");
|
|
413
|
+
expect(unregisterCalls).toHaveLength(cases.length);
|
|
414
|
+
for (const [i, [reason, expected]] of cases.entries()) {
|
|
415
|
+
expect(unregisterCalls).toContainEqual([
|
|
416
|
+
"pending:unregister",
|
|
417
|
+
expect.objectContaining({ id: `w-map-${i}`, reason, status: expected }),
|
|
418
|
+
]);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("U7c: 未知 reason 落 default 兜底 completed(兜底语义的显式登记——未来若改兜底口径,此用例同步更新)", () => {
|
|
423
|
+
fireSessionStart(setup);
|
|
424
|
+
setup.handlers.pendingRegister!({ id: "w-unknown", type: "workflow", name: "op" });
|
|
425
|
+
setup.appendEntryMock.mockClear();
|
|
426
|
+
|
|
427
|
+
setup.handlers.pendingUnregister!({ id: "w-unknown", reason: "some-future-reason" });
|
|
428
|
+
|
|
429
|
+
expect(setup.appendEntryMock).toHaveBeenCalledWith(
|
|
430
|
+
"pending:unregister",
|
|
431
|
+
expect.objectContaining({ id: "w-unknown", reason: "some-future-reason", status: "completed" }),
|
|
432
|
+
);
|
|
433
|
+
});
|
|
387
434
|
});
|
|
388
435
|
|
|
389
436
|
describe("tool count/list (U9-U10)", () => {
|
|
@@ -407,16 +454,16 @@ describe("pendingNotificationsExtension factory", () => {
|
|
|
407
454
|
});
|
|
408
455
|
|
|
409
456
|
describe("写侧幂等(P2)", () => {
|
|
410
|
-
it("
|
|
411
|
-
// bte 对账权威路径 = 直接 appendEntry(不经本包 listener
|
|
412
|
-
//
|
|
413
|
-
//
|
|
457
|
+
it("任一发送方重复发 unregister(对账先落盘、事件后到达等)不重复落盘", async () => {
|
|
458
|
+
// bte 对账权威路径 = 直接 appendEntry(不经本包 listener);对账落盘后,
|
|
459
|
+
// 任一现役发送方重复发 unregister 到 listener——前置判断对同一份 entries
|
|
460
|
+
// 现算发现已注销 → 跳过(历史内存态形态下此路径会产生第二条重复 unregister entry)。
|
|
414
461
|
fireSessionStart(setup);
|
|
415
462
|
setup.handlers.pendingRegister!({ id: "bt-1", type: "bash", name: "run" });
|
|
416
463
|
|
|
417
464
|
// 对账直接落盘(模拟 bte:不经 listener 的 pi.appendEntry)
|
|
418
465
|
setup.appendEntryMock("pending:unregister", { id: "bt-1", reason: "completed", status: "completed" });
|
|
419
|
-
//
|
|
466
|
+
// 重复 unregister 事件到达 listener
|
|
420
467
|
setup.handlers.pendingUnregister!({ id: "bt-1", reason: "completed" });
|
|
421
468
|
|
|
422
469
|
const unregisterCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:unregister");
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* ① subagent-core——notify-host.ts / orchestration/lifecycle.ts /
|
|
17
17
|
* worker-message-pump.ts / round-supervisor/reconcile-sweep.ts(崩溃恢复 sweep
|
|
18
18
|
* 补注销);② base-tool-enhance——bash 投影(notify.ts register/unregister 主链 +
|
|
19
|
-
* process-exit-guard.ts
|
|
19
|
+
* process-exit-guard.ts 退出边沿尽力补 emit(pending-reconcile 对账走 appendEntry
|
|
20
|
+
* 权威路径,不经 emit));
|
|
20
21
|
* ③ subagent-workflow——仅崩溃恢复时 emit pending:unregister(session-lifecycle.ts
|
|
21
22
|
* recoverCrashedRuns 回调)):
|
|
22
23
|
* - emit("pending:register", { id, type, name })
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
*/
|
|
36
37
|
|
|
37
38
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import { mapReasonToStatus as mapReasonToStatusImpl } from "@xyz-agent/extension-protocol";
|
|
38
40
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
39
41
|
import { Type } from "typebox";
|
|
40
42
|
|
|
@@ -183,7 +185,8 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
183
185
|
debugLog("debug", "listener: pending:unregister parsed", parsed);
|
|
184
186
|
|
|
185
187
|
// 未知/已注销 id 忽略(U8):对 entries 现算(有 register 且无任何 unregister
|
|
186
|
-
// 才落盘)——bte
|
|
188
|
+
// 才落盘)——bte 对账已直接落盘的注销在此同样生效,任一发送方重复 unregister
|
|
189
|
+
// 被本前置判断拦截,天然不重复落盘。
|
|
187
190
|
const status = mapReasonToStatus(parsed.reason);
|
|
188
191
|
if (!isPendingActive(currentEntries(), parsed.id)) {
|
|
189
192
|
debugLog("debug", "listener: pending:unregister ignored (unknown id)", { id: parsed.id });
|
|
@@ -277,18 +280,13 @@ function parseUnregisterEvent(data: unknown): ParsedUnregister | null {
|
|
|
277
280
|
};
|
|
278
281
|
}
|
|
279
282
|
|
|
280
|
-
/** 将事件 reason 映射为 pending:unregister entry 的 status
|
|
283
|
+
/** 将事件 reason 映射为 pending:unregister entry 的 status 字段(落盘契约组成部分)。
|
|
284
|
+
* 权威单点在 protocol pending-entries(ext-simplify-17 D10)——本函数是对 protocol
|
|
285
|
+
* 导出的类型收窄包装(PendingStatus 枚举留在本包,protocol 不引 extension 侧类型),
|
|
286
|
+
* 既有调用点零改动;bte 对账写侧同引 protocol 单点,两侧映射不再可能漂移。
|
|
287
|
+
* 词表演化背景(U5 / §3.2.5 通知词表对齐)见 protocol 实现处 JSDoc。 */
|
|
281
288
|
function mapReasonToStatus(reason: string): PendingStatus {
|
|
282
|
-
|
|
283
|
-
case "completed": return "completed";
|
|
284
|
-
case "failed": return "failed";
|
|
285
|
-
case "cancelled": return "cancelled";
|
|
286
|
-
case "expired": return "expired";
|
|
287
|
-
case "time_limited": return "time_limited";
|
|
288
|
-
case "budget_limited": return "failed";
|
|
289
|
-
case "aborted": return "aborted";
|
|
290
|
-
default: return "completed";
|
|
291
|
-
}
|
|
289
|
+
return mapReasonToStatusImpl(reason) as PendingStatus;
|
|
292
290
|
}
|
|
293
291
|
|
|
294
292
|
/** 格式化 active 列表为可读文本 */
|
package/src/state.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* - hasPendingId / isPendingActive: 写侧落盘前置判断(register 去重 / unregister 活跃判定)
|
|
9
9
|
*
|
|
10
10
|
* 设计要点:
|
|
11
|
+
* - 差集规则本体(register 首见去重 + unregister 全局抵消 + id 非法跳过)单点在
|
|
12
|
+
* @xyz-agent/extension-protocol 的 pending-entries(与 bte 对账同源消费,
|
|
13
|
+
* ext-simplify-13 E6);本文件在其上只保留 pending 产品语义:类型归一 /
|
|
14
|
+
* opts.types / currentSessionId 过滤与写侧判定
|
|
11
15
|
* - session entries 是唯一状态源,本文件无内存状态(历史的内存 registry、session_start
|
|
12
16
|
* 重建、TTL 与 shutdown 机器已删除)——「落盘了什么」与「查询到什么」共用同一份扫描,
|
|
13
17
|
* 结构上不可分歧
|
|
@@ -16,6 +20,7 @@
|
|
|
16
20
|
* 对齐 goal continuation 守卫语义);历史 session 文件中遗留的带 expiresAt 的
|
|
17
21
|
* register entry 无需迁移——差集语义不读该键
|
|
18
22
|
*/
|
|
23
|
+
import { applyPendingDiff, scanPendingEntries } from "@xyz-agent/extension-protocol";
|
|
19
24
|
|
|
20
25
|
/** 异步操作类型(来源:workflow / subagent / bash 后台任务) */
|
|
21
26
|
export type PendingType = "workflow" | "subagent" | "bash";
|
|
@@ -39,27 +44,6 @@ export interface PendingEntry {
|
|
|
39
44
|
sessionId: string;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
/** pending:register entry 在 entries 里的最小可识别形状 */
|
|
43
|
-
interface RegisterEntryData {
|
|
44
|
-
id: unknown;
|
|
45
|
-
type: unknown;
|
|
46
|
-
name: unknown;
|
|
47
|
-
registeredAt: unknown;
|
|
48
|
-
sessionId: unknown;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** pending:unregister entry 在 entries 里的最小可识别形状。
|
|
52
|
-
* T2 通知由 subagent-workflow 自有通道 bg-notify-render 承担,不经本事件。 */
|
|
53
|
-
interface UnregisterEntryData {
|
|
54
|
-
id: unknown;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** SessionEntry 的最小可识别形状(duck-typed,避免依赖 SDK 具体类型) */
|
|
58
|
-
interface EntryLike {
|
|
59
|
-
customType?: string;
|
|
60
|
-
data?: unknown;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
47
|
/** countActiveFromEntries 的过滤选项。 */
|
|
64
48
|
export interface CountActiveOptions {
|
|
65
49
|
/** 只统计指定类型的活跃 pending;缺省 = 全部类型(subagent + workflow + bash) */
|
|
@@ -99,9 +83,9 @@ export function countActiveFromEntries(
|
|
|
99
83
|
entries: unknown[],
|
|
100
84
|
opts?: CountActiveOptions,
|
|
101
85
|
): CountActiveResult {
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
const active = filterActiveRegisters(
|
|
86
|
+
// 差集本体(首见去重 + 抵消 + id 非法跳过,S-10 容错在内)单点委托 protocol;
|
|
87
|
+
// 本层只叠加 pending 产品过滤(normalize / types / currentSessionId,见 filterActiveRegisters)
|
|
88
|
+
const active = filterActiveRegisters(applyPendingDiff(scanPendingEntries(entries)), opts);
|
|
105
89
|
|
|
106
90
|
return {
|
|
107
91
|
count: active.length,
|
|
@@ -121,9 +105,9 @@ export function hasPendingId(entries: unknown[], id: string): boolean {
|
|
|
121
105
|
|
|
122
106
|
/**
|
|
123
107
|
* 写侧前置判断②:id 是否活跃(有 register 且无任何 unregister)。
|
|
124
|
-
* unregister listener 落盘前判断用(U8 未知/已注销 id
|
|
125
|
-
* appendEntry
|
|
126
|
-
*
|
|
108
|
+
* unregister listener 落盘前判断用(U8 未知/已注销 id 忽略);构造性一致:
|
|
109
|
+
* bte 对账直接 appendEntry 的注销与事件落盘的注销落在同一份 entries 上,
|
|
110
|
+
* 任一发送方重复 unregister 均被本前置判断拦截,天然不重复落盘。
|
|
127
111
|
*/
|
|
128
112
|
export function isPendingActive(entries: unknown[], id: string): boolean {
|
|
129
113
|
const { registerEntries, unregisteredIds } = scanPendingEntries(entries);
|
|
@@ -131,43 +115,15 @@ export function isPendingActive(entries: unknown[], id: string): boolean {
|
|
|
131
115
|
return registerEntries.some(({ data }) => data.id === id);
|
|
132
116
|
}
|
|
133
117
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
unregisteredIds: Set<string>;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** 单趟扫描 entries 按 customType 分流(pending:register / pending:unregister)。 */
|
|
141
|
-
function scanPendingEntries(entries: unknown[]): PendingEntryScan {
|
|
142
|
-
const scan: PendingEntryScan = { registerEntries: [], unregisteredIds: new Set() };
|
|
143
|
-
|
|
144
|
-
for (const raw of entries as EntryLike[]) {
|
|
145
|
-
// S-10:同 countActiveFromEntries——null/undefined 元素先守卫再访问字段。
|
|
146
|
-
if (!raw || typeof raw !== "object") continue;
|
|
147
|
-
if (raw.customType === "pending:register") {
|
|
148
|
-
scan.registerEntries.push({ data: (raw.data ?? {}) as RegisterEntryData });
|
|
149
|
-
} else if (raw.customType === "pending:unregister") {
|
|
150
|
-
const data = (raw.data ?? {}) as UnregisterEntryData;
|
|
151
|
-
if (typeof data.id === "string") {
|
|
152
|
-
scan.unregisteredIds.add(data.id);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return scan;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** 差集过滤:跳过 id 非法 / 已注销 / 重复 register / 跨 session 残留的 entry,按 opts.types 过滤类型。 */
|
|
118
|
+
/** pending 特有过滤层:normalize 归一 + opts.types 过滤 + 跨 session 残留过滤。
|
|
119
|
+
* 差集本体(首见去重 / 抵消 / id 非法跳过)已由 protocol applyPendingDiff 单点
|
|
120
|
+
* 完成——本函数不做任何差集判定,只消费其活跃结果。 */
|
|
161
121
|
function filterActiveRegisters(
|
|
162
|
-
|
|
163
|
-
unregisteredIds: Set<string>,
|
|
122
|
+
activeRegisters: Array<{ data: Record<string, unknown> }>,
|
|
164
123
|
opts?: CountActiveOptions,
|
|
165
124
|
): PendingEntry[] {
|
|
166
125
|
const active: PendingEntry[] = [];
|
|
167
|
-
const
|
|
168
|
-
for (const { data } of registerEntries) {
|
|
169
|
-
if (typeof data.id !== "string" || unregisteredIds.has(data.id) || seen.has(data.id)) continue;
|
|
170
|
-
seen.add(data.id);
|
|
126
|
+
for (const { data } of activeRegisters) {
|
|
171
127
|
const entry = normalizeRegisterEntry(data, "");
|
|
172
128
|
if (opts?.types && !opts.types.includes(entry.type)) continue;
|
|
173
129
|
// 跨 session 残留跳过(基准 = opts.currentSessionId)。判据读原始 data.sessionId
|
|
@@ -202,8 +158,9 @@ export function normalizePendingType(raw: unknown): PendingType {
|
|
|
202
158
|
return "workflow";
|
|
203
159
|
}
|
|
204
160
|
|
|
205
|
-
/** 从 entry data 归一化为 PendingEntry
|
|
206
|
-
|
|
161
|
+
/** 从 entry data 归一化为 PendingEntry(补默认值,容错缺失字段)。
|
|
162
|
+
* id 的 string 合法性已由差集层(applyPendingDiff)判定,此处直接窄化。 */
|
|
163
|
+
function normalizeRegisterEntry(data: Record<string, unknown>, currentSessionId: string): PendingEntry {
|
|
207
164
|
const registeredAt = typeof data.registeredAt === "number" ? data.registeredAt : Date.now();
|
|
208
165
|
return {
|
|
209
166
|
id: data.id as string,
|