@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.
Files changed (35) hide show
  1. package/README.md +31 -0
  2. package/index.ts +1 -0
  3. package/package.json +54 -0
  4. package/skills/base-tool-enhance-ext-config/SKILL.md +76 -0
  5. package/src/__tests__/background-lifecycle.test.ts +634 -0
  6. package/src/__tests__/bash-tool.test.ts +573 -0
  7. package/src/__tests__/config.test.ts +193 -0
  8. package/src/__tests__/force-patterns.test.ts +230 -0
  9. package/src/__tests__/index.test.ts +133 -0
  10. package/src/__tests__/kill-tree.test.ts +76 -0
  11. package/src/__tests__/notify.test.ts +335 -0
  12. package/src/__tests__/pending-reconcile.test.ts +237 -0
  13. package/src/__tests__/reaper.test.ts +373 -0
  14. package/src/__tests__/registry.test.ts +149 -0
  15. package/src/__tests__/task-store.test.ts +156 -0
  16. package/src/__tests__/tool-error-audit.test.ts +92 -0
  17. package/src/background/notify.ts +218 -0
  18. package/src/background/output-tail.ts +84 -0
  19. package/src/background/pending-reconcile.ts +169 -0
  20. package/src/background/poller.ts +91 -0
  21. package/src/background/process-exit-guard.ts +106 -0
  22. package/src/background/registry.ts +203 -0
  23. package/src/background/spawn-background.ts +275 -0
  24. package/src/background/subagent-guard.ts +21 -0
  25. package/src/background/task-store.ts +125 -0
  26. package/src/background/types.ts +103 -0
  27. package/src/bash-kill-tool.ts +144 -0
  28. package/src/bash-output-tool.ts +131 -0
  29. package/src/bash-tool.ts +226 -0
  30. package/src/config.ts +167 -0
  31. package/src/force-patterns.ts +236 -0
  32. package/src/index.ts +90 -0
  33. package/src/kill-tree.ts +100 -0
  34. package/src/reaper.ts +313 -0
  35. package/src/tool-error-audit.ts +78 -0
