@zhushanwen/pi-pending-notifications 0.10.1 → 0.11.1

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.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Cross-extension async operation registration/query mechanism for Pi.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,8 +34,8 @@
34
34
  "typebox": "1.3.27"
35
35
  },
36
36
  "dependencies": {
37
- "@zhushanwen/extension-protocol": "0.23.0",
38
- "@zhushanwen/pi-delivery-port": "0.2.1",
37
+ "@zhushanwen/extension-protocol": "0.25.0",
38
+ "@zhushanwen/pi-delivery-port": "0.3.1",
39
39
  "@zhushanwen/pi-extension-logger": "0.6.2"
40
40
  },
41
41
  "peerDependenciesMeta": {
@@ -45,6 +45,6 @@
45
45
  },
46
46
  "scripts": {
47
47
  "typecheck": "npx tsc --noEmit",
48
- "test": "vitest run"
48
+ "test": "vitest run --pool=threads"
49
49
  }
50
50
  }
@@ -412,6 +412,76 @@ describe("collect_results tool", () => {
412
412
  ]);
413
413
  });
414
414
 
415
+ it("[结清五值回填 §3.3.6] 注销 reason 事实经结清解析回填 outcome:completed/failed/cancelled/lost/no_result 分别可达", async () => {
416
+ // 回填权威 = 结清视图(reason 事实 → resolveSettlementReason),不经
417
+ // mapReasonToStatus 旧映射——lost/no_result 在旧 status 副档恒落 unknown,
418
+ // 本用例五值逐一可达即锁定断层修复(真 listener 落盘 → 全链路回填)。
419
+ const cases: Array<[string, string]> = [
420
+ ["ok-1", "completed"],
421
+ ["ok-2", "failed"],
422
+ ["ok-3", "cancelled"],
423
+ ["ok-4", "lost"],
424
+ ["ok-5", "no_result"],
425
+ ];
426
+ for (const [id] of cases) setup.entries.push(makeRegisterEntry(id));
427
+ setup.port.collectArm.mockReturnValue(cases.map(([id]) => ({ id, state: "waiting" })));
428
+
429
+ const promise = runCollect(setup, { ids: cases.map(([id]) => id) });
430
+ const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
431
+ // 真实 listener 落盘注销 entry:reason 事实 + mapReasonToStatus 的 status 副档
432
+ // (lost/no_result 的 status 落 unknown——旧映射通道下 outcome 恒 unknown 的缺陷形态)
433
+ for (const [id, reason] of cases) setup.handlers.pendingUnregister!({ id, reason });
434
+ // 端口 consume 真实返回形态(MF-1-6):只含载荷,type/outcome 归回填
435
+ setup.port.consume.mockReturnValueOnce(cases.map(([id]) => ({ id, content: `payload-${id}` })));
436
+
437
+ arrivalCb();
438
+ const res = await promise;
439
+
440
+ expect(res.details.collected).toEqual(
441
+ cases.map(([id, outcome]) => ({
442
+ id,
443
+ type: "workflow",
444
+ outcome,
445
+ content: `payload-${id}`,
446
+ })),
447
+ );
448
+ });
449
+
450
+ it("[畸形 data 兜底 §3.3.6] 结清视图缺位(reason 非 string)取 legacy status 副档,与端口快照通道同规则", async () => {
451
+ // 两通道统一填充规则(结清优先 + legacy status 兜底):注销 entry 的 reason
452
+ // 非 string 是畸形 data(结清视图不入,pending-entries.ts absorbUnregisterEntry),
453
+ // status 合法在册——回填取 status 值,与端口快照通道(collectFieldsFor 同款
454
+ // `??` 形态)对同一 id 产出一致;reason 合法时结清视图优先(兜底不触发);
455
+ // 两视图皆缺位保持 undefined(诚实缺省)。status 值经真 listener 落盘契约同源
456
+ // (mapReasonToStatus 映射产物),此处直插 entry 镜像落盘形态。
457
+ setup.entries.push(
458
+ makeRegisterEntry("ok-1"),
459
+ makeRegisterEntry("mf-1"),
460
+ makeRegisterEntry("nil-1"),
461
+ { customType: "pending:unregister", data: { id: "ok-1", reason: "completed", status: "completed" } },
462
+ { customType: "pending:unregister", data: { id: "mf-1", reason: 42, status: "cancelled" } },
463
+ { customType: "pending:unregister", data: { id: "nil-1", reason: 42, status: "not-a-status" } },
464
+ );
465
+ setup.port.collectArm.mockReturnValue(
466
+ ["ok-1", "mf-1", "nil-1"].map((id) => ({ id, state: "waiting" })),
467
+ );
468
+
469
+ const promise = runCollect(setup, { ids: ["ok-1", "mf-1", "nil-1"] });
470
+ const arrivalCb = setup.port.onCollectArrival.mock.calls[0][0] as () => void;
471
+ setup.port.consume.mockReturnValueOnce(
472
+ ["ok-1", "mf-1", "nil-1"].map((id) => ({ id, content: `payload-${id}` })),
473
+ );
474
+
475
+ arrivalCb();
476
+ const res = await promise;
477
+
478
+ expect(res.details.collected).toEqual([
479
+ { id: "ok-1", type: "workflow", outcome: "completed", content: "payload-ok-1" },
480
+ { id: "mf-1", type: "workflow", outcome: "cancelled", content: "payload-mf-1" },
481
+ { id: "nil-1", type: "workflow", outcome: undefined, content: "payload-nil-1" },
482
+ ]);
483
+ });
484
+
415
485
  it("超时路径:已收部分进 collected、未完成进 stillOpen(诚实返回,不抛错)", async () => {
416
486
  setup.entries.push(makeRegisterEntry("fast"), makeRegisterEntry("slow"));
417
487
  setup.port.collectArm.mockReturnValue([
@@ -14,7 +14,7 @@
14
14
 
15
15
  /* eslint-disable taste/no-unsafe-cast */
16
16
 
17
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
18
18
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
19
 
20
20
  import pendingNotificationsExtension from "../index";
@@ -422,7 +422,7 @@ describe("pendingNotificationsExtension factory", () => {
422
422
  }
423
423
  });
424
424
 
425
- it("U7c: 未知 reason 落 default 兜底 completed(兜底语义的显式登记——未来若改兜底口径,此用例同步更新)", () => {
425
+ it("U7c: 未知 reason 落 default 兜底 unknown(fail-visible 缺省——§3.3.11④ fail-open=completed 废除,不产假成功)", () => {
426
426
  fireSessionStart(setup);
427
427
  setup.handlers.pendingRegister!({ id: "w-unknown", type: "workflow", name: "op" });
428
428
  setup.appendEntryMock.mockClear();
@@ -431,7 +431,24 @@ describe("pendingNotificationsExtension factory", () => {
431
431
 
432
432
  expect(setup.appendEntryMock).toHaveBeenCalledWith(
433
433
  "pending:unregister",
434
- expect.objectContaining({ id: "w-unknown", reason: "some-future-reason", status: "completed" }),
434
+ expect.objectContaining({ id: "w-unknown", reason: "some-future-reason", status: "unknown" }),
435
+ );
436
+ });
437
+
438
+ it("[subagent-observability §3.3.11④] reason 缺失(emit 方漏传的畸形 data)→ 结清 reason = no_result,不产 completed 假成功", () => {
439
+ fireSessionStart(setup);
440
+ setup.handlers.pendingRegister!({ id: "w-no-reason", type: "workflow", name: "op" });
441
+ setup.appendEntryMock.mockClear();
442
+
443
+ setup.handlers.pendingUnregister!({ id: "w-no-reason" });
444
+
445
+ expect(setup.appendEntryMock).toHaveBeenCalledWith(
446
+ "pending:unregister",
447
+ expect.objectContaining({ id: "w-no-reason", reason: "no_result", status: "unknown" }),
448
+ );
449
+ expect(setup.appendEntryMock).not.toHaveBeenCalledWith(
450
+ "pending:unregister",
451
+ expect.objectContaining({ reason: "completed" }),
435
452
  );
436
453
  });
437
454
  });
@@ -454,6 +471,21 @@ describe("pendingNotificationsExtension factory", () => {
454
471
  const ids = (res.details as { items: PendingEntry[] }).items.map((i) => i.id);
455
472
  expect(ids.sort()).toEqual(["s-1", "w-1"]);
456
473
  });
474
+
475
+ it("renderResult 接线(C-ext-05):注册定义携带函数成员 + 折叠态输出 details.summary 一行", async () => {
476
+ fireSessionStart(setup);
477
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "a" });
478
+ const tool = setup.registerToolMock.mock.calls[0][0] as {
479
+ renderResult: (result: unknown, options: { expanded: boolean }, theme: Theme) => { render: (width: number) => string[] };
480
+ };
481
+ expect(typeof tool.renderResult).toBe("function");
482
+ const res = await runTool(setup, { action: "count" });
483
+ const renderTheme = { fg: (_token: string, text: string) => text } as unknown as Theme;
484
+ const lines = tool.renderResult(res, { expanded: false }, renderTheme).render(400);
485
+ // Text.render 按宽度 pad,trim 后断言(折叠态 = details.summary 一行)
486
+ expect(lines.length).toBe(1);
487
+ expect(lines[0].trim()).toBe("1 pending operations");
488
+ });
457
489
  });
458
490
 
459
491
  describe("写侧幂等(P2)", () => {
package/src/index.ts CHANGED
@@ -259,6 +259,9 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
259
259
  debugLog("warn", "listener: pending:unregister parse failed", data);
260
260
  return;
261
261
  }
262
+ if (parsed.reasonMalformed) {
263
+ debugLog("warn", "listener: pending:unregister reason missing/malformed (emit-side contract breach) → settled no_result", data);
264
+ }
262
265
 
263
266
  debugLog("debug", "listener: pending:unregister parsed", parsed);
264
267
 
@@ -424,8 +427,8 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
424
427
  await awaitCollectArrivals(port, waitingIds, collected, timeoutMs, signal);
425
428
 
426
429
  // 终局序列(设计 §3.1③):consume 取走剩余已到达挂起条目(超时/abort 路径的
427
- // 部分收取),collectRelease 解散等待集并把未收挂起条目直通内核 flush(跳过
428
- // 合批窗口,恢复 push)。空等待路径不会走到这里(提前返回)。
430
+ // 部分收取),collectRelease 解散等待集并把未收挂起条目直通内核 flush(边沿
431
+ // 即投——攒批窗已删 §3.3.7③,恢复 push)。空等待路径不会走到这里(提前返回)。
429
432
  collected.push(...port.consume());
430
433
  port.collectRelease();
431
434
 
@@ -464,8 +467,9 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
464
467
  case "ready":
465
468
  case "unregistered-no-entry":
466
469
  // 快照收取:ready = 日志条目已有终局的读侧快照(不写新终局);
467
- // unregistered-no-entry = 第四态无载荷有限信息形态(outcome 取注销
468
- // status、note 说明来源,端口侧组装)。两态的 entry 契约在册。
470
+ // unregistered-no-entry = 第四态无载荷有限信息形态(outcome 由端口
471
+ // 按结清视图优先 + legacy status 兜底填充、note 说明来源,端口侧
472
+ // 组装)。两态的 entry 契约在册。
469
473
  buckets.collected.push({ id: snap.id, ...(snap.entry ?? {}) });
470
474
  break;
471
475
  case "in-push-pipeline":
@@ -526,22 +530,27 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
526
530
  });
