@zhushanwen/pi-base-tool-enhance 0.2.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 +31 -0
- package/index.ts +1 -0
- package/package.json +54 -0
- package/skills/base-tool-enhance-ext-config/SKILL.md +76 -0
- package/src/__tests__/background-lifecycle.test.ts +634 -0
- package/src/__tests__/bash-tool.test.ts +573 -0
- package/src/__tests__/config.test.ts +193 -0
- package/src/__tests__/force-patterns.test.ts +230 -0
- package/src/__tests__/index.test.ts +133 -0
- package/src/__tests__/kill-tree.test.ts +76 -0
- package/src/__tests__/notify.test.ts +335 -0
- package/src/__tests__/pending-reconcile.test.ts +237 -0
- package/src/__tests__/reaper.test.ts +373 -0
- package/src/__tests__/registry.test.ts +149 -0
- package/src/__tests__/task-store.test.ts +156 -0
- package/src/__tests__/tool-error-audit.test.ts +92 -0
- package/src/background/notify.ts +218 -0
- package/src/background/output-tail.ts +84 -0
- package/src/background/pending-reconcile.ts +169 -0
- package/src/background/poller.ts +91 -0
- package/src/background/process-exit-guard.ts +106 -0
- package/src/background/registry.ts +203 -0
- package/src/background/spawn-background.ts +275 -0
- package/src/background/subagent-guard.ts +21 -0
- package/src/background/task-store.ts +125 -0
- package/src/background/types.ts +103 -0
- package/src/bash-kill-tool.ts +144 -0
- package/src/bash-output-tool.ts +131 -0
- package/src/bash-tool.ts +226 -0
- package/src/config.ts +167 -0
- package/src/force-patterns.ts +236 -0
- package/src/index.ts +90 -0
- package/src/kill-tree.ts +100 -0
- package/src/reaper.ts +313 -0
- package/src/tool-error-audit.ts +78 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// src/__tests__/background-lifecycle.test.ts —— background 核心生命周期集成:
|
|
2
|
+
// 真实 spawn 边沿收尾 / 轮询器自动收尾 / killing intent 双侧 / timeout / 并发上限 /
|
|
3
|
+
// 收殓 / D15 abort / bash_output / bash_kill(黑盒:经工具 execute 断言使用者可见行为)
|
|
4
|
+
import { existsSync, mkdtempSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
vi.setConfig({ testTimeout: 20000 });
|
|
11
|
+
|
|
12
|
+
// getAgentDir → 测试临时数据目录(工具 execute 内部调用,不写真 ~/.pi/agent)。
|
|
13
|
+
// hoisted 回调里不能引用顶层 import(初始化顺序),用可变引用在 import 完成后注入
|
|
14
|
+
const { dataDirRef } = vi.hoisted(() => ({ dataDirRef: { dir: "" } }));
|
|
15
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
16
|
+
getAgentDir: () => dataDirRef.dir,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
// kill-tree 转发真实杀 + 记录调用(timeout 分支断言 killProcessTree 被调)
|
|
20
|
+
const { killTreeCalls } = vi.hoisted(() => ({ killTreeCalls: [] as number[] }));
|
|
21
|
+
vi.mock("../kill-tree.ts", async (importOriginal) => {
|
|
22
|
+
const orig = await importOriginal<typeof import("../kill-tree.ts")>();
|
|
23
|
+
return {
|
|
24
|
+
...orig,
|
|
25
|
+
killProcessTree: (pid: number): void => {
|
|
26
|
+
killTreeCalls.push(pid);
|
|
27
|
+
orig.killProcessTree(pid);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
import { createBashKillToolDefinition } from "../bash-kill-tool.ts";
|
|
33
|
+
import { createBashOutputToolDefinition } from "../bash-output-tool.ts";
|
|
34
|
+
import { pollTickForTest, setOnTaskExit, stopPoller } from "../background/poller.ts";
|
|
35
|
+
import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "../background/registry.ts";
|
|
36
|
+
import type { RegistryEntry } from "../background/types.ts";
|
|
37
|
+
import { DEFAULT_MAX_CONCURRENT_BACKGROUND, spawnBackgroundTask } from "../background/spawn-background.ts";
|
|
38
|
+
import { clearTaskStoreForTest, getActiveTasks, getTask } from "../background/task-store.ts";
|
|
39
|
+
import { reapBackgroundTasksNow, resetProcessExitGuardForTest } from "../background/process-exit-guard.ts";
|
|
40
|
+
import { isPidAlive } from "../kill-tree.ts";
|
|
41
|
+
|
|
42
|
+
vi.setConfig({ testTimeout: 20000 });
|
|
43
|
+
|
|
44
|
+
const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-data-"));
|
|
45
|
+
dataDirRef.dir = DATA_DIR;
|
|
46
|
+
|
|
47
|
+
const SESSION_ID = "sess-lifecycle";
|
|
48
|
+
const REGISTRY_PATH = getRegistryPath(DATA_DIR, SESSION_ID);
|
|
49
|
+
|
|
50
|
+
function makeCtx(sessionId: string = SESSION_ID): { cwd: string; sessionManager: { getSessionId: () => string } } {
|
|
51
|
+
return { cwd: process.cwd(), sessionManager: { getSessionId: () => sessionId } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const bashOutput = createBashOutputToolDefinition();
|
|
55
|
+
const bashKill = createBashKillToolDefinition();
|
|
56
|
+
|
|
57
|
+
async function outputTool(args: { task_id?: string }, sessionId: string = SESSION_ID): Promise<string> {
|
|
58
|
+
const result = await bashOutput.execute("call-1", args, undefined, undefined, makeCtx(sessionId) as never);
|
|
59
|
+
return result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function killTool(taskId: string, sessionId: string = SESSION_ID): Promise<string> {
|
|
63
|
+
const result = await bashKill.execute("call-1", { task_id: taskId }, undefined, undefined, makeCtx(sessionId) as never);
|
|
64
|
+
return result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sleep(ms: number): Promise<void> {
|
|
68
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function spawnBg(command: string, extra: { timeoutSec?: number; maxConcurrent?: number } = {}) {
|
|
72
|
+
return spawnBackgroundTask({
|
|
73
|
+
command,
|
|
74
|
+
cwd: process.cwd(),
|
|
75
|
+
dataDir: DATA_DIR,
|
|
76
|
+
sessionId: SESSION_ID,
|
|
77
|
+
...extra,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 手写 registry 条目(模拟他进程/历史 session 写入的形状)。 */
|
|
82
|
+
function makeRegistryEntry(overrides: Partial<RegistryEntry> & { taskId: string }): RegistryEntry {
|
|
83
|
+
return {
|
|
84
|
+
pid: 424242,
|
|
85
|
+
command: "echo foreign",
|
|
86
|
+
outputFile: "/tmp/foreign.log",
|
|
87
|
+
startedAt: 1,
|
|
88
|
+
state: "running",
|
|
89
|
+
ownerPiPid: 999999,
|
|
90
|
+
sessionId: SESSION_ID,
|
|
91
|
+
...overrides,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
killTreeCalls.length = 0;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
// 残留活跃任务先杀干净再清表(防句柄/进程泄漏影响后续测试)
|
|
101
|
+
for (const task of getActiveTasks()) {
|
|
102
|
+
try {
|
|
103
|
+
process.kill(-task.pid, "SIGKILL");
|
|
104
|
+
} catch {
|
|
105
|
+
try {
|
|
106
|
+
process.kill(task.pid, "SIGKILL");
|
|
107
|
+
} catch {
|
|
108
|
+
// already dead
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
clearTaskStoreForTest();
|
|
113
|
+
stopPoller();
|
|
114
|
+
setOnTaskExit(undefined);
|
|
115
|
+
resetProcessExitGuardForTest();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("real spawn lifecycle (poll edge finalization)", () => {
|
|
119
|
+
it("short task: running → exited with exitCode 0, output file readable, registry terminal", async () => {
|
|
120
|
+
const spawned = spawnBg("sleep 0.3 && echo done");
|
|
121
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
122
|
+
const { task } = spawned;
|
|
123
|
+
expect(task.state).toBe("running");
|
|
124
|
+
expect(existsSync(task.outputFile)).toBe(true);
|
|
125
|
+
// registry 登记即写 running 条目(含 ownerPiPid,M5 属主判定依据)
|
|
126
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
|
|
127
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.ownerPiPid).toBe(process.pid);
|
|
128
|
+
|
|
129
|
+
await sleep(700); // 等命令退出 + libuv reap
|
|
130
|
+
pollTickForTest();
|
|
131
|
+
|
|
132
|
+
const finalized = getTask(task.taskId);
|
|
133
|
+
expect(finalized?.state).toBe("exited");
|
|
134
|
+
expect(finalized?.exitCode).toBe(0);
|
|
135
|
+
expect(finalized?.reason).toBe("natural");
|
|
136
|
+
expect(finalized?.durationMs).toBeGreaterThan(0);
|
|
137
|
+
|
|
138
|
+
const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId);
|
|
139
|
+
expect(registryEntry?.state).toBe("exited");
|
|
140
|
+
expect(registryEntry?.reason).toBe("natural");
|
|
141
|
+
|
|
142
|
+
const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as { output: string; exitCode: number };
|
|
143
|
+
expect(detail.output).toContain("done");
|
|
144
|
+
expect(detail.exitCode).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("poller interval auto-finalizes without manual tick (lazy start/stop)", async () => {
|
|
148
|
+
const spawned = spawnBg("sleep 0.3 && echo auto");
|
|
149
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
150
|
+
const { task } = spawned;
|
|
151
|
+
// 不手动 tick,等真实 2s 轮询边沿(命令 0.3s 已退出,首轮 tick 即收尾)
|
|
152
|
+
await sleep(2800);
|
|
153
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
154
|
+
// 无活跃条目后轮询器自停(防空转)
|
|
155
|
+
expect(getActiveTasks()).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("nonzero exit code is surfaced (exitCode 1)", async () => {
|
|
159
|
+
const spawned = spawnBg("sleep 0.2; exit 3");
|
|
160
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
161
|
+
const { task } = spawned;
|
|
162
|
+
await sleep(600);
|
|
163
|
+
pollTickForTest();
|
|
164
|
+
expect(getTask(task.taskId)?.exitCode).toBe(3);
|
|
165
|
+
const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId);
|
|
166
|
+
expect(registryEntry?.exitCode).toBe(3);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("onTaskExit callback (M3 hook point) fires with the finalized task", async () => {
|
|
170
|
+
const seen: string[] = [];
|
|
171
|
+
setOnTaskExit((task) => seen.push(`${task.taskId}:${task.state}:${task.reason}`));
|
|
172
|
+
const spawned = spawnBg("true");
|
|
173
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
174
|
+
await sleep(500);
|
|
175
|
+
pollTickForTest();
|
|
176
|
+
expect(seen).toHaveLength(1);
|
|
177
|
+
expect(seen[0]).toBe(`${spawned.task.taskId}:exited:natural`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("spawned entry carries pidStartTime for reaper precise comparison (M5→M3)", async () => {
|
|
181
|
+
const spawned = spawnBg("sleep 30");
|
|
182
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
183
|
+
const { task } = spawned;
|
|
184
|
+
// 单例表与 registry 两侧均含字段(epoch 秒;ps 可用平台读取成功)
|
|
185
|
+
expect(typeof task.pidStartTime).toBe("number");
|
|
186
|
+
const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId) as
|
|
187
|
+
| { pidStartTime?: number }
|
|
188
|
+
| undefined;
|
|
189
|
+
expect(registryEntry?.pidStartTime).toBe(task.pidStartTime);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("bash_output tool", () => {
|
|
194
|
+
it("list merges store + registry terminal entries, store wins on same id", async () => {
|
|
195
|
+
// 单例表:一个 running 真任务
|
|
196
|
+
const spawned = spawnBg("sleep 5");
|
|
197
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
198
|
+
// registry 手写:另一个终态历史条目(不在单例表——模拟已被 LRU 淘汰后回落)
|
|
199
|
+
writeRegistryEntry(REGISTRY_PATH, {
|
|
200
|
+
taskId: "bt-9000-hist",
|
|
201
|
+
pid: 1,
|
|
202
|
+
command: "x".repeat(100),
|
|
203
|
+
outputFile: "/tmp/gone.log",
|
|
204
|
+
startedAt: 1,
|
|
205
|
+
state: "exited",
|
|
206
|
+
ownerPiPid: 1,
|
|
207
|
+
sessionId: SESSION_ID,
|
|
208
|
+
exitCode: 0,
|
|
209
|
+
reason: "natural",
|
|
210
|
+
endedAt: 2,
|
|
211
|
+
durationMs: 1,
|
|
212
|
+
});
|
|
213
|
+
// registry 手写:与单例表同 id 的过期 exited 版本(须被单例表 running 覆盖)
|
|
214
|
+
writeRegistryEntry(
|
|
215
|
+
REGISTRY_PATH,
|
|
216
|
+
taskToRegistryEntry({ ...spawned.task, state: "exited" as const, ownerPiPid: 1 }),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const listed = JSON.parse(await outputTool({})) as {
|
|
220
|
+
tasks: Array<{ task_id: string; state: string; command: string }>;
|
|
221
|
+
};
|
|
222
|
+
const byId = new Map(listed.tasks.map((t) => [t.task_id, t]));
|
|
223
|
+
expect(byId.get(spawned.task.taskId)?.state).toBe("running"); // 单例表优先
|
|
224
|
+
expect(byId.get("bt-9000-hist")?.state).toBe("exited");
|
|
225
|
+
// 命令展示截断(前 80 字符)
|
|
226
|
+
expect(byId.get("bt-9000-hist")?.command.length).toBeLessThanOrEqual(81);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("unknown task_id throws with list hint", async () => {
|
|
230
|
+
await expect(outputTool({ task_id: "bt-none" })).rejects.toThrow(/No such task/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("P0-1: list merges only TERMINAL registry entries — running entries stay invisible (§3.5)", async () => {
|
|
234
|
+
// 独立 session 目录:单例表空 + registry 含 running 与 exited 两条他进程条目
|
|
235
|
+
// (模拟 resume 被强杀 session:reaper 转终态前的窗口里 registry 残留 running)
|
|
236
|
+
const sid = "sess-p0-list";
|
|
237
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, sid), makeRegistryEntry({ taskId: "bt-xrun", state: "running" }));
|
|
238
|
+
writeRegistryEntry(
|
|
239
|
+
getRegistryPath(DATA_DIR, sid),
|
|
240
|
+
makeRegistryEntry({ taskId: "bt-xdone", state: "exited", exitCode: 0, reason: "natural", endedAt: 2, durationMs: 1 }),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const listed = JSON.parse(await outputTool({}, sid)) as { tasks: Array<{ task_id: string; state: string }> };
|
|
244
|
+
const ids = listed.tasks.map((t) => t.task_id);
|
|
245
|
+
expect(ids).toContain("bt-xdone"); // 终态条目并入(查历史)
|
|
246
|
+
expect(ids).not.toContain("bt-xrun"); // running 条目不并入——无幻影 running 行
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("P0-1: detail registry fallback is terminal-only — cross-process running entry not queryable (§3.5)", async () => {
|
|
250
|
+
const sid = "sess-p0-detail";
|
|
251
|
+
const path = getRegistryPath(DATA_DIR, sid);
|
|
252
|
+
writeRegistryEntry(path, makeRegistryEntry({ taskId: "bt-xrun2", state: "running" }));
|
|
253
|
+
writeRegistryEntry(path, makeRegistryEntry({ taskId: "bt-xdone2", state: "exited", exitCode: 3 }));
|
|
254
|
+
|
|
255
|
+
// running 条目不可查(等价于本进程不存在该任务)
|
|
256
|
+
await expect(outputTool({ task_id: "bt-xrun2" }, sid)).rejects.toThrow(/No such task/);
|
|
257
|
+
// 终态条目可查(历史回落)
|
|
258
|
+
const detail = JSON.parse(await outputTool({ task_id: "bt-xdone2" }, sid)) as { state: string; exitCode: number };
|
|
259
|
+
expect(detail.state).toBe("exited");
|
|
260
|
+
expect(detail.exitCode).toBe(3);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("deleted output file degrades to <lost> without crashing (§3.6)", async () => {
|
|
264
|
+
const spawned = spawnBg("sleep 0.2 && echo gone");
|
|
265
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
266
|
+
const { task } = spawned;
|
|
267
|
+
await sleep(500);
|
|
268
|
+
pollTickForTest();
|
|
269
|
+
unlinkSync(task.outputFile);
|
|
270
|
+
const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as {
|
|
271
|
+
output: string;
|
|
272
|
+
state: string;
|
|
273
|
+
};
|
|
274
|
+
expect(detail.output).toBe("<lost>");
|
|
275
|
+
expect(detail.state).toBe("exited");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("output tail respects line cap", async () => {
|
|
279
|
+
// 2100 行输出 → tail 只保留最后 2000 行
|
|
280
|
+
const spawned = spawnBg("for i in $(seq 1 2100); do echo line$i; done");
|
|
281
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
282
|
+
const { task } = spawned;
|
|
283
|
+
await sleep(900);
|
|
284
|
+
pollTickForTest();
|
|
285
|
+
const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as {
|
|
286
|
+
output: string;
|
|
287
|
+
truncated: boolean;
|
|
288
|
+
};
|
|
289
|
+
const lines = detail.output.split("\n");
|
|
290
|
+
expect(lines.length).toBeLessThanOrEqual(2000);
|
|
291
|
+
expect(detail.truncated).toBe(true);
|
|
292
|
+
expect(detail.output).toContain("line2100");
|
|
293
|
+
expect(detail.output).not.toContain("line50\n");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
298
|
+
it("kill marks BOTH store and registry as killing before the poll edge lands", async () => {
|
|
299
|
+
const spawned = spawnBg("sleep 60");
|
|
300
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
301
|
+
const { task } = spawned;
|
|
302
|
+
|
|
303
|
+
const killResult = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
|
|
304
|
+
expect(killResult.killed).toBe(true);
|
|
305
|
+
|
|
306
|
+
// 轮询未收尾(sleep 60 才死、kill 后立即断言):两侧 killing 即可见,无倒挂
|
|
307
|
+
expect(getTask(task.taskId)?.state).toBe("killing");
|
|
308
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
|
|
309
|
+
// kill-tree 确实对该 pid 发过令
|
|
310
|
+
expect(killTreeCalls).toContain(task.pid);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("kill edge: intent consumed → exited with reason killed", async () => {
|
|
314
|
+
const spawned = spawnBg("sleep 60");
|
|
315
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
316
|
+
const { task } = spawned;
|
|
317
|
+
await killTool(task.taskId);
|
|
318
|
+
await sleep(500); // SIGKILL 生效
|
|
319
|
+
pollTickForTest();
|
|
320
|
+
const finalized = getTask(task.taskId);
|
|
321
|
+
expect(finalized?.state).toBe("exited");
|
|
322
|
+
expect(finalized?.reason).toBe("killed");
|
|
323
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.reason).toBe("killed");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("no such task → killed:false with hint", async () => {
|
|
327
|
+
const result = JSON.parse(await killTool("bt-ghost")) as {
|
|
328
|
+
killed: boolean;
|
|
329
|
+
reason: string;
|
|
330
|
+
hint: string;
|
|
331
|
+
};
|
|
332
|
+
expect(result.killed).toBe(false);
|
|
333
|
+
expect(result.reason).toBe("no such task");
|
|
334
|
+
expect(result.hint).toContain("bash_output");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("already exited task → killed:false with exit code", async () => {
|
|
338
|
+
const spawned = spawnBg("sleep 0.2");
|
|
339
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
340
|
+
const { task } = spawned;
|
|
341
|
+
await sleep(500);
|
|
342
|
+
pollTickForTest();
|
|
343
|
+
const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
|
|
344
|
+
expect(result.killed).toBe(false);
|
|
345
|
+
expect(result.reason).toBe("already exited (code 0)");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("P0-2.1: registry-only running entry (other pi process) → cross-process rejection, no kill signal", async () => {
|
|
349
|
+
// 独立 session 目录:registry-only running 条目 = 他进程任务(本进程活跃任务必在单例表)
|
|
350
|
+
const sid = "sess-p0-kill";
|
|
351
|
+
const foreignPid = process.pid; // 活进程 pid——若误走 kill 会误杀测试进程自身,同时断言不发生
|
|
352
|
+
writeRegistryEntry(
|
|
353
|
+
getRegistryPath(DATA_DIR, sid),
|
|
354
|
+
makeRegistryEntry({ taskId: "bt-foreign-run", state: "running", pid: foreignPid, sessionId: sid }),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const result = JSON.parse(await killTool("bt-foreign-run", sid)) as {
|
|
358
|
+
killed: boolean;
|
|
359
|
+
reason: string;
|
|
360
|
+
hint?: string;
|
|
361
|
+
};
|
|
362
|
+
expect(result.killed).toBe(false);
|
|
363
|
+
expect(result.reason).toBe("cross-process running task owned by another pi process");
|
|
364
|
+
expect(result.hint).toContain("reaper");
|
|
365
|
+
expect(killTreeCalls).not.toContain(foreignPid); // 未发 kill 信号
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("P0-2.1: registry terminal entry → already exited(回落限定终态,终态不可 kill)", async () => {
|
|
369
|
+
const sid = "sess-p0-kill";
|
|
370
|
+
writeRegistryEntry(
|
|
371
|
+
getRegistryPath(DATA_DIR, sid),
|
|
372
|
+
makeRegistryEntry({
|
|
373
|
+
taskId: "bt-foreign-done",
|
|
374
|
+
state: "orphaned",
|
|
375
|
+
sessionId: sid,
|
|
376
|
+
endedAt: 2,
|
|
377
|
+
durationMs: 1,
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
const result = JSON.parse(await killTool("bt-foreign-done", sid)) as { killed: boolean; reason: string };
|
|
381
|
+
expect(result.killed).toBe(false);
|
|
382
|
+
expect(result.reason).toBe("already exited");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("P0-2.2: store entry whose pid already died (poll edge not landed) → already exited style, no kill/intent", async () => {
|
|
386
|
+
const spawned = spawnBg("sleep 0.2");
|
|
387
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
388
|
+
const { task } = spawned;
|
|
389
|
+
await sleep(500); // 进程已死但不 tick——单例表仍 running
|
|
390
|
+
expect(getTask(task.taskId)?.state).toBe("running");
|
|
391
|
+
|
|
392
|
+
const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
|
|
393
|
+
expect(result.killed).toBe(false);
|
|
394
|
+
expect(result.reason).toContain("already exited");
|
|
395
|
+
expect(killTreeCalls).not.toContain(task.pid); // 死 pid 不发 kill
|
|
396
|
+
expect(getTask(task.taskId)?.state).toBe("running"); // 未标 killing intent(终态归轮询边沿)
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("P0-2.2: pidStartTime mismatch (pid reuse suspected) → refuse kill", async () => {
|
|
400
|
+
const spawned = spawnBg("sleep 30");
|
|
401
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
402
|
+
const { task } = spawned;
|
|
403
|
+
// 构造不匹配的登记值(真实进程 start time 之外的时间)——pid 活但身份不符
|
|
404
|
+
task.pidStartTime = (task.pidStartTime ?? 0) + 500;
|
|
405
|
+
|
|
406
|
+
const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string; hint?: string };
|
|
407
|
+
expect(result.killed).toBe(false);
|
|
408
|
+
expect(result.reason).toContain("pid reuse suspected");
|
|
409
|
+
expect(result.hint).toContain("manually");
|
|
410
|
+
expect(killTreeCalls).not.toContain(task.pid); // 宁不杀勿误杀
|
|
411
|
+
expect(getTask(task.taskId)?.state).toBe("running"); // 未标 intent
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("P0-2.2: matching pidStartTime → kill proceeds(start time 校验不误拦正常 kill)", async () => {
|
|
415
|
+
const spawned = spawnBg("sleep 30");
|
|
416
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
417
|
+
const { task } = spawned;
|
|
418
|
+
// spawn 时读到的真实 start time 原样保留 → 校验通过,正常 kill 路径
|
|
419
|
+
const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean };
|
|
420
|
+
expect(result.killed).toBe(true);
|
|
421
|
+
expect(killTreeCalls).toContain(task.pid);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("concurrency cap (D10, default 8)", () => {
|
|
426
|
+
it("rejects the 9th task with the oldest task id in the error", async () => {
|
|
427
|
+
const tasks: string[] = [];
|
|
428
|
+
for (let i = 0; i < DEFAULT_MAX_CONCURRENT_BACKGROUND; i++) {
|
|
429
|
+
const spawned = spawnBg("sleep 30");
|
|
430
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
431
|
+
tasks.push(spawned.task.taskId);
|
|
432
|
+
await sleep(20); // 错开 startedAt 保证「最老」判定稳定
|
|
433
|
+
}
|
|
434
|
+
const ninth = spawnBg("echo should-fail");
|
|
435
|
+
expect(ninth.ok).toBe(false);
|
|
436
|
+
if (!ninth.ok) {
|
|
437
|
+
expect(ninth.error).toContain(`max ${DEFAULT_MAX_CONCURRENT_BACKGROUND} concurrent`);
|
|
438
|
+
expect(ninth.error).toContain(tasks[0]); // 最老 task_id
|
|
439
|
+
expect(ninth.error).toContain("bash_kill");
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("P0-3: maxConcurrent=0 with zero active tasks is rejected (no silent pass, limit stays effective)", () => {
|
|
444
|
+
// 0 活跃 >= 0 上限 → oldestActiveTask() undefined——原实现静默放行,上限失效
|
|
445
|
+
const result = spawnBg("echo should-fail", { maxConcurrent: 0 });
|
|
446
|
+
expect(result.ok).toBe(false);
|
|
447
|
+
if (!result.ok) {
|
|
448
|
+
expect(result.error).toContain("concurrency limit configuration invalid");
|
|
449
|
+
expect(result.error).toContain("maxConcurrent=0");
|
|
450
|
+
expect(result.error).toContain("maxConcurrentBackground");
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe("explicit background timeout (D6)", () => {
|
|
456
|
+
it("fires kill-tree at the deadline and finalizes with reason timeout", async () => {
|
|
457
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
458
|
+
let taskId = "";
|
|
459
|
+
let pid = -1;
|
|
460
|
+
try {
|
|
461
|
+
// fake 生效后 spawn:timeout 定时器进 fake 队列,由 advanceTimers 推进
|
|
462
|
+
const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
|
|
463
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
464
|
+
taskId = spawned.task.taskId;
|
|
465
|
+
pid = spawned.task.pid;
|
|
466
|
+
|
|
467
|
+
vi.advanceTimersByTime(1000);
|
|
468
|
+
// 到点:kill-tree 已发令 + 两侧 killing intent(reason 候选 timeout)
|
|
469
|
+
expect(killTreeCalls).toContain(pid);
|
|
470
|
+
expect(getTask(taskId)?.state).toBe("killing");
|
|
471
|
+
expect(getTask(taskId)?.intent?.reason).toBe("timeout");
|
|
472
|
+
expect(readRegistry(REGISTRY_PATH).get(taskId)?.state).toBe("killing");
|
|
473
|
+
} finally {
|
|
474
|
+
vi.useRealTimers();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// SIGKILL 已真实发出:轮询边沿收尾 → exited(reason:"timeout"),终态由边沿写
|
|
478
|
+
await sleep(500);
|
|
479
|
+
pollTickForTest();
|
|
480
|
+
const finalized = getTask(taskId);
|
|
481
|
+
expect(finalized?.state).toBe("exited");
|
|
482
|
+
expect(finalized?.reason).toBe("timeout");
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("P0-4: pid reuse suspected at the deadline → skip kill, intent still marked", () => {
|
|
486
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
487
|
+
try {
|
|
488
|
+
const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
|
|
489
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
490
|
+
const { task } = spawned;
|
|
491
|
+
// 构造不匹配的登记值(真实进程 start time 之外的时间)——pid 活但身份不符,
|
|
492
|
+
// 同 bash_kill P0-2.2 篡改范式
|
|
493
|
+
task.pidStartTime = (task.pidStartTime ?? 0) + 500;
|
|
494
|
+
|
|
495
|
+
vi.advanceTimersByTime(1000);
|
|
496
|
+
// 宁不杀勿误杀:到点不对复用嫌疑 pid 发 kill(整进程组 SIGKILL 会误杀无辜进程)
|
|
497
|
+
expect(killTreeCalls).not.toContain(task.pid);
|
|
498
|
+
// 仅标 killing intent(reason timeout)——终态归轮询边沿/对账收尾
|
|
499
|
+
expect(getTask(task.taskId)?.state).toBe("killing");
|
|
500
|
+
expect(getTask(task.taskId)?.intent?.reason).toBe("timeout");
|
|
501
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
|
|
502
|
+
} finally {
|
|
503
|
+
vi.useRealTimers();
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("P0-4: missing pidStartTime degrades to startedAt check (mismatch → skip kill)", () => {
|
|
508
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
509
|
+
try {
|
|
510
|
+
const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
|
|
511
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
512
|
+
const { task } = spawned;
|
|
513
|
+
// 模拟 ps 不可用平台的 spawn(无 pidStartTime 字段)→ 降级 startedAt 秒级
|
|
514
|
+
// 比较;startedAt 篡改为 epoch 1(实际进程 start time 远大于 0)→ 不匹配
|
|
515
|
+
task.pidStartTime = undefined;
|
|
516
|
+
task.startedAt = 1;
|
|
517
|
+
|
|
518
|
+
vi.advanceTimersByTime(1000);
|
|
519
|
+
expect(killTreeCalls).not.toContain(task.pid);
|
|
520
|
+
expect(getTask(task.taskId)?.state).toBe("killing");
|
|
521
|
+
expect(getTask(task.taskId)?.intent?.reason).toBe("timeout");
|
|
522
|
+
} finally {
|
|
523
|
+
vi.useRealTimers();
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("P0-4: degradation match (recent startedAt) → kill proceeds(降级判据不误拦正常 timeout)", () => {
|
|
528
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
529
|
+
try {
|
|
530
|
+
const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
|
|
531
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
532
|
+
const { task } = spawned;
|
|
533
|
+
// 无 pidStartTime + 真实 startedAt(登记晚于 spawn → 原进程 start time 必然
|
|
534
|
+
// ≤ floor(startedAt/1000),floor 单调性)→ 降级判据匹配,正常发 kill
|
|
535
|
+
task.pidStartTime = undefined;
|
|
536
|
+
|
|
537
|
+
vi.advanceTimersByTime(1000);
|
|
538
|
+
expect(killTreeCalls).toContain(task.pid);
|
|
539
|
+
expect(getTask(task.taskId)?.state).toBe("killing");
|
|
540
|
+
} finally {
|
|
541
|
+
vi.useRealTimers();
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("natural completion before the deadline cancels the timer (no late kill)", async () => {
|
|
546
|
+
// 全程真实 timers:命令 0.2s 完成 < 1s 超时
|
|
547
|
+
const before = killTreeCalls.length;
|
|
548
|
+
const spawned = spawnBg("sleep 0.2 && echo quick", { timeoutSec: 1 });
|
|
549
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
550
|
+
const { task } = spawned;
|
|
551
|
+
|
|
552
|
+
await sleep(600); // 命令已退出(未到 1s deadline)
|
|
553
|
+
pollTickForTest();
|
|
554
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
555
|
+
expect(getTask(task.taskId)?.reason).toBe("natural");
|
|
556
|
+
|
|
557
|
+
// 越过 deadline 的时间窗内不得再补杀(终态化必须已清 timer)
|
|
558
|
+
await sleep(900);
|
|
559
|
+
expect(killTreeCalls.length).toBe(before);
|
|
560
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
describe("process-exit reap (D12)", () => {
|
|
565
|
+
it("reaps all active tasks with reason process-exit, kills pids, writes registry terminal", async () => {
|
|
566
|
+
const first = spawnBg("sleep 30");
|
|
567
|
+
const second = spawnBg("sleep 30");
|
|
568
|
+
if (!first.ok || !second.ok) throw new Error("spawn failed");
|
|
569
|
+
const pids = [first.task.pid, second.task.pid];
|
|
570
|
+
expect(pids.every((pid) => isPidAlive(pid))).toBe(true);
|
|
571
|
+
|
|
572
|
+
reapBackgroundTasksNow();
|
|
573
|
+
await sleep(300); // SIGKILL 发出到进程表移除是异步的
|
|
574
|
+
|
|
575
|
+
for (const pid of pids) {
|
|
576
|
+
expect(isPidAlive(pid)).toBe(false);
|
|
577
|
+
}
|
|
578
|
+
for (const t of [first.task, second.task]) {
|
|
579
|
+
const finalized = getTask(t.taskId);
|
|
580
|
+
expect(finalized?.state).toBe("exited");
|
|
581
|
+
expect(finalized?.reason).toBe("process-exit");
|
|
582
|
+
expect(readRegistry(REGISTRY_PATH).get(t.taskId)?.reason).toBe("process-exit");
|
|
583
|
+
}
|
|
584
|
+
expect(getActiveTasks()).toHaveLength(0);
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe("D15: abort signal does not propagate to background tasks", () => {
|
|
589
|
+
it("aborted execute-signal leaves the task running", async () => {
|
|
590
|
+
const controller = new AbortController();
|
|
591
|
+
const spawned = spawnBg("sleep 5");
|
|
592
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
593
|
+
const { task } = spawned;
|
|
594
|
+
// 模拟用户中断当前 turn:signal abort(后台分支不接触 signal,此处模拟上游已 abort)
|
|
595
|
+
controller.abort();
|
|
596
|
+
expect(controller.signal.aborted).toBe(true);
|
|
597
|
+
|
|
598
|
+
await sleep(300);
|
|
599
|
+
// 任务不受中断影响:进程活、状态 running
|
|
600
|
+
expect(isPidAlive(task.pid)).toBe(true);
|
|
601
|
+
expect(getTask(task.taskId)?.state).toBe("running");
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
describe("spawn failure paths (§3.6)", () => {
|
|
606
|
+
it("nonexistent cwd reports the builtin-style error", () => {
|
|
607
|
+
const result = spawnBackgroundTask({
|
|
608
|
+
command: "echo hi",
|
|
609
|
+
cwd: "/definitely/not/exist/dir",
|
|
610
|
+
dataDir: DATA_DIR,
|
|
611
|
+
sessionId: SESSION_ID,
|
|
612
|
+
});
|
|
613
|
+
expect(result.ok).toBe(false);
|
|
614
|
+
if (!result.ok) {
|
|
615
|
+
expect(result.error).toContain("Working directory does not exist");
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("missing shell reports a spawn failure error", () => {
|
|
620
|
+
// 通过临时 SHELL 指向不存在路径模拟 shell 缺失(POSIX 分支)
|
|
621
|
+
const original = process.env.SHELL;
|
|
622
|
+
process.env.SHELL = "/no/such/shell-binary";
|
|
623
|
+
try {
|
|
624
|
+
const result = spawnBg("echo hi");
|
|
625
|
+
expect(result.ok).toBe(false);
|
|
626
|
+
if (!result.ok) {
|
|
627
|
+
expect(result.error).toContain("Failed to start background command");
|
|
628
|
+
}
|
|
629
|
+
} finally {
|
|
630
|
+
if (original === undefined) delete process.env.SHELL;
|
|
631
|
+
else process.env.SHELL = original;
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
});
|