@@ -0,0 +1,373 @@
1
+ // src/__tests__/reaper.test.ts —— M5 reaper 孤儿收殓:三分支判定(属主活跳过 /
2
+ // 属主死+任务活补杀 / 属主死+任务死终态收尾)+ pid 复用防御(start-time 匹配)+
3
+ // 保守跳过路径 + file-lock 并发串行化 + 损坏目录容忍 + 多目录扫描 + 幂等。
4
+ // 全程真进程(detached spawn——与生产 spawn 路径同构:pgid=pid,kill-tree 杀
5
+ // 进程组不会波及测试进程组),无 mock getAgentDir(reaper 直收 dataDir 参数)。
6
+ // 每用例独立 dataDir:残留 running 条目(afterEach 杀掉的进程)不污染后续用例
7
+ // 的分支计数断言。
8
+ import { spawn, type ChildProcess } from "node:child_process";
9
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+
15
+ vi.setConfig({ testTimeout: 20000 });
16
+
17
+ import { getRegistryPath, readRegistry, writeRegistryEntry } from "../background/registry.ts";
18
+ import type { RegistryEntry } from "../background/types.ts";
19
+ import { isPidAlive } from "../kill-tree.ts";
20
+ import { getProcessStartTimeSec, reapOrphanedTasks, type RegistryEntryStartTime } from "../reaper.ts";
21
+
22
+ let dataDir: string;
23
+
24
+ /** 本测试 spawn 的全部进程(owner / task),afterEach 统一杀进程组清理。 */
25
+ const spawnedPids: number[] = [];
26
+
27
+ /** detached spawn(pgid=pid):kill-tree 杀 -pid 精确命中,不波及测试进程组。 */
28
+ function spawnDetached(command: string, args: string[]): ChildProcess {
29
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
30
+ child.unref();
31
+ if (child.pid === undefined) throw new Error(`spawn ${command} failed: no pid`);
32
+ spawnedPids.push(child.pid);
33
+ return child;
34
+ }
35
+
36
+ function sleep(ms: number): Promise<void> {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+
40
+ /** 轮询等进程退出(测试里构造「已死属主 / 已死任务」用)。 */
41
+ async function waitProcessExit(pid: number, timeoutMs = 10000): Promise<void> {
42
+ const deadline = Date.now() + timeoutMs;
43
+ while (isPidAlive(pid)) {
44
+ if (Date.now() > deadline) throw new Error(`process ${pid} did not exit within ${timeoutMs}ms`);
45
+ await sleep(50);
46
+ }
47
+ }
48
+
49
+ type EntryOverrides = Partial<RegistryEntry> & Partial<RegistryEntryStartTime> & {
50
+ pid: number;
51
+ ownerPiPid: number;
52
+ sessionId: string;
53
+ };
54
+
55
+ /** 手写 registry 条目(走真实 writeRegistryEntry——锁内 RMW + 原子写路径)。 */
56
+ function writeTestEntry(overrides: EntryOverrides): string {
57
+ const taskId = overrides.taskId ?? `bt-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
58
+ const entry: RegistryEntry & RegistryEntryStartTime = {
59
+ taskId,
60
+ pid: overrides.pid,
61
+ command: overrides.command ?? "sleep 60",
62
+ outputFile: overrides.outputFile ?? join(dataDir, "unused.log"),
63
+ startedAt: overrides.startedAt ?? Date.now(),
64
+ state: overrides.state ?? "running",
65
+ ownerPiPid: overrides.ownerPiPid,
66
+ sessionId: overrides.sessionId,
67
+ ...(overrides.exitCode !== undefined ? { exitCode: overrides.exitCode } : {}),
68
+ ...(overrides.pidStartTime !== undefined ? { pidStartTime: overrides.pidStartTime } : {}),
69
+ };
70
+ const written = writeRegistryEntry(getRegistryPath(dataDir, overrides.sessionId), entry);
71
+ if (!written.success) throw new Error(written.error);
72
+ return taskId;
73
+ }
74
+
75
+ function entryState(sessionId: string, taskId: string): string | undefined {
76
+ return readRegistry(getRegistryPath(dataDir, sessionId)).get(taskId)?.state;
77
+ }
78
+
79
+ beforeEach(() => {
80
+ dataDir = mkdtempSync(join(tmpdir(), "bte-reaper-"));
81
+ });
82
+
83
+ afterEach(() => {
84
+ // 杀干净全部探针进程(进程组优先,防 detached 子进程泄漏影响后续用例)
85
+ for (const pid of spawnedPids) {
86
+ if (!isPidAlive(pid)) continue;
87
+ try {
88
+ process.kill(-pid, "SIGKILL");
89
+ } catch {
90
+ try {
91
+ process.kill(pid, "SIGKILL");
92
+ } catch {
93
+ // already dead
94
+ }
95
+ }
96
+ }
97
+ spawnedPids.length = 0;
98
+ });
99
+
100
+ describe("getProcessStartTimeSec (platform probe sanity)", () => {
101
+ it("returns a sane epoch-seconds value for a live process", () => {
102
+ const sec = getProcessStartTimeSec(process.pid);
103
+ expect(sec).toBeDefined();
104
+ const nowSec = Math.floor(Date.now() / 1000);
105
+ // 本进程必然启动于 [now - 10min, now + 5s](时钟容差)
106
+ expect(sec!).toBeGreaterThan(nowSec - 600);
107
+ expect(sec!).toBeLessThan(nowSec + 5);
108
+ });
109
+
110
+ it("returns undefined for a dead pid", async () => {
111
+ const short = spawnDetached("sleep", ["0.1"]);
112
+ await waitProcessExit(short.pid!);
113
+ expect(getProcessStartTimeSec(short.pid!)).toBeUndefined();
114
+ });
115
+ });
116
+
117
+ describe("branch ①: owner alive → skip (S8-B no-false-kill)", () => {
118
+ it("skips entries whose owner process is alive; task and owner both survive", async () => {
119
+ const owner = spawnDetached("sleep", ["30"]);
120
+ const task = spawnDetached("sleep", ["30"]);
121
+ const taskId = writeTestEntry({ pid: task.pid!, ownerPiPid: owner.pid!, sessionId: "s-owner-alive" });
122
+
123
+ const result = await reapOrphanedTasks(dataDir);
124
+
125
+ expect(result.ownerAliveSkipped).toBe(1);
126
+ expect(result.killedOrphans).toBe(0);
127
+ expect(result.finalizedOrphans).toBe(0);
128
+ // 防误杀断言:任务进程仍存活(reaper 不介入属主存活的任务)
129
+ expect(isPidAlive(task.pid!)).toBe(true);
130
+ expect(isPidAlive(owner.pid!)).toBe(true);
131
+ expect(entryState("s-owner-alive", taskId)).toBe("running");
132
+ });
133
+
134
+ it("treats ownerPiPid === current process pid as owner-alive (defensive)", async () => {
135
+ const task = spawnDetached("sleep", ["30"]);
136
+ const taskId = writeTestEntry({ pid: task.pid!, ownerPiPid: process.pid, sessionId: "s-self-owner" });
137
+
138
+ const result = await reapOrphanedTasks(dataDir);
139
+
140
+ expect(result.ownerAliveSkipped).toBe(1);
141
+ expect(isPidAlive(task.pid!)).toBe(true);
142
+ expect(entryState("s-self-owner", taskId)).toBe("running");
143
+ });
144
+
145
+ it("skips killing-intent entries while owner alive (owner's own poller finalizes)", async () => {
146
+ const owner = spawnDetached("sleep", ["30"]);
147
+ const task = spawnDetached("sleep", ["30"]);
148
+ const taskId = writeTestEntry({
149
+ pid: task.pid!,
150
+ ownerPiPid: owner.pid!,
151
+ sessionId: "s-killing-owner-alive",
152
+ state: "killing",
153
+ });
154
+
155
+ const result = await reapOrphanedTasks(dataDir);
156
+
157
+ expect(result.ownerAliveSkipped).toBe(1);
158
+ expect(isPidAlive(task.pid!)).toBe(true);
159
+ expect(entryState("s-killing-owner-alive", taskId)).toBe("killing");
160
+ });
161
+ });
162
+
163
+ describe("branch ②: owner dead + task alive → kill-tree + orphaned (S5)", () => {
164
+ it("kills the orphan task and marks the entry orphaned", async () => {
165
+ const owner = spawnDetached("sleep", ["0.1"]); // 短命属主
166
+ const task = spawnDetached("sleep", ["60"]); // 活孤儿任务
167
+ await waitProcessExit(owner.pid!);
168
+ // startedAt = 写条目时刻(晚于任务 spawn)→ 降级校验 actual <= floor(startedAt) 满足
169
+ const taskId = writeTestEntry({ pid: task.pid!, ownerPiPid: owner.pid!, sessionId: "s-orphan-kill" });
170
+
171
+ const result = await reapOrphanedTasks(dataDir);
172
+
173
+ expect(result.killedOrphans).toBe(1);
174
+ await sleep(300); // SIGKILL 发出到进程表移除是异步的
175
+ expect(isPidAlive(task.pid!)).toBe(false);
176
+ expect(entryState("s-orphan-kill", taskId)).toBe("orphaned");
177
+ });
178
+
179
+ it("reaps killing-intent entries the same way when owner is dead", async () => {
180
+ const owner = spawnDetached("sleep", ["0.1"]);
181
+ const task = spawnDetached("sleep", ["60"]);
182
+ await waitProcessExit(owner.pid!);
183
+ const taskId = writeTestEntry({
184
+ pid: task.pid!,
185
+ ownerPiPid: owner.pid!,
186
+ sessionId: "s-killing-orphan",
187
+ state: "killing",
188
+ });
189
+
190
+ const result = await reapOrphanedTasks(dataDir);
191
+
192
+ expect(result.killedOrphans).toBe(1);
193
+ await sleep(300);
194
+ expect(isPidAlive(task.pid!)).toBe(false);
195
+ expect(entryState("s-killing-orphan", taskId)).toBe("orphaned");
196
+ });
197
+
198
+ it("precise pidStartTime field match authorizes the kill (exact comparison path)", async () => {
199
+ const owner = spawnDetached("sleep", ["0.1"]);
200
+ const task = spawnDetached("sleep", ["60"]);
201
+ await waitProcessExit(owner.pid!);
202
+ const startSec = getProcessStartTimeSec(task.pid!);
203
+ expect(startSec).toBeDefined();
204
+ const taskId = writeTestEntry({
205
+ pid: task.pid!,
206
+ ownerPiPid: owner.pid!,
207
+ sessionId: "s-exact-match",
208
+ pidStartTime: startSec,
209
+ });
210
+
211
+ const result = await reapOrphanedTasks(dataDir);
212
+
213
+ expect(result.killedOrphans).toBe(1);
214
+ await sleep(300);
215
+ expect(isPidAlive(task.pid!)).toBe(false);
216
+ expect(entryState("s-exact-match", taskId)).toBe("orphaned");
217
+ });
218
+ });
219
+
220
+ describe("branch ③: owner dead + task dead → terminal-only (no kill)", () => {
221
+ it("marks the stale running entry orphaned without any kill", async () => {
222
+ const owner = spawnDetached("sleep", ["0.1"]);
223
+ const task = spawnDetached("sleep", ["0.1"]);
224
+ await waitProcessExit(owner.pid!);
225
+ await waitProcessExit(task.pid!);
226
+ const taskId = writeTestEntry({ pid: task.pid!, ownerPiPid: owner.pid!, sessionId: "s-terminal-only" });
227
+
228
+ const result = await reapOrphanedTasks(dataDir);
229
+
230
+ expect(result.finalizedOrphans).toBe(1);
231
+ expect(result.killedOrphans).toBe(0); // 不补杀
232
+ expect(result.conservativelySkipped).toBe(0);
233
+ expect(entryState("s-terminal-only", taskId)).toBe("orphaned");
234
+ });
235
+ });
236
+
237
+ describe("pid-reuse defense (§3.6 start-time verification)", () => {
238
+ it("does NOT kill when actual start time is newer than the entry (pid reuse suspicion)", async () => {
239
+ const owner = spawnDetached("sleep", ["0.1"]);
240
+ const task = spawnDetached("sleep", ["60"]);
241
+ await waitProcessExit(owner.pid!);
242
+ // startedAt 拨回很久以前(epoch 早期):活任务进程 start time 必然晚于它
243
+ // → 降级判据 actual > floor(startedAt) → 复用嫌疑 → 不杀
244
+ const taskId = writeTestEntry({
245
+ pid: task.pid!,
246
+ ownerPiPid: owner.pid!,
247
+ sessionId: "s-reuse-degraded",
248
+ startedAt: 1000,
249
+ });
250
+
251
+ const result = await reapOrphanedTasks(dataDir);
252
+
253
+ expect(result.conservativelySkipped).toBe(1);
254
+ expect(result.killedOrphans).toBe(0);
255
+ expect(isPidAlive(task.pid!)).toBe(true); // 无辜进程未被误杀
256
+ expect(entryState("s-reuse-degraded", taskId)).toBe("running"); // 不转终态,交下一周期
257
+ });
258
+
259
+ it("does NOT kill when precise pidStartTime mismatches the live process", async () => {
260
+ const owner = spawnDetached("sleep", ["0.1"]);
261
+ const task = spawnDetached("sleep", ["60"]);
262
+ await waitProcessExit(owner.pid!);
263
+ const taskId = writeTestEntry({
264
+ pid: task.pid!,
265
+ ownerPiPid: owner.pid!,
266
+ sessionId: "s-reuse-exact",
267
+ pidStartTime: 1, // 1970 年:与实际进程 start time 必然不等 → 复用嫌疑
268
+ });
269
+
270
+ const result = await reapOrphanedTasks(dataDir);
271
+
272
+ expect(result.conservativelySkipped).toBe(1);
273
+ expect(isPidAlive(task.pid!)).toBe(true);
274
+ expect(entryState("s-reuse-exact", taskId)).toBe("running");
275
+ });
276
+
277
+ it("conservatively skips the whole disposal when start time is unreadable (platform without ps)", async () => {
278
+ const owner = spawnDetached("sleep", ["0.1"]);
279
+ const task = spawnDetached("sleep", ["60"]);
280
+ await waitProcessExit(owner.pid!);
281
+ const taskId = writeTestEntry({ pid: task.pid!, ownerPiPid: owner.pid!, sessionId: "s-no-starttime" });
282
+
283
+ // 注入「平台取不到 start time」:不补杀也不转终态(宁延迟勿误杀)
284
+ const result = await reapOrphanedTasks(dataDir, { getProcessStartTimeSec: () => undefined });
285
+
286
+ expect(result.conservativelySkipped).toBe(1);
287
+ expect(result.killedOrphans).toBe(0);
288
+ expect(result.finalizedOrphans).toBe(0);
289
+ expect(isPidAlive(task.pid!)).toBe(true);
290
+ expect(entryState("s-no-starttime", taskId)).toBe("running");
291
+ });
292
+ });
293
+
294
+ describe("file-lock serialization (concurrent reapers stay idempotent)", () => {
295
+ it("two concurrent reaper runs dispose each orphan exactly once", async () => {
296
+ const owner = spawnDetached("sleep", ["0.1"]);
297
+ const task = spawnDetached("sleep", ["60"]);
298
+ await waitProcessExit(owner.pid!);
299
+ const taskId = writeTestEntry({ pid: task.pid!, ownerPiPid: owner.pid!, sessionId: "s-concurrent" });
300
+
301
+ const [first, second] = await Promise.all([
302
+ reapOrphanedTasks(dataDir),
303
+ reapOrphanedTasks(dataDir),
304
+ ]);
305
+
306
+ // 锁串行化 + 终态跳过:合计恰好处置一次(后进锁者见 orphaned 终态 no-op)
307
+ expect(first.killedOrphans + second.killedOrphans).toBe(1);
308
+ expect(first.conservativelySkipped + second.conservativelySkipped).toBe(0);
309
+ await sleep(300);
310
+ expect(isPidAlive(task.pid!)).toBe(false);
311
+ expect(entryState("s-concurrent", taskId)).toBe("orphaned");
312
+
313
+ // 幂等:第三遍扫描对已终态条目完全 no-op
314
+ const third = await reapOrphanedTasks(dataDir);
315
+ expect(third.killedOrphans).toBe(0);
316
+ expect(third.finalizedOrphans).toBe(0);
317
+ expect(third.ownerAliveSkipped).toBe(0);
318
+ expect(third.conservativelySkipped).toBe(0);
319
+ });
320
+ });
321
+
322
+ describe("scan robustness (corrupt dir tolerance + multi-dir)", () => {
323
+ it("skips a corrupt registry dir without breaking the scan of other dirs", async () => {
324
+ // 目录 A:损坏 registry.json(非法 JSON——readRegistry rename .corrupt + 空表重建)
325
+ const corruptDir = join(dataDir, "base-tool-enhance", "s-corrupt");
326
+ mkdirSync(corruptDir, { recursive: true });
327
+ writeFileSync(join(corruptDir, "registry.json"), "{ not valid json !!", "utf8");
328
+ // 目录 B:正常孤儿(分支②)
329
+ const owner = spawnDetached("sleep", ["0.1"]);
330
+ const task = spawnDetached("sleep", ["60"]);
331
+ await waitProcessExit(owner.pid!);
332
+ const taskId = writeTestEntry({ pid: task.pid!, ownerPiPid: owner.pid!, sessionId: "s-healthy-next-door" });
333
+
334
+ const result = await reapOrphanedTasks(dataDir);
335
+
336
+ expect(result.scannedDirs).toBeGreaterThanOrEqual(2);
337
+ expect(result.killedOrphans).toBe(1); // B 目录孤儿照常处置,A 目录损坏不中断
338
+ expect(entryState("s-healthy-next-door", taskId)).toBe("orphaned");
339
+ });
340
+
341
+ it("scans multiple session dirs and applies per-entry branches independently", async () => {
342
+ // 目录 A:属主活条目(跳过);目录 B:孤儿(补杀);目录 C:终态遗留(收尾)
343
+ const aliveOwner = spawnDetached("sleep", ["30"]);
344
+ const aliveTask = spawnDetached("sleep", ["30"]);
345
+ writeTestEntry({ pid: aliveTask.pid!, ownerPiPid: aliveOwner.pid!, sessionId: "s-multi-a" });
346
+
347
+ const deadOwner = spawnDetached("sleep", ["0.1"]);
348
+ const orphanTask = spawnDetached("sleep", ["60"]);
349
+ await waitProcessExit(deadOwner.pid!);
350
+ const orphanId = writeTestEntry({ pid: orphanTask.pid!, ownerPiPid: deadOwner.pid!, sessionId: "s-multi-b" });
351
+
352
+ const finOwner = spawnDetached("sleep", ["0.1"]);
353
+ const finTask = spawnDetached("sleep", ["0.1"]);
354
+ await waitProcessExit(finOwner.pid!);
355
+ await waitProcessExit(finTask.pid!);
356
+ const finId = writeTestEntry({ pid: finTask.pid!, ownerPiPid: finOwner.pid!, sessionId: "s-multi-c" });
357
+
358
+ const result = await reapOrphanedTasks(dataDir);
359
+
360
+ expect(result.ownerAliveSkipped).toBe(1);
361
+ expect(result.killedOrphans).toBe(1);
362
+ expect(result.finalizedOrphans).toBe(1);
363
+ expect(isPidAlive(aliveTask.pid!)).toBe(true); // 属主活的任务毫发无损
364
+ expect(entryState("s-multi-b", orphanId)).toBe("orphaned");
365
+ expect(entryState("s-multi-c", finId)).toBe("orphaned");
366
+ });
367
+
368
+ it("handles a missing base dir gracefully (no background tasks ever created)", async () => {
369
+ const result = await reapOrphanedTasks(join(dataDir, "never-exists"));
370
+ expect(result.scannedDirs).toBe(0);
371
+ expect(result.killedOrphans).toBe(0);
372
+ });
373
+ });
@@ -0,0 +1,149 @@
1
+ // src/__tests__/registry.test.ts —— registry 持久化白盒:原子写 / 损坏防御 / LRU / 条目剥离
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+
8
+ import {
9
+ getRegistryPath,
10
+ MAX_TERMINAL_REGISTRY_ENTRIES,
11
+ readRegistry,
12
+ taskToRegistryEntry,
13
+ writeRegistryEntry,
14
+ } from "../background/registry.ts";
15
+ import type { BackgroundTask } from "../background/types.ts";
16
+
17
+ let tmpRoot = "";
18
+
19
+ function freshRegistryPath(): string {
20
+ tmpRoot = mkdtempSync(join(tmpdir(), "bte-registry-"));
21
+ return getRegistryPath(tmpRoot, "session-test");
22
+ }
23
+
24
+ function makeEntry(overrides: Partial<BackgroundTask> = {}) {
25
+ const task: BackgroundTask = {
26
+ taskId: "bt-1000-abcd",
27
+ pid: 111,
28
+ command: "echo hi",
29
+ outputFile: join(tmpRoot || tmpdir(), "bt-1000-abcd.log"),
30
+ registryPath: "/tmp/unused-registry.json",
31
+ startedAt: 1000,
32
+ state: "running",
33
+ ownerPiPid: 222,
34
+ sessionId: "session-test",
35
+ // 运行时字段:taskToRegistryEntry 必须剥离(intent/timeoutTimer/child/registryPath)
36
+ intent: { reason: "killed", at: 1234 },
37
+ child: undefined,
38
+ ...overrides,
39
+ };
40
+ return task;
41
+ }
42
+
43
+ afterEach(() => {
44
+ tmpRoot = "";
45
+ });
46
+
47
+ describe("writeRegistryEntry / readRegistry roundtrip", () => {
48
+ it("creates the registry file with a valid entry and no tmp residue", () => {
49
+ const path = freshRegistryPath();
50
+ const result = writeRegistryEntry(path, taskToRegistryEntry(makeEntry()));
51
+ expect(result.success).toBe(true);
52
+ expect(existsSync(path)).toBe(true);
53
+ // 原子写不留 tmp 中间文件
54
+ const residues = readdirSync(join(path, "..")).filter((f) => f.includes(".tmp_"));
55
+ expect(residues).toEqual([]);
56
+
57
+ const map = readRegistry(path);
58
+ expect(map.get("bt-1000-abcd")?.pid).toBe(111);
59
+ expect(map.get("bt-1000-abcd")?.ownerPiPid).toBe(222);
60
+ });
61
+
62
+ it("serialized entry strips runtime-only fields (intent/child/registryPath)", () => {
63
+ const path = freshRegistryPath();
64
+ writeRegistryEntry(path, taskToRegistryEntry(makeEntry()));
65
+ const raw = JSON.parse(readFileSync(path, "utf8")) as {
66
+ version: number;
67
+ entries: Array<Record<string, unknown>>;
68
+ };
69
+ expect(raw.version).toBe(1);
70
+ const entry = raw.entries[0];
71
+ expect(entry.intent).toBeUndefined();
72
+ expect(entry.child).toBeUndefined();
73
+ expect(entry.registryPath).toBeUndefined();
74
+ });
75
+
76
+ it("same task_id merges as update (not duplicate)", () => {
77
+ const path = freshRegistryPath();
78
+ writeRegistryEntry(path, taskToRegistryEntry(makeEntry()));
79
+ writeRegistryEntry(
80
+ path,
81
+ taskToRegistryEntry(
82
+ makeEntry({ state: "exited", exitCode: 0, reason: "natural", endedAt: 2000, durationMs: 1000 }),
83
+ ),
84
+ );
85
+ const map = readRegistry(path);
86
+ expect(map.size).toBe(1);
87
+ expect(map.get("bt-1000-abcd")?.state).toBe("exited");
88
+ });
89
+ });
90
+
91
+ describe("corruption defense (§3.6)", () => {
92
+ it("bad JSON → renamed .corrupt + empty table rebuild + no crash", () => {
93
+ const path = freshRegistryPath();
94
+ writeRegistryEntry(path, taskToRegistryEntry(makeEntry()));
95
+ // 外力写坏(半程写/手编坏 JSON)
96
+ writeFileSync(path, '{"version":1,"entries":[{ broken', "utf8");
97
+
98
+ const map = readRegistry(path);
99
+ expect(map.size).toBe(0);
100
+ expect(existsSync(`${path}.corrupt`)).toBe(true);
101
+ // 现场保留:.corrupt 里是坏内容本身
102
+ expect(readFileSync(`${path}.corrupt`, "utf8")).toContain("broken");
103
+ // 重建可写:下一次写入恢复工作
104
+ writeRegistryEntry(path, taskToRegistryEntry(makeEntry()));
105
+ expect(readRegistry(path).size).toBe(1);
106
+ });
107
+
108
+ it("shape-invalid content (entries not array) → same corrupt path", () => {
109
+ const path = freshRegistryPath();
110
+ mkdirSync(join(path, ".."), { recursive: true });
111
+ writeFileSync(path, '{"version":1,"entries":"not-an-array"}', "utf8");
112
+ const map = readRegistry(path);
113
+ expect(map.size).toBe(0);
114
+ expect(existsSync(`${path}.corrupt`)).toBe(true);
115
+ });
116
+
117
+ it("missing file → empty table (first spawn before any write)", () => {
118
+ const path = freshRegistryPath();
119
+ expect(readRegistry(path).size).toBe(0);
120
+ });
121
+ });
122
+
123
+ describe("terminal LRU (cap 50, symmetric with task store)", () => {
124
+ it("keeps newest 50 terminal entries, evicts oldest overflow", () => {
125
+ const path = freshRegistryPath();
126
+ const TOTAL = MAX_TERMINAL_REGISTRY_ENTRIES + 5;
127
+ for (let i = 0; i < TOTAL; i++) {
128
+ writeRegistryEntry(
129
+ path,
130
+ taskToRegistryEntry(
131
+ makeEntry({
132
+ taskId: `bt-${1000 + i}-aaaa`,
133
+ startedAt: 1000 + i,
134
+ state: "exited",
135
+ endedAt: 2000 + i,
136
+ }),
137
+ ),
138
+ );
139
+ }
140
+ const map = readRegistry(path);
141
+ expect(map.size).toBe(MAX_TERMINAL_REGISTRY_ENTRIES);
142
+ expect(map.has("bt-1000-aaaa")).toBe(false);
143
+ expect(map.has("bt-1001-aaaa")).toBe(false);
144
+ expect(map.has("bt-1002-aaaa")).toBe(false);
145
+ expect(map.has("bt-1003-aaaa")).toBe(false);
146
+ expect(map.has("bt-1004-aaaa")).toBe(false);
147
+ expect(map.has(`bt-${1000 + TOTAL - 1}-aaaa`)).toBe(true);
148
+ });
149
+ });
@@ -0,0 +1,156 @@
1
+ // src/__tests__/task-store.test.ts —— 单例任务表白盒:task_id 唯一性 / 状态机 / LRU
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+
8
+ import { generateTaskId } from "../background/spawn-background.ts";
9
+ import {
10
+ clearTaskStoreForTest,
11
+ finalizeTask,
12
+ MAX_TERMINAL_TASKS,
13
+ markKillingIntent,
14
+ registerSpawnedTask,
15
+ getAllTasks,
16
+ getActiveTasks,
17
+ getTask,
18
+ } from "../background/task-store.ts";
19
+ import type { BackgroundTask } from "../background/types.ts";
20
+
21
+ function makeTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
22
+ return {
23
+ taskId: overrides.taskId ?? generateTaskId(),
24
+ pid: overrides.pid ?? 424242,
25
+ command: overrides.command ?? "sleep 1",
26
+ outputFile: overrides.outputFile ?? join(mkdtempSync(join(tmpdir(), "bte-")), "out.log"),
27
+ registryPath: overrides.registryPath ?? "/tmp/registry.json",
28
+ startedAt: overrides.startedAt ?? Date.now(),
29
+ state: overrides.state ?? "running",
30
+ ownerPiPid: overrides.ownerPiPid ?? process.pid,
31
+ sessionId: overrides.sessionId ?? "session-a",
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ afterEach(() => {
37
+ clearTaskStoreForTest();
38
+ });
39
+
40
+ describe("generateTaskId", () => {
41
+ it("uses the bt- prefix with <ts>-<rand> shape", () => {
42
+ const id = generateTaskId(1_724_589_012_000);
43
+ expect(id).toMatch(/^bt-1724589012000-[a-z0-9]{6}$/);
44
+ });
45
+
46
+ it("generates unique ids across a burst (no process-local sequence)", () => {
47
+ const ids = new Set<string>();
48
+ const COUNT = 1000;
49
+ for (let i = 0; i < COUNT; i++) {
50
+ const id = generateTaskId();
51
+ ids.add(id);
52
+ }
53
+ // 连续生成无碰撞:自增序列在同 ts 也不碰撞是随机段职责(禁自增,§2.3)
54
+ expect(ids.size).toBe(COUNT);
55
+ });
56
+
57
+ it("uniqueness holds even with identical timestamps (rand segment disambiguates)", () => {
58
+ const fixedTs = 1_724_589_012_999;
59
+ const ids = new Set<string>();
60
+ const COUNT = 500;
61
+ for (let i = 0; i < COUNT; i++) {
62
+ ids.add(generateTaskId(fixedTs));
63
+ }
64
+ expect(ids.size).toBe(COUNT);
65
+ });
66
+ });
67
+
68
+ describe("task store state machine", () => {
69
+ it("register → get → active listing", () => {
70
+ const task = makeTask();
71
+ registerSpawnedTask(task);
72
+ expect(getTask(task.taskId)?.pid).toBe(task.pid);
73
+ expect(getActiveTasks().map((t) => t.taskId)).toEqual([task.taskId]);
74
+ });
75
+
76
+ it("markKillingIntent transitions running → killing and records intent", () => {
77
+ const task = makeTask();
78
+ registerSpawnedTask(task);
79
+ const marked = markKillingIntent(task.taskId, "killed");
80
+ expect(marked?.state).toBe("killing");
81
+ expect(marked?.intent).toEqual({ reason: "killed", at: expect.any(Number) });
82
+ // killing 仍属活跃态(轮询器监护对象)
83
+ expect(getActiveTasks().map((t) => t.taskId)).toEqual([task.taskId]);
84
+ });
85
+
86
+ it("markKillingIntent on terminal task is a no-op (undefined)", () => {
87
+ const task = makeTask();
88
+ registerSpawnedTask(task);
89
+ finalizeTask(task.taskId, { exitCode: 0, reason: "natural", endedAt: Date.now() });
90
+ expect(markKillingIntent(task.taskId, "killed")).toBeUndefined();
91
+ });
92
+
93
+ it("finalizeTask writes terminal fields, consumes intent, computes duration", () => {
94
+ const task = makeTask({ startedAt: Date.now() - 1500 });
95
+ registerSpawnedTask(task);
96
+ markKillingIntent(task.taskId, "timeout");
97
+ const finalized = finalizeTask(task.taskId, {
98
+ exitCode: null,
99
+ reason: "timeout",
100
+ endedAt: Date.now(),
101
+ });
102
+ expect(finalized?.state).toBe("exited");
103
+ expect(finalized?.reason).toBe("timeout");
104
+ expect(finalized?.exitCode).toBeNull();
105
+ expect(finalized?.intent).toBeUndefined();
106
+ expect(finalized?.durationMs).toBeGreaterThanOrEqual(1500);
107
+ expect(getActiveTasks()).toHaveLength(0);
108
+ });
109
+
110
+ it("finalizeTask is idempotent for terminal tasks (single ownership of terminal state)", () => {
111
+ const task = makeTask();
112
+ registerSpawnedTask(task);
113
+ const first = finalizeTask(task.taskId, { exitCode: 0, reason: "natural", endedAt: Date.now() });
114
+ const second = finalizeTask(task.taskId, { exitCode: 1, reason: "killed", endedAt: Date.now() });
115
+ // 第二次终态化不覆盖已定终态
116
+ expect(first?.reason).toBe("natural");
117
+ expect(second?.reason).toBe("natural");
118
+ expect(second?.exitCode).toBe(0);
119
+ });
120
+ });
121
+
122
+ describe("terminal LRU eviction (cap 50)", () => {
123
+ it("evicts oldest terminal entries beyond cap, keeps recent ones", () => {
124
+ const OVERFLOW = 5;
125
+ const total = MAX_TERMINAL_TASKS + OVERFLOW;
126
+ const created: BackgroundTask[] = [];
127
+ for (let i = 0; i < total; i++) {
128
+ const task = makeTask({ startedAt: 1000 + i });
129
+ created.push(task);
130
+ registerSpawnedTask(task);
131
+ finalizeTask(task.taskId, { exitCode: 0, reason: "natural", endedAt: 2000 + i });
132
+ }
133
+ const remaining = getAllTasks();
134
+ expect(remaining).toHaveLength(MAX_TERMINAL_TASKS);
135
+ // 最老的 OVERFLOW 条被淘汰
136
+ for (let i = 0; i < OVERFLOW; i++) {
137
+ expect(getTask(created[i].taskId)).toBeUndefined();
138
+ }
139
+ // 最新的 50 条保留
140
+ for (let i = OVERFLOW; i < total; i++) {
141
+ expect(getTask(created[i].taskId)).toBeDefined();
142
+ }
143
+ });
144
+
145
+ it("active tasks are never evicted by LRU", () => {
146
+ const active = makeTask({ startedAt: 1 });
147
+ registerSpawnedTask(active);
148
+ for (let i = 0; i < MAX_TERMINAL_TASKS + 10; i++) {
149
+ const task = makeTask({ startedAt: 1000 + i });
150
+ registerSpawnedTask(task);
151
+ finalizeTask(task.taskId, { exitCode: 0, reason: "natural", endedAt: 2000 + i });
152
+ }
153
+ expect(getTask(active.taskId)).toBeDefined();
154
+ expect(getTask(active.taskId)?.state).toBe("running");
155
+ });
156
+ });