@zhushanwen/pi-base-tool-enhance 0.4.1 → 0.5.1

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.4.1",
3
+ "version": "0.5.1",
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,10 +28,10 @@
28
28
  "skills/"
29
29
  ],
30
30
  "dependencies": {
31
- "@xyz-agent/extension-protocol": "0.8.1",
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
- "@zhushanwen/pi-llm-shared": "0.5.1"
34
+ "@zhushanwen/pi-llm-shared": "0.6.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@vitest/coverage-v8": "^4.1.9",
@@ -40,7 +40,7 @@
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-coding-agent": "^0.84.4",
42
42
  "typebox": "*",
43
- "@zhushanwen/pi-pending-notifications": "0.5.3"
43
+ "@zhushanwen/pi-pending-notifications": "0.6.0"
44
44
  },
45
45
  "peerDependenciesMeta": {
46
46
  "@zhushanwen/pi-pending-notifications": {
@@ -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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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
- await sleep(700); // 等命令退出 + libuv reap
130
- pollTickForTest();
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
- await sleep(2800);
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 sleep(600);
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 sleep(500);
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 sleep(500);
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
- await sleep(900);
284
- pollTickForTest();
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
- await sleep(500); // SIGKILL 生效
319
- pollTickForTest();
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 sleep(500);
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
- await sleep(500); // 进程已死但不 tick——单例表仍 running
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
- await sleep(500);
480
- pollTickForTest();
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, intent still marked", () => {
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
- // 仅标 killing intent(reason timeout)——终态归轮询边沿/对账收尾
500
- expect(getTask(task.taskId)?.state).toBe("killing");
501
- expect(getTask(task.taskId)?.intent?.reason).toBe("timeout");
502
- expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
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
- expect(getTask(task.taskId)?.state).toBe("killing");
522
- expect(getTask(task.taskId)?.intent?.reason).toBe("timeout");
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
- await sleep(600); // 命令已退出(未到 1s deadline)
554
- pollTickForTest();
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
- await sleep(900);
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
- await sleep(300); // SIGKILL 发出到进程表移除是异步的
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();
@@ -36,7 +36,7 @@ beforeEach(() => {
36
36
  });
37
37
 
38
38
  afterEach(() => {
39
- rmSync(tempDir, { recursive: true, force: true });
39
+ rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
40
40
  });
41
41
 
42
42
  function writeConfigFile(content: string): void {
@@ -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
- // 等退出 + libuv reap(SIGCHLD → waitpid 后 pid 释放)
42
- await sleep(300);
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
- await sleep(300); // 让子 sleep 起来
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
- await sleep(300);
63
-
64
- expect(isPidAlive(pid)).toBe(false);
65
- // 子进程也不得残留:枚举验证(pgrep -P 组长;组长死后 reparent,改为全 pgrep 命令串兜底)
66
- const leftover = spawn("/usr/bin/pgrep", ["-f", "sleep 30"]);
67
- const stdout: string[] = [];
68
- leftover.stdout?.on("data", (d: Buffer) => stdout.push(d.toString()));
69
- const status = await new Promise<number | null>((resolve) => leftover.on("close", resolve));
70
- // pgrep 自身命令行含 "sleep 30" 会自匹配吗:pgrep -f 匹配其他进程,不匹配自身(pgrep 默认排除自己)
71
- const others = stdout.join("").split("\n").filter((line) => line.trim() !== "");
72
- expect(others).toEqual([]);
73
- expect(status).not.toBe(0); // 无匹配 → pgrep exit 1
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
- await sleep(300);
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
- await sleep(500); // 等退出 + libuv reap
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
- await sleep(700);
158
- pollTickForTest();
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
- await sleep(500);
188
- pollTickForTest();
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
- await sleep(700);
222
- pollTickForTest();
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
- await sleep(700);
301
- // 边沿回调内部全捕获:pollTick 不抛,两条任务都完成终态化
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");
@@ -52,7 +52,7 @@ function registerEntry(id: string) {
52
52
  }
53
53
 
54
54
  afterEach(() => {
55
- rmSync(DATA_DIR, { recursive: true, force: true });
55
+ rmSync(DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
56
56
  vi.restoreAllMocks();
57
57
  });
58
58
 
@@ -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
- * (intent.killed → "killed"、intent.timeout → "timeout"、无 intent → "natural";
75
- * "process-exit" 由收殓路径直接调 finalizeTask,不经这里)→ 单例表 + registry
76
- * 两侧写终态 → 触发 onTaskExit 回调(M3 接入点)。
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?.reason ?? "natural";
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
- } else {
244
- killProcessTree(task.pid);
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,
@@ -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