527
531
  }
528
532
 
529
- /** type/outcome 缺失回填(设计 §3.1③ 字段来源表:type 与 pending:register 的
530
- * type 对齐、outcome 与 pending:unregister 的 status 对齐;MF-1-6:端口 consume
531
- * 只返回 {id, content, details},终态字段统一由本处单遍扫描回填):「等待中
532
- * 同步到达」的挂起条目在端口 consume 时点,生产方的注销 entry 尚未落盘
533
- * (managed flushBatch 的 deliver 与 unregisterPending 同步段相邻、deliver 先
534
- * 行;端口 consume 由到达回调在 deliver 栈内同步触发),outcome 恒取空。await
535
- * 恢复后生产方同步栈已完成(注销 entry 已落盘),按本 session entries 现算回填;
536
- * 注销不在册的条目保持 undefined(诚实缺省,不虚构终态)。快照收取条目
537
- * (ready / unregistered-no-entry)的 type/outcome 已由端口 collectArm 填充,
538
- * 回填只补缺不覆盖。 */
533
+ /** type/outcome 缺失回填(设计 §3.1③ 字段来源表 + §3.3.6 结清权威):type 与
534
+ * pending:register 的 type 对齐;outcome 与端口快照通道(delivery-port
535
+ * collectFieldsFor / collectArm)统一填充规则——注销结清视图优先(reason 事实
536
+ * 经 resolveSettlementReason 结清解析,封闭五值 + 词表外 unknown fail-visible,
537
+ * protocol scanPendingFields 单点投影),legacy status 副档兜底。结清视图承载
538
+ * no_result/lost 等新写入方 reason 的真值(旧 status 副档恒落 unknown),兜底
539
+ * 仅服务结清视图缺位的畸形 data(status 合法但 reason 非 string——注销 entry
540
+ * 不入结清视图、status 副档在册),此时两通道同取 status 值保持一致;两视图
541
+ * 皆缺位保持 undefined(诚实缺省,不虚构终态)。「等待中同步到达」的挂起条目
542
+ * 在端口 consume 时点,生产方的注销 entry 尚未落盘(managed flushBatch 的
543
+ * deliver 与 unregisterPending 同步段相邻、deliver 先行;端口 consume 由到达
544
+ * 回调在 deliver 栈内同步触发),outcome 恒取空。await 恢复后生产方同步栈已完成
545
+ * (注销 entry 已落盘),按本 session entries 现算回填;注销不在册的条目保持
546
+ * undefined。快照收取条目(ready / unregistered-no-entry)的 type/outcome 已由
547
+ * 端口 collectArm 填充,回填只补缺不覆盖。 */
539
548
  function backfillCollectFields(ctx: ExtensionContext, collected: CollectedEntry[]): void {
540
549
  if (!collected.some((c) => c.outcome === undefined || c.type === undefined)) return;
541
550
  const fields = scanPendingFields(ctx.sessionManager.getEntries());
542
551
  for (const c of collected) {
543
552
  if (c.type === undefined) c.type = fields.registerTypes.get(c.id);
544
- if (c.outcome === undefined) c.outcome = fields.unregisterStatuses.get(c.id);
553
+ if (c.outcome === undefined) c.outcome = fields.unregisterSettlements.get(c.id) ?? fields.unregisterStatuses.get(c.id);
545
554
  }
546
555
  }
