@zhushanwen/pi-ext-guards 0.4.1 → 0.4.3

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-ext-guards",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Shared runtime-environment guards for Pi extensions — oncePerProcess(key, fn) dedupes cross-session side-effect operations against pi factory re-invocation and handler accumulation (shared library, not a Pi extension, zero dependencies).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,11 +1,12 @@
1
1
  // src/__tests__/ext-guards.test.ts
2
2
  //
3
- // oncePerProcess 单测——验收条款逐条对应(u-guards-pkg ①):
3
+ // oncePerProcess 单测——验收条款逐条对应(u-guards-pkg ①;失败语义按加固审查裁决
4
+ // 修订为「失败不缓存、下次调用可重试」):
4
5
  // a. key 隔离去重:不同 key 互不影响,各自首次调用执行
5
6
  // b. 同 key 双调 fn 仅执行一次
6
- // c. fn 抛错不释放 key:同 key 再调不再执行(每进程至多一次的字面语义)
7
+ // c. fn 抛错不吞不包装,且不缓存——同 key 再调重新执行(可重试)
7
8
  // d. 结果缓存形态:值原样重放(严格同一引用)、Promise 实例重放
8
- // (含 rejected Promise 缓存——不因 rejection 释放 key)
9
+ // e. rejected Promise:落定后释放 key,下次调用重新执行
9
10
  //
10
11
  // 模块级 Map 是被测状态:测试间共享,每个用例用全文件唯一的 key 隔离。
11
12
 
@@ -37,21 +38,68 @@ describe("oncePerProcess", () => {
37
38
  expect(fnB).toHaveBeenCalledTimes(1);
38
39
  });
39
40
 
