@zhushanwen/pi-base-tool-enhance 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-base-tool-enhance",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
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,20 +28,20 @@
28
28
  "skills/"
29
29
  ],
30
30
  "dependencies": {
31
- "@zhushanwen/extension-protocol": "0.17.0",
32
- "@zhushanwen/pi-ext-guards": "0.4.2",
33
- "@zhushanwen/pi-extension-logger": "0.6.1",
34
- "@zhushanwen/pi-file-lock": "0.4.1",
35
- "@zhushanwen/pi-llm-shared": "0.11.0"
31
+ "@zhushanwen/extension-protocol": "0.18.0",
32
+ "@zhushanwen/pi-ext-guards": "0.4.3",
33
+ "@zhushanwen/pi-extension-logger": "0.6.2",
34
+ "@zhushanwen/pi-llm-shared": "0.11.1",
35
+ "@zhushanwen/pi-file-lock": "0.4.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@vitest/coverage-v8": "^4.1.9",
39
39
  "vitest": "^4.1.8"
40
40
  },
41
41
  "peerDependencies": {
42
- "@earendil-works/pi-coding-agent": "^0.84.4",
43
- "typebox": "*",
44
- "@zhushanwen/pi-pending-notifications": "0.8.1"
42
+ "@earendil-works/pi-coding-agent": "^1.0.0",
43
+ "typebox": "1.3.27",
44
+ "@zhushanwen/pi-pending-notifications": "0.8.2"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "@zhushanwen/pi-pending-notifications": {
@@ -1,5 +1,5 @@
1
1
  // src/__tests__/background-lifecycle.test.ts —— background 核心生命周期集成:
2
- // 真实 spawn 边沿收尾 / 轮询器自动收尾 / killing intent 双侧 / timeout / 并发上限 /
2
+ // 真实 spawn 边沿收尾 / exit 事件自动收尾 / killing intent 双侧 / timeout / 并发上限 /
3
3
  // 收殓 / D15 abort / bash_output / bash_kill(黑盒:经工具 execute 断言使用者可见行为)
4
4
  import { existsSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
@@ -38,7 +38,7 @@ import { isPidAlive } from "@zhushanwen/extension-protocol/background-task";
38
38
  import { createBashKillToolDefinition } from "../bash-kill-tool.ts";
39
39
  import { createBashOutputToolDefinition } from "../bash-output-tool.ts";
40
40
  import { handleTaskExit, refreshPiReference, resetNotifyForTest } from "../background/notify.ts";
41
- import { pollTickForTest, setOnTaskExit, stopPoller } from "../background/poller.ts";
41
+ import { setOnTaskExit } from "../background/exit-collector.ts";
42
42
  import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "../background/registry.ts";
43
43
  import type { RegistryEntry } from "../background/types.ts";
44
44
  import { DEFAULT_MAX_CONCURRENT_BACKGROUND, spawnBackgroundTask } from "../background/spawn-background.ts";
@@ -74,19 +74,19 @@ function sleep(ms: number): Promise<void> {
74
74
 
75
75
  /**
76
76
  * 真实子进程链(spawn+exec+sleep+exit)满载下延迟不可预估,deadline 放宽到 10s;
77
- * timeout 路径 / 真实 poller 定时器路径单独放宽到 8s(任务语义内最慢的等待对象)。
77
+ * timeout 路径单独放宽到 8s(任务语义内最慢的等待对象)。
78
78
  */
79
79
  const POLL_DEADLINE_MS = 10_000;
80
80
  const POLL_DEADLINE_SLOW_MS = 8_000;
81
81
 
82
82
  /**
83
- * 轮询直到期望状态出现或超时:每次迭代先 pollTickForTest()(手动 tick 推进状态机)
84
- * 再检查——固定 sleep 后单次 tick 单次断言是满载 flake 源,等状态而非猜时刻。
83
+ * 等待直到期望状态出现或超时:状态机由 exit 事件边沿(真实 ChildProcess exit 监听,
84
+ * ADR-0122 改造后无轮询器)自行推进——固定 sleep 后单次断言是满载 flake 源,等状态
85
+ * 而非猜时刻。
85
86
  */
86
87
  async function pollUntilTicked(check: () => boolean, deadlineMs: number, what: string): Promise<void> {
87
88
  const deadline = Date.now() + deadlineMs;
88
89
  for (;;) {
89
- pollTickForTest();
90
90
  if (check()) return;
91
91
  if (Date.now() > deadline) {
92
92
  throw new Error(`timed out after ${deadlineMs}ms waiting for ${what}`);
@@ -95,17 +95,6 @@ async function pollUntilTicked(check: () => boolean, deadlineMs: number, what: s
95
95
  }
96
96
  }
97
97
 
98
- /** 同 pollUntilTicked 但不手动 tick:语义含「真实定时器/事件自行推进」的用例专用。 */
99
- async function pollUntil(check: () => boolean, deadlineMs: number, what: string): Promise<void> {
100
- const deadline = Date.now() + deadlineMs;
101
- while (!check()) {
102
- if (Date.now() > deadline) {
103
- throw new Error(`timed out after ${deadlineMs}ms waiting for ${what}`);
104
- }
105
- await sleep(25);
106
- }
107
- }
108
-
109
98
  function spawnBg(command: string, extra: { timeoutSec?: number; maxConcurrent?: number } = {}) {
110
99
  return spawnBackgroundTask({
111
100
  command,
@@ -148,7 +137,6 @@ afterEach(() => {
148
137
  }
149
138
  }
150
139
  clearTaskStoreForTest();
151
- stopPoller();
152
140
  setOnTaskExit(undefined);
153
141
  resetProcessExitGuardForTest();
154
142
  // pi 引用(currentPi)统一在 afterEach 复位——用例体内不清:断言先失败时泄漏的
@@ -162,9 +150,9 @@ afterAll(() => {
162
150
  rmSync(DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
163
151
  });
164
152
 
165
- describe("real spawn lifecycle (poll edge finalization)", () => {
153
+ describe("real spawn lifecycle (exit event edge finalization)", () => {
166
154
  it("short task: running → exited with exitCode 0, output file readable, registry terminal", async () => {
167
- // 断言只要求「poll 边沿前 state=running」与退出产物,不依赖命令时长(终态化只在 finalizeTask)
155
+ // 断言只要求「exit 事件边沿前 state=running」与退出产物,不依赖命令时长(终态化只在 finalizeTask)
168
156
  const spawned = spawnBg("sleep 0.1 && echo done");
169
157
  if (!spawned.ok) throw new Error(spawned.error);
170
158
  const { task } = spawned;
@@ -192,28 +180,6 @@ describe("real spawn lifecycle (poll edge finalization)", () => {
192
180
  expect(detail.exitCode).toBe(0);
193
181
  });
194
182
 
195
- it("poller interval auto-finalizes without manual tick (lazy start/stop)", async () => {
196
- // 只 fake setInterval/clearInterval(setTimeout 保持真实:命令退出与 pollUntil
197
- // 的等待都真实流逝)——2s 轮询间隔定时器进 fake 队列,advanceTimersByTime 推进
198
- // 即触发真实 pollTick 边沿。本用例是「真实间隔边沿自动收尾(lazy start/stop)」
199
- // 语义的唯一集成探针:边沿推进逻辑(pollTick 本体)原样执行,仅等待被压缩。
200
- vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
201
- try {
202
- const spawned = spawnBg("sleep 0.1 && echo auto");
203
- if (!spawned.ok) throw new Error(spawned.error);
204
- const { task } = spawned;
205
- // 真实等命令退出(不手动 tick——收尾必须来自轮询器定时器边沿)
206
- await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die without poll edge");
207
- // fake 队列推进一个完整轮询间隔 → 首个真实边沿收尾
208
- vi.advanceTimersByTime(2000);
209
- expect(getTask(task.taskId)?.state).toBe("exited");
210
- // 无活跃条目后轮询器自停(防空转)
211
- expect(getActiveTasks()).toHaveLength(0);
212
- } finally {
213
- vi.useRealTimers();
214
- }
215
- });
216
-
217
183
  it("nonzero exit code is surfaced (exitCode 1)", async () => {
218
184
  // 断言只看 exitCode 透写,不依赖命令时长
219
185
  const spawned = spawnBg("sleep 0.1; exit 3");
@@ -359,7 +325,7 @@ describe("bash_output tool", () => {
359
325
  });
360
326
 
361
327
  describe("bash_kill tool (killing intent, single-point finalization)", () => {
362
- it("kill marks BOTH store and registry as killing before the poll edge lands", async () => {
328
+ it("kill marks BOTH store and registry as killing before the exit event edge lands", async () => {
363
329
  const spawned = spawnBg("sleep 60");
364
330
  if (!spawned.ok) throw new Error(spawned.error);
365
331
  const { task } = spawned;
@@ -367,7 +333,7 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
367
333
  const killResult = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
368
334
  expect(killResult.killed).toBe(true);
369
335
 
370
- // 轮询未收尾(sleep 60 才死、kill 后立即断言):两侧 killing 即可见,无倒挂
336
+ // exit 边沿未到(sleep 60 才死、kill 后立即断言):两侧 killing 即可见,无倒挂
371
337
  expect(getTask(task.taskId)?.state).toBe("killing");
372
338
  expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
373
339
  // kill-tree 确实对该 pid 发过令
@@ -447,22 +413,6 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
447
413
  expect(result.reason).toBe("already exited");
448
414
  });
449
415
 
450
- it("P0-2.2: store entry whose pid already died (poll edge not landed) → already exited style, no kill/intent", async () => {
451
- // 死亡等待 ~0.1s 远小于 2s 真实轮询首轮(单例表仍 running 是本用例前提)
452
- const spawned = spawnBg("sleep 0.1");
453
- if (!spawned.ok) throw new Error(spawned.error);
454
- const { task } = spawned;
455
- // 轮询进程死透(不 tick——单例表仍 running 是本用例前提)
456
- await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die without poll edge");
457
- expect(getTask(task.taskId)?.state).toBe("running");
458
-
459
- const result = JSON.parse(await killTool(task.taskId)) as { killed: boolean; reason: string };
460
- expect(result.killed).toBe(false);
461
- expect(result.reason).toContain("already exited");
462
- expect(killTreeCalls).not.toContain(task.pid); // 死 pid 不发 kill
463
- expect(getTask(task.taskId)?.state).toBe("running"); // 未标 killing intent(终态归轮询边沿)
464
- });
465
-
466
416
  it("P0-2.2: pidStartTime mismatch (pid reuse suspected) → refuse kill", async () => {
467
417
  const spawned = spawnBg("sleep 30");
468
418
  if (!spawned.ok) throw new Error(spawned.error);
@@ -541,7 +491,7 @@ describe("explicit background timeout (D6)", () => {
541
491
  vi.useRealTimers();
542
492
  }
543
493
 
544
- // SIGKILL 已真实发出:轮询边沿收尾 → exited(reason:"timeout"),终态由边沿写
494
+ // SIGKILL 已真实发出:exit 事件边沿收尾 → exited(reason:"timeout"),终态由边沿写
545
495
  //(timeout 路径满载下 SIGKILL 生效延迟不可预估,deadline 放宽到 8s)
546
496
  await pollUntilTicked(
547
497
  () => getTask(taskId)?.state === "exited" && getTask(taskId)?.reason === "timeout",
@@ -568,7 +518,7 @@ describe("explicit background timeout (D6)", () => {
568
518
  expect(killTreeCalls).not.toContain(task.pid);
569
519
  // D6-en/R2-S1 加固:身份校验不过 = 登记原进程已不在(死亡或被复用),intent
570
520
  // 标记一并跳过——无条件改写内存 intent 为 timeout 会冲掉 UI 代杀 / AI bash_kill
571
- // 预写的 killed(误报 timed out 唤醒 AI);终态归轮询边沿按事实收尾
521
+ // 预写的 killed(误报 timed out 唤醒 AI);终态归 exit 事件边沿按事实收尾
572
522
  expect(getTask(task.taskId)?.state).toBe("running");
573
523
  expect(getTask(task.taskId)?.intent).toBeUndefined();
574
524
  expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
@@ -632,7 +582,7 @@ describe("explicit background timeout (D6)", () => {
632
582
 
633
583
  // 越过 deadline 的时间窗内不得再补杀(终态化必须已清 timer):轮询真实时钟
634
584
  // 越过 deadline+200ms 余量后断言——这里等待的是时钟流逝本身,非子进程事件
635
- await pollUntil(
585
+ await pollUntilTicked(
636
586
  () => Date.now() >= task.startedAt + 500 + 200,
637
587
  POLL_DEADLINE_SLOW_MS,
638
588
  "real clock to pass the deadline window",
@@ -642,7 +592,7 @@ describe("explicit background timeout (D6)", () => {
642
592
  });
643
593
  });
644
594
 
645
- describe("D6-en: poller finalization reads back registry killing (cross-process UI kill)", () => {
595
+ describe("D6-en: exit edge finalization reads back registry killing (cross-process UI kill)", () => {
646
596
  it("memory intent missing + registry killing → reason killed; AI not woken (no sendMessage)", async () => {
647
597
  const pi = { events: { emit: vi.fn() }, sendMessage: vi.fn(), appendEntry: vi.fn() };
648
598
  refreshPiReference(pi as unknown as ExtensionAPI);
@@ -655,8 +605,8 @@ describe("D6-en: poller finalization reads back registry killing (cross-process
655
605
  writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
656
606
  expect(getTask(task.taskId)?.intent).toBeUndefined(); // 前置:内存无 intent
657
607
  process.kill(task.pid, "SIGKILL");
658
- await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die after SIGKILL");
659
- pollTickForTest();
608
+ // exit 事件边沿自动收尾(读回 registry killing → killed)
609
+ await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to read back killing");
660
610
 
661
611
  const finalized = getTask(task.taskId);
662
612
  expect(finalized?.state).toBe("exited");
@@ -677,10 +627,11 @@ describe("D6-en: poller finalization reads back registry killing (cross-process
677
627
  // 内存预写 killed intent(AI bash_kill 形态),registry 刻意停留 running
678
628
  //(模拟 intent 落盘失败)——若实现误读回,会按 running 判 natural
679
629
  expect(markKillingIntent(task.taskId, "killed")).toBeDefined();
630
+ // 前置(SIGKILL 前断言——事件模式下死亡即边沿收尾,registry 停留 running
631
+ //(模拟 intent 落盘失败,markKillingIntent 不写盘):若实现误读回会按 running 判 natural
632
+ expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
680
633
  process.kill(task.pid, "SIGKILL");
681
- await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die after SIGKILL");
682
- expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running"); // 前置成立
683
- pollTickForTest();
634
+ await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to finalize with intent");
684
635
  expect(getTask(task.taskId)?.reason).toBe("killed"); // intent 优先,非 natural
685
636
  });
686
637
 
@@ -690,8 +641,8 @@ describe("D6-en: poller finalization reads back registry killing (cross-process
690
641
  const { task } = spawned;
691
642
  writeFileSync(REGISTRY_PATH, "{corrupted", "utf8"); // 读回路径解析失败
692
643
  process.kill(task.pid, "SIGKILL");
693
- await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die after SIGKILL");
694
- pollTickForTest();
644
+ // exit 事件边沿自动收尾(读回失败按无 intent 处理)
645
+ await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", 5000, "exit edge to finalize");
695
646
  const finalized = getTask(task.taskId);
696
647
  expect(finalized?.state).toBe("exited"); // 终态化未被阻塞
697
648
  expect(finalized?.reason).toBe("natural"); // 读失败按无 intent 处理
@@ -699,7 +650,7 @@ describe("D6-en: poller finalization reads back registry killing (cross-process
699
650
  });
700
651
 
701
652
  describe("D6-en/R2-S1 hardening: armBackgroundTimeout skips kill+mark when recorded pid is gone", () => {
702
- it("UI kill × timeout overlap: no timeout mark; poll edge reads back registry killing → killed", async () => {
653
+ it("UI kill × timeout overlap: no timeout mark; exit edge reads back registry killing → killed", async () => {
703
654
  // 真实时间必须真实流过(R2-S1 前提:到点时 pid 已死才有「跳过补杀」可言),
704
655
  // fake 时钟会让 timeout 回调先于进程死亡触发
705
656
  const spawned = spawnBg("sleep 30", { timeoutSec: 0.5 });
@@ -708,20 +659,18 @@ describe("D6-en/R2-S1 hardening: armBackgroundTimeout skips kill+mark when recor
708
659
  // UI 代杀(跨进程形态):registry 预写 killing + 直接杀进程,不经内存
709
660
  writeRegistryEntry(REGISTRY_PATH, taskToRegistryEntry({ ...task, state: "killing" }));
710
661
  process.kill(task.pid, "SIGKILL");
711
- await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die before the deadline");
662
+ // exit 事件边沿在 deadline 前收尾(D6-en 读回 registry killing → killed)
663
+ await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to read back killing before deadline");
712
664
  // 越过 deadline(0.5s)+200ms 余量:轮询真实时钟,等待的是时钟流逝本身
713
- await pollUntil(
665
+ await pollUntilTicked(
714
666
  () => Date.now() >= task.startedAt + 500 + 200,
715
667
  POLL_DEADLINE_SLOW_MS,
716
668
  "real clock to pass the deadline window",
717
669
  );
718
- // 加固生效:不补杀、不改写内存(无 intent)、不冲掉 registry 侧 killing 预写
670
+ // 加固生效:到点时任务已终态 → 不补杀、intent 不被改写为 timeout、reason 保持 killed
719
671
  expect(killTreeCalls).not.toContain(task.pid);
720
- expect(getTask(task.taskId)?.state).toBe("running");
721
- expect(getTask(task.taskId)?.intent).toBeUndefined();
722
- expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("killing");
723
- pollTickForTest();
724
672
  expect(getTask(task.taskId)?.state).toBe("exited");
673
+ expect(getTask(task.taskId)?.intent).toBeUndefined();
725
674
  expect(getTask(task.taskId)?.reason).toBe("killed"); // D6-en 读回(非 timeout)
726
675
  });
727
676
 
@@ -731,38 +680,37 @@ describe("D6-en/R2-S1 hardening: armBackgroundTimeout skips kill+mark when recor
731
680
  const { task } = spawned;
732
681
  const killed = JSON.parse(await killTool(task.taskId)) as { killed: boolean };
733
682
  expect(killed.killed).toBe(true);
734
- await pollUntil(() => !isPidAlive(task.pid), 5000, "process to die before the deadline");
735
- // 越过 deadline(0.5s)+200ms 余量:轮询真实时钟——pid 已死 → 跳过 timeout mark
736
- await pollUntil(
683
+ // exit 事件边沿在 deadline 前收尾(intent 优先 → killed)
684
+ await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", 5000, "exit edge to finalize killed before deadline");
685
+ // 越过 deadline(0.5s)+200ms 余量:轮询真实时钟——任务已终态 → 跳过 timeout mark
686
+ await pollUntilTicked(
737
687
  () => Date.now() >= task.startedAt + 500 + 200,
738
688
  POLL_DEADLINE_SLOW_MS,
739
689
  "real clock to pass the deadline window",
740
690
  );
741
- expect(getTask(task.taskId)?.intent?.reason).toBe("killed"); // 未被覆盖为 timeout
691
+ expect(getTask(task.taskId)?.intent).toBeUndefined(); // 终态化消费 intent,不再存在
742
692
  expect(killTreeCalls.filter((pid) => pid === task.pid)).toHaveLength(1); // 无第二次 kill
743
- pollTickForTest();
744
693
  expect(getTask(task.taskId)?.state).toBe("exited");
745
694
  expect(getTask(task.taskId)?.reason).toBe("killed"); // 加固后保持 killed(无通知路径)
746
695
  });
747
696
 
748
- it("natural death before the deadline: no timeout mark; poll edge finalizes natural (accepted shift)", async () => {
697
+ it("natural death before the deadline: no timeout mark; exit edge finalizes natural (accepted shift)", async () => {
749
698
  // 命令 0.1s 自然退出 < 0.5s deadline;真实时间必须真实流过(fake 会先于死亡触发回调)
750
699
  const before = killTreeCalls.length;
751
700
  const spawned = spawnBg("sleep 0.1", { timeoutSec: 0.5 });
752
701
  if (!spawned.ok) throw new Error(spawned.error);
753
702
  const { task } = spawned;
754
- await pollUntil(() => !isPidAlive(task.pid), 5000, "natural death before the deadline");
755
- // 越过 deadline(0.5s)+200ms 余量(仍在 2s 真实轮询 tick 之前,不手动 tick)
756
- await pollUntil(
703
+ // exit 事件边沿在 deadline 前收尾
704
+ await pollUntilTicked(() => getTask(task.taskId)?.state === "exited", 5000, "natural death before the deadline");
705
+ // 越过 deadline(0.5s)+200ms 余量:等待的是时钟流逝本身
706
+ await pollUntilTicked(
757
707
  () => Date.now() >= task.startedAt + 500 + 200,
758
708
  POLL_DEADLINE_SLOW_MS,
759
709
  "real clock to pass the deadline window",
760
710
  );
761
711
  expect(killTreeCalls.length).toBe(before); // 死任务不补杀
762
- expect(getTask(task.taskId)?.state).toBe("running"); // 轮询未收尾,未被标 killing
712
+ expect(getTask(task.taskId)?.state).toBe("exited");
763
713
  expect(getTask(task.taskId)?.intent).toBeUndefined();
764
- expect(readRegistry(REGISTRY_PATH).get(task.taskId)?.state).toBe("running");
765
- pollTickForTest();
766
714
  expect(getTask(task.taskId)?.reason).toBe("natural"); // 语义微移:natural 非 timeout
767
715
  });
768
716
  });
@@ -777,7 +725,7 @@ describe("process-exit reap (D12)", () => {
777
725
 
778
726
  reapBackgroundTasksNow();
779
727
  // SIGKILL 发出到进程表移除是异步的:轮询等 pid 判死(满载下延迟不可预估)
780
- await pollUntil(() => pids.every((pid) => !isPidAlive(pid)), 5000, "reaped pids to die");
728
+ await pollUntilTicked(() => pids.every((pid) => !isPidAlive(pid)), 5000, "reaped pids to die");
781
729
 
782
730
  for (const pid of pids) {
783
731
  expect(isPidAlive(pid)).toBe(false);
@@ -18,7 +18,7 @@ import {
18
18
  refreshPiReference,
19
19
  resetNotifyForTest,
20
20
  } from "../background/notify.ts";
21
- import { pollTickForTest, setOnTaskExit, stopPoller } from "../background/poller.ts";
21
+ import { setOnTaskExit } from "../background/exit-collector.ts";
22
22
  import { spawnBackgroundTask } from "../background/spawn-background.ts";
23
23
  import {
24
24
  clearTaskStoreForTest,
@@ -63,13 +63,12 @@ function sleep(ms: number): Promise<void> {
63
63
  const POLL_DEADLINE_MS = 10_000;
64
64
 
65
65
  /**
66
- * 轮询直到期望状态出现或超时:每次迭代先 pollTickForTest()(手动 tick 推进状态机)
67
- * 再检查——固定 sleep 后单次 tick 单次断言是满载 flake 源,等状态而非猜时刻。
66
+ * 等待直到期望状态出现或超时:exit 事件边沿(真实 ChildProcess exit 监听)负责
67
+ * 推进状态机——固定 sleep 后单次断言是满载 flake 源,等状态而非猜时刻。
68
68
  */
69
69
  async function pollUntilTicked(check: () => boolean, what: string): Promise<void> {
70
70
  const deadline = Date.now() + POLL_DEADLINE_MS;
71
71
  for (;;) {
72
- pollTickForTest();
73
72
  if (check()) return;
74
73
  if (Date.now() > deadline) {
75
74
  throw new Error(`timed out after ${POLL_DEADLINE_MS}ms waiting for ${what}`);
@@ -87,7 +86,7 @@ function spawnBg(command: string) {
87
86
  });
88
87
  }
89
88
 
90
- /** 构造终态条目直调 handleTaskExit(通知行为单元,不经真实轮询时序)。 */
89
+ /** 构造终态条目直调 handleTaskExit(通知行为单元,不经真实 exit 边沿时序)。 */
91
90
  function finalizedTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
92
91
  return {
93
92
  taskId: "bt-1700000000-test01",
@@ -125,7 +124,6 @@ function killLeftoverTasks(): void {
125
124
  afterEach(() => {
126
125
  killLeftoverTasks();
127
126
  clearTaskStoreForTest();
128
- stopPoller();
129
127
  setOnTaskExit(undefined);
130
128
  resetNotifyForTest();
131
129
  vi.restoreAllMocks();
@@ -173,7 +171,7 @@ describe("register emit (data flow ⑤)", () => {
173
171
  });
174
172
  });
175
173
 
176
- describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
174
+ describe("exit-edge notification (⑧⑨, exit-edge wiring)", () => {
177
175
  it("natural exit 0: unregister emit reason 'completed' + sendMessage steer with exact params", async () => {
178
176
  const pi = createMockPi();
179
177
  attach(pi);
@@ -182,7 +180,7 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
182
180
  if (!spawned.ok) throw new Error(spawned.error);
183
181
  const { task } = spawned;
184
182
 
185
- // 轮询到 exit 边沿收尾(handleTaskExit 同步完成 unregister emit + sendMessage)
183
+ // 等到 exit 边沿收尾(handleTaskExit 同步完成 unregister emit + sendMessage)
186
184
  await pollUntilTicked(
187
185
  () => pi.events.emit.mock.calls.some((c) => c[0] === "pending:unregister"),
188
186
  "pending:unregister (completed)",
@@ -203,7 +201,7 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
203
201
  expect(message.content).toContain("exit 0");
204
202
  expect(message.content).toContain("done"); // tail 摘要
205
203
  expect(options).toEqual({ deliverAs: "steer", triggerTurn: true });
206
- // U1(D1):真实轮询边沿投递的 details 与终态条目逐字段一致(非 fixture 常量)
204
+ // U1(D1):真实 exit 边沿投递的 details 与终态条目逐字段一致(非 fixture 常量)
207
205
  const finalizedEntry = getTask(task.taskId);
208
206
  expect(message.details).toMatchObject({
209
207
  taskId: task.taskId,
@@ -224,7 +222,7 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
224
222
 
225
223
  markKillingIntent(task.taskId, "killed");
226
224
  killProcessTree(task.pid);
227
- // 轮询到 kill 边沿收尾(SIGKILL 生效 + tick 终态化)
225
+ // 等到 kill 边沿收尾(SIGKILL 生效 + exit 事件终态化)
228
226
  await pollUntilTicked(() => getTask(task.taskId)?.reason === "killed", "reason=killed finalization");
229
227
 
230
228
  expect(getTask(task.taskId)?.reason).toBe("killed");
@@ -259,7 +257,7 @@ describe("exit-edge notification (⑧⑨, poll edge wiring)", () => {
259
257
  if (!spawned.ok) throw new Error(spawned.error);
260
258
  const { task } = spawned;
261
259
 
262
- // 轮询到 exit 边沿收尾(reason failed)
260
+ // 等到 exit 边沿收尾(reason failed)
263
261
  await pollUntilTicked(
264
262
  () => pi.events.emit.mock.calls.some((c) => c[0] === "pending:unregister"),
265
263
  "pending:unregister (failed)",
@@ -391,7 +389,7 @@ describe("D17: pi reference refresh (session replacement takeover)", () => {
391
389
  expect(fresh.sendMessage).toHaveBeenCalledTimes(1);
392
390
  });
393
391
 
394
- it("poller keeps running when notification path throws inside the exit edge", async () => {
392
+ it("exit edge survives notification path throwing inside the callback", async () => {
395
393
  const stale = createMockPi();
396
394
  stale.sendMessage = vi.fn(() => {
397
395
  throw new Error("stale bus disposed");
@@ -406,15 +404,13 @@ describe("D17: pi reference refresh (session replacement takeover)", () => {
406
404
  const first = spawnBg("sleep 0.1 && echo one");
407
405
  const second = spawnBg("sleep 0.1 && echo two");
408
406
  if (!first.ok || !second.ok) throw new Error("spawn failed");
409
- // 边沿回调内部全捕获:轮询到两条任务都完成终态化(每迭代 tick 后查)
407
+ // 边沿回调内部全捕获:等到两条任务都完成终态化(exit 事件自行推进)
410
408
  await pollUntilTicked(
411
409
  () =>
412
410
  getTask(first.task.taskId)?.state === "exited" &&
413
411
  getTask(second.task.taskId)?.state === "exited",
414
412
  "both tasks to finalize",
415
413
  );
416
- // tick 不抛(异常全捕获)
417
- expect(() => pollTickForTest()).not.toThrow();
418
414
  expect(getTask(first.task.taskId)?.state).toBe("exited");
419
415
  expect(getTask(second.task.taskId)?.state).toBe("exited");
420
416
  });
@@ -597,7 +597,7 @@ describe("redelivery: exclusion criteria (decision 2 strict terminal + killed re
597
597
  expect(pi.sendMessage).not.toHaveBeenCalled();
598
598
  });
599
599
 
600
- it("running leftover with dead pid is NOT redelivered (poller write-failure domain, unregister covers)", async () => {
600
+ it("running leftover with dead pid is NOT redelivered (exit-edge write-failure domain, unregister covers)", async () => {
601
601
  const entry = makeRegistryEntry({ taskId: "bt-1700000000-rund01", state: "running", pid: deadPid() });
602
602
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
603
603
  const pi = createMockPi();
@@ -79,7 +79,7 @@ describe("task store state machine", () => {
79
79
  const marked = markKillingIntent(task.taskId, "killed");
80
80
  expect(marked?.state).toBe("killing");
81
81
  expect(marked?.intent).toEqual({ reason: "killed", at: expect.any(Number) });
82
- // killing 仍属活跃态(轮询器监护对象)
82
+ // killing 仍属活跃态(exit 边沿监护对象)
83
83
  expect(getActiveTasks().map((t) => t.taskId)).toEqual([task.taskId]);
84
84
  });
85
85
 
@@ -89,4 +89,38 @@ describe("setupToolErrorAudit", () => {
89
89
 
90
90
  expect(pi.appendEntry).not.toHaveBeenCalled();
91
91
  });
92
+
93
+ // D13②:嵌套子调用失败不单独记账(父工具的 tool_execution_end 是记账终点)
94
+
95
+ it("skips nested sub-call failure (parentToolCallId present) — no audit entry", async () => {
96
+ const pi = createMockPi();
97
+ setupToolErrorAudit(pi as unknown as ExtensionAPI);
98
+ const handler = getRegisteredHandler(pi);
99
+
100
+ await handler({
101
+ isError: true,
102
+ toolName: "bash",
103
+ toolCallId: "call-child-1",
104
+ parentToolCallId: "call-parent-0",
105
+ result: { content: [{ type: "text", text: "child failed" }] },
106
+ });
107
+
108
+ expect(pi.appendEntry).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("empty-string parentToolCallId is treated as top-level call — entry still recorded", async () => {
112
+ const pi = createMockPi();
113
+ setupToolErrorAudit(pi as unknown as ExtensionAPI);
114
+ const handler = getRegisteredHandler(pi);
115
+
116
+ await handler({
117
+ isError: true,
118
+ toolName: "bash",
119
+ toolCallId: "call-4",
120
+ parentToolCallId: "",
121
+ result: { content: [{ type: "text", text: "top-level failed" }] },
122
+ });
123
+
124
+ expect(pi.appendEntry).toHaveBeenCalledTimes(1);
125
+ });
92
126
  });
@@ -1,34 +1,31 @@
1
1
  /**
2
- * 模块级轮询器单例(D17:exit 感知靠轮询不靠 ChildProcess 闭包)。
2
+ * exit 边沿收尾(ADR-0122 改造:原 2s kill(pid,0) 轮询器已删,感知归 ChildProcess
3
+ * exit 事件——事件驱动,无时间驱动成分)。
3
4
  *
4
- * 为什么轮询:同进程 session 替换(fork/switch/new)会重建 eventBus 并重新 load
5
- * extension,ChildProcess exit 闭包监听里的 bus/pi 引用全部 stale——完成通知在
6
- * 新 session 不可达。kill(pid,0) 轮询 + 模块级单例跨替换免疫,2s 延迟对分钟级
7
- * 任务无感。
5
+ * 为什么事件可用(原 D17 轮询的根因已消解):同进程 session 替换(fork/switch/new)
6
+ * 重建 eventBus 并重新 load extension 实例,但 jiti 模块体只执行一次——本模块与
7
+ * task-store / notify 均为模块级单例,跨替换存活;exit 监听挂在 ChildProcess 对象
8
+ * (进程内实体,与 bus 生命周期无关)上,回调链(finalizeExitedTask → task-store
9
+ * 模块级表 → notify 模块级 currentPi)不含任何实例级 bus/pi 引用。通知可达性由
10
+ * notify.ts 的 D17 pi 引用刷新承担(新实例 load 时 refreshPiReference 指向新 pi)。
8
11
  *
9
- * 惰性启停:无 running/killing 条目时清定时器(防空转泄漏),新任务登记时重启。
12
+ * 幂等:exit 事件与 bash_kill / timeout 收尾并发时,finalizeTask 的终态守卫
13
+ * (isTerminalState)保证单一终态归属,先到者写、后到者 no-op。
10
14
  *
11
15
  * 已知竞态(设计文档 §3.5 原样登记,不修):同进程 session 替换 dispose → 新实例
12
16
  * load 间毫秒窗口任务恰好完成时通知可能落旧 bus——窗口极窄且后果可恢复(bash_output
13
- * 可查、对账可补),不加同步握手。
17
+ * 可查、对账可补),不加同步握手。父进程死亡场景不归本模块(exit 监听随进程消失,
18
+ * 由 process-exit-guard 收殓 + runtime 孤儿收殓兜底)。
14
19
  */
15
20
 
16
- import { isPidAlive } from "@zhushanwen/extension-protocol/background-task";
17
-
18
21
  import { readTailSummary } from "./output-tail.ts";
19
22
  import { readRegistry, taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
20
- import { finalizeTask, getActiveTasks } from "./task-store.ts";
23
+ import { finalizeTask } from "./task-store.ts";
21
24
  import type { BackgroundTask, BackgroundTaskEndReason } from "./types.ts";
22
25
 
23
- /** 轮询间隔(设计文档 §3.5:约 2s)。 */
24
- export const POLL_INTERVAL_MS = 2000;
25
-
26
- let pollTimer: ReturnType<typeof setInterval> | undefined;
27
-
28
26
  /**
29
27
  * M3 通知接入点(本单元 no-op 占位):exit 边沿收尾(单例表 + registry 终态写完)
30
28
  * 之后同步回调。M3 在这里接 pending:unregister emit + sendMessage steer。
31
- * 不在 M2 实现任何通知行为。
32
29
  */
33
30
  let onTaskExitCallback: ((task: BackgroundTask) => void) | undefined;
34
31
 
@@ -36,38 +33,16 @@ export function setOnTaskExit(callback: ((task: BackgroundTask) => void) | undef
36
33
  onTaskExitCallback = callback;
37
34
  }
38
35
 
39
- /** 惰性启动:有活跃条目才跑定时器(spawn 登记与 kill/timeout 标记后调用)。 */
40
- export function ensurePollerRunning(): void {
41
- if (pollTimer !== undefined) return;
42
- pollTimer = setInterval(pollTick, POLL_INTERVAL_MS);
43
- // 不阻止进程退出(收殓路径 stopPoller 统一清理)
44
- pollTimer.unref?.();
45
- }
46
-
47
- export function stopPoller(): void {
48
- if (pollTimer !== undefined) {
49
- clearInterval(pollTimer);
50
- pollTimer = undefined;
51
- }
52
- }
53
-
54
- function pollTick(): void {
55
- const active = getActiveTasks();
56
- if (active.length === 0) {
57
- stopPoller();
58
- return;
59
- }
60
- for (const task of active) {
61
- // libuv 自动 reap 后 kill(pid,0) 报 ESRCH(判死),ChildProcess.exitCode 仍可读
62
- if (!isPidAlive(task.pid)) {
63
- finalizeExitedTask(task);
64
- }
65
- }
66
- }
67
-
68
- /** 测试专用:手动跑一轮轮询(不依赖 2s 定时器)。 */
69
- export function pollTickForTest(): void {
70
- pollTick();
36
+ /**
37
+ * spawn 登记时挂接 exit 监听(唯一调用点 = spawn-background.ts):
38
+ * exit 边沿触发收尾(读 exitCode → 组装 tail 摘要 → reason 判定 → 两侧写终态 →
39
+ * M3 回调)。error 监听仍由 spawn 侧持 no-op(spawn 异步失败只防崩溃,不推进状态
40
+ * ——spawn 失败即无 pid 无任务)。
41
+ */
42
+ export function attachExitCollector(task: BackgroundTask): void {
43
+ task.child?.on("exit", () => {
44
+ finalizeExitedTask(task);
45
+ });
71
46
  }
72
47
 
73
48
  /**
@@ -7,14 +7,16 @@
7
7
  * - 完成通知经 pi.sendMessage({customType, content, display:true},
8
8
  * {deliverAs:"steer", triggerTurn:true}) 驱动新 turn
9
9
  *
10
- * D17 pi 引用刷新:轮询器与通知通路持模块级「当前 pi 引用」——同进程 session 替换
10
+ * D17 pi 引用刷新:通知通路持模块级「当前 pi 引用」——同进程 session 替换
11
11
  * (/fork、选择器切换、RPC session.*)会重建 eventBus 并重新 load extension,本
12
12
  * 模块引用由新实例 load 时 refreshPiReference 刷新,完成通知投递新 session。
13
+ * (exit-collector 的收尾回调链同样全模块级,exit 事件边沿由此跨替换可达——
14
+ * ADR-0122 改造:原 2s 轮询器已删。)
13
15
  *
14
16
  * 已知竞态(设计 §3.5 原样登记,不修):dispose → 新实例 load 间毫秒窗口任务恰好
15
17
  * 完成时,sendMessage/emit 落旧 bus 丢一条——对账在该 session 重开时补 unregister,
16
18
  * 完成内容可由 bash_output 查询;窗口极窄且后果可恢复,不加同步握手。旧引用 throw
17
- * (旧 bus 已 dispose)时捕获降级为日志,不中断轮询(后续任务仍可通知)。
19
+ * (旧 bus 已 dispose)时捕获降级为日志,不中断后续任务的通知(边沿收尾不受影响)。
18
20
  *
19
21
  * kill 路径不 sendMessage(§3.5「bash_kill 终态收尾的单点归属」):reason:"killed"
20
22
  * 只 emit unregister——kill 调用方就在当前 turn 等结果,双发是噪音。
@@ -108,7 +110,7 @@ export function emitPendingRegister(task: BackgroundTask): void {
108
110
  * data 形态对齐 pending-notifications parseUnregisterEvent 期望:{id, reason}——
109
111
  * status 不在 emit data 里(listener 用 mapReasonToStatus(reason) 自行计算);
110
112
  * appendEntry 侧(对账/收殓)的落盘形态 {id, reason, status} 见 pending-reconcile.ts。
111
- * 轮询器 exit 边沿与进程退出收殓两条路径共用。
113
+ * exit 事件边沿与进程退出收殓两条路径共用。
112
114
  */
113
115
  export function emitPendingUnregister(
114
116
  taskId: string,
@@ -127,7 +129,7 @@ export function emitPendingUnregister(
127
129
  }
128
130
 
129
131
  /**
130
- * ⑧⑨ 轮询器 exit 边沿的完成通知入口(poller setOnTaskExit 接线,index.ts load 时挂)。
132
+ * ⑧⑨ exit 事件边沿的完成通知入口(exit-collector setOnTaskExit 接线,index.ts load 时挂)。
131
133
  * 入参是 finalizeTask 之后的终态条目(state=exited)。kill 路径不 sendMessage
132
134
  * (文件头);process-exit 不经过这里(收殓路径直接 finalizeTask + 只 emit)。
133
135
  */
@@ -152,8 +154,8 @@ function sendTaskFinishedMessage(task: BackgroundTask): void {
152
154
  { deliverAs: "steer", triggerTurn: true },
153
155
  );
154
156
  } catch (err) {
155
- // 旧 bus 已 dispose(session 替换毫秒窗口)——降级日志,不中断轮询(文件头已知竞态)
156
- logger.warn("background task notify sendMessage failed; poll continues", {
157
+ // 旧 bus 已 dispose(session 替换毫秒窗口)——降级日志,不中断后续收尾(文件头已知竞态)
158
+ logger.warn("background task notify sendMessage failed (stale bus?); notification skipped, later tasks unaffected", {
157
159
  detail: {
158
160
  taskId: task.taskId,
159
161
  err: toErrorMessage(err),
@@ -18,7 +18,7 @@ const SUMMARY_TAIL_LINES = 5;
18
18
  const SUMMARY_MAX_CHARS = 800;
19
19
 
20
20
  /**
21
- * 轮询器 exit 边沿的 tail 摘要(存进条目、M3 通知用):末尾几行的紧凑文本。
21
+ * exit 事件边沿收尾的 tail 摘要(存进条目、M3 通知用):末尾几行的紧凑文本。
22
22
  */
23
23
  export function readTailSummary(outputFile: string, maxChars: number = SUMMARY_MAX_CHARS): string | undefined {
24
24
  const tail = readOutputTailPrimitive(outputFile, { maxLines: SUMMARY_TAIL_LINES, maxBytes: maxChars });
@@ -22,7 +22,7 @@
22
22
  * 同步写补投标记 entry 保幂等。覆盖的失效面 = 主路径单次投递丢失(进程死亡收殓、
23
23
  * 投递 throw、投递被接受但未落盘即崩溃)。**补投是 registry 全集的独立遍历,不挂
24
24
  * pending 差集循环**(决策 2):差集候选 = pending:register 还挂着的任务,而失效
25
- * 形态「poller 边沿 unregister 已写、sendMessage 才 throw」下差集为空,挂靠即永久
25
+ * 形态「exit 边沿 unregister 已写、sendMessage 才 throw」下差集为空,挂靠即永久
26
26
  * 漏补。补投**不代写 pending:unregister**——收尾保持差集循环唯一写点(避免绕过
27
27
  * listener 的 isPendingActive 幂等门落冗余 entry)。
28
28
  *
@@ -33,7 +33,7 @@
33
33
  *
34
34
  * 补投终态判据(决策 2):protocol isTerminalState 严格终态(exited/orphaned)——
35
35
  * **显式不用下方 isTerminalByRegistry 宽判据**(宽判据含 running/killing 且 pid 判死,
36
- * 会把 killing 遗留——kill 已发令、poller 终态化前进程死亡,实为被杀任务——误判为
36
+ * 会把 killing 遗留——kill 已发令、exit 边沿终态化前进程死亡,实为被杀任务——误判为
37
37
  * 可补投);且 state=exited 时 reason≠killed(killed 是 reason 枚举值而非独立
38
38
  * state,state 级过滤排不掉,必须 reason 级显式排除——kill 的发起方当次交互已同步
39
39
  * 获知结果,补投是重复刺激)。killing/running+判死遗留不补投(保守正确,退化现状)。
@@ -85,9 +85,9 @@ const logger = getLogger("base-tool-enhance");
85
85
  /**
86
86
  * 对账依赖的最小 pi 面(结构兼容 ExtensionAPI 的子集;测试注入不造完整 pi)。
87
87
  *
88
- * sendMessage 返回类型 `void | Promise<void>` 是 pi 0.84.4 实装的适配登记
89
- * (bg-task-notify-durability 实装核实):ExtensionAPI.sendMessage 的类型声明与
90
- * loader.js:296 / agent-session.js:2004 两级桥接均不返回 promise(bindCore 包装层
88
+ * sendMessage 返回类型 `void | Promise<void>` 是 pi 实装的适配登记
89
+ * (bg-task-notify-durability 实装核实,1.0.0 复核同构):ExtensionAPI.sendMessage 的类型声明与
90
+ * loader.js / agent-session.js bindCore 两级桥接均不返回 promise(bindCore 包装层
91
91
  * 以 .catch 吞 rejection 转 runner emitError),真实 pi 注入时恒返回 undefined——
92
92
  * 「await resolve 即已落盘」在实装通道上拿不到。await 两者皆合法:
93
93
  * - 同步 throw(assertActive 失败 / 旧 bus)在调用点抛出,可捕;
@@ -32,7 +32,6 @@ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
32
32
  import { getLogger } from "@zhushanwen/pi-extension-logger";
33
33
 
34
34
  import { emitPendingUnregister } from "./notify.ts";
35
- import { stopPoller } from "./poller.ts";
36
35
  import { taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
37
36
  import { finalizeTask, getActiveTasks } from "./task-store.ts";
38
37
 
@@ -67,14 +66,13 @@ export function installProcessExitGuard(): void {
67
66
 
68
67
  /**
69
68
  * 收殓动作(导出供测试直接调用,不真杀 pi 进程):
70
- * 轮询器停止 → 遍历**单例表**活跃条目 kill-tree(单例表 = 本进程任务全集,天然
69
+ * 遍历**单例表**活跃条目 kill-tree(单例表 = 本进程任务全集,天然
71
70
  * 不含他进程条目——不遍历 registry,否则 ephemeral 附着进程退出会误杀属主进程的
72
71
  * 任务)→ registry 写终态 exited(reason:"process-exit")。
73
72
  *
74
- * 终态与轮询器边沿共用 finalizeTask(单一终态归属)。
73
+ * 终态与 exit 事件边沿共用 finalizeTask(单一终态归属)。
75
74
  */
76
75
  export function reapBackgroundTasksNow(): void {
77
- stopPoller();
78
76
  for (const task of getActiveTasks()) {
79
77
  try {
80
78
  // 回退路径诊断经 onFallback 注入 logger 适配(ext-simplify-13 D2)
@@ -22,8 +22,8 @@ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
22
22
  import { getLogger } from "@zhushanwen/pi-extension-logger";
23
23
 
24
24
  import { truncateCommand } from "./command-display.ts";
25
+ import { attachExitCollector } from "./exit-collector.ts";
25
26
  import { emitPendingRegister } from "./notify.ts";
26
- import { ensurePollerRunning } from "./poller.ts";
27
27
  import { getRegistryPath, taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
28
28
  import { countActiveTasks, markKillingIntent, oldestActiveTask, registerSpawnedTask } from "./task-store.ts";
29
29
  import type { BackgroundTask } from "./types.ts";
@@ -111,7 +111,7 @@ function shellForPlatform(): { shell: string; args: string[] } {
111
111
 
112
112
  /**
113
113
  * 启动后台任务:并发检查 → spawn(输出重定向 .log)→ 单例表登记 + registry 写
114
- * running 条目 → 显式 timeout 定时器 → 启动轮询器。
114
+ * running 条目 → 显式 timeout 定时器 → 挂接 exit 事件边沿收尾。
115
115
  * 立即返回,不等待命令退出(turn 不被占用,G1)。
116
116
  */
117
117
  export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgroundResult {
@@ -164,8 +164,9 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
164
164
  detached: true,
165
165
  stdio: ["ignore", outputFd, outputFd],
166
166
  });
167
- // 唯一的事件监听例外:no-op error listener 防 spawn 异步失败 emit error 无监听
168
- // 导致进程崩溃(EventEmitter 语义)。不做任何状态推进——exit 感知归轮询器(D17)。
167
+ // no-op error listener 防 spawn 异步失败 emit error 无监听导致进程崩溃
168
+ //(EventEmitter 语义),不做任何状态推进——exit 感知归 exit 事件边沿
169
+ //(attachExitCollector,ADR-0122 改造:原 2s 轮询器已删)。
169
170
  child.on("error", () => {});
170
171
  child.unref();
171
172
  } catch (err) {
@@ -221,25 +222,26 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
221
222
  registerSpawnedTask(task);
222
223
  writeRegistryEntry(registryPath, taskToRegistryEntry(task));
223
224
  emitPendingRegister(task);
224
- ensurePollerRunning();
225
+ // exit 事件边沿挂接(事件驱动感知,ADR-0122 改造后无轮询器)
226
+ attachExitCollector(task);
225
227
  return { ok: true, task };
226
228
  }
227
229
 
228
230
  /**
229
231
  * 后台显式 timeout(D6:任务寿命可由使用者显式约束)。到点:pid 身份校验通过 →
230
- * kill-tree + 两侧标 killing intent(reason 候选 timeout)——实际终态由轮询器边沿
231
- * 收尾写(单一终态归属),此处不写终态。身份校验不过(登记原进程已不在:已死或
232
+ * kill-tree + 两侧标 killing intent(reason 候选 timeout)——实际终态由 exit 事件
233
+ * 边沿收尾写(单一终态归属),此处不写终态。身份校验不过(登记原进程已不在:已死或
232
234
  * pid 复用嫌疑)→ 跳过 kill **且跳过 intent 标记**(D6-en/R2-S1 加固):无条件
233
235
  * markKillingIntent("timeout") 会把 UI 代杀 / AI bash_kill 刚预写的 killed 改写成
234
- * timeout → reason=timeout → sendMessage 误报「timed out」唤醒 AI;跳过后由轮询器
236
+ * timeout → reason=timeout → sendMessage 误报「timed out」唤醒 AI;跳过后由 exit
235
237
  * 边沿按事实收尾(自然死亡 → natural;registry 预写 killing → 经 D6-en 读回判
236
- * killed)。接受语义微移:到期前 ~2s 内自然死亡的任务改标 natural(仅影响 AI
238
+ * killed)。接受语义微移:到期前极窄窗口内自然死亡的任务改标 natural(仅影响 AI
237
239
  * 通知文案)。
238
240
  */
239
241
  function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
240
242
  const timer = setTimeout(() => {
241
243
  // pid 复用防御(§3.6,同 bash_kill 范式,宁不杀勿误杀):
242
- // 任务早已退出(exit 边沿未被轮询器收尾或竞态未及)且 pid 在到点前被系统复用
244
+ // 任务早已退出(exit 边沿未被收尾或竞态未及)且 pid 在到点前被系统复用
243
245
  // 时,直接 killProcessTree 会杀掉复用 pid 上的无辜进程(整进程组 SIGKILL)。
244
246
  if (!isRecordedPidStillOriginal(task)) {
245
247
  logger.warn("background timeout: pid identity unverified (reuse suspected or start time unreadable), skipping kill", {
@@ -252,9 +254,7 @@ function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
252
254
  });
253
255
  // D6-en/R2-S1 加固:登记原进程已不在时同样跳过 intent 标记——内存 intent
254
256
  // 是本进程权威(task-store.ts 头部不变量),被无条件改写为 timeout 即丢失
255
- // killed 语义(D6-en 读回与 AI bash_kill 预写双双失效)。此处提前 return
256
- // 时轮询器必在跑(本任务仍是活跃条目,惰性自停条件是活跃数为 0),边沿
257
- // 收尾不受影响。
257
+ // killed 语义(D6-en 读回与 AI bash_kill 预写双双失效)。
258
258
  return;
259
259
  }
260
260
  // 回退路径诊断经 onFallback 注入 logger 适配(ext-simplify-13 D2)
@@ -266,7 +266,6 @@ function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
266
266
  const marked = markKillingIntent(task.taskId, "timeout");
267
267
  if (marked === undefined) return;
268
268
  writeRegistryEntry(marked.registryPath, taskToRegistryEntry(marked));
269
- ensurePollerRunning();
270
269
  }, timeoutSec * MS_PER_SECOND);
271
270
  timer.unref?.();
272
271
  task.timeoutTimer = timer;
@@ -15,14 +15,14 @@
15
15
  *
16
16
  * 【不变量登记(D6-en,注释级)】extension 对某条目的**每次 registry 写都先经该
17
17
  * 条目的内存状态更新**——registry 侧 killing 状态因此恒为内存态的投影,runtime
18
- * 跨进程预写的 killing 不会被 stale 内存态冲回,D6-en intent 读回(poller.ts)的
19
- * 正确性依赖此约束。全部 5 个 writeRegistryEntry 调用点逐一成立:
18
+ * 跨进程预写的 killing 不会被 stale 内存态冲回,D6-en intent 读回(exit-collector.ts)
19
+ * 的正确性依赖此约束。全部 5 个 writeRegistryEntry 调用点逐一成立:
20
20
  * ① spawn-background.ts spawnBackgroundTask:registerSpawnedTask(新条目首登记)
21
21
  * 先于 registry 写 running;
22
22
  * ② bash-kill-tool.ts:markKillingIntent(taskId,"killed") 先于 registry 写 killing;
23
23
  * ③ spawn-background.ts armBackgroundTimeout:markKillingIntent(taskId,"timeout")
24
24
  * 先于 registry 写 killing(pid 已死路径提前 return,不触达写点);
25
- * ④ poller.ts finalizeExitedTask:finalizeTask(内存终态化)先于 registry 写 exited;
25
+ * ④ exit-collector.ts finalizeExitedTask:finalizeTask(内存终态化)先于 registry 写 exited;
26
26
  * ⑤ process-exit-guard.ts reapBackgroundTasksNow:finalizeTask 先于 registry 写
27
27
  * exited(process-exit)(设计枚举 4 点时未列本点,实际同序成立,一并登记)。
28
28
  * 未来新增写路径(如 maintenance 类)必须保持该顺序,违反即 intent 可丢失。
@@ -61,7 +61,7 @@ export function getAllTasks(): BackgroundTask[] {
61
61
  return [...taskTable.values()];
62
62
  }
63
63
 
64
- /** 活跃条目(running | killing),轮询器监护对象。 */
64
+ /** 活跃条目(running | killing),exit 事件边沿的监护对象。 */
65
65
  export function getActiveTasks(): BackgroundTask[] {
66
66
  return getAllTasks().filter((t) => isActiveState(t.state));
67
67
  }
@@ -99,9 +99,9 @@ export interface FinalizeOutcome {
99
99
  }
100
100
 
101
101
  /**
102
- * 终态化(exited):唯一终态写入口。轮询器 exit 边沿、进程退出收殓两条路径收敛
103
- * 到这里(bash_kill 不直接写终态——单一终态归属,§3.5「bash_kill 终态收尾的
104
- * 单点归属」)。消费 intent、清 timeout 定时器、算 durationMs、LRU 淘汰溢出终态。
102
+ * 终态化(exited):唯一终态写入口。exit 事件边沿(exit-collector)、进程退出收殓
103
+ * 两条路径收敛到这里(bash_kill 不直接写终态——单一终态归属,§3.5「bash_kill 终态
104
+ * 收尾的单点归属」)。消费 intent、清 timeout 定时器、算 durationMs、LRU 淘汰溢出终态。
105
105
  */
106
106
  export function finalizeTask(taskId: string, outcome: FinalizeOutcome): BackgroundTask | undefined {
107
107
  const task = taskTable.get(taskId);
@@ -33,9 +33,9 @@ export {
33
33
  } from "@zhushanwen/extension-protocol";
34
34
 
35
35
  /**
36
- * killing intent:bash_kill / 后台 timeout 已发令、轮询器 exit 边沿未确认的瞬态标记。
36
+ * killing intent:bash_kill / 后台 timeout 已发令、exit 事件边沿未确认的瞬态标记。
37
37
  * 标记写入单例表与 registry 两侧(查询面立即可见,无「已 kill 仍 running」倒挂);
38
- * 轮询边沿据此决定终态 reason,消费后清除。
38
+ * exit 边沿据此决定终态 reason,消费后清除。
39
39
  */
40
40
  export interface KillingIntent {
41
41
  reason: Extract<ContractEndReason, "killed" | "timeout">;
@@ -45,10 +45,10 @@ export interface KillingIntent {
45
45
  /**
46
46
  * 单例任务表条目(运行时权威,D17 根基)。
47
47
  *
48
- * child 引用**只用于读 exitCode/signalCode,禁止挂事件监听**——exit 感知统一走
49
- * 轮询器 kill(pid,0) 边沿;闭包式 exit 监听在同进程 session 替换后指向 stale bus
50
- * (D17)。child.on("error") 是唯一例外(spawn-background 内的 no-op,防进程崩溃,
51
- * 不做任何状态推进)。
48
+ * child 引用**只用于读 exitCode/signalCode 与 exit-collector 的 exit 监听挂接**
49
+ * (attachExitCollector,ADR-0122 改造:exit 感知归事件边沿——回调链全模块级,
50
+ * 无实例级 bus/pi 引用,session 替换不产生 stale)。child.on("error") 是另一处
51
+ * 监听(spawn-background 内的 no-op,防进程崩溃,不做任何状态推进)。
52
52
  */
53
53
  export interface BackgroundTask {
54
54
  taskId: string;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 职责边界:只负责杀进程树 + kill 前把单例表与 registry **两侧**标 killing intent
5
5
  * (查询面立即可见——kill 返回后 bash_output 即显示 killing,无「已 kill 仍 running」
6
- * 倒挂窗口)。实际终态(exited, reason:"killed")由轮询器 exit 边沿收尾写——
6
+ * 倒挂窗口)。实际终态(exited, reason:"killed")由 exit 事件边沿收尾写——
7
7
  * bash_kill 不直接写终态(单一终态归属),也不 sendMessage(kill 调用方就在当前
8
8
  * turn 内等结果,再发 steer 通知是双发噪音——按 notify.ts 的单点归属规则,kill
9
9
  * 路径不 sendMessage,reason:"killed" 只 emit unregister,终态由 handleTaskExit 收尾)。
@@ -24,7 +24,6 @@ import { Type } from "typebox";
24
24
  import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
25
25
  import { getLogger } from "@zhushanwen/pi-extension-logger";
26
26
 
27
- import { ensurePollerRunning } from "./background/poller.ts";
28
27
  import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "./background/registry.ts";
29
28
  import { getAllTasks, markKillingIntent } from "./background/task-store.ts";
30
29
  import { isActiveState, isTerminalState, type RegistryEntry } from "./background/types.ts";
@@ -100,13 +99,13 @@ export function createBashKillToolDefinition() {
100
99
  }
101
100
 
102
101
  // pid 复用防御(§3.6 宁不杀勿误杀):
103
- // - pid 已死 → already exited 风格返回,不发 kill(终态由轮询边沿收尾)
102
+ // - pid 已死 → already exited 风格返回,不发 kill(终态由 exit 边沿收尾)
104
103
  // - 有 pidStartTime 字段 → 校验实际进程 start time 匹配;不匹配/读不到 =
105
104
  // 复用嫌疑,拒绝 kill 并说明
106
- // - 无字段(spawn 时 ps 不可用平台)→ 放行:本进程轮询器 ≤2s 前判过活,
105
+ // - 无字段(spawn 时 ps 不可用平台)→ 放行:登记原进程在 spawn 时刻存活,
107
106
  // 复用窗口毫秒级;registry 终态条目不会走到这里(上面 already exited)
108
107
  if (!isPidAlive(fromStore.pid)) {
109
- return killedFalse("already exited (process no longer alive; final state pending poll)");
108
+ return killedFalse("already exited (process no longer alive; final state will be written on task exit)");
110
109
  }
111
110
  if (fromStore.pidStartTime !== undefined) {
112
111
  const actualStartSec = getProcessStartTimeSec(fromStore.pid);
@@ -114,7 +113,7 @@ export function createBashKillToolDefinition() {
114
113
  return killedFalse(
115
114
  "cannot verify process start time; refusing to kill (better safe than sorry)",
116
115
  `pid ${fromStore.pid} is alive but its start time is unreadable, so pid reuse cannot be ruled out; ` +
117
- "poll bash_output for the poll edge, or kill the process group manually if certain",
116
+ "poll bash_output for the final state, or kill the process group manually if certain",
118
117
  );
119
118
  }
120
119
  if (actualStartSec !== fromStore.pidStartTime) {
@@ -141,8 +140,7 @@ export function createBashKillToolDefinition() {
141
140
  detail: { pid: fromStore.pid, err: toErrorMessage(err) },
142
141
  }),
143
142
  );
144
- // 轮询器确保在跑:边沿收尾(写终态)依赖它
145
- ensurePollerRunning();
143
+ // 终态由 exit 事件边沿收尾(exit-collector,事件驱动;无轮询器)
146
144
  return textResult(
147
145
  JSON.stringify(
148
146
  {
package/src/bash-tool.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * (前台未填 → foregroundTimeoutSeconds,后台未填 → backgroundTimeoutSeconds)
16
16
  */
17
17
 
18
- import type { AgentToolUpdateCallback, BashToolDetails, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import type { AgentToolUpdateCallback, BashToolDetails, ExtensionToolContext } from "@earendil-works/pi-coding-agent";
19
19
  import { createBashToolDefinition, getAgentDir } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
21
21
 
@@ -76,7 +76,7 @@ const ENHANCED_BASH_DESCRIPTION = [
76
76
  */
77
77
  function startBackgroundAndReply(
78
78
  command: string,
79
- ctx: ExtensionContext,
79
+ ctx: ExtensionToolContext,
80
80
  config: BaseToolEnhanceConfig,
81
81
  timeoutSec: number | undefined,
82
82
  extraNotes: string[] = [],
@@ -162,7 +162,9 @@ export function createBashOverrideToolDefinition() {
162
162
  // 与官方 delegate 同型(展开透传后泛型 TDetails=BashToolDetails|undefined
163
163
  // 随 render 面传入,execute 签名必须对齐,否则 registerTool 赋值检查不过)
164
164
  onUpdate: AgentToolUpdateCallback<BashToolDetails | undefined> | undefined,
165
- ctx: ExtensionContext,
165
+ // pi 1.0.0:ToolDefinition.execute 第 5 参 ctx 收窄为 ExtensionToolContext
166
+ // (ExtensionContext + tools/executeTool,嵌套工具调用面)
167
+ ctx: ExtensionToolContext,
166
168
  ) {
167
169
  // 配置每次 execute 读时加载(热重载契约:禁止上层缓存,同进程改配置文件
168
170
  // 不重启即生效)。D14:subagent 降级是全量的——白名单与 background 参数
package/src/index.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * M1:同名 override pi 内置 bash 工具 + 前台委托官方工厂 + 工具报错审计 hook(D11)。
5
5
  * M2:background 任务核心生命周期——bash background 分支(spawn 后台 + registry +
6
- * 轮询器单例任务表)、bash_output / bash_kill 工具、进程退出收殓、subagent 降级。
7
- * M3:pending-notifications 通知接入——load 时刷新轮询器通知通路的 pi 引用(D17
6
+ * 模块级单例任务表)、bash_output / bash_kill 工具、进程退出收殓、subagent 降级。
7
+ * M3:pending-notifications 通知接入——load 时刷新通知通路的 pi 引用(D17
8
8
  * session 替换接管)+ 挂 exit 边沿通知回调(unregister emit + sendMessage steer)+
9
9
  * session_start 对账(appendEntry 权威路径兜底 pending 收尾)+ 完成通知补投
10
10
  * (bg-task-notify-durability:终态无痕迹无标记的任务下次激活合并补投 + 同步幂等标记)。
@@ -33,7 +33,7 @@ import { createBashOverrideToolDefinition } from "./bash-tool.ts";
33
33
  import { handleTaskExit, refreshPiReference } from "./background/notify.ts";
34
34
  import { installProcessExitGuard } from "./background/process-exit-guard.ts";
35
35
  import { reconcilePendingEntries } from "./background/pending-reconcile.ts";
36
- import { setOnTaskExit } from "./background/poller.ts";
36
+ import { setOnTaskExit } from "./background/exit-collector.ts";
37
37
  import { setupToolErrorAudit } from "./tool-error-audit.ts";
38
38
 
39
39
  const logger = getLogger("base-tool-enhance");
@@ -43,7 +43,7 @@ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
43
43
  // ResourceLoader/eventBus + extension 重新 load → 本调用把通知通路(notify.ts
44
44
  // 模块级引用)切到新 pi——任务发起于旧 session 而完成通知投递新 session。
45
45
  refreshPiReference(pi);
46
- // ⑧⑨ 轮询器 exit 边沿 → pending:unregister emit + sendMessage steer(kill 路径
46
+ // ⑧⑨ exit 事件边沿 → pending:unregister emit + sendMessage steer(kill 路径
47
47
  // 不 sendMessage,见 notify.ts 单点归属规则)。重复 load 幂等(覆盖同一回调)
48
48
  setOnTaskExit(handleTaskExit);
49
49
  // 同名 "bash" 覆盖内置工具(pi agent-session _refreshToolRegistry:custom 定义后注册者胜)
@@ -25,6 +25,8 @@ interface ToolExecutionEndLikeEvent {
25
25
  isError: boolean;
26
26
  toolName: string;
27
27
  toolCallId: string;
28
+ /** 嵌套子调用标记(pi 1.0:另一工具(如 codemode 脚本)发起的调用携带;实装锚点 = dist/core/extensions/types.d.ts ToolExecutionEndEvent :837-838) */
29
+ parentToolCallId?: string;
28
30
  result?: unknown;
29
31
  }
30
32
 
@@ -65,6 +67,10 @@ export function setupToolErrorAudit(pi: ExtensionAPI): void {
65
67
  pi.on("tool_execution_end", async (event: unknown) => {
66
68
  const e = event as ToolExecutionEndLikeEvent;
67
69
  if (!e.isError) return;
70
+ // D13②:嵌套子调用失败不单独记账——父工具的 tool_execution_end 是记账终点,
71
+ // 子调用(parentToolCallId 非空)再记一条 = session-manager 现役嵌套派发场景下
72
+ // 审计条目膨胀(一父 N 子记 N+1 条)。
73
+ if (typeof e.parentToolCallId === "string" && e.parentToolCallId.length > 0) return;
68
74
 
69
75
  const errorText = extractErrorText(e.result);
70
76