547
556
  }
@@ -574,27 +583,35 @@ function parseRegisterEvent(data: unknown): ParsedRegister | null {
574
583
  interface ParsedUnregister {
575
584
  id: string;
576
585
  reason: string;
586
+ /** reason 缺失/非 string(emit 方违约的畸形 data)——缺省 no_result 结清时置位,
587
+ * 调用点 debugLog warn 留痕([subagent-observability §3.3.11④])。 */
588
+ reasonMalformed?: boolean;
577
589
  }
578
590
 
579
591
  /** 解析 pending:unregister 事件 data(容错缺失/类型错误字段)。
580
- * T2 通知由 subagent-workflow 自有通道 bg-notify-render 承担,不经本事件。 */
592
+ * T2 通知由 subagent-workflow 自有通道 bg-notify-render 承担,不经本事件。
593
+ * [subagent-observability §3.3.11④] reason 缺失/非 string(emit 方漏传的违约
594
+ * 形态)缺省 no_result 不产 completed——无事实可依不产假成功结清,与
595
+ * resolveSettlementReason / resolveSettlement 缺省方向一致;畸形经调用点
596
+ * warn 留痕。 */
581
597
  function parseUnregisterEvent(data: unknown): ParsedUnregister | null {
582
598
  if (typeof data !== "object" || data === null) return null;
583
599
  const d = data as Record<string, unknown>;
584
600
  if (typeof d.id !== "string") return null;
585
- return {
586
- id: d.id,
587
- reason: typeof d.reason === "string" ? d.reason : "completed",
588
- };
601
+ if (typeof d.reason !== "string") {
602
+ return { id: d.id, reason: "no_result", reasonMalformed: true };
603
+ }
604
+ return { id: d.id, reason: d.reason };
589
605
  }
590
606
 
591
607
  /** 将事件 reason 映射为 pending:unregister entry 的 status 字段(落盘契约组成部分)。
592
608
  * 权威单点在 protocol pending-entries(ext-simplify-17 D10)——本函数是对 protocol
593
609
  * 导出的类型收窄包装(PendingStatus 枚举留在本包,protocol 不引 extension 侧类型),
594
610
  * 既有调用点零改动;bte 对账写侧同引 protocol 单点,两侧映射不再可能漂移。
595
- * 词表演化背景(U5 / §3.2.5 通知词表对齐)见 protocol 实现处 JSDoc。 */
611
+ * 词表演化背景(U5 / §3.2.5 通知词表对齐)见 protocol 实现处 JSDoc;unknown 缺省
612
+ * 成员两侧词表同批收录,收窄赋值成立无需断言。 */
596
613
  function mapReasonToStatus(reason: string): PendingStatus {
597
- return mapReasonToStatusImpl(reason) as PendingStatus;
614
+ return mapReasonToStatusImpl(reason);
598
615
  }
599
616
 
600
617
  /** 格式化 active 列表为可读文本 */
@@ -4,32 +4,7 @@
4
4
 
5
5
  import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { Text } from "@earendil-works/pi-tui";
7
- import { firstContentText } from "@zhushanwen/extension-protocol";
8
-
9
- /** content[0] 的 text 纯文本(content 缺失或非 text 类型时兜底空串)。 */
10
- function contentText(result: unknown): string {
11
- const content = fieldValue(result, "content");
12
- if (!Array.isArray(content)) return "";
13
- return firstContentText({ content });
14
- }
15
-
16
- /** unknown 值按字段名安全取值(index-signature 收窄,替代全可选结构断言)。 */
17
- function fieldValue(v: unknown, key: string): unknown {
18
- if (typeof v !== "object" || v === null) return undefined;
19
- return (v as Record<string, unknown>)[key];
20
- }
21
-
22
- /** 从 details 安全读取 summary(容错非对象形态与旧版本 entry)。 */
23
- function readDetailsSummary(result: unknown): string | undefined {
24
- const summary = fieldValue(fieldValue(result, "details"), "summary");
25
- return typeof summary === "string" && summary !== "" ? summary : undefined;
26
- }
27
-
28
- /** 折叠态回退:content 文本首行(保持一行摘要形态)。 */
29
- function firstLine(text: string): string {
30
- const idx = text.indexOf("\n");
31
- return idx === -1 ? text : text.slice(0, idx);
32
- }
7
+ import { firstLine, readDetailsSummary, resultContentText } from "@zhushanwen/extension-protocol";
33
8
 
34
9
  /** 折叠态(expanded=false)= details.summary 一行(dim);展开态 = content 纯文本。 */
35
10
  export function renderPendingNotificationsResult(
@@ -37,7 +12,7 @@ export function renderPendingNotificationsResult(
37
12
  options: { expanded: boolean },
38
13
  theme: Theme,
39
14
  ): Text {
40
- if (options.expanded) return new Text(contentText(result), 0, 0);
41
- const summary = readDetailsSummary(result) ?? firstLine(contentText(result));
15
+ if (options.expanded) return new Text(resultContentText(result), 0, 0);
16
+ const summary = readDetailsSummary(result) ?? firstLine(resultContentText(result));
42
17
  return new Text(theme.fg("dim", summary), 0, 0);
43
18
  }
package/src/state.ts CHANGED
@@ -39,7 +39,9 @@ export type PendingType = "workflow" | "subagent" | "bash" | "session";
39
39
  export type PendingKind = "lifetime";
40
40
 
41
41
  /** 异步操作终态/过渡状态。active = 仍在运行;其他都视为已结束 */
42
- export type PendingStatus = "active" | "completed" | "failed" | "cancelled" | "expired" | "time_limited" | "aborted";
42
+ /** unknown = 词表外 reason 的解析缺省(fail-visible——§3.3.11④ fail-open=completed 废除,
43
+ * 不可判读时如实标注不产假成功;仍属「已结束」——非 active)。 */
44
+ export type PendingStatus = "active" | "completed" | "failed" | "cancelled" | "expired" | "time_limited" | "aborted" | "unknown";
43
45
 
44
46
  /** 一个异步操作的完整描述(= pending:register entry 的 data 字段) */
45
47
  export interface PendingEntry {
@@ -125,9 +127,9 @@ export function computeCollectWaitKeys(entries: unknown[], currentSessionId: str
125
127
 
126
128
  /**
127
129
  * collect 终局回填的字段视图单点在 protocol scanPendingFields(register 首见
128
- * type + 注销 status 的 MAPPED 成员,与 delivery-port 快照基线同一映射规则,
129
- * MF-2-1;回填时序背景见 index.ts collectResults 回填段)——本文件不持有
130
- * 字段映射实现。
130
+ * type + 注销 status 视图 + 注销 reason 事实的结清视图——回填 outcome 读结清视图
131
+ * (§3.3.6 权威通道),与 delivery-port 快照基线同一扫描规则,MF-2-1;回填时序
132
+ * 背景见 index.ts collectResults 回填段)——本文件不持有字段映射实现。
131
133
  */
132
134
 
133
135
  /**