@zhushanwen/pi-ext-guards 0.2.1 → 0.3.0

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.2.1",
3
+ "version": "0.3.0",
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",
@@ -0,0 +1,167 @@
1
+ // src/__tests__/guard-stale-ctx.test.ts
2
+ //
3
+ // guardStaleCtx 单测——验收条款逐条对应(crash-resilience u1-ext-guard):
4
+ // a. 前置代际检查:isCtxStale=true → fn 不执行、stale 静默降级(返回 undefined)
5
+ // b. 非 stale 错误原样上抛(守卫不吞真实 bug,同步 throw 与 async rejection 双面)
6
+ // c. onStale 回调:前置命中无参调用;文案分诊命中携带错误调用;缺省走 XYZ_AGENT_DEBUG=1 stderr
7
+ // d. 文案子串匹配:STALE_CTX_MARKER 命中即静默(isCtxStale=false 时文案兜底仍生效)
8
+ // e. async fn:stale rejection → resolve undefined;非 stale rejection → 原样 reject
9
+ // f. 正常路径:返回值/resolve 值原样透传(守卫不改变正常路径,A2 负面验证的单测面)
10
+
11
+ import { describe, expect, it, vi, afterEach } from "vitest";
12
+
13
+ import { STALE_CTX_MARKER, guardStaleCtx } from "../index.ts";
14
+
15
+ /** pi 实装 stale 文案的完整形态(E1 崩溃堆栈原文,探针 PS-30 守卫其稳定性)。 */
16
+ const PI_STALE_ERROR = `This extension ctx is stale ${STALE_CTX_MARKER} or reload. Do not use a captured pi or command ctx after ctx.newSession().`;
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllEnvs();
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe("guardStaleCtx 前置代际检查(G1 形态)", () => {
24
+ it("isCtxStale=true:fn 完全不执行,stale 静默降级返回 undefined", () => {
25
+ const fn = vi.fn(() => "should not run");
26
+
27
+ const result = guardStaleCtx(fn, {
28
+ isCtxStale: () => true,
29
+ onStale: () => {},
30
+ });
31
+
32
+ expect(fn).not.toHaveBeenCalled();
33
+ expect(result).toBeUndefined();
34
+ });
35
+
36
+ it("前置命中:onStale 以无参调用(无捕获错误)", () => {
37
+ const onStale = vi.fn();
38
+
39
+ guardStaleCtx(
40
+ () => {
41
+ throw new Error("never reached");
42
+ },
43
+ { isCtxStale: () => true, onStale },
44
+ );
45
+
46
+ expect(onStale).toHaveBeenCalledTimes(1);
47
+ expect(onStale.mock.calls[0]).toHaveLength(0);
48
+ });
49
+
50
+ it("isCtxStale=false(缺省):fn 正常执行、返回值原样透传(不改变正常路径)", () => {
51
+ const shared = { ok: true };
52
+
53
+ const result = guardStaleCtx(() => shared, { isCtxStale: () => false, onStale: () => {} });
54
+
55
+ expect(result).toBe(shared);
56
+ });
57
+ });
58
+
59
+ describe("guardStaleCtx 同步抛错分诊(F2 形态同步面)", () => {
60
+ it("非 stale 错误:原样上抛(同一错误实例,不吞不包装)", () => {
61
+ const boom = new Error("real bug: schema validation exploded");
62
+ const onStale = vi.fn();
63
+
64
+ expect(() =>
65
+ guardStaleCtx(() => {
66
+ throw boom;
67
+ }, { isCtxStale: () => false, onStale }),
68
+ ).toThrow(boom);
69
+ expect(onStale).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("文案子串匹配:isCtxStale=false 但错误文案含 STALE_CTX_MARKER → 静默降级(reload 盲区兜底)", () => {
73
+ const onStale = vi.fn();
74
+
75
+ const result = guardStaleCtx(() => {
76
+ throw new Error(PI_STALE_ERROR);
77
+ }, { isCtxStale: () => false, onStale });
78
+
79
+ expect(result).toBeUndefined();
80
+ expect(onStale).toHaveBeenCalledTimes(1);
81
+ const captured: unknown = onStale.mock.calls[0]?.[0];
82
+ expect(captured).toBeInstanceOf(Error);
83
+ expect((captured as Error).message).toContain(STALE_CTX_MARKER);
84
+ });
85
+
86
+ it("非 Error 的 thrown 值含文案子串 → 同样分诊为 stale(String(value) 兜底)", () => {
87
+ const onStale = vi.fn();
88
+
89
+ const result = guardStaleCtx(() => {
90
+ throw `wrapper: ${STALE_CTX_MARKER} (string throw)`;
91
+ }, { onStale });
92
+
93
+ expect(result).toBeUndefined();
94
+ expect(onStale).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it("代际翻转兜底:抛错后 isCtxStale() 翻转为 true → 分诊 stale(in-flight 回调场景)", () => {
98
+ let stale = false;
99
+ const onStale = vi.fn();
100
+
101
+ const result = guardStaleCtx(() => {
102
+ stale = true; // 模拟同步窗口外的代际翻转(真实场景为 rejection 到达时已翻转)
103
+ throw new Error("boom");
104
+ }, { isCtxStale: () => stale, onStale });
105
+
106
+ expect(result).toBeUndefined();
107
+ expect(onStale).toHaveBeenCalledTimes(1);
108
+ });
109
+ });
110
+
111
+ describe("guardStaleCtx async fn(F2 形态异步面,scheduler tick 迁移形态)", () => {
112
+ it("stale rejection:resolve 为 undefined(不 reject,fire-and-forget 安全)", async () => {
113
+ const onStale = vi.fn();
114
+
115
+ const result = guardStaleCtx(async () => {
116
+ throw new Error(PI_STALE_ERROR);
117
+ }, { isCtxStale: () => false, onStale });
118
+
119
+ await expect(result).resolves.toBeUndefined();
120
+ expect(onStale).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ it("非 stale rejection:原样 reject 给调用方的 .catch(同一错误实例)", async () => {
124
+ const boom = new Error("async real bug");
125
+ const onStale = vi.fn();
126
+
127
+ const result = guardStaleCtx(async () => {
128
+ throw boom;
129
+ }, { isCtxStale: () => false, onStale });
130
+
131
+ await expect(result).rejects.toThrow(boom);
132
+ expect(onStale).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it("async 正常路径:resolve 值原样透传", async () => {
136
+ const result = guardStaleCtx(async () => "tick done", { onStale: () => {} });
137
+
138
+ await expect(result).resolves.toBe("tick done");
139
+ });
140
+ });
141
+
142
+ describe("guardStaleCtx onStale 缺省(默认 debug 日志)", () => {
143
+ it("XYZ_AGENT_DEBUG=1:stderr 写入守卫降级行(含 label 与错误文案)", () => {
144
+ vi.stubEnv("XYZ_AGENT_DEBUG", "1");
145
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
146
+
147
+ guardStaleCtx(() => {
148
+ throw new Error(PI_STALE_ERROR);
149
+ }, { label: "pkg:scene" });
150
+
151
+ expect(stderr).toHaveBeenCalledTimes(1);
152
+ const line = String(stderr.mock.calls[0]?.[0]);
153
+ expect(line).toContain("[ext-guards] stale ctx degraded (pkg:scene)");
154
+ expect(line).toContain(STALE_CTX_MARKER);
155
+ });
156
+
157
+ it("XYZ_AGENT_DEBUG 未设:静默(不写 stderr)", () => {
158
+ vi.stubEnv("XYZ_AGENT_DEBUG", "");
159
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
160
+
161
+ guardStaleCtx(() => {
162
+ throw new Error(PI_STALE_ERROR);
163
+ });
164
+
165
+ expect(stderr).not.toHaveBeenCalled();
166
+ });
167
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * PS-30 探针:pi ExtensionRunner 的 stale ctx 错误文案契约(crash-resilience D1 守卫
3
+ * 分诊词锁,docs/pi-semantics.json PS-30)。
4
+ *
5
+ * 断言方式(静态直读 dist,同 packages/runtime pi-semantics-prompt-rejection.test.ts 范式):
6
+ * - runner.js:`invalidate()` 默认 message 含 "stale after session replacement"
7
+ * (staleMessage 的唯一写入点)+ `assertActive()` 的 `throw new Error(this.staleMessage)`
8
+ * 形态(E1 崩溃堆栈帧 `assertActive → sendUserMessage` 的实装机制)。
9
+ * - loader.js:pi API(sendMessage / sendUserMessage / appendEntry)的 `assertActive()`
10
+ * 前置——守卫分诊词覆盖「跨 session 存活回调触碰捕获 pi」的面。
11
+ * - runner.js:ctx 的 compact / abort / shutdown 受 assertActive 保护——守卫分诊词
12
+ * 同样覆盖「回调触碰捕获 ctx」的面。
13
+ *
14
+ * 该文案是 ext-guards `guardStaleCtx` 的兜底分诊依据(代际检测为主判,本文案为
15
+ * reload 盲区兜底)。pi 升级红 = 文案/机制漂移:守卫退化为「全部上抛」(回到现状
16
+ * 崩溃链路,不更危险),同步 ext-guards STALE_CTX_MARKER 与本文档引用后更新 verifiedWith。
17
+ *
18
+ * 运行:cd extensions/shared/ext-guards && npx vitest run src/__tests__/pi-semantics-stale-ctx-wording.test.ts
19
+ */
20
+ import { describe, it, expect } from "vitest";
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ /** 定位实装 pi-coding-agent dist(cwd 逐级上溯,同 runtime 探针范式)。 */
25
+ function locatePiCodingAgentDist(): string | null {
26
+ let dir = process.cwd();
27
+ for (let i = 0; i < 8; i++) {
28
+ const candidate = join(dir, "node_modules", "@earendil-works", "pi-coding-agent", "dist");
29
+ if (existsSync(join(candidate, "core", "extensions", "runner.js"))) return candidate;
30
+ const parent = join(dir, "..");
31
+ if (parent === dir) break;
32
+ dir = parent;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ const PI_DIST = locatePiCodingAgentDist();
38
+ const SKIP_REASON = PI_DIST
39
+ ? ""
40
+ : "node_modules/@earendil-works/pi-coding-agent/dist 不可达(cwd 上溯未命中,零依赖包无 pi devDep——在仓库根或任意可解析 pi 的目录跑 extensions:test)";
41
+ // skip 提示由 describe.skipIf 的标题携带(下行 SKIP_REASON 已拼入),不经 console
42
+
43
+ const RUNNER_SRC = PI_DIST ? readFileSync(join(PI_DIST, "core", "extensions", "runner.js"), "utf-8") : "";
44
+ const LOADER_SRC = PI_DIST ? readFileSync(join(PI_DIST, "core", "extensions", "loader.js"), "utf-8") : "";
45
+
46
+ describe.skipIf(!PI_DIST)(
47
+ `PS-30 探针:stale ctx 错误文案与 assertActive 机制${SKIP_REASON ? `|skip:${SKIP_REASON}` : ""}`,
48
+ () => {
49
+ it("invalidate() 默认 message 含 stale after session replacement(守卫兜底分诊词的 pi 侧源头)", () => {
50
+ expect(
51
+ RUNNER_SRC.includes("stale after session replacement"),
52
+ "PS-30 漂移:stale 默认文案消失/变更——guardStaleCtx 文案兜底分诊失效退化为全部上抛(回到现状崩溃链路),同步 ext-guards STALE_CTX_MARKER 后更新 verifiedWith",
53
+ ).toBe(true);
54
+ });
55
+
56
+ it("assertActive 以 throw new Error(this.staleMessage) 同步抛错(E1 崩溃机制的实装锚点)", () => {
57
+ const assertIdx = RUNNER_SRC.indexOf("assertActive() {");
58
+ expect(assertIdx, "PS-30 漂移:assertActive 方法消失/改名——复核 runner.js 锚点").toBeGreaterThanOrEqual(0);
59
+ const body = RUNNER_SRC.slice(assertIdx, assertIdx + 300);
60
+ expect(
61
+ body.includes("throw new Error(this.staleMessage)"),
62
+ "PS-30 漂移:assertActive 不再同步 throw staleMessage——E1 型崩溃机制改形,复核 guardStaleCtx 分诊前提",
63
+ ).toBe(true);
64
+ });
65
+
66
+ it("pi API(sendMessage/sendUserMessage/appendEntry)前置 assertActive(守卫覆盖捕获 pi 的触碰面)", () => {
67
+ for (const method of ["sendMessage(message, options)", "sendUserMessage(content, options)", "appendEntry(customType, data)"]) {
68
+ const idx = LOADER_SRC.indexOf(method);
69
+ expect(idx, `PS-30 漂移:loader.js pi API ${method} 消失/改签名——复核锚点`).toBeGreaterThanOrEqual(0);
70
+ const window = LOADER_SRC.slice(idx, idx + 160);
71
+ expect(
72
+ window.includes("assertActive()"),
73
+ `PS-30 漂移:pi API ${method} 不再前置 assertActive——stale 触碰面改形,复核守卫分诊词的覆盖面`,
74
+ ).toBe(true);
75
+ }
76
+ });
77
+
78
+ it("ctx 的 compact/abort/shutdown 受 assertActive 保护(守卫覆盖捕获 ctx 的触碰面)", () => {
79
+ for (const entry of ["compact: (options) => {", "abort: () => {", "shutdown: () => {"]) {
80
+ const idx = RUNNER_SRC.indexOf(entry);
81
+ expect(idx, `PS-30 漂移:ctx.${entry.replace(/[: ].*/, "")} 定义消失/改形——复核 createContext 锚点`).toBeGreaterThanOrEqual(0);
82
+ const window = RUNNER_SRC.slice(idx, idx + 200);
83
+ expect(
84
+ window.includes("assertActive()"),
85
+ `PS-30 漂移:ctx.${entry.replace(/[: ].*/, "")} 不再受 assertActive 保护——stale 触碰面改形,复核守卫分诊词的覆盖面`,
86
+ ).toBe(true);
87
+ }
88
+ });
89
+ },
90
+ );
package/src/index.ts CHANGED
@@ -82,3 +82,163 @@ export function oncePerProcess<T>(key: string, fn: () => T): T {
82
82
  export function toErrorMessage(e: unknown): string {
83
83
  return e instanceof Error ? e.message : String(e);
84
84
  }
85
+
86
+ // ── stale ctx 守卫(guardStaleCtx,崩溃韧性 D1)────────────────────────
87
+ //
88
+ // 背景(docs/design/crash-resilience.md §3.3 D1 / §2.2 事件 E1):pi 的 extension API
89
+ // 对象(pi / ctx)在 session 替换(newSession/fork/switchSession/reload)后被 runner
90
+ // 标记 stale,再调用其方法会**同步抛错**(loader.js/runner.js 的 assertActive,错误文案
91
+ // 含 STALE_CTX_MARKER)。跨 session 生命周期存活的异步回调(compact 的 onComplete/onError、
92
+ // timer tick、延迟回调)在该窗口触碰 pi/ctx = pi 进程被无人接的同步 throw 炸死(9/3 E1
93
+ // 实锤:smart-context compact onError → sendUserMessage → assertActive → exit 1)。
94
+ // GUI 的每次切 session/新建/重载都在制造失效窗口——这是「pi 单独用不崩、套 GUI 就崩」
95
+ // 的核心机制差异。本守卫把 scheduler 已验证的防御范式(G1 代际前置检查 + F2 catch 文案
96
+ // 分诊)泛化成共享原语,接入方不再各自内联。
97
+
98
+ /**
99
+ * pi ExtensionRunner 在 session 替换后标记 stale ctx 的错误文案片段。
100
+ *
101
+ * pi 语义断言(0.84.4 实装:runner.js `invalidate()` 默认 message + `assertActive()`
102
+ * `throw new Error(this.staleMessage)`),登记于 docs/pi-semantics.json PS-30
103
+ * (探针:extensions/shared/ext-guards/src/__tests__/pi-semantics-stale-ctx-wording.test.ts),
104
+ * 随 C-proc-08 pi 版本门禁自动重验。文案变更时守卫退化为「全部上抛」(回到现状崩溃链路,
105
+ * 有 pi-crash log 取证,不更危险),门禁报红提示更新分诊词。
106
+ */
107
+ export const STALE_CTX_MARKER = "stale after session replacement";
108
+
109
+ /** guardStaleCtx 的可选项。全部可选——最简接入 = 只传 fn(纯文案兜底分诊)。 */
110
+ export interface GuardStaleCtxOptions {
111
+ /** 观测标签(默认降级日志的前缀,建议 "包名:场景" 形态)。 */
112
+ label?: string;
113
+ /**
114
+ * 前置代际检查(G1 形态,主判):返回 true 表示本回调所属的 session 已被替换,
115
+ * 守卫完全不执行 fn、直接走 stale 降级。调用方注入,同 scheduler 的模块级代数
116
+ * 计数器模式(session_start 递增模块级代数,旧代闭包捕获值 < 模块值即 stale)。
117
+ * 缺省(不注入)恒视为非 stale——前置检查关闭,完全依赖错误文案兜底分诊
118
+ * (D1 降级语义声明:无代际计数器的接入方合法形态,文案由 PS-30 门禁守卫)。
119
+ */
120
+ isCtxStale?: () => boolean;
121
+ /**
122
+ * stale 降级通知(静默降级的观测点):前置检查命中时无参调用;fn 抛错被分诊为
123
+ * stale 时以捕获的错误为参调用。缺省 = 仅 XYZ_AGENT_DEBUG=1 时写一行 stderr
124
+ * debug(本包零依赖、无 pi handle,不能走 extension-logger;需要落盘日志的
125
+ * 接入方传入自己的 debugLog/logger.warn)。
126
+ */
127
+ onStale?: (error?: unknown) => void;
128
+ }
129
+
130
+ /** PromiseLike 判定(in 收窄,无 cast):守卫据此为 async fn 的 rejection 挂同一分诊。 */
131
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
132
+ return (
133
+ typeof value === "object" &&
134
+ value !== null &&
135
+ "then" in value &&
136
+ typeof value.then === "function"
137
+ );
138
+ }
139
+
140
+ /** 缺省降级通知:XYZ_AGENT_DEBUG=1 时 stderr 一行(对齐 extensions 包 XYZ_AGENT_DEBUG 惯例)。 */
141
+ function defaultOnStale(label: string, error?: unknown): void {
142
+ if (process.env.XYZ_AGENT_DEBUG !== "1") return;
143
+ const detail =
144
+ error === undefined
145
+ ? "generation check (isCtxStale)"
146
+ : error instanceof Error
147
+ ? error.message
148
+ : String(error);
149
+ const prefix = label === "" ? "" : ` (${label})`;
150
+ process.stderr.write(`[ext-guards] stale ctx degraded${prefix}: ${detail}\n`);
151
+ }
152
+
153
+ /**
154
+ * 包裹「跨 session 生命周期存活的异步回调」:stale ctx 错误静默降级,非 stale 错误
155
+ * 原样上抛(守卫不吞真实 bug)。消灭 E1 型崩溃源(crash-resilience D1)。
156
+ *
157
+ * 语义三段(与 scheduler 范式逐条对应,scheduler/runtime.ts startScheduler 是迁移先例):
158
+ *
159
+ * 1. **前置代际检查(G1 形态,主判)**:`opts.isCtxStale?.()` 为 true → 不执行 fn
160
+ * (完全不触碰捕获的 stale pi/ctx)、调 `opts.onStale?.()`(无参)、返回 undefined。
161
+ * 2. **fn 同步执行 + 同步抛错分诊(F2 形态的同步面)**:错误文案含
162
+ * `STALE_CTX_MARKER`(或抛错后 isCtxStale 翻转)→ 调 `opts.onStale?.(error)`、
163
+ * 返回 undefined;**非 stale 类错误原样上抛**——守卫不吞真实 bug(上抛的崩溃链路
164
+ * 与 E1 相同,有 pi-crash log 取证 + 错误规格表走 T4 恢复链路)。
165
+ * 3. **Promise 透传分诊(F2 形态的异步面)**:fn 返回 Promise 时守卫对 rejection
166
+ * 挂同一分诊——stale → resolve 为 undefined,非 stale → 原样 reject 给调用方的
167
+ * `.catch`。返回的是新 Promise 实例(原实例的 rejection 已被守卫接管,调用方不得
168
+ * 再对原实例挂 then/catch,否则非 stale 错误会走原实例 rejection)。
169
+ *
170
+ * 分诊谓词 = `isCtxStale?.() || message.includes(STALE_CTX_MARKER)`,与 scheduler
171
+ * 迁移前字面一致:代际为主判(不依赖 pi 文案),文案为兜底(覆盖 reload 产生全新
172
+ * 模块环境后旧闭包代数冻结、isCtxStale 恒 false 的盲区)。
173
+ *
174
+ * @param fn 无参回调(闭包捕获业务所需的 pi/ctx——被捕获的是注册时的引用,这正是
175
+ * stale 风险的来源,也是守卫存在的理由)
176
+ * @returns fn 的返回值;stale 降级路径返回 undefined(前置检查命中时 fn 未执行、无
177
+ * Promise 可言,故 async 重载的返回类型是 `Promise<R | undefined> | undefined`——
178
+ * 前置命中即 undefined)。fire-and-forget 调用方直接 `void`;需要接非 stale 错误的
179
+ * 调用方(scheduler tick 形态)用 `?.catch`——前置命中返回 undefined 时无 rejection
180
+ * 可接(retireStaleTimer 已由 onStale 执行),非 stale rejection 才会流到 `.catch`。
181
+ *
182
+ * @example fire-and-forget 回调(smart-context compact onError)
183
+ * ```ts
184
+ * onError: (err) => guardStaleCtx(() => pi.sendUserMessage(...), { label: "smart-context:compact" })
185
+ * ```
186
+ * @example timer tick(scheduler 迁移形态:非 stale 错误外层 warn 不终止调度)
187
+ * ```ts
188
+ * void guardStaleCtx(() => this.tickScheduler(), { isCtxStale, onStale: () => this.retireStaleTimer() })
189
+ * ?.catch((err) => logger.warn("tick error", { error: toErrorMessage(err) }));
190
+ * ```
191
+ */
192
+ export function guardStaleCtx<R>(
193
+ fn: () => Promise<R>,
194
+ opts?: GuardStaleCtxOptions,
195
+ ): Promise<R | undefined> | undefined;
196
+ export function guardStaleCtx<R>(fn: () => R, opts?: GuardStaleCtxOptions): R | undefined;
197
+ export function guardStaleCtx(fn: () => unknown, opts?: GuardStaleCtxOptions): unknown {
198
+ const label = opts?.label ?? "";
199
+ const isCtxStale = opts?.isCtxStale;
200
+ const onStale = opts?.onStale;
201
+ const isStale = (): boolean => isCtxStale?.() ?? false;
202
+ // 与 scheduler 迁移前字面一致的分诊谓词:代际主判 || 文案兜底
203
+ const triage = (error: unknown): boolean =>
204
+ isStale() || toErrorMessage(error).includes(STALE_CTX_MARKER);
205
+ const reportStale = (error?: unknown): void => {
206
+ if (onStale !== undefined) {
207
+ // 前置命中(无捕获错误)无参调用;文案分诊命中携带错误——保持 JSDoc 契约
208
+ if (error === undefined) onStale();
209
+ else onStale(error);
210
+ } else {
211
+ defaultOnStale(label, error);
212
+ }
213
+ };
214
+
215
+ // 1. 前置代际检查:stale 则完全不触碰捕获的 pi/ctx(P-guard-holds 的守卫第一道)
216
+ if (isStale()) {
217
+ reportStale();
218
+ return undefined;
219
+ }
220
+
221
+ // 2. fn 同步执行 + 同步抛错分诊
222
+ let result: unknown;
223
+ try {
224
+ result = fn();
225
+ } catch (error) {
226
+ if (triage(error)) {
227
+ reportStale(error);
228
+ return undefined;
229
+ }
230
+ throw error;
231
+ }
232
+
233
+ // 3. async fn:rejection 走同一分诊(stale → resolve undefined;非 stale → 原样 reject)
234
+ if (isPromiseLike(result)) {
235
+ return result.then(undefined, (error: unknown) => {
236
+ if (triage(error)) {
237
+ reportStale(error);
238
+ return undefined;
239
+ }
240
+ throw error;
241
+ });
242
+ }
243
+ return result;
244
+ }