@zhushanwen/pi-base-tool-enhance 0.3.0 → 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/README.md +1 -1
- package/package.json +7 -6
- package/src/__tests__/background-lifecycle.test.ts +4 -3
- package/src/__tests__/index.test.ts +8 -6
- package/src/__tests__/kill-tree.test.ts +49 -1
- package/src/__tests__/maintenance-once.test.ts +164 -0
- package/src/__tests__/pending-reconcile.test.ts +1 -1
- package/src/__tests__/registry.test.ts +2 -1
- package/src/background/pending-reconcile.ts +6 -5
- package/src/background/poller.ts +0 -4
- package/src/background/process-exit-guard.ts +6 -5
- package/src/background/registry.ts +21 -20
- package/src/background/spawn-background.ts +9 -9
- package/src/background/subagent-guard.ts +3 -3
- package/src/background/task-store.ts +2 -2
- package/src/background/types.ts +29 -48
- package/src/bash-kill-tool.ts +10 -9
- package/src/bash-output-tool.ts +2 -2
- package/src/bash-tool.ts +3 -3
- package/src/index.ts +37 -31
- package/src/kill-tree.ts +53 -2
- package/src/__tests__/reaper.test.ts +0 -373
- package/src/reaper.ts +0 -313
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zhushanwen/pi-base-tool-enhance
|
|
2
2
|
|
|
3
|
-
同名 override pi 内置 bash 工具的增强层:前台行为 100% 委托 pi 官方工厂(`createBashToolDefinition`),增量提供 background 模式、强制后台白名单与双模式可配置超时。承接已废弃 unified-hooks
|
|
3
|
+
同名 override pi 内置 bash 工具的增强层:前台行为 100% 委托 pi 官方工厂(`createBashToolDefinition`),增量提供 background 模式、强制后台白名单与双模式可配置超时。承接已废弃 unified-hooks 的核心能力(测试类拦截式 guard → force-test 白名单命中自动转后台,匹配语义较原 guard 有调整、登记于 force-patterns.ts;网络类挂死保护 → 可配置前台默认超时,弱承接;工具报错审计 → tool_error 审计 hook,entry customType 保持 `unified-hooks:tool-error` 历史连续)。
|
|
4
4
|
|
|
5
5
|
设计文档(SSOT):`docs/design/base-tool-enhance.md`。
|
|
6
6
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-base-tool-enhance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design: docs/design/base-tool-enhance.md)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -28,18 +28,19 @@
|
|
|
28
28
|
"skills/"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@zhushanwen/pi-
|
|
33
|
-
"@zhushanwen/pi-
|
|
31
|
+
"@xyz-agent/extension-protocol": "0.8.0",
|
|
32
|
+
"@zhushanwen/pi-extension-logger": "0.4.0",
|
|
33
|
+
"@zhushanwen/pi-file-lock": "0.2.0",
|
|
34
|
+
"@zhushanwen/pi-llm-shared": "0.5.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@vitest/coverage-v8": "^4.1.9",
|
|
37
38
|
"vitest": "^4.1.8"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
|
-
"@earendil-works/pi-coding-agent": "^0.84.
|
|
41
|
+
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
41
42
|
"typebox": "*",
|
|
42
|
-
"@zhushanwen/pi-pending-notifications": "0.5.
|
|
43
|
+
"@zhushanwen/pi-pending-notifications": "0.5.2"
|
|
43
44
|
},
|
|
44
45
|
"peerDependenciesMeta": {
|
|
45
46
|
"@zhushanwen/pi-pending-notifications": {
|
|
@@ -177,7 +177,7 @@ describe("real spawn lifecycle (poll edge finalization)", () => {
|
|
|
177
177
|
expect(seen[0]).toBe(`${spawned.task.taskId}:exited:natural`);
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
it("spawned entry carries pidStartTime for
|
|
180
|
+
it("spawned entry carries pidStartTime for kill-side precise comparison (M5→M3)", async () => {
|
|
181
181
|
const spawned = spawnBg("sleep 30");
|
|
182
182
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
183
183
|
const { task } = spawned;
|
|
@@ -232,7 +232,7 @@ describe("bash_output tool", () => {
|
|
|
232
232
|
|
|
233
233
|
it("P0-1: list merges only TERMINAL registry entries — running entries stay invisible (§3.5)", async () => {
|
|
234
234
|
// 独立 session 目录:单例表空 + registry 含 running 与 exited 两条他进程条目
|
|
235
|
-
// (模拟 resume 被强杀 session:
|
|
235
|
+
// (模拟 resume 被强杀 session:runtime 收殓转终态前的窗口里 registry 残留 running)
|
|
236
236
|
const sid = "sess-p0-list";
|
|
237
237
|
writeRegistryEntry(getRegistryPath(DATA_DIR, sid), makeRegistryEntry({ taskId: "bt-xrun", state: "running" }));
|
|
238
238
|
writeRegistryEntry(
|
|
@@ -361,7 +361,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
|
361
361
|
};
|
|
362
362
|
expect(result.killed).toBe(false);
|
|
363
363
|
expect(result.reason).toBe("cross-process running task owned by another pi process");
|
|
364
|
-
|
|
364
|
+
// 收殓已下沉 runtime(u-bte-remove):hint 指向 runtime 收殓而非 reaper
|
|
365
|
+
expect(result.hint).toContain("xyz-agent runtime");
|
|
365
366
|
expect(killTreeCalls).not.toContain(foreignPid); // 未发 kill 信号
|
|
366
367
|
});
|
|
367
368
|
|
|
@@ -63,7 +63,7 @@ describe("baseToolEnhanceExtension entry", () => {
|
|
|
63
63
|
expect(pi.on).toHaveBeenCalledWith("tool_execution_end", expect.any(Function));
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
it("registers a session_start handler (
|
|
66
|
+
it("registers a session_start handler (reconcile chain, M3)", () => {
|
|
67
67
|
setupOfficialFactory();
|
|
68
68
|
const pi = createMockPi();
|
|
69
69
|
|
|
@@ -73,10 +73,10 @@ describe("baseToolEnhanceExtension entry", () => {
|
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
describe("session_start chain:
|
|
77
|
-
it("settles a zombie register via appendEntry on the SAME pi reference
|
|
76
|
+
describe("session_start chain: reconcile (M3, reap sunk into runtime by u-bte-remove)", () => {
|
|
77
|
+
it("settles a zombie register via appendEntry on the SAME pi reference", async () => {
|
|
78
78
|
setupOfficialFactory();
|
|
79
|
-
// 独立临时 dataDir
|
|
79
|
+
// 独立临时 dataDir(维护链只读不扫:收殓已下沉 runtime),registry 预置终态僵尸条目
|
|
80
80
|
const dataDir = mkdtempSync(join(tmpdir(), "bte-index-"));
|
|
81
81
|
dataDirRef.dir = dataDir;
|
|
82
82
|
try {
|
|
@@ -99,7 +99,9 @@ describe("session_start chain: reaper first, reconcile after (M3)", () => {
|
|
|
99
99
|
ctx: { sessionManager: { getSessionId: () => string; getEntries: () => unknown[] } },
|
|
100
100
|
) => void;
|
|
101
101
|
expect(handler).toBeDefined();
|
|
102
|
-
|
|
102
|
+
// pi runner emit session_start 必带事件(SessionStartEvent.reason 必填,
|
|
103
|
+
// pi 0.84.4 实装核实)——维护链入口日志消费 reason
|
|
104
|
+
handler({ type: "session_start", reason: "resume" }, {
|
|
103
105
|
sessionManager: {
|
|
104
106
|
getSessionId: () => sessionId,
|
|
105
107
|
getEntries: () => [
|
|
@@ -111,7 +113,7 @@ describe("session_start chain: reaper first, reconcile after (M3)", () => {
|
|
|
111
113
|
},
|
|
112
114
|
});
|
|
113
115
|
|
|
114
|
-
// handler
|
|
116
|
+
// handler 同步直跑对账(u-bte-remove 后无 reap await)——等待落定
|
|
115
117
|
await vi.waitFor(() =>
|
|
116
118
|
expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
|
|
117
119
|
id: "bt-1700000000-idx001",
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
// src/__tests__/kill-tree.test.ts —— 进程树 kill 真实进程验证(POSIX 进程组语义)
|
|
2
|
+
// + pid 身份判据(getProcessStartTimeSec / pidStartMatchesRegistered——原 reaper.test.ts
|
|
3
|
+
// 覆盖平移:收殓下沉 runtime 后 helper 移入本文件,kill 侧自防御仍消费)
|
|
2
4
|
import { spawn } from "node:child_process";
|
|
3
5
|
|
|
4
6
|
import { describe, expect, it, vi } from "vitest";
|
|
5
7
|
|
|
6
8
|
vi.setConfig({ testTimeout: 20000 });
|
|
7
9
|
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
getProcessStartTimeSec,
|
|
12
|
+
isPidAlive,
|
|
13
|
+
killProcessTree,
|
|
14
|
+
pidStartMatchesRegistered,
|
|
15
|
+
} from "../kill-tree.ts";
|
|
9
16
|
|
|
10
17
|
function sleep(ms: number): Promise<void> {
|
|
11
18
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -74,3 +81,44 @@ describe("killProcessTree (process group semantics)", () => {
|
|
|
74
81
|
expect(() => killProcessTree(pid)).not.toThrow();
|
|
75
82
|
});
|
|
76
83
|
});
|
|
84
|
+
|
|
85
|
+
describe("getProcessStartTimeSec (platform probe sanity)", () => {
|
|
86
|
+
it("returns a sane epoch-seconds value for a live process", () => {
|
|
87
|
+
const sec = getProcessStartTimeSec(process.pid);
|
|
88
|
+
expect(sec).toBeDefined();
|
|
89
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
90
|
+
// 本进程必然启动于 [now - 10min, now + 5s](时钟容差)
|
|
91
|
+
expect(sec!).toBeGreaterThan(nowSec - 600);
|
|
92
|
+
expect(sec!).toBeLessThan(nowSec + 5);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns undefined for a dead pid", async () => {
|
|
96
|
+
const child = spawnDetached("sleep 0.1");
|
|
97
|
+
const pid = child.pid;
|
|
98
|
+
if (pid === undefined) throw new Error("no pid");
|
|
99
|
+
await sleep(500); // 等退出 + libuv reap
|
|
100
|
+
expect(getProcessStartTimeSec(pid)).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("pidStartMatchesRegistered (pid identity predicate, §3.6)", () => {
|
|
105
|
+
it("exact match on registered pidStartTime authorizes (precise path)", () => {
|
|
106
|
+
expect(pidStartMatchesRegistered(1000, 1000, 1_700_000_000_000)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("registered mismatch (pid reuse suspicion) rejects", () => {
|
|
110
|
+
expect(pidStartMatchesRegistered(2000, 1000, 1_700_000_000_000)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("missing registration degrades to startedAt-seconds bound: original process authorizes", () => {
|
|
114
|
+
// 登记发生在 spawn 之后 → 原进程 start time 必然 ≤ floor(startedAt/1000)
|
|
115
|
+
const startedAtMs = 1_700_000_123_456; // floor = 1_700_000_123
|
|
116
|
+
expect(pidStartMatchesRegistered(1_700_000_123, undefined, startedAtMs)).toBe(true);
|
|
117
|
+
expect(pidStartMatchesRegistered(1_700_000_122, undefined, startedAtMs)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("missing registration degrades: newer-than-registration start time rejects (reuse)", () => {
|
|
121
|
+
const startedAtMs = 1_700_000_123_456;
|
|
122
|
+
expect(pidStartMatchesRegistered(1_700_000_124, undefined, startedAtMs)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/__tests__/maintenance-once.test.ts —— 收殓下沉后的 session_start 维护链
|
|
2
|
+
// 语义(实施单元 u-bte-remove,设计 docs/design/file-lock-unification-and-reaper-
|
|
3
|
+
// sink.md §3.2 D2「extension 删 session_start reaper」/ §3.3 D3 粒度段 / §4 S6
|
|
4
|
+
// 「批 2 后 reap 类操作不再执行」):
|
|
5
|
+
// - reconcilePendingEntries 是 session 级豁免类,每 session_start 派发都执行
|
|
6
|
+
// (startup/resume/new 多派发 ×N——含 factory 二调 handler 累积的真实形态)
|
|
7
|
+
// - 触发面消失:reaper.ts 模块文件已删除(文件级断言,见首条用例)+ 维护链不再触发
|
|
8
|
+
// 全局文件锁(withFileLock——原 reapOrphanedTasks 的 reaper.lock 路径)
|
|
9
|
+
// - 入口无条件 debug 日志按派发次数出现,detail 仅含 reason(reapSkipped 字段
|
|
10
|
+
// 随 reap 调用移除,S6 观测通道语义更新)
|
|
11
|
+
// 断言方式:reconcile / logger / file-lock 全部间谍注入,观察调用次数与参数。
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
17
|
+
|
|
18
|
+
const { reconcileMock, loggerMock, officialBashFactoryMock, dataDirRef, withFileLockMock } =
|
|
19
|
+
vi.hoisted(() => ({
|
|
20
|
+
reconcileMock: vi.fn(),
|
|
21
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
22
|
+
officialBashFactoryMock: vi.fn(),
|
|
23
|
+
dataDirRef: { dir: "/tmp/bte-fake-agent-dir" },
|
|
24
|
+
withFileLockMock: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
28
|
+
createBashToolDefinition: officialBashFactoryMock,
|
|
29
|
+
getAgentDir: () => dataDirRef.dir,
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
32
|
+
getLogger: () => loggerMock,
|
|
33
|
+
}));
|
|
34
|
+
// 全局扫描探针:withFileLock 是原 reapOrphanedTasks 持 reaper.lock 的唯一锁路径
|
|
35
|
+
// (registry 写走的是 withFileLockSync)——维护链触发它 = 全局扫描回流
|
|
36
|
+
vi.mock("@zhushanwen/pi-file-lock", () => ({
|
|
37
|
+
withFileLock: withFileLockMock,
|
|
38
|
+
withFileLockSync: vi.fn(() => {
|
|
39
|
+
throw new Error("unexpected registry write in maintenance chain");
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
42
|
+
vi.mock("../background/pending-reconcile.ts", () => ({
|
|
43
|
+
reconcilePendingEntries: reconcileMock,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import type { ExtensionAPI, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
47
|
+
|
|
48
|
+
function createMockPi() {
|
|
49
|
+
return {
|
|
50
|
+
registerTool: vi.fn(),
|
|
51
|
+
on: vi.fn(),
|
|
52
|
+
appendEntry: vi.fn(),
|
|
53
|
+
events: { emit: vi.fn(), on: vi.fn() },
|
|
54
|
+
sendMessage: vi.fn(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getSessionStartHandler(pi: ReturnType<typeof createMockPi>) {
|
|
59
|
+
const handler = pi.on.mock.calls.find((call) => call[0] === "session_start")?.[1];
|
|
60
|
+
expect(handler).toBeTypeOf("function");
|
|
61
|
+
return handler as (event: SessionStartEvent, ctx: unknown) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeEvent(reason: SessionStartEvent["reason"]): SessionStartEvent {
|
|
65
|
+
return { type: "session_start", reason };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function makeCtx() {
|
|
69
|
+
return {
|
|
70
|
+
sessionManager: { getSessionId: () => "sid-once", getEntries: () => [] },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function loadExtension() {
|
|
75
|
+
return import("../index.ts");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
reconcileMock.mockReset();
|
|
80
|
+
// 官方 bash 工厂返回最小工具定义(override 定义 spread initial.name)
|
|
81
|
+
officialBashFactoryMock.mockReset();
|
|
82
|
+
officialBashFactoryMock.mockReturnValue({
|
|
83
|
+
name: "bash",
|
|
84
|
+
label: "bash",
|
|
85
|
+
description: "official",
|
|
86
|
+
parameters: {},
|
|
87
|
+
execute: vi.fn(),
|
|
88
|
+
});
|
|
89
|
+
withFileLockMock.mockReset();
|
|
90
|
+
loggerMock.debug.mockClear();
|
|
91
|
+
loggerMock.warn.mockClear();
|
|
92
|
+
loggerMock.error.mockClear();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("session_start maintenance chain after reap sink (u-bte-remove)", () => {
|
|
96
|
+
it("no longer ships a reaper: the module is gone from the extension (trigger surface removed)", () => {
|
|
97
|
+
// 等价改写(fallow unresolved-import):原断言动态 import 已删除模块期待
|
|
98
|
+
// rejects——静态分析无法理解故意性。模块文件不存在 ⟹ import 必 rejects,
|
|
99
|
+
// 蕴含成立,断言价值不变(触发面消失),文件级判定更直接。路径用 join 逐段
|
|
100
|
+
// 拼装:fallow 会把 ../reaper.ts 形态的相对路径字符串按 import specifier 扫描
|
|
101
|
+
const reaperModulePath = join(dirname(fileURLToPath(import.meta.url)), "..", "reaper.ts");
|
|
102
|
+
expect(existsSync(reaperModulePath)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("runs reconcilePendingEntries on EVERY session_start dispatch (session-scoped exempt, D3)", async () => {
|
|
106
|
+
const mod = await loadExtension();
|
|
107
|
+
const pi = createMockPi();
|
|
108
|
+
mod.default(pi as unknown as ExtensionAPI);
|
|
109
|
+
const handler = getSessionStartHandler(pi);
|
|
110
|
+
const ctx = makeCtx();
|
|
111
|
+
|
|
112
|
+
// 多派发覆盖真实形态:startup + resume 双发(桌面端每次激活)+ new;
|
|
113
|
+
// factory 二调 handler 累积时同一事件被多组 handler 各跑一次,同理多次执行
|
|
114
|
+
handler(makeEvent("startup"), ctx);
|
|
115
|
+
handler(makeEvent("resume"), ctx);
|
|
116
|
+
handler(makeEvent("new"), ctx);
|
|
117
|
+
|
|
118
|
+
expect(reconcileMock).toHaveBeenCalledTimes(3);
|
|
119
|
+
expect(reconcileMock).toHaveBeenNthCalledWith(1, expect.anything(), dataDirRef.dir, "sid-once", []);
|
|
120
|
+
expect(reconcileMock).toHaveBeenLastCalledWith(expect.anything(), dataDirRef.dir, "sid-once", []);
|
|
121
|
+
// 无全局扫描:维护链不触发原 reaper 的跨进程锁路径,也无告警
|
|
122
|
+
expect(withFileLockMock).not.toHaveBeenCalled();
|
|
123
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("emits the unconditional entry debug log per dispatch with reason only (S6 observation channel)", async () => {
|
|
127
|
+
const mod = await loadExtension();
|
|
128
|
+
const pi = createMockPi();
|
|
129
|
+
mod.default(pi as unknown as ExtensionAPI);
|
|
130
|
+
const handler = getSessionStartHandler(pi);
|
|
131
|
+
const ctx = makeCtx();
|
|
132
|
+
|
|
133
|
+
handler(makeEvent("startup"), ctx);
|
|
134
|
+
handler(makeEvent("resume"), ctx);
|
|
135
|
+
|
|
136
|
+
// 每次派发恰好一条入口日志(无条件 = 不以任何条件短路),reason 取自事件;
|
|
137
|
+
// reapSkipped 字段已随 reap 下沉移除(u-bte-remove 变更登记,S6「批 2 后
|
|
138
|
+
// reap 类操作不再执行」)
|
|
139
|
+
expect(loggerMock.debug).toHaveBeenCalledTimes(2);
|
|
140
|
+
expect(loggerMock.debug).toHaveBeenNthCalledWith(1, "session_start maintenance dispatch", {
|
|
141
|
+
detail: { reason: "startup" },
|
|
142
|
+
});
|
|
143
|
+
expect(loggerMock.debug).toHaveBeenNthCalledWith(2, "session_start maintenance dispatch", {
|
|
144
|
+
detail: { reason: "resume" },
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("keeps reconcile failures non-fatal: warn logged, dispatch chain not throwing", async () => {
|
|
149
|
+
reconcileMock.mockImplementation(() => {
|
|
150
|
+
throw new Error("reconcile boom");
|
|
151
|
+
});
|
|
152
|
+
const mod = await loadExtension();
|
|
153
|
+
const pi = createMockPi();
|
|
154
|
+
mod.default(pi as unknown as ExtensionAPI);
|
|
155
|
+
const handler = getSessionStartHandler(pi);
|
|
156
|
+
|
|
157
|
+
// 对账抛错被维护链吞掉记 warn(僵尸停留差集,下一 session_start 幂等重查)
|
|
158
|
+
expect(() => handler(makeEvent("resume"), makeCtx())).not.toThrow();
|
|
159
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
160
|
+
"session_start pending reconcile failed; zombies retried next session start",
|
|
161
|
+
expect.objectContaining({ detail: expect.objectContaining({ err: "reconcile boom" }) }),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -135,7 +135,7 @@ describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry
|
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
describe("reconcile scenario ②:
|
|
138
|
+
describe("reconcile scenario ②: collector-only orphan (registry orphaned, session file untouched)", () => {
|
|
139
139
|
it("appends unregister with cancelled for orphaned entries", () => {
|
|
140
140
|
const entry = makeRegistryEntry({ state: "orphaned" });
|
|
141
141
|
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
|
|
@@ -5,9 +5,10 @@ import { join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { afterEach, describe, expect, it } from "vitest";
|
|
7
7
|
|
|
8
|
+
import { MAX_TERMINAL_REGISTRY_ENTRIES } from "@xyz-agent/extension-protocol";
|
|
9
|
+
|
|
8
10
|
import {
|
|
9
11
|
getRegistryPath,
|
|
10
|
-
MAX_TERMINAL_REGISTRY_ENTRIES,
|
|
11
12
|
readRegistry,
|
|
12
13
|
taskToRegistryEntry,
|
|
13
14
|
writeRegistryEntry,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 职责:对「session entries 差集显示 active、但任务已终态」的 bt- 任务补写
|
|
5
5
|
* pending:unregister entry。覆盖三类 otherwise 悬空场景(设计原文):
|
|
6
6
|
* ① 进程 graceful 退出收殓时 pi API 已不可用,unregister 没写成 entry;
|
|
7
|
-
* ②
|
|
7
|
+
* ② 强杀后收殓侧只改 registry(标 orphaned),碰不了 session 文件;
|
|
8
8
|
* ③ fork 后任务完成通知写进新 session,旧 session 文件的 register 成僵尸。
|
|
9
9
|
*
|
|
10
10
|
* 收尾写法(权威路径):直接 pi.appendEntry("pending:unregister", {id, reason,
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
*
|
|
18
18
|
* 终态判据:registry state ∈ {exited, orphaned},或(state=running/killing 且
|
|
19
19
|
* kill(pid,0) 判死:收殓/写盘失败遗留的 running 条目按事实终态处理)。
|
|
20
|
-
* 不改 registry
|
|
20
|
+
* 不改 registry——终态写入归收殓侧(runtime reaper)/轮询器(单点归属),对账只清
|
|
21
|
+
* pending 侧。
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* 执行链(index.ts session_start 链内):收殓下沉 runtime 后(u-bte-remove)本链
|
|
24
|
+
* 仅剩对账——「对账见 running+pid 活则不动作,下一 session_start 兜底」的幂等
|
|
25
|
+
* 语义覆盖孤儿终态由 runtime 异步写入的时序窗口。
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
package/src/background/poller.ts
CHANGED
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
* 挂起进程,pi 的 ignore 语义完全失效
|
|
19
19
|
* - rpc/print 模式 Ctrl+C:pi 无 SIGINT listener(dist 全量 grep 唯一注册点在 TUI
|
|
20
20
|
* 挂起态)→ 默认处置进程即死。探针实测:无 listener 的信号默认终止**不触发
|
|
21
|
-
* exit 事件**,同步收殓在该路径本就不可达;孤儿由
|
|
22
|
-
*
|
|
21
|
+
* exit 事件**,同步收殓在该路径本就不可达;孤儿由 xyz-agent runtime 收殓器按
|
|
22
|
+
* 属主判定收殓(u-bte-remove 下沉后;与 SIGKILL 强杀同类,设计内兜底)
|
|
23
23
|
* - cleanup-only listener 更不可取:注册即抑制默认终止,rpc 前台 Ctrl+C 后 pi 变
|
|
24
24
|
* 成杀不死的僵尸进程
|
|
25
25
|
*
|
|
26
|
-
* 子进程死后 registry
|
|
26
|
+
* 子进程死后 registry 目录由 xyz-agent runtime 启动期兜底扫描覆盖(收殓下沉)——
|
|
27
|
+
* 本单元不管。
|
|
27
28
|
*/
|
|
28
29
|
|
|
29
30
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
@@ -56,7 +57,7 @@ export function installProcessExitGuard(): void {
|
|
|
56
57
|
// SIGTERM:只收殓不 exit——pi rpc-mode 的 SIGTERM handler(先注册先跑)走自己的
|
|
57
58
|
// shutdown 流程,最终 process.exit 触发下方 exit 兜底(幂等跳过)。
|
|
58
59
|
// SIGINT 刻意不挂 handler——会破坏 pi TUI 挂起态的 ignoreSigint 语义,且 rpc
|
|
59
|
-
// 默认终止路径 exit 事件本就不触发、孤儿归
|
|
60
|
+
// 默认终止路径 exit 事件本就不触发、孤儿归 runtime 收殓兜底(完整论证见文件头
|
|
60
61
|
// 「不挂 SIGINT handler」专段)
|
|
61
62
|
process.once("SIGTERM", cleanup);
|
|
62
63
|
// 同步兜底:exit handler 里只能做同步收殓(kill-tree 同步 + registry 同步原子写)
|
|
@@ -77,7 +78,7 @@ export function reapBackgroundTasksNow(): void {
|
|
|
77
78
|
try {
|
|
78
79
|
killProcessTree(task.pid);
|
|
79
80
|
} catch (err) {
|
|
80
|
-
// 单条 kill 失败不阻断其余条目收殓;进程将退出,残余由
|
|
81
|
+
// 单条 kill 失败不阻断其余条目收殓;进程将退出,残余由 runtime 收殓兜底
|
|
81
82
|
logger.warn("kill-tree failed during process-exit reap", {
|
|
82
83
|
detail: { taskId: task.taskId, pid: task.pid, err: err instanceof Error ? err.message : String(err) },
|
|
83
84
|
});
|
|
@@ -2,23 +2,33 @@
|
|
|
2
2
|
* registry.json 持久化读写(per-sessionId 目录,D8)。
|
|
3
3
|
*
|
|
4
4
|
* 路径:<dataDir>/base-tool-enhance/<sessionId>/registry.json(dataDir = pi
|
|
5
|
-
* getAgentDir() 同源路径,由调用方解析传入)。条目记 ownerPiPid
|
|
6
|
-
*
|
|
5
|
+
* getAgentDir() 同源路径,由调用方解析传入)。条目记 ownerPiPid——收殓侧属主
|
|
6
|
+
* 判定依据(u-bte-remove 后 = xyz-agent runtime background-task-reaper),M2 只负责
|
|
7
|
+
* 写入。
|
|
7
8
|
*
|
|
8
9
|
* 写入协议:
|
|
9
10
|
* - 原子写 temp+rename(tmp 名带 pid+随机段防并发碰撞,llm-shared saveConfig 范式)
|
|
10
11
|
* - 锁内 RMW(@zhushanwen/pi-file-lock withFileLockSync)——同 sessionId 目录可能
|
|
11
12
|
* 被桌面端 ephemeral 附着进程与发起进程并发写,跨进程互斥只依赖同一 lockfile
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* (runtime 收殓器写终态用 xyz-agent 统一锁,与该 lockfile 互斥)
|
|
14
|
+
* - 锁获取失败不降级无锁写:返回 {success:false},条目停留 running 由 runtime
|
|
15
|
+
* 收殓兜底(§3.5「registry/entry 写不进则条目停留 running」)
|
|
14
16
|
* - 损坏读取防御(§3.6):解析失败/形状非法 → 重命名 .corrupt 保留现场 + 按空表
|
|
15
17
|
* 重建 + warn 日志
|
|
16
18
|
* - 终态条目 LRU 上限 50(与单例表对称)
|
|
19
|
+
*
|
|
20
|
+
* 契约改引(u-bte-remove):版本常量 / LRU 上限 / 文件形状自
|
|
21
|
+
* @xyz-agent/extension-protocol background-task.ts 契约引入(此前本地重复定义)。
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
20
25
|
import { dirname, join } from "node:path";
|
|
21
26
|
|
|
27
|
+
import {
|
|
28
|
+
BACKGROUND_TASK_REGISTRY_VERSION,
|
|
29
|
+
type BackgroundTaskRegistryFile,
|
|
30
|
+
MAX_TERMINAL_REGISTRY_ENTRIES,
|
|
31
|
+
} from "@xyz-agent/extension-protocol";
|
|
22
32
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
23
33
|
import { withFileLockSync } from "@zhushanwen/pi-file-lock";
|
|
24
34
|
|
|
@@ -26,10 +36,6 @@ import { isTerminalState, type BackgroundTask, type RegistryEntry } from "./type
|
|
|
26
36
|
|
|
27
37
|
const logger = getLogger("base-tool-enhance");
|
|
28
38
|
|
|
29
|
-
/** registry 文件格式版本(未来结构变更时迁移判据)。 */
|
|
30
|
-
const REGISTRY_VERSION = 1;
|
|
31
|
-
/** 终态条目 LRU 上限(与 task-store MAX_TERMINAL_TASKS 对称,§3.5)。 */
|
|
32
|
-
export const MAX_TERMINAL_REGISTRY_ENTRIES = 50;
|
|
33
39
|
const JSON_INDENT = 2;
|
|
34
40
|
// tmp 随机段参数(llm-shared uniqueTmpPath 同款:36 进制随机串,跳过 "0." 前缀)
|
|
35
41
|
const TMP_RADIX = 36;
|
|
@@ -44,11 +50,6 @@ export function getRegistryPath(dataDir: string, sessionId: string): string {
|
|
|
44
50
|
return join(getBaseToolEnhanceDir(dataDir), sessionId, "registry.json");
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
interface RegistryFileShape {
|
|
48
|
-
version: number;
|
|
49
|
-
entries: RegistryEntry[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
53
|
/**
|
|
53
54
|
* 最低限度形状校验:核心标识字段缺失即整条丢弃(不因单条脏数据报废全表)。
|
|
54
55
|
*/
|
|
@@ -68,7 +69,7 @@ function isValidRegistryEntry(item: unknown): item is RegistryEntry {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/** 校验并归一化 registry 文件内容;形状非法返回 undefined(走 corrupt 路径)。 */
|
|
71
|
-
function parseRegistryContent(raw: string):
|
|
72
|
+
function parseRegistryContent(raw: string): BackgroundTaskRegistryFile | undefined {
|
|
72
73
|
let parsed: unknown;
|
|
73
74
|
try {
|
|
74
75
|
parsed = JSON.parse(raw);
|
|
@@ -77,12 +78,12 @@ function parseRegistryContent(raw: string): RegistryFileShape | undefined {
|
|
|
77
78
|
}
|
|
78
79
|
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
79
80
|
const { version, entries } = parsed as Record<string, unknown>;
|
|
80
|
-
if (version !==
|
|
81
|
+
if (version !== BACKGROUND_TASK_REGISTRY_VERSION || !Array.isArray(entries)) return undefined;
|
|
81
82
|
const valid: RegistryEntry[] = [];
|
|
82
83
|
for (const item of entries) {
|
|
83
84
|
if (isValidRegistryEntry(item)) valid.push(item);
|
|
84
85
|
}
|
|
85
|
-
return { version:
|
|
86
|
+
return { version: BACKGROUND_TASK_REGISTRY_VERSION, entries: valid };
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
/** .corrupt 落点:固定名优先;已存在则带时间戳,不覆盖前一份现场。 */
|
|
@@ -145,7 +146,7 @@ export function taskToRegistryEntry(task: BackgroundTask): RegistryEntry {
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
function serializeRegistry(entries: RegistryEntry[]): string {
|
|
148
|
-
const shape:
|
|
149
|
+
const shape: BackgroundTaskRegistryFile = { version: BACKGROUND_TASK_REGISTRY_VERSION, entries };
|
|
149
150
|
return `${JSON.stringify(shape, null, JSON_INDENT)}\n`;
|
|
150
151
|
}
|
|
151
152
|
|
|
@@ -172,8 +173,8 @@ function atomicWriteRegistry(registryPath: string, content: string): void {
|
|
|
172
173
|
/**
|
|
173
174
|
* 写入/更新单条 registry 条目(锁内 RMW:读全量 → 合并同 id 覆盖 → 终态 LRU 50 →
|
|
174
175
|
* 原子写)。任务登记(running)、killing intent、终态三条路径共用。
|
|
175
|
-
* 失败返回 {success:false}——调用方按「写不进则条目停留 running,
|
|
176
|
-
*
|
|
176
|
+
* 失败返回 {success:false}——调用方按「写不进则条目停留 running,runtime 收殓
|
|
177
|
+
* 兜底」处理(M2 只 warn,不重试不阻断主流程)。
|
|
177
178
|
*/
|
|
178
179
|
export function writeRegistryEntry(
|
|
179
180
|
registryPath: string,
|
|
@@ -195,7 +196,7 @@ export function writeRegistryEntry(
|
|
|
195
196
|
return { success: true };
|
|
196
197
|
} catch (err) {
|
|
197
198
|
const message = err instanceof Error ? err.message : String(err);
|
|
198
|
-
logger.warn("registry write failed; entry stays as-is (
|
|
199
|
+
logger.warn("registry write failed; entry stays as-is (runtime reaper will collect the orphan)", {
|
|
199
200
|
detail: { path: registryPath, taskId: entry.taskId, err: message },
|
|
200
201
|
});
|
|
201
202
|
return { success: false, error: message };
|
|
@@ -14,8 +14,7 @@ import { randomBytes } from "node:crypto";
|
|
|
14
14
|
|
|
15
15
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
16
16
|
|
|
17
|
-
import { killProcessTree } from "../kill-tree.ts";
|
|
18
|
-
import { getProcessStartTimeSec, pidStartMatchesRegistered } from "../reaper.ts";
|
|
17
|
+
import { getProcessStartTimeSec, killProcessTree, pidStartMatchesRegistered } from "../kill-tree.ts";
|
|
19
18
|
import { emitPendingRegister } from "./notify.ts";
|
|
20
19
|
import { ensurePollerRunning } from "./poller.ts";
|
|
21
20
|
import { getRegistryPath, taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
|
|
@@ -187,8 +186,8 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
|
|
|
187
186
|
return { ok: false, error: "Failed to start background command: no pid acquired" };
|
|
188
187
|
}
|
|
189
188
|
|
|
190
|
-
// M3
|
|
191
|
-
// 精确比较防 pid 复用误杀。读取失败省略(undefined
|
|
189
|
+
// M3 补写字段:spawn 后立即读子进程 start time(epoch 秒),供收殓/kill 侧
|
|
190
|
+
// 精确比较防 pid 复用误杀。读取失败省略(undefined 不进条目)——消费方降级走
|
|
192
191
|
// startedAt 秒级校验兜底,不报错不阻断 spawn
|
|
193
192
|
const pidStartTime = getProcessStartTimeSec(child.pid);
|
|
194
193
|
|
|
@@ -211,8 +210,9 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
|
|
|
211
210
|
}
|
|
212
211
|
|
|
213
212
|
// 登记顺序(§3.5 数据流 ③④⑤):单例表(运行时权威)→ registry(持久化)→
|
|
214
|
-
// pending:register emit。registry 写失败不阻断(条目停留 running 由
|
|
215
|
-
//
|
|
213
|
+
// pending:register emit。registry 写失败不阻断(条目停留 running 由 runtime
|
|
214
|
+
// 收殓器兜底——孤儿判定按 ownerPiPid,§3.5;u-bte-remove 下沉后不再依赖本包
|
|
215
|
+
// 扫描);emit 失败同样无害(peer 未加载/引用未注入时无 listener,通知
|
|
216
216
|
// 链路缺失不影响任务本体)
|
|
217
217
|
registerSpawnedTask(task);
|
|
218
218
|
writeRegistryEntry(registryPath, taskToRegistryEntry(task));
|
|
@@ -228,7 +228,7 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
|
|
|
228
228
|
*/
|
|
229
229
|
function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
|
|
230
230
|
const timer = setTimeout(() => {
|
|
231
|
-
// pid 复用防御(§3.6,同
|
|
231
|
+
// pid 复用防御(§3.6,同 bash_kill 范式,宁不杀勿误杀):
|
|
232
232
|
// 任务早已退出(exit 边沿未被轮询器收尾或竞态未及)且 pid 在到点前被系统复用
|
|
233
233
|
// 时,直接 killProcessTree 会杀掉复用 pid 上的无辜进程(整进程组 SIGKILL)。
|
|
234
234
|
if (!isRecordedPidStillOriginal(task)) {
|
|
@@ -254,8 +254,8 @@ function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
|
|
|
254
254
|
|
|
255
255
|
/**
|
|
256
256
|
* 到点 pid 身份校验:true = 登记时的原进程仍占用该 pid(可安全 kill-tree)。
|
|
257
|
-
* 判据(精确比较 / startedAt 秒级降级 / 读不到保守 false)单点定义于
|
|
258
|
-
* 的 pidStartMatchesRegistered
|
|
257
|
+
* 判据(精确比较 / startedAt 秒级降级 / 读不到保守 false)单点定义于 kill-tree.ts
|
|
258
|
+
* 的 pidStartMatchesRegistered(原 reaper.ts 单点,收殓下沉后随删除平移)。
|
|
259
259
|
*/
|
|
260
260
|
function isRecordedPidStillOriginal(task: BackgroundTask): boolean {
|
|
261
261
|
const actualStartSec = getProcessStartTimeSec(task.pid);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* subagent 进程识别(D14 降级判据,探针 P5)。
|
|
3
3
|
*
|
|
4
|
-
* subagent-
|
|
5
|
-
* 进程**无条件**注入身份贯穿 env(PI_SUBAGENT_ROOT_SESSION_ID / SELF_RECORD_ID /
|
|
4
|
+
* subagent-core 的 buildChildEnv(packages/subagent-core/src/execution/session-runner.ts)
|
|
5
|
+
* 对每个子 agent 进程**无条件**注入身份贯穿 env(PI_SUBAGENT_ROOT_SESSION_ID / SELF_RECORD_ID /
|
|
6
6
|
* DEPTH / ROOT_CWD,见其源码注释「无条件注入每个 subagent」)。任一存在 = 当前
|
|
7
7
|
* pi 进程是 subagent 子进程 → 本扩展降级:background:true 被忽略走前台同步语义。
|
|
8
8
|
*
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 回);且子进程死后其 registry 目录永远不会再有 session 启动,孤儿无人 reap。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
/** subagent-
|
|
13
|
+
/** subagent-core(session-runner)注入的身份贯穿 env 前缀(跨包契约,P5 探针确认)。 */
|
|
14
14
|
const SUBAGENT_ENV_KEYS = [
|
|
15
15
|
"PI_SUBAGENT_ROOT_SESSION_ID",
|
|
16
16
|
"PI_SUBAGENT_SELF_RECORD_ID",
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 条目唯一来源 = 本进程 execute 的 spawn(registerSpawnedTask 全仓唯一调用点 =
|
|
9
9
|
* spawn-background.ts)。他进程 / 历史 session 的 running 条目**永不进表**——
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* 零恢复零接管,孤儿处置权统一归收殓侧(u-bte-remove 后 = xyz-agent runtime)
|
|
11
|
+
* 按属主裁决;终态条目可从 registry 读,仅供 bash_output 查历史。
|
|
12
12
|
*
|
|
13
13
|
* 终态条目 LRU 上限 MAX_TERMINAL_ENTRIES(与 registry 对称),淘汰后 bash_output
|
|
14
14
|
* 回落 registry 查询。
|