@zhushanwen/pi-base-tool-enhance 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/background-lifecycle.test.ts +206 -37
- package/src/__tests__/bash-tool.test.ts +1 -1
- package/src/__tests__/config.test.ts +1 -1
- package/src/__tests__/index.test.ts +1 -1
- package/src/__tests__/kill-tree.test.ts +55 -18
- package/src/__tests__/notify.test.ts +41 -8
- package/src/__tests__/pending-reconcile.test.ts +1 -1
- package/src/background/poller.ts +23 -6
- package/src/background/spawn-background.ts +15 -4
- package/src/background/task-store.ts +16 -1
- package/src/bash-output-tool.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-base-tool-enhance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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,7 +28,7 @@
|
|
|
28
28
|
"skills/"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@xyz-agent/extension-protocol": "0.8.
|
|
31
|
+
"@xyz-agent/extension-protocol": "0.8.2",
|
|
32
32
|
"@zhushanwen/pi-extension-logger": "0.4.1",
|
|
33
33
|
"@zhushanwen/pi-file-lock": "0.2.1",
|
|
34
34
|
"@zhushanwen/pi-llm-shared": "0.5.1"
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// src/__tests__/background-lifecycle.test.ts —— background 核心生命周期集成:
|
|
2
2
|
// 真实 spawn 边沿收尾 / 轮询器自动收尾 / killing intent 双侧 / timeout / 并发上限 /
|
|
3
3
|
// 收殓 / D15 abort / bash_output / bash_kill(黑盒:经工具 execute 断言使用者可见行为)
|
|
4
|
-
import { existsSync, mkdtempSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
10
|
|
|
10
11
|
vi.setConfig({ testTimeout: 20000 });
|
|
11
12
|
|
|
@@ -31,16 +32,15 @@ vi.mock("../kill-tree.ts", async (importOriginal) => {
|
|
|
31
32
|
|
|
32
33
|
import { createBashKillToolDefinition } from "../bash-kill-tool.ts";
|
|
33
34
|
import { createBashOutputToolDefinition } from "../bash-output-tool.ts";
|
|
35
|
+
import { handleTaskExit, refreshPiReference, resetNotifyForTest } from "../background/notify.ts";
|
|
34
36
|
import { pollTickForTest, setOnTaskExit, stopPoller } from "../background/poller.ts";
|
|
35
37
|
import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "../background/registry.ts";
|
|
36
38
|
import type { RegistryEntry } from "../background/types.ts";
|
|
37
39
|
import { DEFAULT_MAX_CONCURRENT_BACKGROUND, spawnBackgroundTask } from "../background/spawn-background.ts";
|
|
38
|
-
import { clearTaskStoreForTest, getActiveTasks, getTask } from "../background/task-store.ts";
|
|
40
|
+
import { clearTaskStoreForTest, getActiveTasks, getTask, markKillingIntent } from "../background/task-store.ts";
|
|
39
41
|
import { reapBackgroundTasksNow, resetProcessExitGuardForTest } from "../background/process-exit-guard.ts";
|
|
40
42
|
import { isPidAlive } from "../kill-tree.ts";
|
|
41
43
|
|
|
42
|
-
vi.setConfig({ testTimeout: 20000 });
|
|
43
|
-
|
|
44
44
|
const DATA_DIR = mkdtempSync(join(tmpdir(), "bte-data-"));
|
|
45
45
|
dataDirRef.dir = DATA_DIR;
|
|
46
46
|
|
|
@@ -68,6 +68,40 @@ function sleep(ms: number): Promise<void> {
|
|
|
68
68
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* 真实子进程链(spawn+exec+sleep+exit)满载下延迟不可预估,deadline 放宽到 10s;
|
|
73
|
+
* timeout 路径 / 真实 poller 定时器路径单独放宽到 8s(任务语义内最慢的等待对象)。
|
|
74
|
+
*/
|
|
75
|
+
const POLL_DEADLINE_MS = 10_000;
|
|
76
|
+
const POLL_DEADLINE_SLOW_MS = 8_000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 轮询直到期望状态出现或超时:每次迭代先 pollTickForTest()(手动 tick 推进状态机)
|
|
80
|
+
* 再检查——固定 sleep 后单次 tick 单次断言是满载 flake 源,等状态而非猜时刻。
|
|
81
|
+
*/
|
|
82
|
+
async function pollUntilTicked(check: () => boolean, deadlineMs: number, what: string): Promise<void> {
|
|
83
|
+
const deadline = Date.now() + deadlineMs;
|
|
84
|
+
for (;;) {
|
|
85
|
+
pollTickForTest();
|
|
86
|
+
if (check()) return;
|
|
87
|
+
if (Date.now() > deadline) {
|
|
88
|
+
throw new Error(`timed out after ${deadlineMs}ms waiting for ${what}`);
|
|
89
|
+
}
|
|
90
|
+
await sleep(25);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 同 pollUntilTicked 但不手动 tick:语义含「真实定时器/事件自行推进」的用例专用。 */
|
|
95
|
+
async function pollUntil(check: () => boolean, deadlineMs: number, what: string): Promise<void> {
|
|
96
|
+
const deadline = Date.now() + deadlineMs;
|
|
97
|
+
while (!check()) {
|
|
98
|
+
if (Date.now() > deadline) {
|
|
99
|
+
throw new Error(`timed out after ${deadlineMs}ms waiting for ${what}`);
|
|
100
|
+
}
|
|
101
|
+
await sleep(25);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
function spawnBg(command: string, extra: { timeoutSec?: number; maxConcurrent?: number } = {}) {
|
|
72
106
|
return spawnBackgroundTask({
|
|
73
107
|
command,
|
|
@@ -113,6 +147,15 @@ afterEach(() => {
|
|
|
113
147
|
stopPoller();
|
|
114
148
|
setOnTaskExit(undefined);
|
|
115
149
|
resetProcessExitGuardForTest();
|
|
150
|
+
// pi 引用(currentPi)统一在 afterEach 复位——用例体内不清:断言先失败时泄漏的
|
|
151
|
+
// currentPi 会被同文件后续用例的 emitPendingRegister 消费(发往死引用)
|
|
152
|
+
resetNotifyForTest();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// 测试红线:临时目录自建自删(模块级 DATA_DIR 全文件共用,最后统一清)
|
|
156
|
+
// maxRetries 防 teardown 递归删除与在途异步写竞争的 ENOTEMPTY 满载 flake
|
|
157
|
+
afterAll(() => {
|
|
158
|
+
rmSync(DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
116
159
|
});
|
|
117
160
|
|
|
118
161
|
describe("real spawn lifecycle (poll edge finalization)", () => {
|
|
@@ -126,8 +169,8 @@ describe("real spawn lifecycle (poll edge finalization)", () => {
|
|
|
126
169
|
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
|
|
127
170
|
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.ownerPiPid).toBe(process.pid);
|
|
128
171
|
|
|
129
|
-
|
|
130
|
-
|
|
172
|
+
// 轮询等命令退出 + libuv reap(tick 收尾;满载下延迟不可预估)
|
|
173
|
+
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
|
|
131
174
|
|
|
132
175
|
const finalized = getTask(task.taskId);
|
|
133
176
|
expect(finalized?.state).toBe("exited");
|
|
@@ -148,8 +191,9 @@ describe("real spawn lifecycle (poll edge finalization)", () => {
|
|
|
148
191
|
const spawned = spawnBg("sleep 0.3 && echo auto");
|
|
149
192
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
150
193
|
const { task } = spawned;
|
|
151
|
-
// 不手动 tick,等真实 2s 轮询边沿(命令 0.3s 已退出,首轮 tick
|
|
152
|
-
|
|
194
|
+
// 不手动 tick,等真实 2s 轮询边沿(命令 0.3s 已退出,首轮 tick 即收尾):
|
|
195
|
+
// 轮询等终态出现(真实定时器 + 满载下轮询边沿延迟不可预估)
|
|
196
|
+
await pollUntil(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_SLOW_MS, "poller auto-finalization");
|
|
153
197
|
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
154
198
|
// 无活跃条目后轮询器自停(防空转)
|
|
155
199
|
expect(getActiveTasks()).toHaveLength(0);
|
|
@@ -159,8 +203,7 @@ describe("real spawn lifecycle (poll edge finalization)", () => {
|
|
|
159
203
|
const spawned = spawnBg("sleep 0.2; exit 3");
|
|
160
204
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
161
205
|
const { task } = spawned;
|
|
162
|
-
await
|
|
163
|
-
pollTickForTest();
|
|
206
|
+
await pollUntilTicked(() => getTask(task.taskId)?.exitCode === 3, POLL_DEADLINE_MS, "exitCode to surface");
|
|
164
207
|
expect(getTask(task.taskId)?.exitCode).toBe(3);
|
|
165
208
|
const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId);
|
|
166
209
|
expect(registryEntry?.exitCode).toBe(3);
|
|
@@ -171,8 +214,7 @@ describe("real spawn lifecycle (poll edge finalization)", () => {
|
|
|
171
214
|
setOnTaskExit((task) => seen.push(`${task.taskId}:${task.state}:${task.reason}`));
|
|
172
215
|
const spawned = spawnBg("true");
|
|
173
216
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
174
|
-
await
|
|
175
|
-
pollTickForTest();
|
|
217
|
+
await pollUntilTicked(() => seen.length === 1, POLL_DEADLINE_MS, "onTaskExit callback to fire");
|
|
176
218
|
expect(seen).toHaveLength(1);
|
|
177
219
|
expect(seen[0]).toBe(`${spawned.task.taskId}:exited:natural`);
|
|
178
220
|
});
|
|
@@ -264,8 +306,7 @@ describe("bash_output tool", () => {
|
|
|
264
306
|
const spawned = spawnBg("sleep 0.2 && echo gone");
|
|
265
307
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
266
308
|
const { task } = spawned;
|
|
267
|
-
await
|
|
268
|
-
pollTickForTest();
|
|
309
|
+
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
|
|
269
310
|
unlinkSync(task.outputFile);
|
|
270
311
|
const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as {
|
|
271
312
|
output: string;
|
|
@@ -280,8 +321,8 @@ describe("bash_output tool", () => {
|
|
|
280
321
|
const spawned = spawnBg("for i in $(seq 1 2100); do echo line$i; done");
|
|
281
322
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
282
323
|
const { task } = spawned;
|
|
283
|
-
|
|
284
|
-
|
|
324
|
+
// 轮询等 2100 行输出跑完退出收尾(满载下 shell 循环耗时不可预估)
|
|
325
|
+
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "output loop to finish");
|
|
285
326
|
const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as {
|
|
286
327
|
output: string;
|
|
287
328
|
truncated: boolean;
|
|
@@ -315,8 +356,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
|
315
356
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
316
357
|
const { task } = spawned;
|
|
317
358
|
await killTool(task.taskId);
|
|
318
|
-
|
|
319
|
-
|
|
359
|
+
// 轮询到 kill 边沿收尾(SIGKILL 生效 + tick 终态化)
|
|
360
|
+
await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", POLL_DEADLINE_MS, "killed finalization");
|
|
320
361
|
const finalized = getTask(task.taskId);
|
|
321
362
|
expect(finalized?.state).toBe("exited");
|
|
322
363
|
expect(finalized?.reason).toBe("killed");
|
|
@@ -338,8 +379,7 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
|
338
379
|
const spawned = spawnBg("sleep 0.2");
|
|
339
380
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
340
381
|
const { task } = spawned;
|
|
341
|
-
await
|
|
342
|
-
pollTickForTest();
|
|
382
|
+
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
|
|
343
383
|
const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
|
|
344
384
|
expect(result.killed).toBe(false);
|
|
345
385
|
expect(result.reason).toBe("already exited (code 0)");
|
|
@@ -387,7 +427,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
|
|
|
387
427
|
const spawned = spawnBg("sleep 0.2");
|
|
388
428
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
389
429
|
const { task } = spawned;
|
|
390
|
-
|
|
430
|
+
// 轮询进程死透(不 tick——单例表仍 running 是本用例前提)
|
|
431
|
+
await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die without poll edge");
|
|
391
432
|
expect(getTask(task.taskId)?.state).toBe("running");
|
|
392
433
|
|
|
393
434
|
const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
|
|
@@ -476,14 +517,18 @@ describe("explicit background timeout (D6)", () => {
|
|
|
476
517
|
}
|
|
477
518
|
|
|
478
519
|
// SIGKILL 已真实发出:轮询边沿收尾 → exited(reason:"timeout"),终态由边沿写
|
|
479
|
-
|
|
480
|
-
|
|
520
|
+
//(timeout 路径满载下 SIGKILL 生效延迟不可预估,deadline 放宽到 8s)
|
|
521
|
+
await pollUntilTicked(
|
|
522
|
+
() => getTask(taskId)?.state === "exited" && getTask(taskId)?.reason === "timeout",
|
|
523
|
+
POLL_DEADLINE_SLOW_MS,
|
|
524
|
+
"timeout finalization",
|
|
525
|
+
);
|
|
481
526
|
const finalized = getTask(taskId);
|
|
482
527
|
expect(finalized?.state).toBe("exited");
|
|
483
528
|
expect(finalized?.reason).toBe("timeout");
|
|
484
529
|
});
|
|
485
530
|
|
|
486
|
-
it("P0-4: pid reuse suspected at the deadline → skip kill
|
|
531
|
+
it("P0-4/D6-en R2-S1: pid reuse suspected at the deadline → skip kill AND skip intent mark", () => {
|
|
487
532
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
488
533
|
try {
|
|
489
534
|
const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
|
|
@@ -496,16 +541,18 @@ describe("explicit background timeout (D6)", () => {
|
|
|
496
541
|
vi.advanceTimersByTime(1000);
|
|
497
542
|
// 宁不杀勿误杀:到点不对复用嫌疑 pid 发 kill(整进程组 SIGKILL 会误杀无辜进程)
|
|
498
543
|
expect(killTreeCalls).not.toContain(task.pid);
|
|
499
|
-
//
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
expect(
|
|
544
|
+
// D6-en/R2-S1 加固:身份校验不过 = 登记原进程已不在(死亡或被复用),intent
|
|
545
|
+
// 标记一并跳过——无条件改写内存 intent 为 timeout 会冲掉 UI 代杀 / AI bash_kill
|
|
546
|
+
// 预写的 killed(误报 timed out 唤醒 AI);终态归轮询边沿按事实收尾
|
|
547
|
+
expect(getTask(task.taskId)?.state).toBe("running");
|
|
548
|
+
expect(getTask(task.taskId)?.intent).toBeUndefined();
|
|
549
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
|
|
503
550
|
} finally {
|
|
504
551
|
vi.useRealTimers();
|
|
505
552
|
}
|
|
506
553
|
});
|
|
507
554
|
|
|
508
|
-
it("P0-4: missing pidStartTime degrades to startedAt check (mismatch → skip kill)", () => {
|
|
555
|
+
it("P0-4/D6-en R2-S1: missing pidStartTime degrades to startedAt check (mismatch → skip kill and intent mark)", () => {
|
|
509
556
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
510
557
|
try {
|
|
511
558
|
const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
|
|
@@ -518,8 +565,10 @@ describe("explicit background timeout (D6)", () => {
|
|
|
518
565
|
|
|
519
566
|
vi.advanceTimersByTime(1000);
|
|
520
567
|
expect(killTreeCalls).not.toContain(task.pid);
|
|
521
|
-
|
|
522
|
-
expect(getTask(task.taskId)?.
|
|
568
|
+
// D6-en/R2-S1 加固:身份校验不过同样跳过 intent 标记(防冲掉 killed 预写)
|
|
569
|
+
expect(getTask(task.taskId)?.state).toBe("running");
|
|
570
|
+
expect(getTask(task.taskId)?.intent).toBeUndefined();
|
|
571
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
|
|
523
572
|
} finally {
|
|
524
573
|
vi.useRealTimers();
|
|
525
574
|
}
|
|
@@ -550,18 +599,135 @@ describe("explicit background timeout (D6)", () => {
|
|
|
550
599
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
551
600
|
const { task } = spawned;
|
|
552
601
|
|
|
553
|
-
|
|
554
|
-
|
|
602
|
+
// 轮询命令退出收尾(未到 1s deadline)
|
|
603
|
+
await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_SLOW_MS, "natural finalization");
|
|
555
604
|
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
556
605
|
expect(getTask(task.taskId)?.reason).toBe("natural");
|
|
557
606
|
|
|
558
|
-
// 越过 deadline 的时间窗内不得再补杀(终态化必须已清 timer
|
|
559
|
-
|
|
607
|
+
// 越过 deadline 的时间窗内不得再补杀(终态化必须已清 timer):轮询真实时钟
|
|
608
|
+
// 越过 deadline+余量后断言——这里等待的是时钟流逝本身,非子进程事件
|
|
609
|
+
await pollUntil(
|
|
610
|
+
() => Date.now() >= task.startedAt + 1_000 + 500,
|
|
611
|
+
POLL_DEADLINE_SLOW_MS,
|
|
612
|
+
"real clock to pass the deadline window",
|
|
613
|
+
);
|
|
560
614
|
expect(killTreeCalls.length).toBe(before);
|
|
561
615
|
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
562
616
|
});
|
|
563
617
|
});
|
|
564
618
|
|
|
619
|
+
describe("D6-en: poller finalization reads back registry killing (cross-process UI kill)", () => {
|
|
620
|
+
it("memory intent missing + registry killing → reason killed; AI not woken (no sendMessage)", async () => {
|
|
621
|
+
const pi = { events: { emit: vi.fn() }, sendMessage: vi.fn(), appendEntry: vi.fn() };
|
|
622
|
+
refreshPiReference(pi as unknown as ExtensionAPI);
|
|
623
|
+
setOnTaskExit(handleTaskExit);
|
|
624
|
+
const spawned = spawnBg("sleep 30");
|
|
625
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
626
|
+
const { task } = spawned;
|
|
627
|
+
// runtime UI 代杀形态:只写 registry 侧 killing(跨进程,pi 内存表不可见)+
|
|
628
|
+
// 杀进程树,不经本进程 markKillingIntent
|
|
629
|
+
writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
|
|
630
|
+
expect(getTask(task.taskId)?.intent).toBeUndefined(); // 前置:内存无 intent
|
|
631
|
+
process.kill(task.pid, "SIGKILL");
|
|
632
|
+
await sleep(500);
|
|
633
|
+
expect(isPidAlive(task.pid)).toBe(false);
|
|
634
|
+
pollTickForTest();
|
|
635
|
+
|
|
636
|
+
const finalized = getTask(task.taskId);
|
|
637
|
+
expect(finalized?.state).toBe("exited");
|
|
638
|
+
expect(finalized?.reason).toBe("killed"); // 读回命中(无 intent 时旧实现得 natural)
|
|
639
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.reason).toBe("killed");
|
|
640
|
+
// 下游效果(P7 主路径口径):killed → 只 emit unregister(cancelled),不 sendMessage
|
|
641
|
+
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
642
|
+
id: task.taskId,
|
|
643
|
+
reason: "cancelled",
|
|
644
|
+
});
|
|
645
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("memory intent present wins — registry NOT read back (killed kept while registry stays running)", async () => {
|
|
649
|
+
const spawned = spawnBg("sleep 30");
|
|
650
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
651
|
+
const { task } = spawned;
|
|
652
|
+
// 内存预写 killed intent(AI bash_kill 形态),registry 刻意停留 running
|
|
653
|
+
//(模拟 intent 落盘失败)——若实现误读回,会按 running 判 natural
|
|
654
|
+
expect(markKillingIntent(task.taskId, "killed")).toBeDefined();
|
|
655
|
+
process.kill(task.pid, "SIGKILL");
|
|
656
|
+
await sleep(500);
|
|
657
|
+
expect(isPidAlive(task.pid)).toBe(false);
|
|
658
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running"); // 前置成立
|
|
659
|
+
pollTickForTest();
|
|
660
|
+
expect(getTask(task.taskId)?.reason).toBe("killed"); // intent 优先,非 natural
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("registry read failure (corrupt file) degrades to no-intent natural; finalization not blocked", async () => {
|
|
664
|
+
const spawned = spawnBg("sleep 30");
|
|
665
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
666
|
+
const { task } = spawned;
|
|
667
|
+
writeFileSync(REGISTRY_PATH, "{corrupted", "utf8"); // 读回路径解析失败
|
|
668
|
+
process.kill(task.pid, "SIGKILL");
|
|
669
|
+
await sleep(500);
|
|
670
|
+
pollTickForTest();
|
|
671
|
+
const finalized = getTask(task.taskId);
|
|
672
|
+
expect(finalized?.state).toBe("exited"); // 终态化未被阻塞
|
|
673
|
+
expect(finalized?.reason).toBe("natural"); // 读失败按无 intent 处理
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
describe("D6-en/R2-S1 hardening: armBackgroundTimeout skips kill+mark when recorded pid is gone", () => {
|
|
678
|
+
it("UI kill × timeout overlap: no timeout mark; poll edge reads back registry killing → killed", async () => {
|
|
679
|
+
const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
|
|
680
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
681
|
+
const { task } = spawned;
|
|
682
|
+
// UI 代杀(跨进程形态):registry 预写 killing + 直接杀进程,不经内存
|
|
683
|
+
writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
|
|
684
|
+
process.kill(task.pid, "SIGKILL");
|
|
685
|
+
await sleep(500); // 进程死亡 + libuv reap(deadline 1s 之前)
|
|
686
|
+
expect(isPidAlive(task.pid)).toBe(false);
|
|
687
|
+
await sleep(700); // 越过 deadline(累计 ~1.2s,仍在 2s 轮询 tick 之前)
|
|
688
|
+
// 加固生效:不补杀、不改写内存(无 intent)、不冲掉 registry 侧 killing 预写
|
|
689
|
+
expect(killTreeCalls).not.toContain(task.pid);
|
|
690
|
+
expect(getTask(task.taskId)?.state).toBe("running");
|
|
691
|
+
expect(getTask(task.taskId)?.intent).toBeUndefined();
|
|
692
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
|
|
693
|
+
pollTickForTest();
|
|
694
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
695
|
+
expect(getTask(task.taskId)?.reason).toBe("killed"); // D6-en 读回(非 timeout)
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("AI bash_kill × timeout overlap: killed intent survives (no 'timed out' misreport)", async () => {
|
|
699
|
+
const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
|
|
700
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
701
|
+
const { task } = spawned;
|
|
702
|
+
const killed = JSON.parse(await killTool(task.taskId)) as { killed: boolean };
|
|
703
|
+
expect(killed.killed).toBe(true);
|
|
704
|
+
await sleep(500); // 进程死亡 + reap(deadline 之前)
|
|
705
|
+
expect(isPidAlive(task.pid)).toBe(false);
|
|
706
|
+
await sleep(700); // 越过 deadline:pid 已死 → 跳过 timeout mark
|
|
707
|
+
expect(getTask(task.taskId)?.intent?.reason).toBe("killed"); // 未被覆盖为 timeout
|
|
708
|
+
expect(killTreeCalls.filter((pid) => pid === task.pid)).toHaveLength(1); // 无第二次 kill
|
|
709
|
+
pollTickForTest();
|
|
710
|
+
expect(getTask(task.taskId)?.state).toBe("exited");
|
|
711
|
+
expect(getTask(task.taskId)?.reason).toBe("killed"); // 加固后保持 killed(无通知路径)
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("natural death before the deadline: no timeout mark; poll edge finalizes natural (accepted shift)", async () => {
|
|
715
|
+
const before = killTreeCalls.length;
|
|
716
|
+
const spawned = spawnBg("sleep 0.3", { timeoutSec: 1 });
|
|
717
|
+
if (!spawned.ok) throw new Error(spawned.error);
|
|
718
|
+
const { task } = spawned;
|
|
719
|
+
await sleep(600); // 自然死亡 + reap,deadline(1s) 未到
|
|
720
|
+
expect(getTask(task.taskId)?.state).toBe("running"); // 轮询未收尾(不手动 tick)
|
|
721
|
+
await sleep(600); // 越过 deadline(累计 ~1.2s,仍在 2s 轮询 tick 之前)
|
|
722
|
+
expect(killTreeCalls.length).toBe(before); // 死任务不补杀
|
|
723
|
+
expect(getTask(task.taskId)?.state).toBe("running"); // 未被标 killing
|
|
724
|
+
expect(getTask(task.taskId)?.intent).toBeUndefined();
|
|
725
|
+
expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
|
|
726
|
+
pollTickForTest();
|
|
727
|
+
expect(getTask(task.taskId)?.reason).toBe("natural"); // 语义微移:natural 非 timeout
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
|
|
565
731
|
describe("process-exit reap (D12)", () => {
|
|
566
732
|
it("reaps all active tasks with reason process-exit, kills pids, writes registry terminal", async () => {
|
|
567
733
|
const first = spawnBg("sleep 30");
|
|
@@ -571,7 +737,8 @@ describe("process-exit reap (D12)", () => {
|
|
|
571
737
|
expect(pids.every((pid) => isPidAlive(pid))).toBe(true);
|
|
572
738
|
|
|
573
739
|
reapBackgroundTasksNow();
|
|
574
|
-
|
|
740
|
+
// SIGKILL 发出到进程表移除是异步的:轮询等 pid 判死(满载下延迟不可预估)
|
|
741
|
+
await pollUntil(() => pids.every((pid) => !isPidAlive(pid)), 5000, "reaped pids to die");
|
|
575
742
|
|
|
576
743
|
for (const pid of pids) {
|
|
577
744
|
expect(isPidAlive(pid)).toBe(false);
|
|
@@ -596,6 +763,8 @@ describe("D15: abort signal does not propagate to background tasks", () => {
|
|
|
596
763
|
controller.abort();
|
|
597
764
|
expect(controller.signal.aborted).toBe(true);
|
|
598
765
|
|
|
766
|
+
// 非断言性观察窗口(留事件传播时间):断言的是「任务不受 abort 影响」的
|
|
767
|
+
// 负向不变量,期望状态恒真,轮询语义不适用
|
|
599
768
|
await sleep(300);
|
|
600
769
|
// 任务不受中断影响:进程活、状态 running
|
|
601
770
|
expect(isPidAlive(task.pid)).toBe(true);
|
|
@@ -64,7 +64,7 @@ function useConfig(config: Record<string, unknown>): void {
|
|
|
64
64
|
|
|
65
65
|
afterEach(() => {
|
|
66
66
|
for (const dir of configTempDirs.splice(0)) {
|
|
67
|
-
rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
68
68
|
}
|
|
69
69
|
agentDirRef.dir = "/tmp/bte-fake-agent-dir";
|
|
70
70
|
clearConfigCache();
|
|
@@ -127,7 +127,7 @@ describe("session_start chain: reconcile (M3, reap sunk into runtime by u-bte-re
|
|
|
127
127
|
reason: "cancelled",
|
|
128
128
|
});
|
|
129
129
|
} finally {
|
|
130
|
-
rmSync(dataDir, { recursive: true, force: true });
|
|
130
|
+
rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
131
131
|
dataDirRef.dir = "/tmp/bte-fake-agent-dir";
|
|
132
132
|
resetNotifyForTest();
|
|
133
133
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/__tests__/kill-tree.test.ts —— 进程树 kill 真实进程验证(POSIX 进程组语义)
|
|
2
2
|
// + pid 身份判据(getProcessStartTimeSec / pidStartMatchesRegistered——原 reaper.test.ts
|
|
3
3
|
// 覆盖平移:收殓下沉 runtime 后 helper 移入本文件,kill 侧自防御仍消费)
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
|
|
6
6
|
import { describe, expect, it, vi } from "vitest";
|
|
7
7
|
|
|
@@ -18,6 +18,30 @@ function sleep(ms: number): Promise<void> {
|
|
|
18
18
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 轮询直到期望状态出现或超时:真实进程事件(退出 → libuv reap → pid 释放)在满载
|
|
23
|
+
* 下延迟不可预估,固定 sleep 后单次断言是满载 flake 源——等状态而非猜时刻。
|
|
24
|
+
*/
|
|
25
|
+
async function pollUntil(check: () => boolean, deadlineMs: number, what: string): Promise<void> {
|
|
26
|
+
const deadline = Date.now() + deadlineMs;
|
|
27
|
+
while (!check()) {
|
|
28
|
+
if (Date.now() > deadline) {
|
|
29
|
+
throw new Error(`timed out after ${deadlineMs}ms waiting for ${what}`);
|
|
30
|
+
}
|
|
31
|
+
await sleep(25);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** pgrep -P 组内枚举:列组长直接子 pid(仅本进程组内,非全机扫描)。 */
|
|
36
|
+
function listChildPids(pid: number): number[] {
|
|
37
|
+
const result = spawnSync("/usr/bin/pgrep", ["-P", String(pid)], { encoding: "utf8" });
|
|
38
|
+
// exit 1 = 无匹配(空表正常态);解析失败按空表处理,由轮询重试兜住 spawn 竞态
|
|
39
|
+
return (result.stdout ?? "")
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map((line) => Number.parseInt(line.trim(), 10))
|
|
42
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
21
45
|
/** 真实 detached spawn(与本包后台任务同款形态:自成进程组)。 */
|
|
22
46
|
function spawnDetached(command: string) {
|
|
23
47
|
const child = spawn("/bin/sh", ["-c", command], {
|
|
@@ -38,8 +62,8 @@ describe("isPidAlive", () => {
|
|
|
38
62
|
const child = spawnDetached("true");
|
|
39
63
|
const pid = child.pid;
|
|
40
64
|
if (pid === undefined) throw new Error("no pid");
|
|
41
|
-
//
|
|
42
|
-
await
|
|
65
|
+
// 轮询等退出 + libuv reap(SIGCHLD → waitpid 后 pid 释放)
|
|
66
|
+
await pollUntil(() => !isPidAlive(pid), 5000, "pid to be freed after reap");
|
|
43
67
|
expect(isPidAlive(pid)).toBe(false);
|
|
44
68
|
});
|
|
45
69
|
|
|
@@ -55,29 +79,41 @@ describe("killProcessTree (process group semantics)", () => {
|
|
|
55
79
|
const child = spawnDetached("sleep 30 & sleep 30 & wait");
|
|
56
80
|
const pid = child.pid;
|
|
57
81
|
if (pid === undefined) throw new Error("no pid");
|
|
58
|
-
|
|
82
|
+
// 轮询等两个孙子 sleep 真正 fork 出来,并在 kill 之前用组内枚举(pgrep -P)
|
|
83
|
+
// 记录孙子 pid 快照——kill 后只对这些记录过的 pid 断言,禁止全机 pgrep -f
|
|
84
|
+
// 扫描(满载并行时其他包测试会真实 spawn 同名命令,跨包互踩)
|
|
85
|
+
let grandchildPids: number[] = [];
|
|
86
|
+
await pollUntil(
|
|
87
|
+
() => {
|
|
88
|
+
grandchildPids = listChildPids(pid);
|
|
89
|
+
return isPidAlive(pid) && grandchildPids.length >= 2;
|
|
90
|
+
},
|
|
91
|
+
5000,
|
|
92
|
+
"grandchildren to spawn inside the group",
|
|
93
|
+
);
|
|
59
94
|
expect(isPidAlive(pid)).toBe(true);
|
|
60
95
|
|
|
61
96
|
killProcessTree(pid);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
// 轮询组长死(等 libuv reap,deadline 5s)
|
|
98
|
+
await pollUntil(() => !isPidAlive(pid), 5000, "group leader to die");
|
|
99
|
+
|
|
100
|
+
// 记录过的孙子 pid 也不得残留。覆盖面说明:killProcessTree 是进程组 kill
|
|
101
|
+
// (POSIX kill -- -<pgid>),孙子与组长同组、组长死后 reparent 不换组,
|
|
102
|
+
// 故「组 kill + 组内 pid 记录」已覆盖「reparent 后孙子也能被杀」的原始意图;
|
|
103
|
+
// pid 复用概率在 5s 轮询窗口内可忽略
|
|
104
|
+
await pollUntil(
|
|
105
|
+
() => grandchildPids.every((gpid) => !isPidAlive(gpid)),
|
|
106
|
+
5000,
|
|
107
|
+
"recorded grandchildren to die",
|
|
108
|
+
);
|
|
74
109
|
});
|
|
75
110
|
|
|
76
111
|
it("killing an already-dead pid is a silent no-op", async () => {
|
|
77
112
|
const child = spawnDetached("true");
|
|
78
113
|
const pid = child.pid;
|
|
79
114
|
if (pid === undefined) throw new Error("no pid");
|
|
80
|
-
|
|
115
|
+
// 轮询等死透再 kill:断言语义是「对已死 pid 发令为无害 no-op」
|
|
116
|
+
await pollUntil(() => !isPidAlive(pid), 5000, "pid to die");
|
|
81
117
|
expect(() => killProcessTree(pid)).not.toThrow();
|
|
82
118
|
});
|
|
83
119
|
});
|
|
@@ -96,7 +132,8 @@ describe("getProcessStartTimeSec (platform probe sanity)", () => {
|
|
|
96
132
|
const child = spawnDetached("sleep 0.1");
|
|
97
133
|
const pid = child.pid;
|
|
98
134
|
if (pid === undefined) throw new Error("no pid");
|
|
99
|
-
|
|
135
|
+
// 轮询等退出 + libuv reap(满载下延迟不可预估)
|
|
136
|
+
await pollUntil(() => getProcessStartTimeSec(pid) === undefined, 5000, "ps record to vanish");
|
|
100
137
|
expect(getProcessStartTimeSec(pid)).toBeUndefined();
|
|
101
138
|
});
|
|
102
139
|
});
|
|
@@ -50,6 +50,27 @@ function sleep(ms: number): Promise<void> {
|
|
|
50
50
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 真实子进程链(spawn+exec+sleep+exit)满载下延迟不可预估,deadline 放宽到 10s。
|
|
55
|
+
*/
|
|
56
|
+
const POLL_DEADLINE_MS = 10_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 轮询直到期望状态出现或超时:每次迭代先 pollTickForTest()(手动 tick 推进状态机)
|
|
60
|
+
* 再检查——固定 sleep 后单次 tick 单次断言是满载 flake 源,等状态而非猜时刻。
|
|
61
|
+
*/
|
|
62
|
+
async function pollUntilTicked(check: () => boolean, what: string): Promise<void> {
|
|
63
|
+
const deadline = Date.now() + POLL_DEADLINE_MS;
|
|
64
|
+
for (;;) {
|
|
65
|
+
pollTickForTest();
|
|
66
|
+
if (check()) return;
|
|
67
|
+
if (Date.now() > deadline) {
|
|
68
|
+
throw new Error(`timed out after ${POLL_DEADLINE_MS}ms waiting for ${what}`);
|
|
69
|
+
}
|
|
70
|
+
await sleep(25);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
function spawnBg(command: string) {
|
|
54
75
|
return spawnBackgroundTask({
|
|
55
76
|
command,
|
|
@@ -154,8 +175,11 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
|
|
|
154
175
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
155
176
|
const { task } = spawned;
|
|
156
177
|
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
// 轮询到 exit 边沿收尾(handleTaskExit 同步完成 unregister emit + sendMessage)
|
|
179
|
+
await pollUntilTicked(
|
|
180
|
+
() => pi.events.emit.mock.calls.some((c) => c[0] === "pending:unregister"),
|
|
181
|
+
"pending:unregister (completed)",
|
|
182
|
+
);
|
|
159
183
|
|
|
160
184
|
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
161
185
|
id: task.taskId,
|
|
@@ -184,8 +208,8 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
|
|
|
184
208
|
|
|
185
209
|
markKillingIntent(task.taskId, "killed");
|
|
186
210
|
killProcessTree(task.pid);
|
|
187
|
-
|
|
188
|
-
|
|
211
|
+
// 轮询到 kill 边沿收尾(SIGKILL 生效 + tick 终态化)
|
|
212
|
+
await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", "reason=killed finalization");
|
|
189
213
|
|
|
190
214
|
expect(getTask(task.taskId)?.reason).toBe("killed");
|
|
191
215
|
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
@@ -218,8 +242,11 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
|
|
|
218
242
|
if (!spawned.ok) throw new Error(spawned.error);
|
|
219
243
|
const { task } = spawned;
|
|
220
244
|
|
|
221
|
-
|
|
222
|
-
|
|
245
|
+
// 轮询到 exit 边沿收尾(reason failed)
|
|
246
|
+
await pollUntilTicked(
|
|
247
|
+
() => pi.events.emit.mock.calls.some((c) => c[0] === "pending:unregister"),
|
|
248
|
+
"pending:unregister (failed)",
|
|
249
|
+
);
|
|
223
250
|
|
|
224
251
|
expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
|
|
225
252
|
id: task.taskId,
|
|
@@ -297,8 +324,14 @@ describe("D17: pi reference refresh (session replacement takeover)", () => {
|
|
|
297
324
|
const first = spawnBg("sleep 0.3 && echo one");
|
|
298
325
|
const second = spawnBg("sleep 0.3 && echo two");
|
|
299
326
|
if (!first.ok || !second.ok) throw new Error("spawn failed");
|
|
300
|
-
|
|
301
|
-
|
|
327
|
+
// 边沿回调内部全捕获:轮询到两条任务都完成终态化(每迭代 tick 后查)
|
|
328
|
+
await pollUntilTicked(
|
|
329
|
+
() =>
|
|
330
|
+
getTask(first.task.taskId)?.state === "exited" &&
|
|
331
|
+
getTask(second.task.taskId)?.state === "exited",
|
|
332
|
+
"both tasks to finalize",
|
|
333
|
+
);
|
|
334
|
+
// tick 不抛(异常全捕获)
|
|
302
335
|
expect(() => pollTickForTest()).not.toThrow();
|
|
303
336
|
expect(getTask(first.task.taskId)?.state).toBe("exited");
|
|
304
337
|
expect(getTask(second.task.taskId)?.state).toBe("exited");
|
package/src/background/poller.ts
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
|
|
16
16
|
import { isPidAlive } from "../kill-tree.ts";
|
|
17
17
|
import { readTailSummary } from "./output-tail.ts";
|
|
18
|
-
import { taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
|
|
18
|
+
import { readRegistry, taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
|
|
19
19
|
import { finalizeTask, getActiveTasks } from "./task-store.ts";
|
|
20
|
-
import type { BackgroundTask } from "./types.ts";
|
|
20
|
+
import type { BackgroundTask, BackgroundTaskEndReason } from "./types.ts";
|
|
21
21
|
|
|
22
22
|
/** 轮询间隔(设计文档 §3.5:约 2s)。 */
|
|
23
23
|
export const POLL_INTERVAL_MS = 2000;
|
|
@@ -71,13 +71,14 @@ export function pollTickForTest(): void {
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* exit 边沿收尾(单一终态归属):读 exitCode → 组装 tail 摘要 → reason 判定
|
|
74
|
-
*
|
|
75
|
-
* "
|
|
76
|
-
*
|
|
74
|
+
* (内存 intent 优先:intent.killed → "killed"、intent.timeout → "timeout";intent
|
|
75
|
+
* 缺省 → 读回 registry:state==="killing" → "killed"(D6-en,跨进程 UI 代杀预写),
|
|
76
|
+
* 否则/读失败 → "natural";"process-exit" 由收殓路径直接调 finalizeTask,不经这里)
|
|
77
|
+
* → 单例表 + registry 两侧写终态 → 触发 onTaskExit 回调(M3 接入点)。
|
|
77
78
|
*/
|
|
78
79
|
function finalizeExitedTask(task: BackgroundTask): void {
|
|
79
80
|
const exitCode = task.child?.exitCode ?? null;
|
|
80
|
-
const reason = task.intent
|
|
81
|
+
const reason = task.intent !== undefined ? task.intent.reason : readBackReasonFromRegistry(task);
|
|
81
82
|
const endedAt = Date.now();
|
|
82
83
|
const tailSummary = readTailSummary(task.outputFile);
|
|
83
84
|
const finalized = finalizeTask(task.taskId, { exitCode, reason, endedAt, tailSummary });
|
|
@@ -85,3 +86,19 @@ function finalizeExitedTask(task: BackgroundTask): void {
|
|
|
85
86
|
writeRegistryEntry(finalized.registryPath, taskToRegistryEntry(finalized));
|
|
86
87
|
onTaskExitCallback?.(finalized);
|
|
87
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* D6-en intent 读回:内存 intent 缺省时读回本条目的 registry 条目——
|
|
92
|
+
* state==="killing" ↔ reason=killed。跨进程 UI 代杀(runtime kill handler)只写
|
|
93
|
+
* registry 侧 killing(对 pi 内存表不可见),且 killing 条目落盘剥离 intent
|
|
94
|
+
* (taskToRegistryEntry),state 字段是唯一可读信号;判 killed 后 handleTaskExit
|
|
95
|
+
* 不 sendMessage → 主路径 AI 零感知(background-task-sidebar-view 设计 D6-en)。
|
|
96
|
+
* 读失败/条目缺失/状态非 killing → 按无 intent("natural")处理,不阻塞终态化
|
|
97
|
+
* (readRegistry 对读失败/损坏一律返回空表,读侧无锁依赖 tmp+rename 原子写)。
|
|
98
|
+
* 内存 intent 存在时不进入本函数(本进程 bash_kill/timeout 预写是内存权威,
|
|
99
|
+
* 不读回——见 task-store.ts 头部不变量登记)。
|
|
100
|
+
*/
|
|
101
|
+
function readBackReasonFromRegistry(task: BackgroundTask): BackgroundTaskEndReason {
|
|
102
|
+
const entry = readRegistry(task.registryPath).get(task.taskId);
|
|
103
|
+
return entry?.state === "killing" ? "killed" : "natural";
|
|
104
|
+
}
|
|
@@ -222,9 +222,15 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
/**
|
|
225
|
-
* 后台显式 timeout(D6:任务寿命可由使用者显式约束)。到点:pid
|
|
225
|
+
* 后台显式 timeout(D6:任务寿命可由使用者显式约束)。到点:pid 身份校验通过 →
|
|
226
226
|
* kill-tree + 两侧标 killing intent(reason 候选 timeout)——实际终态由轮询器边沿
|
|
227
|
-
*
|
|
227
|
+
* 收尾写(单一终态归属),此处不写终态。身份校验不过(登记原进程已不在:已死或
|
|
228
|
+
* pid 复用嫌疑)→ 跳过 kill **且跳过 intent 标记**(D6-en/R2-S1 加固):无条件
|
|
229
|
+
* markKillingIntent("timeout") 会把 UI 代杀 / AI bash_kill 刚预写的 killed 改写成
|
|
230
|
+
* timeout → reason=timeout → sendMessage 误报「timed out」唤醒 AI;跳过后由轮询器
|
|
231
|
+
* 边沿按事实收尾(自然死亡 → natural;registry 预写 killing → 经 D6-en 读回判
|
|
232
|
+
* killed)。接受语义微移:到期前 ~2s 内自然死亡的任务改标 natural(仅影响 AI
|
|
233
|
+
* 通知文案)。
|
|
228
234
|
*/
|
|
229
235
|
function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
|
|
230
236
|
const timer = setTimeout(() => {
|
|
@@ -240,9 +246,14 @@ function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
|
|
|
240
246
|
startedAt: task.startedAt,
|
|
241
247
|
},
|
|
242
248
|
});
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
// D6-en/R2-S1 加固:登记原进程已不在时同样跳过 intent 标记——内存 intent
|
|
250
|
+
// 是本进程权威(task-store.ts 头部不变量),被无条件改写为 timeout 即丢失
|
|
251
|
+
// killed 语义(D6-en 读回与 AI bash_kill 预写双双失效)。此处提前 return
|
|
252
|
+
// 时轮询器必在跑(本任务仍是活跃条目,惰性自停条件是活跃数为 0),边沿
|
|
253
|
+
// 收尾不受影响。
|
|
254
|
+
return;
|
|
245
255
|
}
|
|
256
|
+
killProcessTree(task.pid);
|
|
246
257
|
const marked = markKillingIntent(task.taskId, "timeout");
|
|
247
258
|
if (marked === undefined) return;
|
|
248
259
|
writeRegistryEntry(marked.registryPath, taskToRegistryEntry(marked));
|
|
@@ -12,6 +12,20 @@
|
|
|
12
12
|
*
|
|
13
13
|
* 终态条目 LRU 上限 MAX_TERMINAL_ENTRIES(与 registry 对称),淘汰后 bash_output
|
|
14
14
|
* 回落 registry 查询。
|
|
15
|
+
*
|
|
16
|
+
* 【不变量登记(D6-en,注释级)】extension 对某条目的**每次 registry 写都先经该
|
|
17
|
+
* 条目的内存状态更新**——registry 侧 killing 状态因此恒为内存态的投影,runtime
|
|
18
|
+
* 跨进程预写的 killing 不会被 stale 内存态冲回,D6-en intent 读回(poller.ts)的
|
|
19
|
+
* 正确性依赖此约束。全部 5 个 writeRegistryEntry 调用点逐一成立:
|
|
20
|
+
* ① spawn-background.ts spawnBackgroundTask:registerSpawnedTask(新条目首登记)
|
|
21
|
+
* 先于 registry 写 running;
|
|
22
|
+
* ② bash-kill-tool.ts:markKillingIntent(taskId,"killed") 先于 registry 写 killing;
|
|
23
|
+
* ③ spawn-background.ts armBackgroundTimeout:markKillingIntent(taskId,"timeout")
|
|
24
|
+
* 先于 registry 写 killing(pid 已死路径提前 return,不触达写点);
|
|
25
|
+
* ④ poller.ts finalizeExitedTask:finalizeTask(内存终态化)先于 registry 写 exited;
|
|
26
|
+
* ⑤ process-exit-guard.ts reapBackgroundTasksNow:finalizeTask 先于 registry 写
|
|
27
|
+
* exited(process-exit)(设计枚举 4 点时未列本点,实际同序成立,一并登记)。
|
|
28
|
+
* 未来新增写路径(如 maintenance 类)必须保持该顺序,违反即 intent 可丢失。
|
|
15
29
|
*/
|
|
16
30
|
|
|
17
31
|
import {
|
|
@@ -61,7 +75,8 @@ export function oldestActiveTask(): BackgroundTask | undefined {
|
|
|
61
75
|
|
|
62
76
|
/**
|
|
63
77
|
* 标 killing intent(瞬态 running→killing)。bash_kill 与后台 timeout 定时器共用;
|
|
64
|
-
* 调用方负责同步写 registry
|
|
78
|
+
* 调用方负责同步写 registry 侧(两侧一致是查询面可见性前提;先内存后 registry
|
|
79
|
+
* 的顺序约束见文件头部不变量登记)。
|
|
65
80
|
*/
|
|
66
81
|
export function markKillingIntent(
|
|
67
82
|
taskId: string,
|
package/src/bash-output-tool.ts
CHANGED
|
@@ -32,6 +32,8 @@ const JSON_INDENT = 2;
|
|
|
32
32
|
const BASH_OUTPUT_DESCRIPTION = [
|
|
33
33
|
"Fetch output and status of a background bash task started with bash {background:true}.",
|
|
34
34
|
"Provide task_id to get task detail: state (running|killing|exited|orphaned), exitCode, reason, duration and tail output (last 2000 lines / 50KB).",
|
|
35
|
+
'reason "killed" means the task was terminated by the user from the UI — do not restart the command unless explicitly asked.',
|
|
36
|
+
'reason "orphaned" means the owner process died before the task finished — the task is no longer supervised.',
|
|
35
37
|
"Omit task_id to list known background tasks (process-wide; may include tasks started under earlier sessions).",
|
|
36
38
|
].join("\n");
|
|
37
39
|
|