@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,335 @@
|
|
|
1
|
+
// src/__tests__/notify.test.ts —— M3 通知通路单元:
|
|
2
|
+
// register emit 形态(数据流 ⑤)/ exit 边沿通知(⑧⑨,含 killed 不 sendMessage)/
|
|
3
|
+
// 通知文案 / D17 pi 引用刷新 / 收殓路径补 emit(process-exit 不 sendMessage)
|
|
4
|
+
import { mkdtempSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
import { killProcessTree } from "../kill-tree.ts";
|
|
12
|
+
import {
|
|
13
|
+
BACKGROUND_BASH_CUSTOM_TYPE,
|
|
14
|
+
buildNotificationContent,
|
|
15
|
+
emitPendingRegister,
|
|
16
|
+
handleTaskExit,
|
|
17
|
+
refreshPiReference,
|
|
18
|
+
resetNotifyForTest,
|
|
19
|
+
} from "../background/notify.ts";
|
|
20
|
+
import { pollTickForTest, setOnTaskExit, stopPoller } from "../background/poller.ts";
|
|
21
|
+
import { spawnBackgroundTask } from "../background/spawn-background.ts";
|
|
22
|
+
import {
|
|
23
|
+
clearTaskStoreForTest,
|
|
24
|
+
getActiveTasks,
|
|
25
|
+
getTask,
|
|
26
|
+
markKillingIntent,
|
|
27
|
+
} from "../background/task-store.ts";
|
|
28
|
+
import type { BackgroundTask } from "../background/types.ts";
|
|
29
|
+
|
|
30
|
+
vi.setConfig({ testTimeout: 20000 });
|
|
31
|
+
|
|
32
|
+
const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-notify-"));
|
|
33
|
+
const SESSION_ID = "sess-notify";
|
|
34
|
+
|
|
35
|
+
interface MockPi {
|
|
36
|
+
events: { emit: ReturnType<typeof vi.fn> };
|
|
37
|
+
sendMessage: ReturnType<typeof vi.fn>;
|
|
38
|
+
appendEntry: ReturnType<typeof vi.fn>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createMockPi(): MockPi {
|
|
42
|
+
return { events: { emit: vi.fn() }, sendMessage: vi.fn(), appendEntry: vi.fn() };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function attach(pi: MockPi): void {
|
|
46
|
+
refreshPiReference(pi as unknown as ExtensionAPI);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sleep(ms: number): Promise<void> {
|
|
50
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function spawnBg(command: string) {
|
|
54
|
+
return spawnBackgroundTask({
|
|
55
|
+
command,
|
|
56
|
+
cwd: process.cwd(),
|
|
57
|
+
dataDir: DATA_DIR,
|
|
58
|
+
sessionId: SESSION_ID,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 构造终态条目直调 handleTaskExit(通知行为单元,不经真实轮询时序)。 */
|
|
63
|
+
function finalizedTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
|
|
64
|
+
return {
|
|
65
|
+
taskId: "bt-1700000000-test01",
|
|
66
|
+
pid: 4321,
|
|
67
|
+
command: "pnpm test",
|
|
68
|
+
outputFile: "/tmp/out/bt-1700000000-test01.log",
|
|
69
|
+
registryPath: "/tmp/out/registry.json",
|
|
70
|
+
startedAt: 1_700_000_000_000,
|
|
71
|
+
state: "exited",
|
|
72
|
+
ownerPiPid: process.pid,
|
|
73
|
+
sessionId: SESSION_ID,
|
|
74
|
+
exitCode: 0,
|
|
75
|
+
reason: "natural",
|
|
76
|
+
endedAt: 1_700_000_192_000,
|
|
77
|
+
durationMs: 192_000,
|
|
78
|
+
tailSummary: "Tests: 42 passed",
|
|
79
|
+
...overrides,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function killLeftoverTasks(): void {
|
|
84
|
+
for (const task of getActiveTasks()) {
|
|
85
|
+
try {
|
|
86
|
+
process.kill(-task.pid, "SIGKILL");
|
|
87
|
+
} catch {
|
|
88
|
+
try {
|
|
89
|
+
process.kill(task.pid, "SIGKILL");
|
|
90
|
+
} catch {
|
|
91
|
+
// already dead
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
killLeftoverTasks();
|
|
99
|
+
clearTaskStoreForTest();
|
|
100
|
+
stopPoller();
|
|
101
|
+
setOnTaskExit(undefined);
|
|
102
|
+
resetNotifyForTest();
|
|
103
|
+
vi.restoreAllMocks();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("register emit (data flow ⑤)", () => {
|
|
107
|
+
it("emits pending:register {id, type:'bash', name} with NO expiresAt key after successful spawn", () => {
|
|
108
|
+
const pi = createMockPi();
|
|
109
|
+
attach(pi);
|
|
110
|
+
const spawned = spawnBg("sleep 0.5 && echo hi");
|
|
111
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
112
|
+
|
|
113
|
+
expect(pi.events.emit).toHaveBeenCalledWith("pending:register", {
|
|
114
|
+
id: spawned.task.taskId,
|
|
115
|
+
type: "bash",
|
|
116
|
+
name: "sleep 0.5 && echo hi",
|
|
117
|
+
});
|
|
118
|
+
// process 档(D16):emit 不携带 expiresAt——键级断言防字段悄悄混入
|
|
119
|
+
const payload = pi.events.emit.mock.calls.find((c) => c[0] === "pending:register")?.[1] as Record<
|
|
120
|
+
string,
|
|
121
|
+
unknown
|
|
122
|
+
>;
|
|
123
|
+
expect(Object.keys(payload)).not.toContain("expiresAt");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("truncates name to 80 chars + ellipsis for long commands", () => {
|
|
127
|
+
const pi = createMockPi();
|
|
128
|
+
attach(pi);
|
|
129
|
+
const longCommand = `echo ${"x".repeat(200)}`;
|
|
130
|
+
const spawned = spawnBg(longCommand);
|
|
131
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
132
|
+
|
|
133
|
+
const payload = pi.events.emit.mock.calls.find((c) => c[0] === "pending:register")?.[1] as {
|
|
134
|
+
name: string;
|
|
135
|
+
};
|
|
136
|
+
expect(payload.name).toBe(`${longCommand.slice(0, 80)}…`);
|
|
137
|
+
expect(payload.name.length).toBe(81);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("skips emit silently (no throw) when pi reference is not attached", () => {
|
|
141
|
+
resetNotifyForTest();
|
|
142
|
+
const spawned = spawnBg("echo hi");
|
|
143
|
+
// 引用未注入(测试/极早期窗口):emit 通路 no-op,spawn 本体不受影响
|
|
144
|
+
expect(spawned.ok).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
|
|
149
|
+
it("natural exit 0: unregister emit reason 'completed' + sendMessage steer with exact params", async () => {
|
|
150
|
+
const pi = createMockPi();
|
|
151
|
+
attach(pi);
|
|
152
|
+
setOnTaskExit(handleTaskExit);
|
|
153
|
+
const spawned = spawnBg("sleep 0.3 && echo done");
|
|
154
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
155
|
+
const { task } = spawned;
|
|
156
|
+
|
|
157
|
+
await sleep(700);
|
|
158
|
+
pollTickForTest();
|
|
159
|
+
|
|
160
|
+
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
161
|
+
id: task.taskId,
|
|
162
|
+
reason: "completed",
|
|
163
|
+
});
|
|
164
|
+
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
165
|
+
const [message, options] = pi.sendMessage.mock.calls[0] as [
|
|
166
|
+
{ customType: string; content: string; display: boolean },
|
|
167
|
+
{ deliverAs: string; triggerTurn: boolean },
|
|
168
|
+
];
|
|
169
|
+
expect(message.customType).toBe(BACKGROUND_BASH_CUSTOM_TYPE);
|
|
170
|
+
expect(message.display).toBe(true);
|
|
171
|
+
expect(message.content).toContain(task.taskId);
|
|
172
|
+
expect(message.content).toContain("exit 0");
|
|
173
|
+
expect(message.content).toContain("done"); // tail 摘要
|
|
174
|
+
expect(options).toEqual({ deliverAs: "steer", triggerTurn: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("killed: emits unregister reason 'cancelled' but does NOT sendMessage (single-point rule)", async () => {
|
|
178
|
+
const pi = createMockPi();
|
|
179
|
+
attach(pi);
|
|
180
|
+
setOnTaskExit(handleTaskExit);
|
|
181
|
+
const spawned = spawnBg("sleep 60");
|
|
182
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
183
|
+
const { task } = spawned;
|
|
184
|
+
|
|
185
|
+
markKillingIntent(task.taskId, "killed");
|
|
186
|
+
killProcessTree(task.pid);
|
|
187
|
+
await sleep(500);
|
|
188
|
+
pollTickForTest();
|
|
189
|
+
|
|
190
|
+
expect(getTask(task.taskId)?.reason).toBe("killed");
|
|
191
|
+
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
192
|
+
id: task.taskId,
|
|
193
|
+
reason: "cancelled",
|
|
194
|
+
});
|
|
195
|
+
// kill 调用方就在当前 turn 等结果:sendMessage 双发是噪音,绝不发送
|
|
196
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("timeout: still sends steer message with reason 'time_limited'", () => {
|
|
200
|
+
const pi = createMockPi();
|
|
201
|
+
attach(pi);
|
|
202
|
+
handleTaskExit(finalizedTask({ reason: "timeout", exitCode: null, durationMs: 15000 }));
|
|
203
|
+
|
|
204
|
+
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
205
|
+
id: "bt-1700000000-test01",
|
|
206
|
+
reason: "time_limited",
|
|
207
|
+
});
|
|
208
|
+
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
209
|
+
const [message] = pi.sendMessage.mock.calls[0] as [{ content: string }, unknown];
|
|
210
|
+
expect(message.content).toContain("timed out");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("natural nonzero exit: reason 'failed', content marks failure", async () => {
|
|
214
|
+
const pi = createMockPi();
|
|
215
|
+
attach(pi);
|
|
216
|
+
setOnTaskExit(handleTaskExit);
|
|
217
|
+
const spawned = spawnBg("sleep 0.3; exit 3");
|
|
218
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
219
|
+
const { task } = spawned;
|
|
220
|
+
|
|
221
|
+
await sleep(700);
|
|
222
|
+
pollTickForTest();
|
|
223
|
+
|
|
224
|
+
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
225
|
+
id: task.taskId,
|
|
226
|
+
reason: "failed",
|
|
227
|
+
});
|
|
228
|
+
const [message] = pi.sendMessage.mock.calls[0] as [{ content: string }, unknown];
|
|
229
|
+
expect(message.content).toContain("failed (exit 3,");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("notification content (§3.1 sample)", () => {
|
|
234
|
+
it("finished: contains task_id, exit code, duration, command, Full output path, bash_output hint", () => {
|
|
235
|
+
const content = buildNotificationContent(finalizedTask());
|
|
236
|
+
expect(content).toContain("bt-1700000000-test01");
|
|
237
|
+
expect(content).toContain("exit 0");
|
|
238
|
+
expect(content).toContain("3m12s"); // 192000ms
|
|
239
|
+
expect(content).toContain("pnpm test");
|
|
240
|
+
expect(content).toContain("Last lines: Tests: 42 passed");
|
|
241
|
+
expect(content).toContain("Full output: /tmp/out/bt-1700000000-test01.log");
|
|
242
|
+
expect(content).toContain('bash_output {task_id:"bt-1700000000-test01"}');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("failed: head marks failed with exit code; omits Last lines when tail empty", () => {
|
|
246
|
+
const content = buildNotificationContent(
|
|
247
|
+
finalizedTask({ exitCode: 3, tailSummary: undefined, durationMs: 5000 }),
|
|
248
|
+
);
|
|
249
|
+
expect(content).toContain("failed (exit 3, 5s)");
|
|
250
|
+
expect(content).not.toContain("Last lines");
|
|
251
|
+
expect(content).toContain("Full output:");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("duration formatting: seconds / minutes / hours", () => {
|
|
255
|
+
expect(buildNotificationContent(finalizedTask({ durationMs: 45000 }))).toContain(", 45s):");
|
|
256
|
+
expect(buildNotificationContent(finalizedTask({ durationMs: 192000 }))).toContain("3m12s");
|
|
257
|
+
expect(buildNotificationContent(finalizedTask({ durationMs: 3_723_000 }))).toContain("1h02m03s");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("D17: pi reference refresh (session replacement takeover)", () => {
|
|
262
|
+
it("second load wins: notification goes through the NEW reference, old one untouched", () => {
|
|
263
|
+
const piA = createMockPi();
|
|
264
|
+
const piB = createMockPi();
|
|
265
|
+
attach(piA);
|
|
266
|
+
attach(piB); // session 替换 → 重新 load → 引用刷新
|
|
267
|
+
handleTaskExit(finalizedTask());
|
|
268
|
+
expect(piB.sendMessage).toHaveBeenCalledTimes(1);
|
|
269
|
+
expect(piA.sendMessage).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("stale reference throwing sendMessage does not break notification; later tasks still notify", () => {
|
|
273
|
+
const stale = createMockPi();
|
|
274
|
+
stale.sendMessage = vi.fn(() => {
|
|
275
|
+
throw new Error("stale bus disposed");
|
|
276
|
+
});
|
|
277
|
+
attach(stale);
|
|
278
|
+
expect(() => handleTaskExit(finalizedTask())).not.toThrow();
|
|
279
|
+
|
|
280
|
+
const fresh = createMockPi();
|
|
281
|
+
attach(fresh);
|
|
282
|
+
handleTaskExit(finalizedTask({ taskId: "bt-1700000000-test02" }));
|
|
283
|
+
expect(fresh.sendMessage).toHaveBeenCalledTimes(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("poller keeps running when notification path throws inside the exit edge", async () => {
|
|
287
|
+
const stale = createMockPi();
|
|
288
|
+
stale.sendMessage = vi.fn(() => {
|
|
289
|
+
throw new Error("stale bus disposed");
|
|
290
|
+
});
|
|
291
|
+
stale.events.emit = vi.fn(() => {
|
|
292
|
+
throw new Error("stale bus emit");
|
|
293
|
+
});
|
|
294
|
+
attach(stale);
|
|
295
|
+
setOnTaskExit(handleTaskExit);
|
|
296
|
+
|
|
297
|
+
const first = spawnBg("sleep 0.3 && echo one");
|
|
298
|
+
const second = spawnBg("sleep 0.3 && echo two");
|
|
299
|
+
if (!first.ok || !second.ok) throw new Error("spawn failed");
|
|
300
|
+
await sleep(700);
|
|
301
|
+
// 边沿回调内部全捕获:pollTick 不抛,两条任务都完成终态化
|
|
302
|
+
expect(() => pollTickForTest()).not.toThrow();
|
|
303
|
+
expect(getTask(first.task.taskId)?.state).toBe("exited");
|
|
304
|
+
expect(getTask(second.task.taskId)?.state).toBe("exited");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("process-exit reap: best-effort unregister emit, no sendMessage", () => {
|
|
309
|
+
it("reapBackgroundTasksNow emits pending:unregister reason 'cancelled' (process-exit)", async () => {
|
|
310
|
+
const { reapBackgroundTasksNow, resetProcessExitGuardForTest } = await import(
|
|
311
|
+
"../background/process-exit-guard.ts"
|
|
312
|
+
);
|
|
313
|
+
const pi = createMockPi();
|
|
314
|
+
attach(pi);
|
|
315
|
+
const spawned = spawnBg("sleep 30");
|
|
316
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
317
|
+
|
|
318
|
+
reapBackgroundTasksNow();
|
|
319
|
+
|
|
320
|
+
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
321
|
+
id: spawned.task.taskId,
|
|
322
|
+
reason: "cancelled",
|
|
323
|
+
});
|
|
324
|
+
// 进程都退了,无投递目标:绝不 sendMessage
|
|
325
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
326
|
+
resetProcessExitGuardForTest();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("register emit direct unit (no spawn)", () => {
|
|
331
|
+
it("does nothing when reference unset; payload unchanged when attached late", () => {
|
|
332
|
+
resetNotifyForTest();
|
|
333
|
+
expect(() => emitPendingRegister(finalizedTask({ state: "running" }))).not.toThrow();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// src/__tests__/pending-reconcile.test.ts —— M3 session_start 对账单元(§3.5 接入细则 4):
|
|
2
|
+
// 差集收集 / 三类僵尸场景 appendEntry 权威路径 + 尽力 emit / 活任务与缺条目保守跳过
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
collectUnsettledTaskIds,
|
|
12
|
+
reconcilePendingEntries,
|
|
13
|
+
type ReconcilePi,
|
|
14
|
+
} from "../background/pending-reconcile.ts";
|
|
15
|
+
import { getRegistryPath, writeRegistryEntry } from "../background/registry.ts";
|
|
16
|
+
import type { RegistryEntry } from "../background/types.ts";
|
|
17
|
+
|
|
18
|
+
const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-reconcile-"));
|
|
19
|
+
const SESSION_ID = "sess-reconcile";
|
|
20
|
+
|
|
21
|
+
function createMockPi(overrides: Partial<Pick<ReconcilePi, "appendEntry">> = {}): ReconcilePi {
|
|
22
|
+
return {
|
|
23
|
+
appendEntry: vi.fn(),
|
|
24
|
+
events: { emit: vi.fn() },
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeRegistryEntry(overrides: Partial<RegistryEntry> = {}): RegistryEntry {
|
|
30
|
+
return {
|
|
31
|
+
taskId: "bt-1700000000-zomb01",
|
|
32
|
+
pid: 99999,
|
|
33
|
+
command: "sleep 3600",
|
|
34
|
+
outputFile: "/tmp/out.log",
|
|
35
|
+
startedAt: 1_700_000_000_000,
|
|
36
|
+
state: "orphaned",
|
|
37
|
+
ownerPiPid: 1,
|
|
38
|
+
sessionId: SESSION_ID,
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 已死 pid:spawnSync 同步等待退出 + libuv reap,返回时 pid 必已终止。 */
|
|
44
|
+
function deadPid(): number {
|
|
45
|
+
const result = spawnSync("true");
|
|
46
|
+
if (result.pid === undefined) throw new Error("no pid acquired for dead-pid probe");
|
|
47
|
+
return result.pid;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function registerEntry(id: string) {
|
|
51
|
+
return { customType: "pending:register", data: { id, type: "bash", name: "sleep 3600" } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
rmSync(DATA_DIR, { recursive: true, force: true });
|
|
56
|
+
vi.restoreAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("collectUnsettledTaskIds (bt- prefixed register/unregister diff)", () => {
|
|
60
|
+
it("collects bt- registers without a matching unregister", () => {
|
|
61
|
+
const ids = collectUnsettledTaskIds([
|
|
62
|
+
registerEntry("bt-a"),
|
|
63
|
+
registerEntry("bt-b"),
|
|
64
|
+
{ customType: "pending:unregister", data: { id: "bt-b", reason: "completed" } },
|
|
65
|
+
]);
|
|
66
|
+
expect([...ids]).toEqual(["bt-a"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ignores non-bt ids (subagent bg-/run- namespace not ours)", () => {
|
|
70
|
+
const ids = collectUnsettledTaskIds([
|
|
71
|
+
registerEntry("bg-1"),
|
|
72
|
+
registerEntry("run-x-1"),
|
|
73
|
+
registerEntry("bt-a"),
|
|
74
|
+
]);
|
|
75
|
+
expect([...ids]).toEqual(["bt-a"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("dedupes repeated registers and tolerates malformed entries", () => {
|
|
79
|
+
const ids = collectUnsettledTaskIds([
|
|
80
|
+
null,
|
|
81
|
+
undefined,
|
|
82
|
+
{ customType: "pending:register" }, // data 缺失
|
|
83
|
+
{ customType: "pending:register", data: { id: 42 } }, // id 非字符串
|
|
84
|
+
registerEntry("bt-a"),
|
|
85
|
+
registerEntry("bt-a"),
|
|
86
|
+
{ customType: "other" },
|
|
87
|
+
]);
|
|
88
|
+
expect([...ids]).toEqual(["bt-a"]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry never written)", () => {
|
|
93
|
+
it("appends pending:unregister {id, reason, status} matching pending-notifications entry shape + best-effort emit", () => {
|
|
94
|
+
const entry = makeRegistryEntry({
|
|
95
|
+
taskId: "bt-1700000000-zomb01",
|
|
96
|
+
state: "exited",
|
|
97
|
+
reason: "natural",
|
|
98
|
+
exitCode: 0,
|
|
99
|
+
});
|
|
100
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
|
|
101
|
+
const pi = createMockPi();
|
|
102
|
+
|
|
103
|
+
const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
|
|
104
|
+
|
|
105
|
+
expect(result.reconciled).toBe(1);
|
|
106
|
+
// 落盘形态逐字段对齐 pending-notifications index.ts unregister listener:{id, reason, status}
|
|
107
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
|
|
108
|
+
id: entry.taskId,
|
|
109
|
+
reason: "completed",
|
|
110
|
+
status: "completed",
|
|
111
|
+
});
|
|
112
|
+
// emit 形态 {id, reason}(status 由 listener mapReasonToStatus 计算)
|
|
113
|
+
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
114
|
+
id: entry.taskId,
|
|
115
|
+
reason: "completed",
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("maps exited reason/exitCode through the same mapping as the exit edge", () => {
|
|
120
|
+
const cases: Array<[RegistryEntry, string]> = [
|
|
121
|
+
[makeRegistryEntry({ state: "exited", reason: "natural", exitCode: 3 }), "failed"],
|
|
122
|
+
[makeRegistryEntry({ state: "exited", reason: "timeout", exitCode: null }), "time_limited"],
|
|
123
|
+
[makeRegistryEntry({ state: "exited", reason: "killed", exitCode: null }), "cancelled"],
|
|
124
|
+
[makeRegistryEntry({ state: "exited", reason: "process-exit", exitCode: null }), "cancelled"],
|
|
125
|
+
];
|
|
126
|
+
for (const [entry, expectedReason] of cases) {
|
|
127
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
|
|
128
|
+
const pi = createMockPi();
|
|
129
|
+
reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
|
|
130
|
+
expect(pi.appendEntry).toHaveBeenCalledWith(
|
|
131
|
+
"pending:unregister",
|
|
132
|
+
{ id: entry.taskId, reason: expectedReason, status: expectedReason },
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("reconcile scenario ②: reaper-only orphan (registry orphaned, session file untouched)", () => {
|
|
139
|
+
it("appends unregister with cancelled for orphaned entries", () => {
|
|
140
|
+
const entry = makeRegistryEntry({ state: "orphaned" });
|
|
141
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
|
|
142
|
+
const pi = createMockPi();
|
|
143
|
+
|
|
144
|
+
const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
|
|
145
|
+
|
|
146
|
+
expect(result.reconciled).toBe(1);
|
|
147
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
|
|
148
|
+
id: entry.taskId,
|
|
149
|
+
reason: "cancelled",
|
|
150
|
+
status: "cancelled",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("reconcile scenario ③: running entry whose pid is already dead (fact-terminal)", () => {
|
|
156
|
+
it("appends unregister with cancelled when kill(pid,0) says dead", () => {
|
|
157
|
+
const entry = makeRegistryEntry({ state: "running", pid: deadPid() });
|
|
158
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
|
|
159
|
+
const pi = createMockPi();
|
|
160
|
+
|
|
161
|
+
const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
|
|
162
|
+
|
|
163
|
+
expect(result.reconciled).toBe(1);
|
|
164
|
+
expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
|
|
165
|
+
id: entry.taskId,
|
|
166
|
+
reason: "cancelled",
|
|
167
|
+
status: "cancelled",
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("conservative no-op paths", () => {
|
|
173
|
+
it("running entry with LIVE pid is not settled (D12 task survives session replacement)", () => {
|
|
174
|
+
const entry = makeRegistryEntry({ state: "running", pid: process.pid }); // 当前测试进程 = 活 pid
|
|
175
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
|
|
176
|
+
const pi = createMockPi();
|
|
177
|
+
|
|
178
|
+
const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry(entry.taskId)]);
|
|
179
|
+
|
|
180
|
+
expect(result.reconciled).toBe(0);
|
|
181
|
+
expect(result.skipped).toEqual([entry.taskId]);
|
|
182
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("registry has no entry for the id → skip (terminal state unverifiable)", () => {
|
|
186
|
+
const pi = createMockPi();
|
|
187
|
+
const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [registerEntry("bt-unknown")]);
|
|
188
|
+
expect(result.reconciled).toBe(0);
|
|
189
|
+
expect(result.skipped).toEqual(["bt-unknown"]);
|
|
190
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("entries with no bt- register → early no-op", () => {
|
|
194
|
+
const pi = createMockPi();
|
|
195
|
+
const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
|
|
196
|
+
{ customType: "pending:register", data: { id: "bg-1", type: "subagent" } },
|
|
197
|
+
{ customType: "user" },
|
|
198
|
+
]);
|
|
199
|
+
expect(result.reconciled).toBe(0);
|
|
200
|
+
expect(result.skipped).toEqual([]);
|
|
201
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("unsettled set empty (unregister already present) → no-op", () => {
|
|
205
|
+
const entry = makeRegistryEntry({ state: "orphaned" });
|
|
206
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
|
|
207
|
+
const pi = createMockPi();
|
|
208
|
+
const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
|
|
209
|
+
registerEntry(entry.taskId),
|
|
210
|
+
{ customType: "pending:unregister", data: { id: entry.taskId, reason: "cancelled" } },
|
|
211
|
+
]);
|
|
212
|
+
expect(result.reconciled).toBe(0);
|
|
213
|
+
expect(pi.appendEntry).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("appendEntry failure tolerance", () => {
|
|
218
|
+
it("one appendEntry throw does not block the remaining zombie (count only successful)", () => {
|
|
219
|
+
const first = makeRegistryEntry({ taskId: "bt-1700000000-zomb01", state: "orphaned" });
|
|
220
|
+
const second = makeRegistryEntry({ taskId: "bt-1700000000-zomb02", state: "orphaned" });
|
|
221
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), first);
|
|
222
|
+
writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), second);
|
|
223
|
+
const appendEntry = vi.fn((customType: string, data?: unknown) => {
|
|
224
|
+
if ((data as { id: string }).id === first.taskId) throw new Error("append failed");
|
|
225
|
+
});
|
|
226
|
+
const pi = createMockPi({ appendEntry });
|
|
227
|
+
|
|
228
|
+
const result = reconcilePendingEntries(pi, DATA_DIR, SESSION_ID, [
|
|
229
|
+
registerEntry(first.taskId),
|
|
230
|
+
registerEntry(second.taskId),
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
expect(appendEntry).toHaveBeenCalledTimes(2);
|
|
234
|
+
expect(result.reconciled).toBe(1); // 仅第二条成功计数
|
|
235
|
+
expect(result.skipped).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
});
|