@zhushanwen/pi-pending-notifications 0.7.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-pending-notifications",
3
- "version": "0.7.1",
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": {
@@ -454,16 +454,16 @@ describe("pendingNotificationsExtension factory", () => {
454
454
  });
455
455
 
456
456
  describe("写侧幂等(P2)", () => {
457
- it("bte 对账已直接落盘 unregister 后,收尾尽力补 emit 不重复落盘", async () => {
458
- // bte 对账权威路径 = 直接 appendEntry(不经本包 listener);对账平掉差集后,
459
- // 任务收尾的尽力补 emit 到达 listener——前置判断对同一份 entries 现算发现
460
- // 已注销 → 跳过(历史内存态形态下此路径会产生第二条重复 unregister entry)。
457
+ it("任一发送方重复发 unregister(对账先落盘、事件后到达等)不重复落盘", async () => {
458
+ // bte 对账权威路径 = 直接 appendEntry(不经本包 listener);对账落盘后,
459
+ // 任一现役发送方重复发 unregister listener——前置判断对同一份 entries
460
+ // 现算发现已注销 → 跳过(历史内存态形态下此路径会产生第二条重复 unregister entry)。
461
461
  fireSessionStart(setup);
462
462
  setup.handlers.pendingRegister!({ id: "bt-1", type: "bash", name: "run" });
463
463
 
464
464
  // 对账直接落盘(模拟 bte:不经 listener 的 pi.appendEntry)
465
465
  setup.appendEntryMock("pending:unregister", { id: "bt-1", reason: "completed", status: "completed" });
466
- // 收尾尽力补 emit 到达 listener
466
+ // 重复 unregister 事件到达 listener
467
467
  setup.handlers.pendingUnregister!({ id: "bt-1", reason: "completed" });
468
468
 
469
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 / pending-reconcile.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 对账已直接落盘的注销在此同样生效,收尾尽力补 emit 天然幂等。
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 });
@@ -278,27 +281,12 @@ function parseUnregisterEvent(data: unknown): ParsedUnregister | null {
278
281
  }
279
282
 
280
283
  /** 将事件 reason 映射为 pending:unregister entry 的 status 字段(落盘契约组成部分)。
281
- * [U5 / 永久会话模型 §3.2.5 通知词表对齐] subagent-core stopReason 展示值经注销
282
- * reason 通道到达(interrupted/interrupted-by-restart/interrupted-by-parent aborted、
283
- * reopened completed)——防新词落 default 被记为 completed 的误标(cancelled 同族
284
- * 事故先例:新枚举值漏映射静默落兜底)。 */
284
+ * 权威单点在 protocol pending-entries(ext-simplify-17 D10)——本函数是对 protocol
285
+ * 导出的类型收窄包装(PendingStatus 枚举留在本包,protocol 不引 extension 侧类型),
286
+ * 既有调用点零改动;bte 对账写侧同引 protocol 单点,两侧映射不再可能漂移。
287
+ * 词表演化背景(U5 / §3.2.5 通知词表对齐)见 protocol 实现处 JSDoc。 */
285
288
  function mapReasonToStatus(reason: string): PendingStatus {
286
- switch (reason) {
287
- case "completed": return "completed";
288
- case "failed": return "failed";
289
- case "cancelled": return "cancelled";
290
- case "expired": return "expired";
291
- case "time_limited": return "time_limited";
292
- case "budget_limited": return "failed";
293
- case "aborted": return "aborted";
294
- case "interrupted":
295
- case "interrupted-by-restart":
296
- case "interrupted-by-parent":
297
- return "aborted";
298
- case "reopened":
299
- return "completed";
300
- default: return "completed";
301
- }
289
+ return mapReasonToStatusImpl(reason) as PendingStatus;
302
290
  }
303
291
 
304
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
- // 单趟扫描分流 register/unregister(S-10 容错一致),差集过滤见 filterActiveRegisters
103
- const { registerEntries, unregisteredIds } = scanPendingEntries(entries);
104
- const active = filterActiveRegisters(registerEntries, unregisteredIds, opts);
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 忽略);bte 对账直接
125
- * appendEntry 的注销与事件落盘的注销在同一份 entries 上生效(构造性一致,
126
- * 收尾尽力补 emit 天然幂等)。
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
- /** scanPendingEntries 的结果:register 原始 data 列表 + 已注销 id 集合 */
135
- interface PendingEntryScan {
136
- registerEntries: Array<{ data: RegisterEntryData }>;
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
- registerEntries: Array<{ data: RegisterEntryData }>,
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 seen = new Set<string>();
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
- function normalizeRegisterEntry(data: RegisterEntryData, currentSessionId: string): PendingEntry {
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,