@zhushanwen/pi-ext-guards 0.2.1 → 0.4.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.
|
|
3
|
+
"version": "0.4.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
|
+
);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/__tests__/predicates.test.ts
|
|
2
|
+
//
|
|
3
|
+
// isRecord / isEnoentError / isSubagentProcess 单测(ext-simplify-17 D3/D2/D4 新导出):
|
|
4
|
+
// - isRecord 排数组严版:数组必须 false(钉值,防回退到允数组宽版)
|
|
5
|
+
// - isEnoentError scheduler 严版:仅 Error 子类 + code === "ENOENT" 才 true,
|
|
6
|
+
// 非 Error 对象带 code 是宽版才接受的形态,严版必须 false
|
|
7
|
+
// - isSubagentProcess 三态 + env 注入隔离 + 旧键宁缺勿污钉值(D4 重锚定)
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { isEnoentError, isRecord, isSubagentProcess } from "../index.ts";
|
|
12
|
+
|
|
13
|
+
describe("isRecord", () => {
|
|
14
|
+
it("对象 true", () => {
|
|
15
|
+
expect(isRecord({})).toBe(true);
|
|
16
|
+
expect(isRecord({ key: "value" })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("null false", () => {
|
|
20
|
+
expect(isRecord(null)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("undefined false", () => {
|
|
24
|
+
expect(isRecord(undefined)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("数组 false(排数组钉值——Record 语义不含数组)", () => {
|
|
28
|
+
expect(isRecord([])).toBe(false);
|
|
29
|
+
expect(isRecord([{ a: 1 }])).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("字符串 false", () => {
|
|
33
|
+
expect(isRecord("text")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("isEnoentError", () => {
|
|
38
|
+
it("Error 带 code=ENOENT true(Node fs 错误的构造形态)", () => {
|
|
39
|
+
expect(isEnoentError(Object.assign(new Error("no such file"), { code: "ENOENT" }))).toBe(
|
|
40
|
+
true,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("普通 Error false(无 code 属性)", () => {
|
|
45
|
+
expect(isEnoentError(new Error("x"))).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("code 为其他值 false", () => {
|
|
49
|
+
expect(isEnoentError(Object.assign(new Error("denied"), { code: "EACCES" }))).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("非 Error 对象带 code=ENOENT false(严版:宽版才接受的形态)", () => {
|
|
53
|
+
expect(isEnoentError({ code: "ENOENT" })).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("undefined false", () => {
|
|
57
|
+
expect(isEnoentError(undefined)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("isSubagentProcess", () => {
|
|
62
|
+
it("XYZ_AGENT_SUBAGENT=1 命中(引擎 spawn 链恒注入的现行标记)", () => {
|
|
63
|
+
expect(isSubagentProcess({ XYZ_AGENT_SUBAGENT: "1" })).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("值非 \"1\" 不命中(标记必须是引擎注入的字面 \"1\")", () => {
|
|
67
|
+
expect(isSubagentProcess({ XYZ_AGENT_SUBAGENT: "0" })).toBe(false);
|
|
68
|
+
expect(isSubagentProcess({ XYZ_AGENT_SUBAGENT: "" })).toBe(false);
|
|
69
|
+
expect(isSubagentProcess({ XYZ_AGENT_SUBAGENT: "true" })).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("缺失不命中(顶层会话 → 消费方不降级)", () => {
|
|
73
|
+
expect(isSubagentProcess({})).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("env 参数注入隔离:传入的 env 与宿主 process.env 无关(宿主即使带标记,空 env 也不命中)", () => {
|
|
77
|
+
const saved = process.env.XYZ_AGENT_SUBAGENT;
|
|
78
|
+
try {
|
|
79
|
+
process.env.XYZ_AGENT_SUBAGENT = "1";
|
|
80
|
+
// 显式传 env:宿主的标记不渗透,判据只看参数
|
|
81
|
+
expect(isSubagentProcess({})).toBe(false);
|
|
82
|
+
expect(isSubagentProcess({ XYZ_AGENT_SUBAGENT: "1" })).toBe(true);
|
|
83
|
+
} finally {
|
|
84
|
+
if (saved === undefined) delete process.env.XYZ_AGENT_SUBAGENT;
|
|
85
|
+
else process.env.XYZ_AGENT_SUBAGENT = saved;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("缺省参数读 process.env(不传 env 时以宿主环境为判据)", () => {
|
|
90
|
+
const saved = process.env.XYZ_AGENT_SUBAGENT;
|
|
91
|
+
try {
|
|
92
|
+
process.env.XYZ_AGENT_SUBAGENT = "1";
|
|
93
|
+
expect(isSubagentProcess()).toBe(true);
|
|
94
|
+
delete process.env.XYZ_AGENT_SUBAGENT;
|
|
95
|
+
expect(isSubagentProcess()).toBe(false);
|
|
96
|
+
} finally {
|
|
97
|
+
if (saved === undefined) delete process.env.XYZ_AGENT_SUBAGENT;
|
|
98
|
+
else process.env.XYZ_AGENT_SUBAGENT = saved;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("旧 PI_SUBAGENT_* 身份键不再命中([HISTORICAL] D4 重锚前两键任一存在即命中;引擎协议化后无写入方,宁缺勿污)", () => {
|
|
103
|
+
expect(isSubagentProcess({ PI_SUBAGENT_ROOT_SESSION_ID: "s1" })).toBe(false);
|
|
104
|
+
expect(isSubagentProcess({ PI_SUBAGENT_SELF_RECORD_ID: "r1" })).toBe(false);
|
|
105
|
+
expect(isSubagentProcess({ PI_SUBAGENT_ROOT_SESSION_ID: "s1", PI_SUBAGENT_SELF_RECORD_ID: "r1" })).toBe(
|
|
106
|
+
false,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// @zhushanwen/pi-ext-guards —— pi 运行环境守卫共享库(不是 Pi extension,零依赖纯函数,
|
|
4
4
|
// 无 pi SDK peerDep——供任意 @zhushanwen/pi-* 包与子代理核心消费)。
|
|
5
5
|
//
|
|
6
|
-
// 背景与设计依据:docs/
|
|
6
|
+
// 背景与设计依据:docs/architecture/file-lock-unification-and-reaper-sink.md §2.2 P3 / §3.2 D3。
|
|
7
7
|
// pi 的 extension 缓存按 cwd 失效:switch_session 时 cwd 不变则 factory 被二次调用且
|
|
8
8
|
// handler 累积注册——session_start handler 的真实派发语义是「每 session × factory
|
|
9
9
|
// 调用次数」,不是「每 session 一次」。handler 体内的跨 session 副作用操作(写非本
|
|
@@ -82,3 +82,215 @@ 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
|
+
/**
|
|
87
|
+
* 判断 value 是否为非 null、非数组的 object(`Record` 语义本义:可按字符串键安全取值)。
|
|
88
|
+
*
|
|
89
|
+
* canonical 来自 ext-simplify-17 D3(排数组严版,设计文档
|
|
90
|
+
* docs/architecture/ext-simplify-17-shared-extraction.md §3.1)。
|
|
91
|
+
*/
|
|
92
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 判断 err 是否为 Node fs 的 ENOENT 错误(`Error` 实例且 `code === "ENOENT"`)。
|
|
98
|
+
*
|
|
99
|
+
* canonical 来自 ext-simplify-17 D2(scheduler 严版,`"code" in err` 收窄避免
|
|
100
|
+
* `as Record` 强转——taste/no-unsafe-cast)。
|
|
101
|
+
*/
|
|
102
|
+
export function isEnoentError(err: unknown): boolean {
|
|
103
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── subagent 进程判据(isSubagentProcess,ext-simplify-17 D4)────────────
|
|
107
|
+
//
|
|
108
|
+
// 背景:extension 需要知道「当前 pi 进程是否引擎链上的 subagent 子进程」来决定降级
|
|
109
|
+
// (base-tool-enhance background 降级——bte background 生命周期设计的 D14 项,
|
|
110
|
+
// commit 8599abd0c,该设计未在现行 docs 留档 / smart-context R6 不注册工具,
|
|
111
|
+
// 规则定义见 smart-context src/index.ts R 规则族注释)。判据锚
|
|
112
|
+
// XYZ_AGENT_SUBAGENT=1——subagent-engine-sdk env.ts 的 buildEngineChildEnv 对每个引擎
|
|
113
|
+
// 子进程恒注入(L0 层最后写入),再经引擎内二次 spawn(buildOutboundChildEnv 全量
|
|
114
|
+
// 继承)传到 subagent 内的 bash。旧判据(PI_SUBAGENT_ROOT_SESSION_ID /
|
|
115
|
+
// PI_SUBAGENT_SELF_RECORD_ID 两键)在引擎协议化后已无写入方(20260914 探针证实,
|
|
116
|
+
// 报告 .tmp/dev-flow/ext-simplify-17-d4-probe.md),判据失效态由 D4 重锚收敛于此。
|
|
117
|
+
|
|
118
|
+
/** subagent 进程标记 env 键(引擎 spawn 链恒注入 "1",subagent-engine-sdk env.ts L0)。 */
|
|
119
|
+
export const SUBAGENT_MARKER_ENV = "XYZ_AGENT_SUBAGENT";
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 判断当前进程是否引擎链上的 subagent 子进程。
|
|
123
|
+
*
|
|
124
|
+
* 判据 = `env[SUBAGENT_MARKER_ENV] === "1"`(canonical 来自 ext-simplify-17 D4,
|
|
125
|
+
* 设计文档 docs/architecture/ext-simplify-17-shared-extraction.md §3.1)。口径
|
|
126
|
+
* 「宁缺勿污」:只有引擎 spawn 链上的真 subagent 才命中——人工 export 旧
|
|
127
|
+
* PI_SUBAGENT_ROOT_SESSION_ID / PI_SUBAGENT_SELF_RECORD_ID 身份键不再算(两键现
|
|
128
|
+
* 无写入方,见分节注释)。
|
|
129
|
+
*
|
|
130
|
+
* @param env env 快照;缺省读 process.env,显式传入即与宿主进程环境完全隔离(测试注入用)
|
|
131
|
+
*/
|
|
132
|
+
export function isSubagentProcess(
|
|
133
|
+
env: Record<string, string | undefined> = process.env,
|
|
134
|
+
): boolean {
|
|
135
|
+
return env[SUBAGENT_MARKER_ENV] === "1";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── stale ctx 守卫(guardStaleCtx,崩溃韧性 D1)────────────────────────
|
|
139
|
+
//
|
|
140
|
+
// 背景(docs/design/crash-resilience.md §3.3 D1 / §2.2 事件 E1):pi 的 extension API
|
|
141
|
+
// 对象(pi / ctx)在 session 替换(newSession/fork/switchSession/reload)后被 runner
|
|
142
|
+
// 标记 stale,再调用其方法会**同步抛错**(loader.js/runner.js 的 assertActive,错误文案
|
|
143
|
+
// 含 STALE_CTX_MARKER)。跨 session 生命周期存活的异步回调(compact 的 onComplete/onError、
|
|
144
|
+
// timer tick、延迟回调)在该窗口触碰 pi/ctx = pi 进程被无人接的同步 throw 炸死(9/3 E1
|
|
145
|
+
// 实锤:smart-context compact onError → sendUserMessage → assertActive → exit 1)。
|
|
146
|
+
// GUI 的每次切 session/新建/重载都在制造失效窗口——这是「pi 单独用不崩、套 GUI 就崩」
|
|
147
|
+
// 的核心机制差异。本守卫把 scheduler 已验证的防御范式(G1 代际前置检查 + F2 catch 文案
|
|
148
|
+
// 分诊)泛化成共享原语,接入方不再各自内联。
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* pi ExtensionRunner 在 session 替换后标记 stale ctx 的错误文案片段。
|
|
152
|
+
*
|
|
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),
|
|
156
|
+
* 随 C-proc-08 pi 版本门禁自动重验。文案变更时守卫退化为「全部上抛」(回到现状崩溃链路,
|
|
157
|
+
* 有 pi-crash log 取证,不更危险),门禁报红提示更新分诊词。
|
|
158
|
+
*/
|
|
159
|
+
export const STALE_CTX_MARKER = "stale after session replacement";
|
|
160
|
+
|
|
161
|
+
/** guardStaleCtx 的可选项。全部可选——最简接入 = 只传 fn(纯文案兜底分诊)。 */
|
|
162
|
+
export interface GuardStaleCtxOptions {
|
|
163
|
+
/** 观测标签(默认降级日志的前缀,建议 "包名:场景" 形态)。 */
|
|
164
|
+
label?: string;
|
|
165
|
+
/**
|
|
166
|
+
* 前置代际检查(G1 形态,主判):返回 true 表示本回调所属的 session 已被替换,
|
|
167
|
+
* 守卫完全不执行 fn、直接走 stale 降级。调用方注入,同 scheduler 的模块级代数
|
|
168
|
+
* 计数器模式(session_start 递增模块级代数,旧代闭包捕获值 < 模块值即 stale)。
|
|
169
|
+
* 缺省(不注入)恒视为非 stale——前置检查关闭,完全依赖错误文案兜底分诊
|
|
170
|
+
* (D1 降级语义声明:无代际计数器的接入方合法形态,文案由 PS-30 门禁守卫)。
|
|
171
|
+
*/
|
|
172
|
+
isCtxStale?: () => boolean;
|
|
173
|
+
/**
|
|
174
|
+
* stale 降级通知(静默降级的观测点):前置检查命中时无参调用;fn 抛错被分诊为
|
|
175
|
+
* stale 时以捕获的错误为参调用。缺省 = 仅 XYZ_AGENT_DEBUG=1 时写一行 stderr
|
|
176
|
+
* debug(本包零依赖、无 pi handle,不能走 extension-logger;需要落盘日志的
|
|
177
|
+
* 接入方传入自己的 debugLog/logger.warn)。
|
|
178
|
+
*/
|
|
179
|
+
onStale?: (error?: unknown) => void;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** PromiseLike 判定(in 收窄,无 cast):守卫据此为 async fn 的 rejection 挂同一分诊。 */
|
|
183
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
184
|
+
return (
|
|
185
|
+
typeof value === "object" &&
|
|
186
|
+
value !== null &&
|
|
187
|
+
"then" in value &&
|
|
188
|
+
typeof value.then === "function"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 缺省降级通知:XYZ_AGENT_DEBUG=1 时 stderr 一行(对齐 extensions 包 XYZ_AGENT_DEBUG 惯例)。 */
|
|
193
|
+
function defaultOnStale(label: string, error?: unknown): void {
|
|
194
|
+
if (process.env.XYZ_AGENT_DEBUG !== "1") return;
|
|
195
|
+
const detail =
|
|
196
|
+
error === undefined
|
|
197
|
+
? "generation check (isCtxStale)"
|
|
198
|
+
: error instanceof Error
|
|
199
|
+
? error.message
|
|
200
|
+
: String(error);
|
|
201
|
+
const prefix = label === "" ? "" : ` (${label})`;
|
|
202
|
+
process.stderr.write(`[ext-guards] stale ctx degraded${prefix}: ${detail}\n`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 包裹「跨 session 生命周期存活的异步回调」:stale ctx 错误静默降级,非 stale 错误
|
|
207
|
+
* 原样上抛(守卫不吞真实 bug)。消灭 E1 型崩溃源(crash-resilience D1)。
|
|
208
|
+
*
|
|
209
|
+
* 语义三段(与 scheduler 范式逐条对应,scheduler/runtime.ts startScheduler 是迁移先例):
|
|
210
|
+
*
|
|
211
|
+
* 1. **前置代际检查(G1 形态,主判)**:`opts.isCtxStale?.()` 为 true → 不执行 fn
|
|
212
|
+
* (完全不触碰捕获的 stale pi/ctx)、调 `opts.onStale?.()`(无参)、返回 undefined。
|
|
213
|
+
* 2. **fn 同步执行 + 同步抛错分诊(F2 形态的同步面)**:错误文案含
|
|
214
|
+
* `STALE_CTX_MARKER`(或抛错后 isCtxStale 翻转)→ 调 `opts.onStale?.(error)`、
|
|
215
|
+
* 返回 undefined;**非 stale 类错误原样上抛**——守卫不吞真实 bug(上抛的崩溃链路
|
|
216
|
+
* 与 E1 相同,有 pi-crash log 取证 + 错误规格表走 T4 恢复链路)。
|
|
217
|
+
* 3. **Promise 透传分诊(F2 形态的异步面)**:fn 返回 Promise 时守卫对 rejection
|
|
218
|
+
* 挂同一分诊——stale → resolve 为 undefined,非 stale → 原样 reject 给调用方的
|
|
219
|
+
* `.catch`。返回的是新 Promise 实例(原实例的 rejection 已被守卫接管,调用方不得
|
|
220
|
+
* 再对原实例挂 then/catch,否则非 stale 错误会走原实例 rejection)。
|
|
221
|
+
*
|
|
222
|
+
* 分诊谓词 = `isCtxStale?.() || message.includes(STALE_CTX_MARKER)`,与 scheduler
|
|
223
|
+
* 迁移前字面一致:代际为主判(不依赖 pi 文案),文案为兜底(覆盖 reload 产生全新
|
|
224
|
+
* 模块环境后旧闭包代数冻结、isCtxStale 恒 false 的盲区)。
|
|
225
|
+
*
|
|
226
|
+
* @param fn 无参回调(闭包捕获业务所需的 pi/ctx——被捕获的是注册时的引用,这正是
|
|
227
|
+
* stale 风险的来源,也是守卫存在的理由)
|
|
228
|
+
* @returns fn 的返回值;stale 降级路径返回 undefined(前置检查命中时 fn 未执行、无
|
|
229
|
+
* Promise 可言,故 async 重载的返回类型是 `Promise<R | undefined> | undefined`——
|
|
230
|
+
* 前置命中即 undefined)。fire-and-forget 调用方直接 `void`;需要接非 stale 错误的
|
|
231
|
+
* 调用方(scheduler tick 形态)用 `?.catch`——前置命中返回 undefined 时无 rejection
|
|
232
|
+
* 可接(retireStaleTimer 已由 onStale 执行),非 stale rejection 才会流到 `.catch`。
|
|
233
|
+
*
|
|
234
|
+
* @example fire-and-forget 回调(smart-context compact onError)
|
|
235
|
+
* ```ts
|
|
236
|
+
* onError: (err) => guardStaleCtx(() => pi.sendUserMessage(...), { label: "smart-context:compact" })
|
|
237
|
+
* ```
|
|
238
|
+
* @example timer tick(scheduler 迁移形态:非 stale 错误外层 warn 不终止调度)
|
|
239
|
+
* ```ts
|
|
240
|
+
* void guardStaleCtx(() => this.tickScheduler(), { isCtxStale, onStale: () => this.retireStaleTimer() })
|
|
241
|
+
* ?.catch((err) => logger.warn("tick error", { error: toErrorMessage(err) }));
|
|
242
|
+
* ```
|
|
243
|
+
*/
|
|
244
|
+
export function guardStaleCtx<R>(
|
|
245
|
+
fn: () => Promise<R>,
|
|
246
|
+
opts?: GuardStaleCtxOptions,
|
|
247
|
+
): Promise<R | undefined> | undefined;
|
|
248
|
+
export function guardStaleCtx<R>(fn: () => R, opts?: GuardStaleCtxOptions): R | undefined;
|
|
249
|
+
export function guardStaleCtx(fn: () => unknown, opts?: GuardStaleCtxOptions): unknown {
|
|
250
|
+
const label = opts?.label ?? "";
|
|
251
|
+
const isCtxStale = opts?.isCtxStale;
|
|
252
|
+
const onStale = opts?.onStale;
|
|
253
|
+
const isStale = (): boolean => isCtxStale?.() ?? false;
|
|
254
|
+
// 与 scheduler 迁移前字面一致的分诊谓词:代际主判 || 文案兜底
|
|
255
|
+
const triage = (error: unknown): boolean =>
|
|
256
|
+
isStale() || toErrorMessage(error).includes(STALE_CTX_MARKER);
|
|
257
|
+
const reportStale = (error?: unknown): void => {
|
|
258
|
+
if (onStale !== undefined) {
|
|
259
|
+
// 前置命中(无捕获错误)无参调用;文案分诊命中携带错误——保持 JSDoc 契约
|
|
260
|
+
if (error === undefined) onStale();
|
|
261
|
+
else onStale(error);
|
|
262
|
+
} else {
|
|
263
|
+
defaultOnStale(label, error);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// 1. 前置代际检查:stale 则完全不触碰捕获的 pi/ctx(P-guard-holds 的守卫第一道)
|
|
268
|
+
if (isStale()) {
|
|
269
|
+
reportStale();
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 2. fn 同步执行 + 同步抛错分诊
|
|
274
|
+
let result: unknown;
|
|
275
|
+
try {
|
|
276
|
+
result = fn();
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (triage(error)) {
|
|
279
|
+
reportStale(error);
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
throw error;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 3. async fn:rejection 走同一分诊(stale → resolve undefined;非 stale → 原样 reject)
|
|
286
|
+
if (isPromiseLike(result)) {
|
|
287
|
+
return result.then(undefined, (error: unknown) => {
|
|
288
|
+
if (triage(error)) {
|
|
289
|
+
reportStale(error);
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
}
|