@zhushanwen/pi-base-tool-enhance 0.7.2 → 0.8.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 +8 -8
- package/src/__tests__/background-lifecycle.test.ts +40 -92
- package/src/__tests__/notify.test.ts +11 -15
- package/src/__tests__/pending-reconcile.test.ts +1 -1
- package/src/__tests__/task-store.test.ts +1 -1
- package/src/__tests__/tool-error-audit.test.ts +34 -0
- package/src/background/{poller.ts → exit-collector.ts} +23 -48
- package/src/background/notify.ts +8 -6
- package/src/background/output-tail.ts +1 -1
- package/src/background/pending-reconcile.ts +5 -5
- package/src/background/process-exit-guard.ts +2 -4
- package/src/background/spawn-background.ts +13 -14
- package/src/background/task-store.ts +7 -7
- package/src/background/types.ts +6 -6
- package/src/bash-kill-tool.ts +6 -8
- package/src/bash-tool.ts +5 -3
- package/src/index.ts +4 -4
- package/src/tool-error-audit.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-base-tool-enhance",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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.
|
|
32
|
-
"@zhushanwen/pi-ext-guards": "0.4.
|
|
33
|
-
"@zhushanwen/pi-extension-logger": "0.6.
|
|
31
|
+
"@zhushanwen/extension-protocol": "0.19.0",
|
|
32
|
+
"@zhushanwen/pi-ext-guards": "0.4.3",
|
|
33
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
34
34
|
"@zhushanwen/pi-file-lock": "0.4.1",
|
|
35
|
-
"@zhushanwen/pi-llm-shared": "0.11.
|
|
35
|
+
"@zhushanwen/pi-llm-shared": "0.11.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.
|
|
43
|
-
"typebox": "
|
|
44
|
-
"@zhushanwen/pi-pending-notifications": "0.8.
|
|
42
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
43
|
+
"typebox": "1.3.27",
|
|
44
|
+
"@zhushanwen/pi-pending-notifications": "0.8.3"
|
|
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 边沿收尾 /
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
84
|
-
*
|
|
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 (
|
|
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
|
-
// 断言只要求「
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
659
|
-
|
|
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
|
|
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
|
-
|
|
694
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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("
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
67
|
-
*
|
|
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 (⑧⑨,
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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("
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
*
|
|
2
|
+
* exit 边沿收尾(ADR-0122 改造:原 2s kill(pid,0) 轮询器已删,感知归 ChildProcess
|
|
3
|
+
* exit 事件——事件驱动,无时间驱动成分)。
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
/**
|
package/src/background/notify.ts
CHANGED
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
* - 完成通知经 pi.sendMessage({customType, content, display:true},
|
|
8
8
|
* {deliverAs:"steer", triggerTurn:true}) 驱动新 turn
|
|
9
9
|
*
|
|
10
|
-
* D17 pi
|
|
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
|
-
*
|
|
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
|
-
* ⑧⑨
|
|
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;
|
|
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
|
-
*
|
|
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
|
-
* 形态「
|
|
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 已发令、
|
|
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
|
|
89
|
-
* (bg-task-notify-durability
|
|
90
|
-
* loader.js
|
|
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
|
-
*
|
|
69
|
+
* 遍历**单例表**活跃条目 kill-tree(单例表 = 本进程任务全集,天然
|
|
71
70
|
* 不含他进程条目——不遍历 registry,否则 ephemeral 附着进程退出会误杀属主进程的
|
|
72
71
|
* 任务)→ registry 写终态 exited(reason:"process-exit")。
|
|
73
72
|
*
|
|
74
|
-
*
|
|
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
|
-
//
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 读回(
|
|
19
|
-
*
|
|
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
|
-
* ④
|
|
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
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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);
|
package/src/background/types.ts
CHANGED
|
@@ -33,9 +33,9 @@ export {
|
|
|
33
33
|
} from "@zhushanwen/extension-protocol";
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* killing intent:bash_kill / 后台 timeout
|
|
36
|
+
* killing intent:bash_kill / 后台 timeout 已发令、exit 事件边沿未确认的瞬态标记。
|
|
37
37
|
* 标记写入单例表与 registry 两侧(查询面立即可见,无「已 kill 仍 running」倒挂);
|
|
38
|
-
*
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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;
|
package/src/bash-kill-tool.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 职责边界:只负责杀进程树 + kill 前把单例表与 registry **两侧**标 killing intent
|
|
5
5
|
* (查询面立即可见——kill 返回后 bash_output 即显示 killing,无「已 kill 仍 running」
|
|
6
|
-
* 倒挂窗口)。实际终态(exited, reason:"killed"
|
|
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 不可用平台)→
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
7
|
-
* M3:pending-notifications 通知接入——load
|
|
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/
|
|
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
|
-
// ⑧⑨
|
|
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 定义后注册者胜)
|
package/src/tool-error-audit.ts
CHANGED
|
@@ -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
|
|