@zhushanwen/pi-base-tool-enhance 0.10.0 → 0.10.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-base-tool-enhance",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design decisions documented in source comments)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -28,12 +28,12 @@
28
28
  "skills/"
29
29
  ],
30
30
  "dependencies": {
31
- "@zhushanwen/extension-protocol": "0.22.0",
32
- "@zhushanwen/pi-delivery-port": "0.2.0",
31
+ "@zhushanwen/extension-protocol": "0.24.0",
32
+ "@zhushanwen/pi-delivery-port": "0.3.0",
33
33
  "@zhushanwen/pi-ext-guards": "0.5.0",
34
34
  "@zhushanwen/pi-extension-logger": "0.6.2",
35
35
  "@zhushanwen/pi-file-lock": "0.4.1",
36
- "@zhushanwen/pi-llm-shared": "0.11.3"
36
+ "@zhushanwen/pi-llm-shared": "0.12.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@vitest/coverage-v8": "^4.1.9",
@@ -43,7 +43,7 @@
43
43
  "@earendil-works/pi-coding-agent": "^1.0.0",
44
44
  "@earendil-works/pi-tui": "^1.0.0",
45
45
  "typebox": "1.3.27",
46
- "@zhushanwen/pi-pending-notifications": "0.10.0"
46
+ "@zhushanwen/pi-pending-notifications": "0.11.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "@earendil-works/pi-tui": {
@@ -1,21 +1,57 @@
1
- // src/__tests__/background-lifecycle.test.ts —— background 核心生命周期集成:
1
+ // src/__tests__/background-lifecycle.test.ts —— background 核心生命周期:
2
2
  // 真实 spawn 边沿收尾 / exit 事件自动收尾 / killing intent 双侧 / timeout / 并发上限 /
3
3
  // 收殓 / D15 abort / bash_output / bash_kill(黑盒:经工具 execute 断言使用者可见行为)
4
+ //
5
+ // 轨道拆分(单测层极端测试整治第二波):
6
+ // - 单测层(本文件主体)= 进程端点桩化:vi.mock 把 node:child_process 的 spawn 换成
7
+ // 桩 ChildProcess(EventEmitter + pid/exitCode/unref),protocol 判活 / start time
8
+ // 读取对桩 pid 截获,kill-tree 只记录不转发。被测逻辑——单例表、registry 锁内
9
+ // RMW、killing intent 双侧标记、timeout 定时器杀链编排、exit 边沿收尾、通知入账
10
+ // ——全部真跑;exit 事件由 emitFakeExit 显式派发(exitCode 先落再派发,对齐真
11
+ // ChildProcess 语义),确定性同步、无等待窗口。
12
+ // - 集成组(文件尾门控 describe,TAIJI_TEST_INTEGRATION=1 真跑)= 5 条真子进程核心:
13
+ // spawn+自然退出全链 / bash_kill 真杀进程树 / timeout 真杀链 / 进程退出收殓真杀 /
14
+ // 真实时钟下自然完成不补杀。真子进程满载下延迟不可预估(无确定性完成信号),
15
+ // 属「代码 + OS 组合行为」,本地默认跳过(门控 SSOT = test-guard/integration-gate.ts)。
16
+ // - 预算:单测用例走 vitest 默认;集成组用例单独挂 20s(原文件级预算随拆组下沉到
17
+ // 用例级,非抬升),轮询 deadline 常量(10s / 8s)仅集成组消费。
4
18
  import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
5
19
  import { tmpdir } from "node:os";
6
20
  import { join } from "node:path";
21
+ import { EventEmitter } from "node:events";
7
22
 
8
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
24
  import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
25
 
11
- vi.setConfig({ testTimeout: 20000 });
26
+ import { INTEGRATION_TESTS_ENABLED, INTEGRATION_TESTS_SKIP_REASON } from "../../../../../test-guard/integration-gate.ts";
12
27
 
13
28
  // getAgentDir → 测试临时数据目录(工具 execute 内部调用,不写真 ~/.pi/agent)。
14
29
  // [session-store-layout-inversion 域 5] 工具的 dataDir 来源已换为 resolveTaijiDataDir()
15
30
  // (env 注入)——getAgentDir mock 随换根退役,改 beforeEach stubEnv + afterEach 还原。
16
- // 进程原语(ext-simplify-13 后单点在 protocol 子出口)转发真实杀 + 记录调用
17
- //(timeout 分支断言 killProcessTree 被调)
18
- const { killTreeCalls } = vi.hoisted(() => ({ killTreeCalls: [] as number[] }));
31
+ //
32
+ // 进程端点桩(单测层):vi.mock 两处——
33
+ // 1) node:child_process.spawn:spawnMode.real=false(默认)返回桩 ChildProcess,
34
+ // 集成组 describe 的 beforeEach 置 true 走真 spawn;
35
+ // 2) @zhushanwen/extension-protocol/background-task 的进程原语(kill-tree 发令 /
36
+ // pid 判活 / start time 读取):桩 pid 截获(fakePids 登记表),真 pid 原样转发
37
+ // (timeout 分支断言 killProcessTree 被调;集成组真杀)。
38
+ const { killTreeCalls, fakePids, spawnMode, fakeChildSeq } = vi.hoisted(() => ({
39
+ killTreeCalls: [] as number[],
40
+ fakePids: new Map<number, { alive: boolean; startSec?: number }>(),
41
+ spawnMode: { real: false },
42
+ fakeChildSeq: { n: 0 },
43
+ }));
44
+
45
+ vi.mock("node:child_process", async (importOriginal) => {
46
+ const orig = await importOriginal<typeof import("node:child_process")>();
47
+ const realSpawn = orig.spawn;
48
+ return {
49
+ ...orig,
50
+ spawn: (...args: Parameters<typeof realSpawn>) =>
51
+ spawnMode.real ? realSpawn(...args) : fakeSpawn(),
52
+ };
53
+ });
54
+
19
55
  vi.mock("@zhushanwen/extension-protocol/background-task", async (importOriginal) => {
20
56
  const orig = await importOriginal<
21
57
  typeof import("@zhushanwen/extension-protocol/background-task")
@@ -24,8 +60,18 @@ vi.mock("@zhushanwen/extension-protocol/background-task", async (importOriginal)
24
60
  ...orig,
25
61
  killProcessTree: (pid: number, onFallback?: (step: string, err: unknown) => void): void => {
26
62
  killTreeCalls.push(pid);
63
+ // 桩 pid 上无进程可杀,不转发真信号;真 pid(集成组真杀 / 误杀守卫断言对象)原样转发
64
+ if (fakePids.has(pid)) return;
27
65
  orig.killProcessTree(pid, onFallback);
28
66
  },
67
+ isPidAlive: (pid: number): boolean => {
68
+ const fake = fakePids.get(pid);
69
+ return fake === undefined ? orig.isPidAlive(pid) : fake.alive;
70
+ },
71
+ getProcessStartTimeSec: (pid: number): number | undefined => {
72
+ const fake = fakePids.get(pid);
73
+ return fake === undefined ? orig.getProcessStartTimeSec(pid) : fake.startSec;
74
+ },
29
75
  };
30
76
  });
31
77
 
@@ -36,7 +82,7 @@ import { createBashOutputToolDefinition } from "../bash-output-tool.ts";
36
82
  import { handleTaskExit, refreshPiReference, resetNotifyForTest } from "../background/notify.ts";
37
83
  import { setOnTaskExit } from "../background/exit-collector.ts";
38
84
  import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "../background/registry.ts";
39
- import type { RegistryEntry } from "../background/types.ts";
85
+ import type { BackgroundTask, RegistryEntry } from "../background/types.ts";
40
86
  import { DEFAULT_MAX_CONCURRENT_BACKGROUND, spawnBackgroundTask } from "../background/spawn-background.ts";
41
87
  import { clearTaskStoreForTest, getActiveTasks, getTask, markKillingIntent } from "../background/task-store.ts";
42
88
  import { reapBackgroundTasksNow, resetProcessExitGuardForTest } from "../background/process-exit-guard.ts";
@@ -72,6 +118,45 @@ function sleep(ms: number): Promise<void> {
72
118
  return new Promise((resolve) => setTimeout(resolve, ms));
73
119
  }
74
120
 
121
+ // ──────────────────────── 进程端点桩(单测层专用) ────────────────────────
122
+
123
+ /** 桩 pid 基数:远离真实 pid 区间,fakePids 登记即截获判活/身份读取,与真 pid 互不串扰。 */
124
+ const FAKE_PID_BASE = 4_000_000;
125
+ /**
126
+ * 桩进程 start time(epoch 秒,恒定过去时刻):镜像 macOS ps 可读形态(spawn 即得
127
+ * pidStartTime);取过去值保证 ≤ 任何真实 startedAt 的秒级降级比较恒匹配
128
+ * (pidStartMatchesRegistered 的降级分支用「actual ≤ floor(startedAt/1000)」)。
129
+ */
130
+ const FAKE_START_SEC = 1_700_000_000;
131
+
132
+ /** 桩 ChildProcess 最小形状:BackgroundTask 只消费 pid / exitCode / unref / on('exit')。 */
133
+ type FakeChildProcess = EventEmitter & { pid: number; exitCode: number | null; unref: () => void };
134
+
135
+ /** 桩 spawn:登记 fake pid(默认存活 + 恒定 start time),返回可派发 exit 的假 ChildProcess。 */
136
+ function fakeSpawn(): FakeChildProcess {
137
+ const pid = FAKE_PID_BASE + ++fakeChildSeq.n;
138
+ fakePids.set(pid, { alive: true, startSec: FAKE_START_SEC });
139
+ return Object.assign(new EventEmitter(), {
140
+ pid,
141
+ exitCode: null as number | null,
142
+ unref: () => {},
143
+ });
144
+ }
145
+
146
+ /**
147
+ * 派发桩进程退出(单测层的 exit 事件边沿):exitCode 先落再 emit——对齐真
148
+ * ChildProcess 语义(finalizeExitedTask 从 child.exitCode 读码)。同步触发全部
149
+ * 收尾链(tail 摘要 → reason 判定 → 两侧终态写 → onTaskExit 回调),确定性完成。
150
+ */
151
+ function emitFakeExit(task: BackgroundTask, exitCode: number | null): void {
152
+ const child = task.child as FakeChildProcess | undefined;
153
+ if (child === undefined) throw new Error("fake child missing (already finalized)");
154
+ child.exitCode = exitCode;
155
+ child.emit("exit", exitCode, null);
156
+ }
157
+
158
+ // ──────────────────────── 集成组轮询原语(仅门控 describe 消费) ────────────────────────
159
+
75
160
  /**
76
161
  * 真实子进程链(spawn+exec+sleep+exit)满载下延迟不可预估,deadline 放宽到 10s;
77
162
  * timeout 路径单独放宽到 8s(任务语义内最慢的等待对象)。
@@ -105,6 +190,13 @@ function spawnBg(command: string, extra: { timeoutSec?: number; maxConcurrent?:
105
190
  });
106
191
  }
107
192
 
193
+ /** spawn 后台命令并断言受理成功(拒绝即炸出用例——正向用例样板收敛点)。 */
194
+ function spawnOk(command: string, extra: { timeoutSec?: number; maxConcurrent?: number } = {}) {
195
+ const spawned = spawnBg(command, extra);
196
+ if (!spawned.ok) throw new Error(spawned.error);
197
+ return spawned.task;
198
+ }
199
+
108
200
  /** 手写 registry 条目(模拟他进程/历史 session 写入的形状)。 */
109
201
  function makeRegistryEntry(overrides: Partial<RegistryEntry> & { taskId: string }): RegistryEntry {
110
202
  return {
@@ -126,7 +218,11 @@ beforeEach(() => {
126
218
  afterEach(() => {
127
219
  // env stub 逐用例还原(防同 worker 跨文件残留)
128
220
  vi.unstubAllEnvs();
129
- // 残留活跃任务先杀干净再清表(防句柄/进程泄漏影响后续测试)
221
+ // 进程端点桩复位:集成组的 real 开关关回、桩 pid 登记表清空(防截获串到下一用例)
222
+ spawnMode.real = false;
223
+ fakePids.clear();
224
+ // 残留活跃任务先杀干净再清表(防句柄/进程泄漏影响后续测试);桩 pid 无进程可杀
225
+ // (kill ESRCH 落 catch),真 pid(集成组)正常回收
130
226
  for (const task of getActiveTasks()) {
131
227
  try {
132
228
  process.kill(-task.pid, "SIGKILL");
@@ -152,26 +248,26 @@ afterAll(() => {
152
248
  rmSync(DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
153
249
  });
154
250
 
155
- describe("real spawn lifecycle (exit event edge finalization)", () => {
251
+ // ──────────────────────── 单测层:spawn + exit 边沿收尾(进程端点桩化) ────────────────────────
252
+
253
+ describe("spawn + exit edge finalization (stub process endpoint)", () => {
156
254
  it("short task: running → exited with exitCode 0, output file readable, registry terminal", async () => {
157
- // 断言只要求「exit 事件边沿前 state=running」与退出产物,不依赖命令时长(终态化只在 finalizeTask)
158
- const spawned = spawnBg("sleep 0.1 && echo done");
159
- if (!spawned.ok) throw new Error(spawned.error);
160
- const { task } = spawned;
255
+ const task = spawnOk("sleep 0.1 && echo done");
256
+ // spawn 登记即写 running 条目(含 ownerPiPid,M5 属主判定依据)
161
257
  expect(task.state).toBe("running");
162
258
  expect(existsSync(task.outputFile)).toBe(true);
163
- // registry 登记即写 running 条目(含 ownerPiPid,M5 属主判定依据)
164
259
  expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
165
260
  expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.ownerPiPid).toBe(process.pid);
166
261
 
167
- // 轮询等命令退出 + libuv reap(tick 收尾;满载下延迟不可预估)
168
- await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
262
+ // 桩端点:命令「输出」由测试直写输出文件(真 spawn 下由子进程写,内容同源)
263
+ writeFileSync(task.outputFile, "done\n", "utf8");
264
+ emitFakeExit(task, 0);
169
265
 
170
266
  const finalized = getTask(task.taskId);
171
267
  expect(finalized?.state).toBe("exited");
172
268
  expect(finalized?.exitCode).toBe(0);
173
269
  expect(finalized?.reason).toBe("natural");
174
- expect(finalized?.durationMs).toBeGreaterThan(0);
270
+ expect(finalized?.durationMs).toBeGreaterThanOrEqual(0);
175
271
 
176
272
  const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId);
177
273
  expect(registryEntry?.state).toBe("exited");
@@ -182,33 +278,27 @@ describe("real spawn lifecycle (exit event edge finalization)", () => {
182
278
  expect(detail.exitCode).toBe(0);
183
279
  });
184
280
 
185
- it("nonzero exit code is surfaced (exitCode 1)", async () => {
186
- // 断言只看 exitCode 透写,不依赖命令时长
187
- const spawned = spawnBg("sleep 0.1; exit 3");
188
- if (!spawned.ok) throw new Error(spawned.error);
189
- const { task } = spawned;
190
- await pollUntilTicked(() => getTask(task.taskId)?.exitCode === 3, POLL_DEADLINE_MS, "exitCode to surface");
281
+ it("nonzero exit code is surfaced (exitCode 1)", () => {
282
+ const task = spawnOk("sleep 0.1; exit 3");
283
+ emitFakeExit(task, 3);
191
284
  expect(getTask(task.taskId)?.exitCode).toBe(3);
192
- const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId);
193
- expect(registryEntry?.exitCode).toBe(3);
285
+ expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.exitCode).toBe(3);
194
286
  });
195
287
 
196
- it("onTaskExit callback (M3 hook point) fires with the finalized task", async () => {
288
+ it("onTaskExit callback (M3 hook point) fires with the finalized task", () => {
197
289
  const seen: string[] = [];
198
290
  setOnTaskExit((task) => seen.push(`${task.taskId}:${task.state}:${task.reason}`));
199
- const spawned = spawnBg("true");
200
- if (!spawned.ok) throw new Error(spawned.error);
201
- await pollUntilTicked(() => seen.length === 1, POLL_DEADLINE_MS, "onTaskExit callback to fire");
291
+ const task = spawnOk("true");
292
+ emitFakeExit(task, 0);
293
+ // 桩派发同步走完收尾链(无等待窗口),回调即时可见
202
294
  expect(seen).toHaveLength(1);
203
- expect(seen[0]).toBe(`${spawned.task.taskId}:exited:natural`);
295
+ expect(seen[0]).toBe(`${task.taskId}:exited:natural`);
204
296
  });
205
297
 
206
- it("spawned entry carries pidStartTime for kill-side precise comparison (M5→M3)", async () => {
207
- const spawned = spawnBg("sleep 30");
208
- if (!spawned.ok) throw new Error(spawned.error);
209
- const { task } = spawned;
210
- // 单例表与 registry 两侧均含字段(epoch 秒;ps 可用平台读取成功)
211
- expect(typeof task.pidStartTime).toBe("number");
298
+ it("spawned entry carries pidStartTime for kill-side precise comparison (M5→M3)", () => {
299
+ const task = spawnOk("sleep 30");
300
+ // 桩端点:spawn 即从身份原语读 start time 进条目(真 ps 读取形态由集成轨覆盖)
301
+ expect(task.pidStartTime).toBe(FAKE_START_SEC);
212
302
  const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId) as
213
303
  | { pidStartTime?: number }
214
304
  | undefined;
@@ -216,6 +306,8 @@ describe("real spawn lifecycle (exit event edge finalization)", () => {
216
306
  });
217
307
  });
218
308
 
309
+ // ──────────────────────── 单测层:bash_output 工具 ────────────────────────
310
+
219
311
  describe("bash_output tool", () => {
220
312
  it("description pins the auto-notify contract (on-demand checks only, DO NOT bash sleep)", () => {
221
313
  // 与 bash description / 启动回执同一契约话术(统一 "DO NOT bash sleep")——删改必须红灯
@@ -224,9 +316,8 @@ describe("bash_output tool", () => {
224
316
  });
225
317
 
226
318
  it("list merges store + registry terminal entries, store wins on same id", async () => {
227
- // 单例表:一个 running 真任务
228
- const spawned = spawnBg("sleep 5");
229
- if (!spawned.ok) throw new Error(spawned.error);
319
+ // 单例表:一个 running 桩任务
320
+ const task = spawnOk("sleep 5");
230
321
  // registry 手写:另一个终态历史条目(不在单例表——模拟已被 LRU 淘汰后回落)
231
322
  writeRegistryEntry(REGISTRY_PATH, {
232
323
  taskId: "bt-9000-hist",
@@ -245,14 +336,14 @@ describe("bash_output tool", () => {
245
336
  // registry 手写:与单例表同 id 的过期 exited 版本(须被单例表 running 覆盖)
246
337
  writeRegistryEntry(
247
338
  REGISTRY_PATH,
248
- taskToRegistryEntry({ ...spawned.task, state: "exited" as const, ownerPiPid: 1 }),
339
+ taskToRegistryEntry({ ...task, state: "exited" as const, ownerPiPid: 1 }),
249
340
  );
250
341
 
251
342
  const listed = JSON.parse(await outputTool({})) as {
252
343
  tasks: Array<{ task_id: string; state: string; command: string }>;
253
344
  };
254
345
  const byId = new Map(listed.tasks.map((t) => [t.task_id, t]));
255
- expect(byId.get(spawned.task.taskId)?.state).toBe("running"); // 单例表优先
346
+ expect(byId.get(task.taskId)?.state).toBe("running"); // 单例表优先
256
347
  expect(byId.get("bt-9000-hist")?.state).toBe("exited");
257
348
  // 命令展示截断(前 80 字符)
258
349
  expect(byId.get("bt-9000-hist")?.command.length).toBeLessThanOrEqual(81);
@@ -293,11 +384,8 @@ describe("bash_output tool", () => {
293
384
  });
294
385
 
295
386
  it("deleted output file degrades to <lost> without crashing (§3.6)", async () => {
296
- // 断言只看退出后输出文件缺失的降级路径,不依赖命令时长
297
- const spawned = spawnBg("sleep 0.1 && echo gone");
298
- if (!spawned.ok) throw new Error(spawned.error);
299
- const { task } = spawned;
300
- await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
387
+ const task = spawnOk("sleep 0.1 && echo gone");
388
+ emitFakeExit(task, 0);
301
389
  unlinkSync(task.outputFile);
302
390
  const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as {
303
391
  output: string;
@@ -308,34 +396,33 @@ describe("bash_output tool", () => {
308
396
  });
309
397
 
310
398
  it("output tail respects line cap", async () => {
311
- // 2100 行输出 → tail 只保留最后 2000 行
312
- const spawned = spawnBg("for i in $(seq 1 2100); do echo line$i; done");
313
- if (!spawned.ok) throw new Error(spawned.error);
314
- const { task } = spawned;
315
- // 轮询等 2100 行输出跑完退出收尾(满载下 shell 循环耗时不可预估)
316
- await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "output loop to finish");
399
+ // 2100 行输出 → tail 只保留最后 2000 行(输出内容由测试直写,截断逻辑真跑)
400
+ const task = spawnOk("for i in $(seq 1 2100); do echo line$i; done");
401
+ const lines = Array.from({ length: 2100 }, (_, i) => `line${i + 1}`);
402
+ writeFileSync(task.outputFile, `${lines.join("\n")}\n`, "utf8");
403
+ emitFakeExit(task, 0);
317
404
  const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as {
318
405
  output: string;
319
406
  truncated: boolean;
320
407
  };
321
- const lines = detail.output.split("\n");
322
- expect(lines.length).toBeLessThanOrEqual(2000);
408
+ const got = detail.output.split("\n");
409
+ expect(got.length).toBeLessThanOrEqual(2000);
323
410
  expect(detail.truncated).toBe(true);
324
411
  expect(detail.output).toContain("line2100");
325
412
  expect(detail.output).not.toContain("line50\n");
326
413
  });
327
414
  });
328
415
 
416
+ // ──────────────────────── 单测层:bash_kill 工具(killing intent 双侧) ────────────────────────
417
+
329
418
  describe("bash_kill tool (killing intent, single-point finalization)", () => {
330
419
  it("kill marks BOTH store and registry as killing before the exit event edge lands", async () => {
331
- const spawned = spawnBg("sleep 60");
332
- if (!spawned.ok) throw new Error(spawned.error);
333
- const { task } = spawned;
420
+ const task = spawnOk("sleep 60");
334
421
 
335
422
  const killResult = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
336
423
  expect(killResult.killed).toBe(true);
337
424
 
338
- // exit 边沿未到(sleep 60 才死、kill 后立即断言):两侧 killing 即可见,无倒挂
425
+ // exit 边沿未派发(sleep 60 语义下任务仍在):两侧 killing 即可见,无倒挂
339
426
  expect(getTask(task.taskId)?.state).toBe("killing");
340
427
  expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
341
428
  // kill-tree 确实对该 pid 发过令
@@ -343,12 +430,10 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
343
430
  });
344
431
 
345
432
  it("kill edge: intent consumed → exited with reason killed", async () => {
346
- const spawned = spawnBg("sleep 60");
347
- if (!spawned.ok) throw new Error(spawned.error);
348
- const { task } = spawned;
433
+ const task = spawnOk("sleep 60");
349
434
  await killTool(task.taskId);
350
- // 轮询到 kill 边沿收尾(SIGKILL 生效 + tick 终态化)
351
- await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", POLL_DEADLINE_MS, "killed finalization");
435
+ // 桩端点派发 kill 后的进程死亡(SIGKILL 终止 = exitCode null)
436
+ emitFakeExit(task, null);
352
437
  const finalized = getTask(task.taskId);
353
438
  expect(finalized?.state).toBe("exited");
354
439
  expect(finalized?.reason).toBe("killed");
@@ -367,11 +452,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
367
452
  });
368
453
 
369
454
  it("already exited task → killed:false with exit code", async () => {
370
- // 断言只看已终态条目的 kill 拒绝,不依赖命令时长
371
- const spawned = spawnBg("sleep 0.1");
372
- if (!spawned.ok) throw new Error(spawned.error);
373
- const { task } = spawned;
374
- await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
455
+ const task = spawnOk("sleep 0.1");
456
+ emitFakeExit(task, 0);
375
457
  const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
376
458
  expect(result.killed).toBe(false);
377
459
  expect(result.reason).toBe("already exited (code 0)");
@@ -416,10 +498,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
416
498
  });
417
499
 
418
500
  it("P0-2.2: pidStartTime mismatch (pid reuse suspected) → refuse kill", async () => {
419
- const spawned = spawnBg("sleep 30");
420
- if (!spawned.ok) throw new Error(spawned.error);
421
- const { task } = spawned;
422
- // 构造不匹配的登记值(真实进程 start time 之外的时间)——pid 活但身份不符
501
+ const task = spawnOk("sleep 30");
502
+ // 构造不匹配的登记值(桩身份原语读到的 start time 之外的时间)——pid 活但身份不符
423
503
  task.pidStartTime = (task.pidStartTime ?? 0) + 500;
424
504
 
425
505
  const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string; hint?: string };
@@ -431,24 +511,25 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
431
511
  });
432
512
 
433
513
  it("P0-2.2: matching pidStartTime → kill proceeds(start time 校验不误拦正常 kill)", async () => {
434
- const spawned = spawnBg("sleep 30");
435
- if (!spawned.ok) throw new Error(spawned.error);
436
- const { task } = spawned;
437
- // spawn 时读到的真实 start time 原样保留 → 校验通过,正常 kill 路径
514
+ const task = spawnOk("sleep 30");
515
+ // spawn 时身份原语读到的 start time 原样保留 → 校验通过,正常 kill 路径
438
516
  const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean };
439
517
  expect(result.killed).toBe(true);
440
518
  expect(killTreeCalls).toContain(task.pid);
441
519
  });
442
520
  });
443
521
 
522
+ // ──────────────────────── 单测层:并发上限(D10) ────────────────────────
523
+
444
524
  describe("concurrency cap (D10, default 8)", () => {
445
- it("rejects the 9th task with the oldest task id in the error", async () => {
525
+ it("rejects the 9th task with the oldest task id in the error", () => {
446
526
  const tasks: string[] = [];
447
527
  for (let i = 0; i < DEFAULT_MAX_CONCURRENT_BACKGROUND; i++) {
448
- const spawned = spawnBg("sleep 30");
449
- if (!spawned.ok) throw new Error(spawned.error);
450
- tasks.push(spawned.task.taskId);
451
- await sleep(20); // 错开 startedAt 保证「最老」判定稳定
528
+ const task = spawnOk("sleep 30");
529
+ // 桩端点同毫秒连发:直接改写条目 startedAt 保证「最老」判定确定有序
530
+ //(单例表持同一对象引用;真 spawn 形态靠错峰 sleep,此处无需)
531
+ task.startedAt = 1_000 + i;
532
+ tasks.push(task.taskId);
452
533
  }
453
534
  const ninth = spawnBg("echo should-fail");
454
535
  expect(ninth.ok).toBe(false);
@@ -471,36 +552,29 @@ describe("concurrency cap (D10, default 8)", () => {
471
552
  });
472
553
  });
473
554
 
555
+ // ──────────────────────── 单测层:显式后台 timeout(D6,fake timers 驱动) ────────────────────────
556
+
474
557
  describe("explicit background timeout (D6)", () => {
475
- it("fires kill-tree at the deadline and finalizes with reason timeout", async () => {
558
+ it("fires kill-tree at the deadline and finalizes with reason timeout", () => {
476
559
  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
477
- let taskId = "";
478
- let pid = -1;
560
+ let finalized: BackgroundTask | undefined;
479
561
  try {
480
562
  // fake 生效后 spawn:timeout 定时器进 fake 队列,由 advanceTimers 推进
481
- const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
482
- if (!spawned.ok) throw new Error(spawned.error);
483
- taskId = spawned.task.taskId;
484
- pid = spawned.task.pid;
563
+ const task = spawnOk("sleep 30", { timeoutSec: 1 });
485
564
 
486
565
  vi.advanceTimersByTime(1000);
487
566
  // 到点:kill-tree 已发令 + 两侧 killing intent(reason 候选 timeout)
488
- expect(killTreeCalls).toContain(pid);
489
- expect(getTask(taskId)?.state).toBe("killing");
490
- expect(getTask(taskId)?.intent?.reason).toBe("timeout");
491
- expect(readRegistry(REGISTRY_PATH).get(taskId)?.state).toBe("killing");
567
+ expect(killTreeCalls).toContain(task.pid);
568
+ expect(getTask(task.taskId)?.state).toBe("killing");
569
+ expect(getTask(task.taskId)?.intent?.reason).toBe("timeout");
570
+ expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
571
+
572
+ // 桩端点派发 kill 后的进程死亡:exit 边沿按 intent 收尾 → exited(reason:"timeout")
573
+ emitFakeExit(task, null);
574
+ finalized = getTask(task.taskId);
492
575
  } finally {
493
576
  vi.useRealTimers();
494
577
  }
495
-
496
- // SIGKILL 已真实发出:exit 事件边沿收尾 → exited(reason:"timeout"),终态由边沿写
497
- //(timeout 路径满载下 SIGKILL 生效延迟不可预估,deadline 放宽到 8s)
498
- await pollUntilTicked(
499
- () => getTask(taskId)?.state === "exited" && getTask(taskId)?.reason === "timeout",
500
- POLL_DEADLINE_SLOW_MS,
501
- "timeout finalization",
502
- );
503
- const finalized = getTask(taskId);
504
578
  expect(finalized?.state).toBe("exited");
505
579
  expect(finalized?.reason).toBe("timeout");
506
580
  });
@@ -508,10 +582,8 @@ describe("explicit background timeout (D6)", () => {
508
582
  it("P0-4/D6-en R2-S1: pid reuse suspected at the deadline → skip kill AND skip intent mark", () => {
509
583
  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
510
584
  try {
511
- const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
512
- if (!spawned.ok) throw new Error(spawned.error);
513
- const { task } = spawned;
514
- // 构造不匹配的登记值(真实进程 start time 之外的时间)——pid 活但身份不符,
585
+ const task = spawnOk("sleep 30", { timeoutSec: 1 });
586
+ // 构造不匹配的登记值(身份原语读到的 start time 之外的时间)——pid 活但身份不符,
515
587
  // 同 bash_kill P0-2.2 篡改范式
516
588
  task.pidStartTime = (task.pidStartTime ?? 0) + 500;
517
589
 
@@ -532,11 +604,9 @@ describe("explicit background timeout (D6)", () => {
532
604
  it("P0-4/D6-en R2-S1: missing pidStartTime degrades to startedAt check (mismatch → skip kill and intent mark)", () => {
533
605
  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
534
606
  try {
535
- const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
536
- if (!spawned.ok) throw new Error(spawned.error);
537
- const { task } = spawned;
607
+ const task = spawnOk("sleep 30", { timeoutSec: 1 });
538
608
  // 模拟 ps 不可用平台的 spawn(无 pidStartTime 字段)→ 降级 startedAt 秒级
539
- // 比较;startedAt 篡改为 epoch 1(实际进程 start time 远大于 0)→ 不匹配
609
+ // 比较;startedAt 篡改为 epoch 1(桩 start time 远大于 0)→ 不匹配
540
610
  task.pidStartTime = undefined;
541
611
  task.startedAt = 1;
542
612
 
@@ -554,11 +624,10 @@ describe("explicit background timeout (D6)", () => {
554
624
  it("P0-4: degradation match (recent startedAt) → kill proceeds(降级判据不误拦正常 timeout)", () => {
555
625
  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
556
626
  try {
557
- const spawned = spawnBg("sleep 30", { timeoutSec: 1 });
558
- if (!spawned.ok) throw new Error(spawned.error);
559
- const { task } = spawned;
627
+ const task = spawnOk("sleep 30", { timeoutSec: 1 });
560
628
  // 无 pidStartTime + 真实 startedAt(登记晚于 spawn → 原进程 start time 必然
561
- // ≤ floor(startedAt/1000),floor 单调性)→ 降级判据匹配,正常发 kill
629
+ // ≤ floor(startedAt/1000),floor 单调性;桩 start time 取恒定过去值同判)→
630
+ // 降级判据匹配,正常发 kill
562
631
  task.pidStartTime = undefined;
563
632
 
564
633
  vi.advanceTimersByTime(1000);
@@ -569,46 +638,42 @@ describe("explicit background timeout (D6)", () => {
569
638
  }
570
639
  });
571
640
 
572
- it("natural completion before the deadline cancels the timer (no late kill)", async () => {
573
- // 全程真实 timers:命令 0.1s 完成 < 0.5s 超时(真实时间必须真实流过——
574
- // 本用例证明「到点不补杀」,fake 时钟会让回调先于进程死亡触发)
575
- const before = killTreeCalls.length;
576
- const spawned = spawnBg("sleep 0.1 && echo quick", { timeoutSec: 0.5 });
577
- if (!spawned.ok) throw new Error(spawned.error);
578
- const { task } = spawned;
579
-
580
- // 轮询命令退出收尾(未到 0.5s deadline)
581
- await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_SLOW_MS, "natural finalization");
582
- expect(getTask(task.taskId)?.state).toBe("exited");
583
- expect(getTask(task.taskId)?.reason).toBe("natural");
584
-
585
- // 越过 deadline 的时间窗内不得再补杀(终态化必须已清 timer):轮询真实时钟
586
- // 越过 deadline+200ms 余量后断言——这里等待的是时钟流逝本身,非子进程事件
587
- await pollUntilTicked(
588
- () => Date.now() >= task.startedAt + 500 + 200,
589
- POLL_DEADLINE_SLOW_MS,
590
- "real clock to pass the deadline window",
591
- );
592
- expect(killTreeCalls.length).toBe(before);
593
- expect(getTask(task.taskId)?.state).toBe("exited");
641
+ it("natural completion before the deadline cancels the timer (no late kill)", () => {
642
+ // fake timers + 桩端点:先自然退出(终态化清 timer)再推进时钟越过 deadline——
643
+ // 「到点不补杀」不变量确定性验证(真进程 + 真实时钟形态由集成组覆盖)
644
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
645
+ try {
646
+ const before = killTreeCalls.length;
647
+ const task = spawnOk("sleep 0.1 && echo quick", { timeoutSec: 1 });
648
+
649
+ emitFakeExit(task, 0);
650
+ expect(getTask(task.taskId)?.state).toBe("exited");
651
+ expect(getTask(task.taskId)?.reason).toBe("natural");
652
+
653
+ // 越过 deadline:终态化必须已清 timer(fake 队列中被 clearTimeout 的回调不再触发)
654
+ vi.advanceTimersByTime(5000);
655
+ expect(killTreeCalls.length).toBe(before);
656
+ expect(getTask(task.taskId)?.state).toBe("exited");
657
+ } finally {
658
+ vi.useRealTimers();
659
+ }
594
660
  });
595
661
  });
596
662
 
663
+ // ──────────────────────── 单测层:D6-en intent 读回(exit 边沿 reason 判定) ────────────────────────
664
+
597
665
  describe("D6-en: exit edge finalization reads back registry killing (cross-process UI kill)", () => {
598
- it("memory intent missing + registry killing → reason killed; AI not woken (no sendMessage)", async () => {
666
+ it("memory intent missing + registry killing → reason killed; AI not woken (no sendMessage)", () => {
599
667
  const pi = { events: { emit: vi.fn() }, sendMessage: vi.fn(), appendEntry: vi.fn() };
600
668
  refreshPiReference(pi as unknown as ExtensionAPI);
601
669
  setOnTaskExit(handleTaskExit);
602
- const spawned = spawnBg("sleep 30");
603
- if (!spawned.ok) throw new Error(spawned.error);
604
- const { task } = spawned;
670
+ const task = spawnOk("sleep 30");
605
671
  // runtime UI 代杀形态:只写 registry 侧 killing(跨进程,pi 内存表不可见)+
606
672
  // 杀进程树,不经本进程 markKillingIntent
607
673
  writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
608
674
  expect(getTask(task.taskId)?.intent).toBeUndefined(); // 前置:内存无 intent
609
- process.kill(task.pid, "SIGKILL");
610
- // exit 事件边沿自动收尾(读回 registry killing → killed)
611
- await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to read back killing");
675
+ // 桩端点派发被杀死亡(exit 事件边沿自动收尾,读回 registry killing → killed)
676
+ emitFakeExit(task, null);
612
677
 
613
678
  const finalized = getTask(task.taskId);
614
679
  expect(finalized?.state).toBe("exited");
@@ -622,103 +687,97 @@ describe("D6-en: exit edge finalization reads back registry killing (cross-proce
622
687
  expect(pi.sendMessage).not.toHaveBeenCalled();
623
688
  });
624
689
 
625
- it("memory intent present wins — registry NOT read back (killed kept while registry stays running)", async () => {
626
- const spawned = spawnBg("sleep 30");
627
- if (!spawned.ok) throw new Error(spawned.error);
628
- const { task } = spawned;
690
+ it("memory intent present wins — registry NOT read back (killed kept while registry stays running)", () => {
691
+ const task = spawnOk("sleep 30");
629
692
  // 内存预写 killed intent(AI bash_kill 形态),registry 刻意停留 running
630
693
  //(模拟 intent 落盘失败)——若实现误读回,会按 running 判 natural
631
694
  expect(markKillingIntent(task.taskId, "killed")).toBeDefined();
632
- // 前置(SIGKILL 前断言——事件模式下死亡即边沿收尾,registry 停留 running
633
- //(模拟 intent 落盘失败,markKillingIntent 不写盘):若实现误读回会按 running 判 natural
634
695
  expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
635
- process.kill(task.pid, "SIGKILL");
636
- await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to finalize with intent");
696
+ emitFakeExit(task, null);
637
697
  expect(getTask(task.taskId)?.reason).toBe("killed"); // intent 优先,非 natural
638
698
  });
639
699
 
640
- it("registry read failure (corrupt file) degrades to no-intent natural; finalization not blocked", async () => {
641
- const spawned = spawnBg("sleep 30");
642
- if (!spawned.ok) throw new Error(spawned.error);
643
- const { task } = spawned;
700
+ it("registry read failure (corrupt file) degrades to no-intent natural; finalization not blocked", () => {
701
+ const task = spawnOk("sleep 30");
644
702
  writeFileSync(REGISTRY_PATH, "{corrupted", "utf8"); // 读回路径解析失败
645
- process.kill(task.pid, "SIGKILL");
703
+ emitFakeExit(task, 0);
646
704
  // exit 事件边沿自动收尾(读回失败按无 intent 处理)
647
- await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", 5000, "exit edge to finalize");
648
705
  const finalized = getTask(task.taskId);
649
706
  expect(finalized?.state).toBe("exited"); // 终态化未被阻塞
650
707
  expect(finalized?.reason).toBe("natural"); // 读失败按无 intent 处理
651
708
  });
652
709
  });
653
710
 
711
+ // ──────────────────────── 单测层:D6-en/R2-S1 加固(已终态任务到点不补杀不改写) ────────────────────────
712
+
654
713
  describe("D6-en/R2-S1 hardening: armBackgroundTimeout skips kill+mark when recorded pid is gone", () => {
655
- it("UI kill × timeout overlap: no timeout mark; exit edge reads back registry killing → killed", async () => {
656
- // 真实时间必须真实流过(R2-S1 前提:到点时 pid 已死才有「跳过补杀」可言),
657
- // fake 时钟会让 timeout 回调先于进程死亡触发
658
- const spawned = spawnBg("sleep 30", { timeoutSec: 0.5 });
659
- if (!spawned.ok) throw new Error(spawned.error);
660
- const { task } = spawned;
661
- // UI 代杀(跨进程形态):registry 预写 killing + 直接杀进程,不经内存
662
- writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
663
- process.kill(task.pid, "SIGKILL");
664
- // exit 事件边沿在 deadline 前收尾(D6-en 读回 registry killing → killed)
665
- await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to read back killing before deadline");
666
- // 越过 deadline(0.5s)+200ms 余量:轮询真实时钟,等待的是时钟流逝本身
667
- await pollUntilTicked(
668
- () => Date.now() >= task.startedAt + 500 + 200,
669
- POLL_DEADLINE_SLOW_MS,
670
- "real clock to pass the deadline window",
671
- );
672
- // 加固生效:到点时任务已终态 → 不补杀、intent 不被改写为 timeout、reason 保持 killed
673
- expect(killTreeCalls).not.toContain(task.pid);
674
- expect(getTask(task.taskId)?.state).toBe("exited");
675
- expect(getTask(task.taskId)?.intent).toBeUndefined();
676
- expect(getTask(task.taskId)?.reason).toBe("killed"); // D6-en 读回(非 timeout)
714
+ it("UI kill × timeout overlap: no timeout mark; exit edge reads back registry killing → killed", () => {
715
+ // 桩端点 + fake timers:deadline 前先被 UI 代杀收尾(终态化清 timer),再推进
716
+ // 时钟越过 deadline——「不补杀、不改写 intent」确定性验证(真实时钟形态由集成组覆盖)
717
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
718
+ try {
719
+ const task = spawnOk("sleep 30", { timeoutSec: 1 });
720
+ // UI 代杀(跨进程形态):registry 预写 killing + 直接杀进程,不经内存
721
+ writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
722
+ emitFakeExit(task, null);
723
+ // exit 边沿在 deadline 前收尾(D6-en 读回 registry killing → killed)
724
+ expect(getTask(task.taskId)?.reason).toBe("killed");
725
+
726
+ vi.advanceTimersByTime(5000); // 越过 deadline
727
+ // 加固生效:到点时任务已终态 → 不补杀、intent 不被改写为 timeout、reason 保持 killed
728
+ expect(killTreeCalls).not.toContain(task.pid);
729
+ expect(getTask(task.taskId)?.state).toBe("exited");
730
+ expect(getTask(task.taskId)?.intent).toBeUndefined();
731
+ expect(getTask(task.taskId)?.reason).toBe("killed"); // D6-en 读回(非 timeout)
732
+ } finally {
733
+ vi.useRealTimers();
734
+ }
677
735
  });
678
736
 
679
737
  it("AI bash_kill × timeout overlap: killed intent survives (no 'timed out' misreport)", async () => {
680
- const spawned = spawnBg("sleep 30", { timeoutSec: 0.5 });
681
- if (!spawned.ok) throw new Error(spawned.error);
682
- const { task } = spawned;
683
- const killed = JSON.parse(await killTool(task.taskId)) as { killed: boolean };
684
- expect(killed.killed).toBe(true);
685
- // exit 事件边沿在 deadline 前收尾(intent 优先 → killed)
686
- await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to finalize killed before deadline");
687
- // 越过 deadline(0.5s)+200ms 余量:轮询真实时钟——任务已终态 → 跳过 timeout mark
688
- await pollUntilTicked(
689
- () => Date.now() >= task.startedAt + 500 + 200,
690
- POLL_DEADLINE_SLOW_MS,
691
- "real clock to pass the deadline window",
692
- );
693
- expect(getTask(task.taskId)?.intent).toBeUndefined(); // 终态化消费 intent,不再存在
694
- expect(killTreeCalls.filter((pid) => pid === task.pid)).toHaveLength(1); // 无第二次 kill
695
- expect(getTask(task.taskId)?.state).toBe("exited");
696
- expect(getTask(task.taskId)?.reason).toBe("killed"); // 加固后保持 killed(无通知路径)
697
- });
698
-
699
- it("natural death before the deadline: no timeout mark; exit edge finalizes natural (accepted shift)", async () => {
700
- // 命令 0.1s 自然退出 < 0.5s deadline;真实时间必须真实流过(fake 会先于死亡触发回调)
701
- const before = killTreeCalls.length;
702
- const spawned = spawnBg("sleep 0.1", { timeoutSec: 0.5 });
703
- if (!spawned.ok) throw new Error(spawned.error);
704
- const { task } = spawned;
705
- // exit 事件边沿在 deadline 前收尾
706
- await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", 5000, "natural death before the deadline");
707
- // 越过 deadline(0.5s)+200ms 余量:等待的是时钟流逝本身
708
- await pollUntilTicked(
709
- () => Date.now() >= task.startedAt + 500 + 200,
710
- POLL_DEADLINE_SLOW_MS,
711
- "real clock to pass the deadline window",
712
- );
713
- expect(killTreeCalls.length).toBe(before); // 死任务不补杀
714
- expect(getTask(task.taskId)?.state).toBe("exited");
715
- expect(getTask(task.taskId)?.intent).toBeUndefined();
716
- expect(getTask(task.taskId)?.reason).toBe("natural"); // 语义微移:natural 非 timeout
738
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
739
+ try {
740
+ const task = spawnOk("sleep 30", { timeoutSec: 1 });
741
+ const killed = JSON.parse(await killTool(task.taskId)) as { killed: boolean };
742
+ expect(killed.killed).toBe(true);
743
+ // exit 边沿在 deadline 前收尾(intent 优先 → killed)
744
+ emitFakeExit(task, null);
745
+ expect(getTask(task.taskId)?.reason).toBe("killed");
746
+
747
+ vi.advanceTimersByTime(5000); // 越过 deadline
748
+ expect(getTask(task.taskId)?.intent).toBeUndefined(); // 终态化消费 intent,不再存在
749
+ expect(killTreeCalls.filter((pid) => pid === task.pid)).toHaveLength(1); // 无第二次 kill
750
+ expect(getTask(task.taskId)?.state).toBe("exited");
751
+ expect(getTask(task.taskId)?.reason).toBe("killed"); // 加固后保持 killed(无通知路径)
752
+ } finally {
753
+ vi.useRealTimers();
754
+ }
755
+ });
756
+
757
+ it("natural death before the deadline: no timeout mark; exit edge finalizes natural (accepted shift)", () => {
758
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
759
+ try {
760
+ const before = killTreeCalls.length;
761
+ const task = spawnOk("sleep 0.1", { timeoutSec: 1 });
762
+ // exit 边沿在 deadline 前收尾(自然退出)
763
+ emitFakeExit(task, 0);
764
+ expect(getTask(task.taskId)?.state).toBe("exited");
765
+
766
+ vi.advanceTimersByTime(5000); // 越过 deadline
767
+ expect(killTreeCalls.length).toBe(before); // 死任务不补杀
768
+ expect(getTask(task.taskId)?.state).toBe("exited");
769
+ expect(getTask(task.taskId)?.intent).toBeUndefined();
770
+ expect(getTask(task.taskId)?.reason).toBe("natural"); // 语义微移:natural 非 timeout
771
+ } finally {
772
+ vi.useRealTimers();
773
+ }
717
774
  });
718
775
  });
719
776
 
777
+ // ──────────────────────── 单测层:进程退出收殓(D12,编排逻辑) ────────────────────────
778
+
720
779
  describe("process-exit reap (D12)", () => {
721
- it("reaps all active tasks with reason process-exit, kills pids, writes registry terminal", async () => {
780
+ it("reaps all active tasks with reason process-exit, kills pids, writes registry terminal", () => {
722
781
  const first = spawnBg("sleep 30");
723
782
  const second = spawnBg("sleep 30");
724
783
  if (!first.ok || !second.ok) throw new Error("spawn failed");
@@ -726,11 +785,9 @@ describe("process-exit reap (D12)", () => {
726
785
  expect(pids.every((pid) => isPidAlive(pid))).toBe(true);
727
786
 
728
787
  reapBackgroundTasksNow();
729
- // SIGKILL 发出到进程表移除是异步的:轮询等 pid 判死(满载下延迟不可预估)
730
- await pollUntilTicked(() => pids.every((pid) => !isPidAlive(pid)), 5000, "reaped pids to die");
731
-
788
+ // 桩端点:kill 发令断言 + 终态两侧断言(真 pid 判死由集成组覆盖)
732
789
  for (const pid of pids) {
733
- expect(isPidAlive(pid)).toBe(false);
790
+ expect(killTreeCalls).toContain(pid);
734
791
  }
735
792
  for (const t of [first.task, second.task]) {
736
793
  const finalized = getTask(t.taskId);
@@ -742,25 +799,25 @@ describe("process-exit reap (D12)", () => {
742
799
  });
743
800
  });
744
801
 
802
+ // ──────────────────────── 单测层:D15 abort 不传播 ────────────────────────
803
+
745
804
  describe("D15: abort signal does not propagate to background tasks", () => {
746
- it("aborted execute-signal leaves the task running", async () => {
805
+ it("aborted execute-signal leaves the task running", () => {
747
806
  const controller = new AbortController();
748
- const spawned = spawnBg("sleep 1");
749
- if (!spawned.ok) throw new Error(spawned.error);
750
- const { task } = spawned;
807
+ const task = spawnOk("sleep 1");
751
808
  // 模拟用户中断当前 turn:signal abort(后台分支不接触 signal,此处模拟上游已 abort)
752
809
  controller.abort();
753
810
  expect(controller.signal.aborted).toBe(true);
754
811
 
755
- // 非断言性观察窗口(150ms,留事件传播时间;命令 sleep 1 未退出):断言的是
756
- // 「任务不受 abort 影响」的负向不变量,期望状态恒真,轮询语义不适用
757
- await sleep(150);
758
- // 任务不受中断影响:进程活、状态 running
812
+ // 不变量「spawn 链路不读 signal」同步即成立(无需观察窗);任务不受中断影响:
813
+ // 进程活、状态 running
759
814
  expect(isPidAlive(task.pid)).toBe(true);
760
815
  expect(getTask(task.taskId)?.state).toBe("running");
761
816
  });
762
817
  });
763
818
 
819
+ // ──────────────────────── 单测层:spawn 失败路径(§3.6) ────────────────────────
820
+
764
821
  describe("spawn failure paths (§3.6)", () => {
765
822
  it("nonexistent cwd reports the builtin-style error", () => {
766
823
  const result = spawnBackgroundTask({
@@ -776,9 +833,12 @@ describe("spawn failure paths (§3.6)", () => {
776
833
  });
777
834
 
778
835
  it("missing shell reports a spawn failure error", () => {
779
- // 通过临时 SHELL 指向不存在路径模拟 shell 缺失(POSIX 分支)
836
+ // 通过临时 SHELL 指向不存在路径模拟 shell 缺失(POSIX 分支)。走真 spawn 调用
837
+ // (不存在路径不产生真进程:ENOENT → 无 pid → 失败返回),验证的是 spawn 侧
838
+ // 失败分支而非桩工厂
780
839
  const original = process.env.SHELL;
781
840
  process.env.SHELL = "/no/such/shell-binary";
841
+ spawnMode.real = true;
782
842
  try {
783
843
  const result = spawnBg("echo hi");
784
844
  expect(result.ok).toBe(false);
@@ -786,8 +846,164 @@ describe("spawn failure paths (§3.6)", () => {
786
846
  expect(result.error).toContain("Failed to start background command");
787
847
  }
788
848
  } finally {
849
+ spawnMode.real = false;
789
850
  if (original === undefined) delete process.env.SHELL;
790
851
  else process.env.SHELL = original;
791
852
  }
792
853
  });
793
854
  });
855
+
856
+ // ──────────────────────── 集成组:真子进程核心 5 条(TAIJI_TEST_INTEGRATION 门控) ────────────────────────
857
+
858
+ // 真子进程链(spawn+exec+sleep+exit)满载下延迟不可预估,无确定性完成信号可等——
859
+ // 属「代码 + OS 组合行为」,移出默认单测层:本地默认跳过,CI / 按改动范围触发时
860
+ // 设 TAIJI_TEST_INTEGRATION=1 真跑(门控 SSOT = test-guard/integration-gate.ts)。
861
+ // 保留的 5 条核心 = 真进程端点语义(真 spawn 产物 / 真 SIGKILL 生效 / 真 pid 判死 /
862
+ // 真实时钟不补杀),编排与判定逻辑已由上方单测层全覆盖。
863
+ describe.skipIf(!INTEGRATION_TESTS_ENABLED)(
864
+ `real process core (spawn / kill tree / timeout chain / reap)${INTEGRATION_TESTS_SKIP_REASON ? `|skip:${INTEGRATION_TESTS_SKIP_REASON}` : ""}`,
865
+ () => {
866
+ beforeEach(() => {
867
+ // 真 spawn 模式:node:child_process.spawn 走原实现,进程原语对真 pid 直通
868
+ spawnMode.real = true;
869
+ });
870
+
871
+ it(
872
+ "short task: running → exited with exitCode 0, output file readable, registry terminal, pidStartTime carried",
873
+ { timeout: 20_000 },
874
+ async () => {
875
+ // 断言只要求「exit 事件边沿前 state=running」与退出产物,不依赖命令时长(终态化只在 finalizeTask)
876
+ const task = spawnOk("sleep 0.1 && echo done");
877
+ expect(task.state).toBe("running");
878
+ expect(existsSync(task.outputFile)).toBe(true);
879
+ // registry 登记即写 running 条目(含 ownerPiPid,M5 属主判定依据)
880
+ expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
881
+ expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.ownerPiPid).toBe(process.pid);
882
+ // 真实 ps 读取的 start time 进条目与 registry(M5→M3 kill 侧精确比较依据)
883
+ expect(typeof task.pidStartTime).toBe("number");
884
+ expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.pidStartTime).toBe(task.pidStartTime);
885
+
886
+ // 轮询等命令退出 + libuv reap(tick 收尾;满载下延迟不可预估)
887
+ await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_MS, "task finalization");
888
+
889
+ const finalized = getTask(task.taskId);
890
+ expect(finalized?.state).toBe("exited");
891
+ expect(finalized?.exitCode).toBe(0);
892
+ expect(finalized?.reason).toBe("natural");
893
+ expect(finalized?.durationMs).toBeGreaterThan(0);
894
+
895
+ const registryEntry = readRegistry(REGISTRY_PATH).get(task.taskId);
896
+ expect(registryEntry?.state).toBe("exited");
897
+ expect(registryEntry?.reason).toBe("natural");
898
+
899
+ const detail = JSON.parse(await outputTool({ task_id: task.taskId })) as { output: string; exitCode: number };
900
+ expect(detail.output).toContain("done");
901
+ expect(detail.exitCode).toBe(0);
902
+ },
903
+ );
904
+
905
+ it(
906
+ "kill edge: intent consumed → exited with reason killed",
907
+ { timeout: 20_000 },
908
+ async () => {
909
+ const task = spawnOk("sleep 60");
910
+ await killTool(task.taskId);
911
+ // 轮询到 kill 边沿收尾(SIGKILL 生效 + tick 终态化)
912
+ await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", POLL_DEADLINE_MS, "killed finalization");
913
+ const finalized = getTask(task.taskId);
914
+ expect(finalized?.state).toBe("exited");
915
+ expect(finalized?.reason).toBe("killed");
916
+ expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.reason).toBe("killed");
917
+ },
918
+ );
919
+
920
+ it(
921
+ "fires kill-tree at the deadline and finalizes with reason timeout",
922
+ { timeout: 20_000 },
923
+ async () => {
924
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
925
+ let taskId = "";
926
+ let pid = -1;
927
+ try {
928
+ // fake 生效后 spawn:timeout 定时器进 fake 队列,由 advanceTimers 推进
929
+ const task = spawnOk("sleep 30", { timeoutSec: 1 });
930
+ taskId = task.taskId;
931
+ pid = task.pid;
932
+
933
+ vi.advanceTimersByTime(1000);
934
+ // 到点:kill-tree 已发令 + 两侧 killing intent(reason 候选 timeout)
935
+ expect(killTreeCalls).toContain(pid);
936
+ expect(getTask(taskId)?.state).toBe("killing");
937
+ expect(getTask(taskId)?.intent?.reason).toBe("timeout");
938
+ expect(readRegistry(REGISTRY_PATH).get(taskId)?.state).toBe("killing");
939
+ } finally {
940
+ vi.useRealTimers();
941
+ }
942
+
943
+ // SIGKILL 已真实发出:exit 事件边沿收尾 → exited(reason:"timeout"),终态由边沿写
944
+ //(timeout 路径满载下 SIGKILL 生效延迟不可预估,deadline 放宽到 8s)
945
+ await pollUntilTicked(
946
+ () => getTask(taskId)?.state === "exited" && getTask(taskId)?.reason === "timeout",
947
+ POLL_DEADLINE_SLOW_MS,
948
+ "timeout finalization",
949
+ );
950
+ const finalized = getTask(taskId);
951
+ expect(finalized?.state).toBe("exited");
952
+ expect(finalized?.reason).toBe("timeout");
953
+ },
954
+ );
955
+
956
+ it(
957
+ "reaps all active tasks with reason process-exit, real pids die, writes registry terminal",
958
+ { timeout: 20_000 },
959
+ async () => {
960
+ const first = spawnBg("sleep 30");
961
+ const second = spawnBg("sleep 30");
962
+ if (!first.ok || !second.ok) throw new Error("spawn failed");
963
+ const pids = [first.task.pid, second.task.pid];
964
+ expect(pids.every((pid) => isPidAlive(pid))).toBe(true);
965
+
966
+ reapBackgroundTasksNow();
967
+ // SIGKILL 发出到进程表移除是异步的:轮询等 pid 判死(满载下延迟不可预估)
968
+ await pollUntilTicked(() => pids.every((pid) => !isPidAlive(pid)), POLL_DEADLINE_MS, "reaped pids to die");
969
+
970
+ for (const pid of pids) {
971
+ expect(isPidAlive(pid)).toBe(false);
972
+ }
973
+ for (const t of [first.task, second.task]) {
974
+ const finalized = getTask(t.taskId);
975
+ expect(finalized?.state).toBe("exited");
976
+ expect(finalized?.reason).toBe("process-exit");
977
+ expect(readRegistry(REGISTRY_PATH).get(t.taskId)?.reason).toBe("process-exit");
978
+ }
979
+ expect(getActiveTasks()).toHaveLength(0);
980
+ },
981
+ );
982
+
983
+ it(
984
+ "natural completion before the deadline cancels the timer (no late kill, real clock)",
985
+ { timeout: 20_000 },
986
+ async () => {
987
+ // 全程真实 timers:命令 0.1s 完成 < 0.5s 超时(真实时间必须真实流过——
988
+ // 本用例证明「到点不补杀」,fake 时钟会让回调先于进程死亡触发)
989
+ const before = killTreeCalls.length;
990
+ const task = spawnOk("sleep 0.1 && echo quick", { timeoutSec: 0.5 });
991
+
992
+ // 轮询命令退出收尾(未到 0.5s deadline)
993
+ await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", POLL_DEADLINE_SLOW_MS, "natural finalization");
994
+ expect(getTask(task.taskId)?.state).toBe("exited");
995
+ expect(getTask(task.taskId)?.reason).toBe("natural");
996
+
997
+ // 越过 deadline 的时间窗内不得再补杀(终态化必须已清 timer):轮询真实时钟
998
+ // 越过 deadline+200ms 余量后断言——这里等待的是时钟流逝本身,非子进程事件
999
+ await pollUntilTicked(
1000
+ () => Date.now() >= task.startedAt + 500 + 200,
1001
+ POLL_DEADLINE_SLOW_MS,
1002
+ "real clock to pass the deadline window",
1003
+ );
1004
+ expect(killTreeCalls.length).toBe(before);
1005
+ expect(getTask(task.taskId)?.state).toBe("exited");
1006
+ },
1007
+ );
1008
+ },
1009
+ );
@@ -228,7 +228,8 @@ describe("entry status maps via protocol mapReasonToStatus (D10 single-source, i
228
228
  expect(mapReasonToStatus("interrupted-by-restart")).toBe("aborted");
229
229
  expect(mapReasonToStatus("interrupted-by-parent")).toBe("aborted");
230
230
  expect(mapReasonToStatus("reopened")).toBe("completed");
231
- expect(mapReasonToStatus("some-future-reason")).toBe("completed");
231
+ // 词表外 reason 缺省 unknown(fail-visible——§3.3.11④ fail-open=completed 废除)
232
+ expect(mapReasonToStatus("some-future-reason")).toBe("unknown");
232
233
  });
233
234
  });
234
235
 
@@ -70,6 +70,34 @@ describe("buildBashKillSummary", () => {
70
70
  expect(buildBashKillSummary({ task_id: "t1" }, resultOf(text))).toBe(`Kill failed: ${"x".repeat(80)}…`);
71
71
  });
72
72
 
73
+ it("reason 含换行 → 摘要归一单行(details.summary 单行契约)", () => {
74
+ const text = JSON.stringify({ killed: false, reason: "no such task\ntry bash_output" });
75
+ expect(buildBashKillSummary({ task_id: "t1" }, resultOf(text))).toBe(
76
+ "Kill failed: no such task try bash_output",
77
+ );
78
+ });
79
+
80
+ it("reason 含 emoji 超长 → 码点安全截断不劈代理对(S-3)", () => {
81
+ // "x🎉" = 3 个 UTF-16 码元;UTF-16 slice(0,80) 会在第 80 码元处劈开 🎉 的代理对。
82
+ // 码点口径:60 码点 ≤ 80 → 原样保留(全为完整代理对)
83
+ const text = JSON.stringify({ killed: false, reason: "x🎉".repeat(30) });
84
+ const summary = buildBashKillSummary({ task_id: "t1" }, resultOf(text));
85
+ expect(summary).not.toContain("\n");
86
+ const loneSurrogates = Array.from(summary).filter(
87
+ (ch) => ch.length === 1 && /[\uD800-\uDFFF]/.test(ch),
88
+ );
89
+ expect(loneSurrogates).toHaveLength(0);
90
+
91
+ // 超过 80 码点的 emoji 输入同样不产生孤立代理项(截 80 码点 + 省略号)
92
+ const long = JSON.stringify({ killed: false, reason: "🎉".repeat(100) });
93
+ const clipped = buildBashKillSummary({ task_id: "t1" }, resultOf(long));
94
+ const loneInClipped = Array.from(clipped).filter(
95
+ (ch) => ch.length === 1 && /[\uD800-\uDFFF]/.test(ch),
96
+ );
97
+ expect(loneInClipped).toHaveLength(0);
98
+ expect(clipped).toBe(`Kill failed: ${"🎉".repeat(80)}…`);
99
+ });
100
+
73
101
  it("content 非 JSON(防御)→ 兜底 Done", () => {
74
102
  expect(buildBashKillSummary({ task_id: "t1" }, resultOf(""))).toBe("Done");
75
103
  });
package/src/index.ts CHANGED
@@ -34,7 +34,6 @@ import { createBashOverrideToolDefinition } from "./bash-tool.ts";
34
34
  import { handleTaskExit, refreshPiReference } from "./background/notify.ts";
35
35
  import { installProcessExitGuard } from "./background/process-exit-guard.ts";
36
36
  import { reconcilePendingEntries } from "./background/pending-reconcile.ts";
37
- import { countActiveTasks } from "./background/task-store.ts";
38
37
  import { resolveTaijiDataDir } from "./background/registry.ts";
39
38
  import { setOnTaskExit } from "./background/exit-collector.ts";
40
39
  import { setupToolErrorAudit } from "./tool-error-audit.ts";
@@ -65,7 +64,7 @@ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
65
64
  // handler 消费该 promise,runner.js:632),
66
65
  // promise 由其消费不产生 unhandled rejection;错误在维护链内 async 捕获
67
66
  pi.on("session_start", (event: SessionStartEvent, ctx: ExtensionContext) => {
68
- // 统一投递端口装配(unified-delivery 波 2,设计 §3.7 装配点):custom 车道
67
+ // 统一投递端口装配(unified-delivery 波 2,设计 §3.7 装配点):custom 投递路径
69
68
  // 唯一执行者、恒装配(本扩展为 infrastructure 档必载——两套装配并存违背
70
69
  // 装配单一归属,§3.7)。每次 session_start 重绑(dispose 旧实例 + 新实例
71
70
  // recover 差集重放);消费方(notifier / session-manager / workflow,
@@ -75,7 +74,8 @@ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
75
74
  createPiDeliveryPortHost(pi, ctx, {
76
75
  component: "base-tool-enhance",
77
76
  logger,
78
- hasRunningBackground: () => countActiveTasks() > 0,
77
+ // [subagent-observability §3.3.7③] 原 hasRunningBackground(攒批条件谓词)
78
+ // 已随攒批窗整机制退役删除——投递边沿即投,装配宿主无合批控制面。
79
79
  }),
80
80
  ).recover();
81
81
  return runSessionStartMaintenance(pi, ctx, event.reason);
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { Theme } from "@earendil-works/pi-coding-agent";
10
10
  import { Text } from "@earendil-works/pi-tui";
11
- import { firstContentText } from "@zhushanwen/extension-protocol";
11
+ import { firstContentText, firstLine, readDetailsSummary, resultContentText } from "@zhushanwen/extension-protocol";
12
12
 
13
13
  // ── summary 纯函数 ───────────────────────────────────
14
14
 
@@ -54,6 +54,15 @@ export function buildBashOutputSummary(
54
54
  /** 收起态摘要中失败 reason 的保留长度(超长截断加省略号)。 */
55
55
  const KILL_REASON_SUMMARY_CHARS = 80;
56
56
 
57
+ /** reason 归一单行 + 码点安全截断到上限(超长补省略号;与 goal/todo/plan 的摘要 helper
58
+ * 同语义:换行/连续空白折叠为单空格,Array.from 按码点切分,emoji 代理对不劈半——
59
+ * details.summary 单行契约见 extension-conventions.md)。 */
60
+ function clipSummaryText(text: string, max: number): string {
61
+ const line = text.replace(/\s+/g, " ").trim();
62
+ const chars = Array.from(line);
63
+ return chars.length > max ? chars.slice(0, max).join("") + "…" : line;
64
+ }
65
+
57
66
  /** bash_kill 摘要:killed=true → `Killed task <id>`;killed=false(正常返回的失败形态)
58
67
  * → `Kill failed: <reason>`。 */
59
68
  export function buildBashKillSummary(args: { task_id: string }, result: ToolResultLike): string {
@@ -61,46 +70,18 @@ export function buildBashKillSummary(args: { task_id: string }, result: ToolResu
61
70
  if (parsed === undefined) return "Done";
62
71
  if (parsed.killed === true) return `Killed task ${args.task_id}`;
63
72
  const reason = typeof parsed.reason === "string" ? parsed.reason : "unknown";
64
- const clipped = reason.length > KILL_REASON_SUMMARY_CHARS
65
- ? `${reason.slice(0, KILL_REASON_SUMMARY_CHARS)}…`
66
- : reason;
67
- return `Kill failed: ${clipped}`;
73
+ return `Kill failed: ${clipSummaryText(reason, KILL_REASON_SUMMARY_CHARS)}`;
68
74
  }
69
75
 
70
76
  // ── TUI renderResult ─────────────────────────────────
71
77
 
72
- /** unknown 值按字段名安全取值(index-signature 收窄,替代全可选结构断言)。 */
73
- function fieldValue(v: unknown, key: string): unknown {
74
- if (typeof v !== "object" || v === null) return undefined;
75
- return (v as Record<string, unknown>)[key];
76
- }
77
-
78
- /** 从 details 安全读取 summary(容错非对象形态与旧版本 entry)。 */
79
- function readDetailsSummary(result: unknown): string | undefined {
80
- const summary = fieldValue(fieldValue(result, "details"), "summary");
81
- return typeof summary === "string" && summary !== "" ? summary : undefined;
82
- }
83
-
84
- /** content[0] 的 text 纯文本(content 缺失或非 text 类型时兜底空串)。 */
85
- function contentText(result: unknown): string {
86
- const content = fieldValue(result, "content");
87
- if (!Array.isArray(content)) return "";
88
- return firstContentText({ content });
89
- }
90
-
91
- /** 折叠态回退:JSON 文本取首行(保持一行摘要形态)。 */
92
- function firstLine(text: string): string {
93
- const idx = text.indexOf("\n");
94
- return idx === -1 ? text : text.slice(0, idx);
95
- }
96
-
97
78
  /** 折叠态(expanded=false)= details.summary 一行(dim);展开态 = content 纯文本。 */
98
79
  export function renderBashToolResult(
99
80
  result: unknown,
100
81
  options: { expanded: boolean },
101
82
  theme: Theme,
102
83
  ): Text {
103
- if (options.expanded) return new Text(contentText(result), 0, 0);
104
- const summary = readDetailsSummary(result) ?? firstLine(contentText(result));
84
+ if (options.expanded) return new Text(resultContentText(result), 0, 0);
85
+ const summary = readDetailsSummary(result) ?? firstLine(resultContentText(result));
105
86
  return new Text(theme.fg("dim", summary), 0, 0);
106
87
  }