40
- it("fn 同步抛错:错误原样上抛(不吞不包装),key 不释放——同 key 再调不再执行且重抛同一错误", () => {
41
+ it("fn 同步抛错:错误原样上抛(不吞不包装),不写缓存——同 key 再调重新执行(可重试)", () => {
41
42
  const boom = new Error("reap failed");
42
- const failing = vi.fn(() => {
43
- throw boom;
43
+ let attempts = 0;
44
+ const fn = vi.fn(() => {
45
+ attempts += 1;
46
+ if (attempts === 1) throw boom;
47
+ return "recovered";
44
48
  });
45
49
 
46
- expect(() => oncePerProcess("dedupe:throws", failing)).toThrow(boom);
50
+ expect(() => oncePerProcess("dedupe:throws-retry", fn)).toThrow(boom);
51
+ expect(fn).toHaveBeenCalledTimes(1);
52
+
53
+ // 失败不缓存:下次同 key 调用重新执行 fn,成功结果照常缓存
54
+ expect(oncePerProcess("dedupe:throws-retry", fn)).toBe("recovered");
55
+ expect(fn).toHaveBeenCalledTimes(2);
56
+ // 成功后去重恢复:不再执行
57
+ expect(oncePerProcess("dedupe:throws-retry", fn)).toBe("recovered");
58
+ expect(fn).toHaveBeenCalledTimes(2);
59
+ });
60
+
61
+ it("async fn 抛错:rejected Promise 落定后释放 key——下次调用重新执行(可重试)", async () => {
62
+ const boom = new Error("async reap failed");
63
+ let attempts = 0;
64
+ const fn = vi.fn(async () => {
65
+ attempts += 1;
66
+ if (attempts === 1) throw boom;
67
+ return "recovered";
68
+ });
47
69
 
48
- // 再调换一个「本会成功」的 fn:若 key 被释放,它会执行并返回——这是本条
49
- // 的核心断言(失败不重置,双跑窗口不重新打开)
50
- const shouldNeverRun = vi.fn(() => "second attempt");
51
- expect(() => oncePerProcess("dedupe:throws", shouldNeverRun)).toThrow(boom);
70
+ const first = oncePerProcess("dedupe:async-rejected-retry", fn);
71
+ // 先 attach 断言再消费,避免 unhandledRejection
72
+ await expect(first).rejects.toThrow(boom);
73
+ expect(fn).toHaveBeenCalledTimes(1);
52
74
 
53
- expect(failing).toHaveBeenCalledTimes(1);
54
- expect(shouldNeverRun).not.toHaveBeenCalled();
75
+ // rejection 已落定、key 已释放:下次调用重新执行 fn
76
+ const second = oncePerProcess("dedupe:async-rejected-retry", fn);
77
+ expect(second).not.toBe(first);
78
+ await expect(second).resolves.toBe("recovered");
79
+ expect(fn).toHaveBeenCalledTimes(2);
80
+ });
81
+
82
+ it("async fn:rejection 落定前的同 key 双调仍重放同一实例(落定后释放)", async () => {
83
+ const boom = new Error("async reap failed");
84
+ const fn = vi.fn(async () => {
85
+ throw boom;
86
+ });
87
+
88
+ const first = oncePerProcess("dedupe:async-inflight", fn);
89
+ // 落定前第二次调用:同一 Promise 实例(在飞去重不双跑)
90
+ const second = oncePerProcess("dedupe:async-inflight", fn);
91
+ expect(second).toBe(first);
92
+ // 先 attach 断言再消费,避免 unhandledRejection
93
+ const firstRejection = expect(first).rejects.toThrow(boom);
94
+ await firstRejection;
95
+ await Promise.resolve(); // 让释放 handler 的微任务走完
96
+ expect(fn).toHaveBeenCalledTimes(1);
97
+
98
+ // 落定后第三次调用:key 已释放,fn 重新执行
99
+ const third = oncePerProcess("dedupe:async-inflight", fn);
100
+ expect(third).not.toBe(first);
101
+ await expect(third).rejects.toThrow(boom);
102
+ expect(fn).toHaveBeenCalledTimes(2);
55
103
  });
56
104
 
57
105
  it("结果缓存形态:对象返回值严格同一引用(重放非重新求值)", () => {
@@ -78,20 +126,14 @@ describe("oncePerProcess", () => {
78
126
  expect(fn).toHaveBeenCalledTimes(1);
79
127
  });
80
128
 
81
- it("async fn 抛错:rejected Promise 被缓存——同 key 再调重放同一实例、fn 不再执行", async () => {
82
- const boom = new Error("async reap failed");
83
- const fn = vi.fn(async () => {
84
- throw boom;
85
- });
129
+ it("fn 返回 undefined:值仍被缓存(不因 undefined 误判未执行)", () => {
130
+ const fn = vi.fn(() => undefined);
86
131
 
87
- const first = oncePerProcess("dedupe:async-rejected", fn);
88
- // 先 attach 断言再消费,避免 unhandledRejection
89
- const firstRejection = expect(first).rejects.toThrow(boom);
90
- const second = oncePerProcess("dedupe:async-rejected", fn);
132
+ const first = oncePerProcess("dedupe:undefined-value", fn);
133
+ const second = oncePerProcess("dedupe:undefined-value", fn);
91
134
 
92
- expect(second).toBe(first);
93
- await firstRejection;
94
- await expect(second).rejects.toThrow(boom);
135
+ expect(first).toBeUndefined();
136
+ expect(second).toBeUndefined();
95
137
  expect(fn).toHaveBeenCalledTimes(1);
96
138
  });
97
139
  });
package/src/index.ts CHANGED
@@ -11,10 +11,10 @@
11
11
  // reaper 双跑即此形态)。本包把这类「pi 运行环境隐式坑」的守卫集中一处,业务
12
12
  // extension 引入即用,防线不再散落各包内联。
13
13
 
14
- /** 一次执行的结果记录:正常返回记值、抛错记错误——两者都不释放 key。 */
15
- type ExecutionRecord =
16
- | { readonly outcome: "returned"; readonly value: unknown }
17
- | { readonly outcome: "threw"; readonly error: unknown };
14
+ /** 一次执行的结果记录:只缓存成功结果;失败(同步抛错 / Promise rejection)不缓存,下次调用可重试。 */
15
+ interface ExecutionRecord {
16
+ readonly value: unknown;
17
+ }
18
18
 
19
19
  // 「按进程去重」的物理载体 = 模块级 Map。有效前提:同一进程内模块级状态跨 factory
20
20
  // 二调持久——pi extension 缓存按 cwd 失效时 factory 重跑,但 jiti/Node 模块缓存同图
@@ -23,22 +23,24 @@ type ExecutionRecord =
23
23
  const executions = new Map<string, ExecutionRecord>();
24
24
 
25
25
  /**
26
- * 进程内按 key 去重地执行 fn:同一 key 至多执行一次,后续调用重放首次结果。
26
+ * 进程内按 key 去重地执行 fn:同一 key 至多执行一次**成功**,后续调用重放首次成功结果。
27
27
  *
28
- * 语义细节(设计 §3.2 D3 守卫粒度段 + §3.3 D3 守卫语义):
28
+ * 语义细节(设计 §3.2 D3 守卫粒度段 + §3.3 D3 守卫语义;失败语义按加固审查裁决修订
29
+ * ——「恢复幂等,下次启动重试」承诺优先,见下方失败段):
29
30
  *
30
31
  * - **结果缓存形态(非跳过)**:首次调用执行 fn 并缓存结果;后续同 key 调用不执行
31
32
  * fn,返回首次的返回值——原样重放:对象返回严格同一引用(toBe 级,非重新求值);
32
- * fn 返回 Promise 时重放同一实例,因此 **rejected Promise 同样被缓存**、不因
33
- * rejection 释放 key。
34
- * - **fn 抛错不吞、key 不释放**:同步抛错原样上抛(守卫不捕获、不包装),同时记录
35
- * 该错误——后续同 key 调用重抛同一错误实例、不再执行 fn。验收条款「fn 抛错不阻断
36
- * 后续 handler」的准确语义是**守卫不吞 fn 的错误**(调用方 catch 守卫调用即可继续
37
- * handler 后续逻辑,先例:base-tool-enhance runSessionStartMaintenance 的 try/catch
38
- * 形态),而非把 key 释放给二次执行——失败释放会让 factory 二调双跑窗口重新打开
39
- * (本次事故形态),与 u-bte-guard 内联 flag 先例「reap 抛错不重置 flag」同语义。
40
- * 失败重试不归守卫:需要兜底的场景由宿主的其他触发面承接(该先例的失败兜底即交
41
- * runtime 收殓触发面 B)。
33
+ * fn 返回 Promise 时重放同一实例。
34
+ * - **失败不缓存、可重试**:fn 同步抛错原样上抛(守卫不捕获、不包装)且**不写缓存**;
35
+ * fn 返回的 Promise rejection 落定后释放 key(仅释放本条失败记录,期间同 key 重试
36
+ * 成功的新记录不受影响)——下次同 key 调用重新执行 fn。守卫不自动重试,重试节奏
37
+ * 由调用方决定(宿主的其他触发面承接,如下一个 session_start)。代价(有意接受):
38
+ * 首次失败到重试成功之间,factory 二调的双跑窗口重新打开——重试的是**失败过**的
39
+ * 操作,成功即缓存、窗口随即关闭;对幂等的进程级维护(recoverCrashedRuns「恢复
40
+ * 幂等,下次启动重试」、配置迁移、索引重建等)这正是期望行为。验收条款「fn 抛错
41
+ * 不阻断后续 handler」的准确语义仍是**守卫不吞 fn 的错误**(调用方 catch 守卫调用
42
+ * 即可继续 handler 后续逻辑,先例:base-tool-enhance runSessionStartMaintenance
43
+ * 的 try/catch 形态)。
42
44
  * - **粒度边界**:只包「跨 session 副作用操作」(进程级全局维护类,正确频率就是每
43
45
  * 进程至多一次)。session 级幂等操作(如 pending 对账——读当前 session 的 entries
44
46
  * 与 registry,appendEntry 幂等)必须保持每 session_start 执行,不要挂本守卫;需要
@@ -47,29 +49,40 @@ const executions = new Map<string, ExecutionRecord>();
47
49
  * (如 "base-tool-enhance:reap")避免撞 key。
48
50
  *
49
51
  * @param key 去重键(进程内全局,建议带包名前缀)
50
- * @param fn 无参函数——首次调用时求值一次,后续调用不再执行;需要上下文(pi/ctx)
51
- * 的调用方在闭包里捕获,被闭包捕获的是首次调用处的值(这正是「每进程至多一次」
52
- * 的字面语义)
52
+ * @param fn 无参函数——首次调用时求值一次(直到成功),后续调用不再执行;需要上下文
53
+ * (pi/ctx)的调用方在闭包里捕获,被闭包捕获的是首次调用处的值(这正是「每进程
54
+ * 至多一次成功」的字面语义)
53
55
  * @returns 首次执行的返回值(后续调用重放同一结果;async fn 重放同一 Promise 实例)
54
56
  */
55
57
  export function oncePerProcess<T>(key: string, fn: () => T): T {
56
58
  const hit = executions.get(key);
57
59
  if (hit !== undefined) {
58
- if (hit.outcome === "threw") {
59
- throw hit.error;
60
- }
61
60
  // 同 key 的值由首次调用的泛型参数化记录,模块级 Map 无法按 key 参数化类型,
62
61
  // 断言仅收窄回该泛型(构造点与重放点同函数,无跨来源混装)。
63
62
  return hit.value as T;
64
63
  }
65
- try {
66
- const result = fn();
67
- executions.set(key, { outcome: "returned", value: result });
68
- return result;
69
- } catch (error) {
70
- executions.set(key, { outcome: "threw", error });
71
- throw error;
64
+ const result = fn();
65
+ // 只缓存成功结果:同步抛错不经过本行(原样上抛 + 无缓存,下次调用可重试);
66
+ // Promise 的 rejection 在落定后经下方 handler 释放 key(同样可重试)。
67
+ executions.set(key, { value: result });
68
+ if (isPromiseLike(result)) {
69
+ // 内部挂 rejection 释放 handler(同时消除本条 rejection 的 unhandled 面——
70
+ // 调用方是否自行接 rejection 均安全)。仅当缓存里仍是本条失败记录时删除:
71
+ // rejection 落定前同 key 已被重试成功覆盖的场景不误删新记录。
72
+ void Promise.resolve(result).catch(() => {
73
+ const record = executions.get(key);
74
+ if (record !== undefined && record.value === result) executions.delete(key);
75
+ });
72
76
  }
77
+ return result;
78
+ }
79
+
80
+ /**
81
+ * 测试专用:清空 oncePerProcess 的进程级守卫 Map。供静态 import 形态的测试重置
82
+ * 去重状态(无 resetModules 重建模块图时的等价手段);生产代码禁止调用。
83
+ */
84
+ export function _resetOncePerProcessForTest(): void {
85
+ executions.clear();
73
86
  }
74
87
 
75
88
  /**
@@ -150,9 +163,10 @@ export function isSubagentProcess(
150
163
  /**
151
164
  * pi ExtensionRunner 在 session 替换后标记 stale ctx 的错误文案片段。
152
165
  *
153
- * pi 语义断言(0.84.4 实装:runner.js `invalidate()` 默认 message + `assertActive()`
154
- * `throw new Error(this.staleMessage)`),登记于 docs/pi-semantics.json PS-30
155
- * (探针:extensions/shared/ext-guards/src/__tests__/pi-semantics-stale-ctx-wording.test.ts),
166
+ * pi 语义断言(语义登记 docs/pi-semantics.json PS-30,verifiedWith 以该登记为准:
167
+ * runner.js `invalidate()` 默认 message + `assertActive()`
168
+ * `throw new Error(this.staleMessage)`;
169
+ * 探针:extensions/shared/ext-guards/src/__tests__/pi-semantics-stale-ctx-wording.test.ts),
156
170
  * 随 C-proc-08 pi 版本门禁自动重验。文案变更时守卫退化为「全部上抛」(回到现状崩溃链路,
157
171
  * 有 pi-crash log 取证,不更危险),门禁报红提示更新分诊词。
158
172
  */