@zhushanwen/pi-base-tool-enhance 0.2.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.
Files changed (35) hide show
  1. package/README.md +31 -0
  2. package/index.ts +1 -0
  3. package/package.json +54 -0
  4. package/skills/base-tool-enhance-ext-config/SKILL.md +76 -0
  5. package/src/__tests__/background-lifecycle.test.ts +634 -0
  6. package/src/__tests__/bash-tool.test.ts +573 -0
  7. package/src/__tests__/config.test.ts +193 -0
  8. package/src/__tests__/force-patterns.test.ts +230 -0
  9. package/src/__tests__/index.test.ts +133 -0
  10. package/src/__tests__/kill-tree.test.ts +76 -0
  11. package/src/__tests__/notify.test.ts +335 -0
  12. package/src/__tests__/pending-reconcile.test.ts +237 -0
  13. package/src/__tests__/reaper.test.ts +373 -0
  14. package/src/__tests__/registry.test.ts +149 -0
  15. package/src/__tests__/task-store.test.ts +156 -0
  16. package/src/__tests__/tool-error-audit.test.ts +92 -0
  17. package/src/background/notify.ts +218 -0
  18. package/src/background/output-tail.ts +84 -0
  19. package/src/background/pending-reconcile.ts +169 -0
  20. package/src/background/poller.ts +91 -0
  21. package/src/background/process-exit-guard.ts +106 -0
  22. package/src/background/registry.ts +203 -0
  23. package/src/background/spawn-background.ts +275 -0
  24. package/src/background/subagent-guard.ts +21 -0
  25. package/src/background/task-store.ts +125 -0
  26. package/src/background/types.ts +103 -0
  27. package/src/bash-kill-tool.ts +144 -0
  28. package/src/bash-output-tool.ts +131 -0
  29. package/src/bash-tool.ts +226 -0
  30. package/src/config.ts +167 -0
  31. package/src/force-patterns.ts +236 -0
  32. package/src/index.ts +90 -0
  33. package/src/kill-tree.ts +100 -0
  34. package/src/reaper.ts +313 -0
  35. package/src/tool-error-audit.ts +78 -0
@@ -0,0 +1,92 @@
1
+ // src/__tests__/tool-error-audit.test.ts
2
+ // 审计 hook 等价迁移守卫:customType 与 entry 形态必须与 unified-hooks
3
+ // tool-error-handler 逐字段一致(D11 落点,M1 验收点)。
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+
8
+ import { setupToolErrorAudit } from "../tool-error-audit.ts";
9
+
10
+ interface MockPi {
11
+ on: ReturnType<typeof vi.fn>;
12
+ appendEntry: ReturnType<typeof vi.fn>;
13
+ }
14
+
15
+ function createMockPi(): MockPi {
16
+ return {
17
+ on: vi.fn(),
18
+ appendEntry: vi.fn(),
19
+ };
20
+ }
21
+
22
+ function getRegisteredHandler(pi: MockPi): (event: unknown) => Promise<void> {
23
+ const call = pi.on.mock.calls.find((c: unknown[]) => c[0] === "tool_execution_end");
24
+ expect(call).toBeDefined();
25
+ return call![1] as (event: unknown) => Promise<void>;
26
+ }
27
+
28
+ describe("setupToolErrorAudit", () => {
29
+ it("registers a handler on the tool_execution_end event (pi has no tool_error event)", () => {
30
+ const pi = createMockPi();
31
+ setupToolErrorAudit(pi as unknown as ExtensionAPI);
32
+ expect(pi.on).toHaveBeenCalledWith("tool_execution_end", expect.any(Function));
33
+ expect(pi.on).toHaveBeenCalledTimes(1);
34
+ });
35
+
36
+ it("appends audit entry with the unified-hooks customType and exact field shape on isError:true", async () => {
37
+ const pi = createMockPi();
38
+ setupToolErrorAudit(pi as unknown as ExtensionAPI);
39
+ const handler = getRegisteredHandler(pi);
40
+ const before = Date.now();
41
+
42
+ await handler({
43
+ isError: true,
44
+ toolName: "bash",
45
+ toolCallId: "call-42",
46
+ result: { content: [{ type: "text", text: "timeout:30" }] },
47
+ });
48
+
49
+ expect(pi.appendEntry).toHaveBeenCalledTimes(1);
50
+ const [customType, entry] = pi.appendEntry.mock.calls[0] as [string, Record<string, unknown>];
51
+ expect(customType).toBe("unified-hooks:tool-error");
52
+ // 逐字段一致:timestamp(毫秒区间内)/ toolName / toolCallId / errorText 从 result.content 提取
53
+ expect(typeof entry.timestamp).toBe("number");
54
+ expect(entry.timestamp).toBeGreaterThanOrEqual(before);
55
+ expect(entry.timestamp).toBeLessThanOrEqual(Date.now());
56
+ expect(entry.toolName).toBe("bash");
57
+ expect(entry.toolCallId).toBe("call-42");
58
+ expect(entry.errorText).toBe("timeout:30");
59
+ });
60
+
61
+ it("falls back to null errorText when result carries no extractable text", async () => {
62
+ const pi = createMockPi();
63
+ setupToolErrorAudit(pi as unknown as ExtensionAPI);
64
+ const handler = getRegisteredHandler(pi);
65
+
66
+ await handler({ isError: true, toolName: "read", toolCallId: "call-1" });
67
+
68
+ const [, entry] = pi.appendEntry.mock.calls[0] as [string, Record<string, unknown>];
69
+ expect(entry.errorText).toBeNull();
70
+ });
71
+
72
+ it("falls back to result.error string when content array is absent", async () => {
73
+ const pi = createMockPi();
74
+ setupToolErrorAudit(pi as unknown as ExtensionAPI);
75
+ const handler = getRegisteredHandler(pi);
76
+
77
+ await handler({ isError: true, toolName: "edit", toolCallId: "call-2", result: { error: "bad edit" } });
78
+
79
+ const [, entry] = pi.appendEntry.mock.calls[0] as [string, Record<string, unknown>];
80
+ expect(entry.errorText).toBe("bad edit");
81
+ });
82
+
83
+ it("does not appendEntry when isError is false", async () => {
84
+ const pi = createMockPi();
85
+ setupToolErrorAudit(pi as unknown as ExtensionAPI);
86
+ const handler = getRegisteredHandler(pi);
87
+
88
+ await handler({ isError: false, toolName: "bash", toolCallId: "call-3", result: { content: [] } });
89
+
90
+ expect(pi.appendEntry).not.toHaveBeenCalled();
91
+ });
92
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * 完成通知与 pending-notifications 接入(M3,设计 §3.5 数据流 ⑤⑧⑨ / §3.3 D5·D17)。
3
+ *
4
+ * 通道形态(与 pending-notifications index.ts listener / subagent-workflow notifier
5
+ * 先例对齐——先例经父进程 IPC 投递与本包同进程异步时机不同构,探针 P3 已实测):
6
+ * - register/unregister 经 pi.events.emit(EventBus,pending 侧 pi.events.on 消费)
7
+ * - 完成通知经 pi.sendMessage({customType, content, display:true},
8
+ * {deliverAs:"steer", triggerTurn:true}) 驱动新 turn
9
+ *
10
+ * D17 pi 引用刷新:轮询器与通知通路持模块级「当前 pi 引用」——同进程 session 替换
11
+ * (/fork、选择器切换、RPC session.*)会重建 eventBus 并重新 load extension,本
12
+ * 模块引用由新实例 load 时 refreshPiReference 刷新,完成通知投递新 session。
13
+ *
14
+ * 已知竞态(设计 §3.5 原样登记,不修):dispose → 新实例 load 间毫秒窗口任务恰好
15
+ * 完成时,sendMessage/emit 落旧 bus 丢一条——对账在该 session 重开时补 unregister,
16
+ * 完成内容可由 bash_output 查询;窗口极窄且后果可恢复,不加同步握手。旧引用 throw
17
+ * (旧 bus 已 dispose)时捕获降级为日志,不中断轮询(后续任务仍可通知)。
18
+ *
19
+ * kill 路径不 sendMessage(§3.5「bash_kill 终态收尾的单点归属」):reason:"killed"
20
+ * 只 emit unregister——kill 调用方就在当前 turn 等结果,双发是噪音。
21
+ *
22
+ * peer 版本门槛(D16 独立安装场景)——运行时检测**降级为静态声明**:pi 0.84.1 的
23
+ * ExtensionAPI/ExtensionContext 均无「枚举已加载 extensions」接口(types.d.ts 全文
24
+ * 核实,LoadExtensionsResult 是 loader 内部结果不经 pi 暴露),EventBus 探针式握手
25
+ * 属硬造检测机制(设计禁止)。登记落点:package.json peerDependencies(optional,
26
+ * 未装则通知链路缺失但 bash 后台功能完整)+ extension-dependencies.json dependsOn。
27
+ */
28
+
29
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
31
+
32
+ import type { BackgroundTask, BackgroundTaskEndReason } from "./types.ts";
33
+
34
+ const logger = getLogger("base-tool-enhance");
35
+
36
+ /**
37
+ * 通知消息 customType:桌面 custom_message 通用渲染通道(subagent-bg-notify 先例,
38
+ * §3.1 用户视角「对话流中以 custom entry 形式出现」)。
39
+ */
40
+ export const BACKGROUND_BASH_CUSTOM_TYPE = "background-bash";
41
+
42
+ /**
43
+ * pending register 的 name 截断长度(§3.5 数据流 ⑤「command 前 80 字符」)。
44
+ * 与 spawn-background COMMAND_DISPLAY_LIMIT 同值但刻意不共享 import——notify 被
45
+ * spawn-background import,反向 import 会造成 spawn ↔ notify 循环依赖;两处语义
46
+ * (pending 列表展示 / 错误文案展示)各自独立演化,仅数值对齐。
47
+ */
48
+ const PENDING_NAME_LIMIT = 80;
49
+
50
+ /** 模块级「当前 pi 引用」(D17 核心可变状态,见文件头)。 */
51
+ let currentPi: ExtensionAPI | undefined;
52
+
53
+ /**
54
+ * 刷新当前 pi 引用:extension 实例 load 时调用(index.ts 接线)。
55
+ * session 替换 → extension 重新 load → 引用指向新 pi → 完成通知投递新 session。
56
+ */
57
+ export function refreshPiReference(pi: ExtensionAPI): void {
58
+ currentPi = pi;
59
+ }
60
+
61
+ /** 测试专用:复位模块级 pi 引用(跨测试文件残留清理)。 */
62
+ export function resetNotifyForTest(): void {
63
+ currentPi = undefined;
64
+ }
65
+
66
+ /**
67
+ * 本包终态 reason → pending reason 字符串。
68
+ * 映射值全部落在 pending-notifications mapReasonToStatus 的已知分支(四个值在该
69
+ * switch 全部直通同名 status),保证 emit 后 listener 算出的 status 与本包预期一致:
70
+ * - natural + exitCode 0 → "completed"(正常完成)
71
+ * - natural + 非零/不可知 → "failed"(§3.1 失败路径:通知带 error 与末尾输出摘要)
72
+ * - timeout → "time_limited"
73
+ * - killed → "cancelled"
74
+ * - process-exit → "cancelled"(进程退出收殓终止,非任务自身成败)
75
+ */
76
+ export function toPendingReason(
77
+ reason: BackgroundTaskEndReason,
78
+ exitCode: number | null | undefined,
79
+ ): "completed" | "failed" | "time_limited" | "cancelled" {
80
+ switch (reason) {
81
+ case "timeout":
82
+ return "time_limited";
83
+ case "killed":
84
+ case "process-exit":
85
+ return "cancelled";
86
+ default:
87
+ // natural:exit 0 = 成功;非零(含 null 不可知,保守按失败处理引导查看输出)
88
+ return exitCode === 0 ? "completed" : "failed";
89
+ }
90
+ }
91
+
92
+ /**
93
+ * ⑤ 登记成功后 emit pending:register(§3.5 数据流 ⑤)。
94
+ * 形态对齐 pending-notifications parseRegisterEvent 期望:{id, type, name}——
95
+ * type:"bash" 经 D16 直通(process 档),**不携带 expiresAt**(该字段由 pending 侧
96
+ * listener 按分档决定,process 档落盘省略)。引用未注入或 emit throw(旧 bus)时
97
+ * 静默降级——pending 差集只影响通知链路,不影响任务本体。
98
+ */
99
+ export function emitPendingRegister(task: BackgroundTask): void {
100
+ const pi = currentPi;
101
+ if (pi === undefined) return;
102
+ const name =
103
+ task.command.length > PENDING_NAME_LIMIT
104
+ ? `${task.command.slice(0, PENDING_NAME_LIMIT)}…`
105
+ : task.command;
106
+ try {
107
+ pi.events.emit("pending:register", { id: task.taskId, type: "bash", name });
108
+ } catch (err) {
109
+ logger.warn("pending:register emit failed (stale bus?); task unaffected", {
110
+ detail: { taskId: task.taskId, err: err instanceof Error ? err.message : String(err) },
111
+ });
112
+ }
113
+ }
114
+
115
+ /**
116
+ * ⑧ emit pending:unregister(§3.5 数据流 ⑧)。
117
+ * data 形态对齐 pending-notifications parseUnregisterEvent 期望:{id, reason}——
118
+ * status 不在 emit data 里(listener 用 mapReasonToStatus(reason) 自行计算);
119
+ * appendEntry 侧(对账/收殓)的落盘形态 {id, reason, status} 见 pending-reconcile.ts。
120
+ * 轮询器 exit 边沿与进程退出收殓两条路径共用。
121
+ */
122
+ export function emitPendingUnregister(
123
+ taskId: string,
124
+ reason: BackgroundTaskEndReason,
125
+ exitCode: number | null | undefined,
126
+ ): void {
127
+ const pi = currentPi;
128
+ if (pi === undefined) return;
129
+ try {
130
+ pi.events.emit("pending:unregister", { id: taskId, reason: toPendingReason(reason, exitCode) });
131
+ } catch (err) {
132
+ logger.warn("pending:unregister emit failed (stale bus?); reconcile covers on next session_start", {
133
+ detail: { taskId, err: err instanceof Error ? err.message : String(err) },
134
+ });
135
+ }
136
+ }
137
+
138
+ /**
139
+ * ⑧⑨ 轮询器 exit 边沿的完成通知入口(poller setOnTaskExit 接线,index.ts load 时挂)。
140
+ * 入参是 finalizeTask 之后的终态条目(state=exited)。kill 路径不 sendMessage
141
+ * (文件头);process-exit 不经过这里(收殓路径直接 finalizeTask + 只 emit)。
142
+ */
143
+ export function handleTaskExit(task: BackgroundTask): void {
144
+ emitPendingUnregister(task.taskId, task.reason ?? "natural", task.exitCode ?? null);
145
+ if (task.reason === "killed") return;
146
+ sendTaskFinishedMessage(task);
147
+ }
148
+
149
+ /** ⑨ sendMessage steer 驱动新 turn(探针 P3 已实测同进程异步时机可用)。 */
150
+ function sendTaskFinishedMessage(task: BackgroundTask): void {
151
+ const pi = currentPi;
152
+ if (pi === undefined) return;
153
+ try {
154
+ pi.sendMessage(
155
+ { customType: BACKGROUND_BASH_CUSTOM_TYPE, content: buildNotificationContent(task), display: true },
156
+ { deliverAs: "steer", triggerTurn: true },
157
+ );
158
+ } catch (err) {
159
+ // 旧 bus 已 dispose(session 替换毫秒窗口)——降级日志,不中断轮询(文件头已知竞态)
160
+ logger.warn("background task notify sendMessage failed; poll continues", {
161
+ detail: {
162
+ taskId: task.taskId,
163
+ err: err instanceof Error ? err.message : String(err),
164
+ },
165
+ });
166
+ }
167
+ }
168
+
169
+ /**
170
+ * 通知文案(§3.1 终态样例):
171
+ *
172
+ * [background-bash] bt-x finished (exit 0, 3m12s): pnpm test
173
+ * Last lines: ... Tests: 42 passed ...
174
+ * Full output: <path>; use bash_output {task_id:"bt-x"} for details.
175
+ *
176
+ * 失败任务 head 行标 failed(exit code 即 error 摘要,尾部输出佐证);timeout 标
177
+ * timed out;tailSummary 为空(无输出/文件丢失)时省略 Last lines 行。
178
+ */
179
+ export function buildNotificationContent(task: BackgroundTask): string {
180
+ const duration = formatDurationMs(task.durationMs ?? 0);
181
+ const command =
182
+ task.command.length > PENDING_NAME_LIMIT
183
+ ? `${task.command.slice(0, PENDING_NAME_LIMIT)}…`
184
+ : task.command;
185
+ let head: string;
186
+ if (task.reason === "timeout") {
187
+ head = `[background-bash] ${task.taskId} timed out (${duration}): ${command}`;
188
+ } else if (task.exitCode === 0) {
189
+ head = `[background-bash] ${task.taskId} finished (exit 0, ${duration}): ${command}`;
190
+ } else {
191
+ head = `[background-bash] ${task.taskId} failed (exit ${task.exitCode ?? "unknown"}, ${duration}): ${command}`;
192
+ }
193
+ const lines = [head];
194
+ if (task.tailSummary !== undefined && task.tailSummary.length > 0) {
195
+ lines.push(`Last lines: ${task.tailSummary}`);
196
+ }
197
+ lines.push(`Full output: ${task.outputFile}; use bash_output {task_id:"${task.taskId}"} for details.`);
198
+ return lines.join("\n");
199
+ }
200
+
201
+ /** 耗时换算常量(毫秒/秒/分/时 + 时分两位补零宽度)。 */
202
+ const MS_PER_SECOND = 1000;
203
+ const SECONDS_PER_MINUTE = 60;
204
+ const MINUTES_PER_HOUR = 60;
205
+ const TWO_DIGIT_WIDTH = 2;
206
+
207
+ /** 耗时格式:秒内 "45s" → 分 "3m12s" → 时 "1h02m03s"(时/分段补零两位)。 */
208
+ function formatDurationMs(ms: number): string {
209
+ const totalSec = Math.max(0, Math.round(ms / MS_PER_SECOND));
210
+ const sec = totalSec % SECONDS_PER_MINUTE;
211
+ const min = Math.floor(totalSec / SECONDS_PER_MINUTE) % MINUTES_PER_HOUR;
212
+ const hour = Math.floor(totalSec / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR));
213
+ if (hour > 0) {
214
+ return `${hour}h${String(min).padStart(TWO_DIGIT_WIDTH, "0")}m${String(sec).padStart(TWO_DIGIT_WIDTH, "0")}s`;
215
+ }
216
+ if (min > 0) return `${min}m${sec}s`;
217
+ return `${totalSec}s`;
218
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 输出文件 tail 读取(D7:输出落文件不占内存,查询时按需读尾部)。
3
+ *
4
+ * 截断规则与 pi 内置 bash 一致:末尾 2000 行 / 50KB 先到为准(bash.js truncate.ts
5
+ * DEFAULT_MAX_LINES/DEFAULT_MAX_BYTES)。实现从文件末尾按字节窗口读(不整读大文件,
6
+ * O(maxBytes) 而非 O(fileSize))。
7
+ */
8
+
9
+ import { openSync, readSync, closeSync, statSync } from "node:fs";
10
+
11
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
12
+
13
+ const logger = getLogger("base-tool-enhance");
14
+
15
+ /** pi 内置 bash 同款截断上限(last 2000 lines / 50KB = 51200 bytes,先到为准)。 */
16
+ export const TAIL_MAX_LINES = 2000;
17
+ export const TAIL_MAX_BYTES = 51_200;
18
+ /** 字节窗口余量:截窗口可能吞掉首行前半,余量降低残行概率。 */
19
+ const TAIL_WINDOW_MARGIN_BYTES = 64;
20
+ /** exit 边沿 tail 摘要参数(存条目/M3 通知用)。 */
21
+ const SUMMARY_TAIL_LINES = 5;
22
+ const SUMMARY_MAX_CHARS = 800;
23
+
24
+ export interface TailResult {
25
+ output: string;
26
+ /** 读取窗口被截断(内容超上限)时 true。 */
27
+ truncated: boolean;
28
+ }
29
+
30
+ /**
31
+ * 读文件尾部(行/字节双上限)。文件不存在/不可读返回 undefined——bash_output 对
32
+ * 此降级为 {output:"<lost>"} 不崩溃(§3.6)。
33
+ */
34
+ export function readOutputTail(
35
+ outputFile: string,
36
+ maxLines: number = TAIL_MAX_LINES,
37
+ maxBytes: number = TAIL_MAX_BYTES,
38
+ ): TailResult | undefined {
39
+ let size: number;
40
+ try {
41
+ size = statSync(outputFile).size;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ // 字节窗口从末尾取 maxBytes + 余量(截窗口可能吞掉首行前半,余量降低概率)
46
+ const windowSize = Math.min(size, maxBytes + TAIL_WINDOW_MARGIN_BYTES);
47
+ const buffer = Buffer.alloc(windowSize);
48
+ let fd: number | undefined;
49
+ try {
50
+ fd = openSync(outputFile, "r");
51
+ readSync(fd, buffer, 0, windowSize, size - windowSize);
52
+ } catch {
53
+ return undefined;
54
+ } finally {
55
+ if (fd !== undefined) {
56
+ try {
57
+ closeSync(fd);
58
+ } catch (err) {
59
+ // 已读完内容,close 失败不影响结果,仅留诊断
60
+ logger.debug("output tail close failed", {
61
+ detail: { outputFile, err: err instanceof Error ? err.message : String(err) },
62
+ });
63
+ }
64
+ }
65
+ }
66
+ const text = buffer.toString("utf8");
67
+ const lines = text.split("\n");
68
+ // 窗口起点可能落在行中间:首行是残行时丢弃(它必然不完整)
69
+ const firstLineIsPartial = windowSize < size && lines.length > 0;
70
+ const effectiveLines = firstLineIsPartial ? lines.slice(1) : lines;
71
+ const byteTruncated = size > maxBytes;
72
+ const shown = effectiveLines.slice(-maxLines).join("\n");
73
+ return { output: shown, truncated: byteTruncated || effectiveLines.length > maxLines };
74
+ }
75
+
76
+ /**
77
+ * 轮询器 exit 边沿的 tail 摘要(存进条目、M3 通知用):末尾几行的紧凑文本。
78
+ */
79
+ export function readTailSummary(outputFile: string, maxChars: number = SUMMARY_MAX_CHARS): string | undefined {
80
+ const tail = readOutputTail(outputFile, SUMMARY_TAIL_LINES, maxChars);
81
+ if (tail === undefined) return undefined;
82
+ const compact = tail.output.trim();
83
+ return compact.length > 0 ? compact.slice(-maxChars) : undefined;
84
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * session_start pending 对账(M3,§3.5 接入细则第 4 条——pending 收尾的统一兜底)。
3
+ *
4
+ * 职责:对「session entries 差集显示 active、但任务已终态」的 bt- 任务补写
5
+ * pending:unregister entry。覆盖三类 otherwise 悬空场景(设计原文):
6
+ * ① 进程 graceful 退出收殓时 pi API 已不可用,unregister 没写成 entry;
7
+ * ② 强杀后 reaper 只改 registry(标 orphaned),碰不了 session 文件;
8
+ * ③ fork 后任务完成通知写进新 session,旧 session 文件的 register 成僵尸。
9
+ *
10
+ * 收尾写法(权威路径):直接 pi.appendEntry("pending:unregister", {id, reason,
11
+ * status})——**不走 bus emit 作为权威**:pending-notifications 的 unregister listener
12
+ * 落盘条件是其内存 registry 该 id active(其 registry 只在自身 session_start rebuild
13
+ * 后非空),两个 extension 的加载/派发顺序(CLI --extension 顺序用户可控)无保障,
14
+ * 顺序反转时 emit 被静默吞、对账失效。差集消费方 goal 从持久化 entries 算差集
15
+ * (agent-end.ts getEntries(),不读 pending 内存 registry),appendEntry 对守卫直接
16
+ * 生效。appendEntry 之外尽力补一次 emit(listener 就绪时同步其内存视图,失败无害)。
17
+ *
18
+ * 终态判据:registry state ∈ {exited, orphaned},或(state=running/killing 且
19
+ * kill(pid,0) 判死:收殓/写盘失败遗留的 running 条目按事实终态处理)。
20
+ * 不改 registry——终态写入归 reaper/轮询器(单点归属),对账只清 pending 侧。
21
+ *
22
+ * 执行顺序(index.ts session_start 链内):reaper 先、对账后——先按属主判定处置
23
+ * 孤儿/补写 registry 终态,对账随后读到正确终态;即使颠倒也无静默错误(对账先见
24
+ * running+pid 活则不动作,下一 session_start 兜底)。
25
+ */
26
+
27
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
28
+
29
+ import { isPidAlive } from "../kill-tree.ts";
30
+ import { toPendingReason } from "./notify.ts";
31
+ import { getRegistryPath, readRegistry } from "./registry.ts";
32
+ import { isActiveState, isTerminalState, type RegistryEntry } from "./types.ts";
33
+
34
+ const logger = getLogger("base-tool-enhance");
35
+
36
+ /** 本包 task_id 前缀(§2.3,区别于 subagent-workflow 的 bg-/run-)。 */
37
+ export const BTE_TASK_ID_PREFIX = "bt-";
38
+
39
+ /**
40
+ * 对账依赖的最小 pi 面(结构兼容 ExtensionAPI 的子集;测试注入不造完整 pi)。
41
+ */
42
+ export interface ReconcilePi {
43
+ appendEntry(customType: string, data?: unknown): void;
44
+ events: { emit(channel: string, data: unknown): void };
45
+ }
46
+
47
+ /** 对账结果(日志 + 测试断言面)。 */
48
+ export interface ReconcileResult {
49
+ /** 补写 pending:unregister entry 的任务数。 */
50
+ reconciled: number;
51
+ /** 差集 active 但判据不满足(活任务 / registry 无条目)而保守跳过的 task_id。 */
52
+ skipped: string[];
53
+ }
54
+
55
+ /** entries 的最小可识别形状(duck-typed,与 pending-notifications state.ts EntryLike 同式)。 */
56
+ interface EntryLike {
57
+ customType?: string;
58
+ data?: { id?: unknown } | null;
59
+ }
60
+
61
+ function readEntryId(raw: unknown): string | undefined {
62
+ if (!raw || typeof raw !== "object") return undefined;
63
+ const entry = raw as EntryLike;
64
+ const id = entry.data?.id;
65
+ return typeof id === "string" ? id : undefined;
66
+ }
67
+
68
+ /**
69
+ * 差集:bt- 前缀 pending:register 且无对应 pending:unregister 的 id 集合。
70
+ * 只认 bt- 前缀——workflow/subagent 的 register 不归本包对账(差集算法与
71
+ * pending-notifications countActiveFromEntries 同构:unregister 全局抵消 + register
72
+ * 去重,id 全局唯一前提)。
73
+ */
74
+ export function collectUnsettledTaskIds(entries: unknown[]): Set<string> {
75
+ const unregistered = new Set<string>();
76
+ for (const raw of entries) {
77
+ if (!raw || typeof raw !== "object") continue;
78
+ if ((raw as EntryLike).customType !== "pending:unregister") continue;
79
+ const id = readEntryId(raw);
80
+ if (id !== undefined && id.startsWith(BTE_TASK_ID_PREFIX)) unregistered.add(id);
81
+ }
82
+ const active = new Set<string>();
83
+ for (const raw of entries) {
84
+ if (!raw || typeof raw !== "object") continue;
85
+ if ((raw as EntryLike).customType !== "pending:register") continue;
86
+ const id = readEntryId(raw);
87
+ if (id === undefined || !id.startsWith(BTE_TASK_ID_PREFIX)) continue;
88
+ if (unregistered.has(id) || active.has(id)) continue;
89
+ active.add(id);
90
+ }
91
+ return active;
92
+ }
93
+
94
+ /**
95
+ * 对账主体(同步:readRegistry / kill(pid,0) / appendEntry 均同步,session_start
96
+ * 链内毫秒级完成)。每个僵尸任务 appendEntry 一次 + 尽力 emit 一次。
97
+ */
98
+ export function reconcilePendingEntries(
99
+ pi: ReconcilePi,
100
+ dataDir: string,
101
+ sessionId: string,
102
+ entries: unknown[],
103
+ ): ReconcileResult {
104
+ const result: ReconcileResult = { reconciled: 0, skipped: [] };
105
+ const unsettled = collectUnsettledTaskIds(entries);
106
+ if (unsettled.size === 0) return result;
107
+
108
+ const registry = readRegistry(getRegistryPath(dataDir, sessionId));
109
+ for (const id of unsettled) {
110
+ const entry = registry.get(id);
111
+ if (entry === undefined) {
112
+ // registry 无条目:终态无从判定(LRU 淘汰的终态条目其 unregister entry 应已
113
+ // 落盘,差集里还出现 = spawn 后 registry 写失败等罕见路径)——保守不动作,
114
+ // 差集残留交给 pending 自身 TTL 之外的 next-session 对账重查
115
+ result.skipped.push(id);
116
+ continue;
117
+ }
118
+ if (!isTerminalByRegistry(entry)) {
119
+ // D12 活任务(running/killing 且 pid 活):任务跨 session 替换续存,不收尾
120
+ result.skipped.push(id);
121
+ continue;
122
+ }
123
+ const pendingReason = settledPendingReason(entry);
124
+ try {
125
+ pi.appendEntry("pending:unregister", { id, reason: pendingReason, status: pendingReason });
126
+ } catch (err) {
127
+ logger.warn("reconcile appendEntry failed; retry on next session_start", {
128
+ detail: { id, err: err instanceof Error ? err.message : String(err) },
129
+ });
130
+ continue;
131
+ }
132
+ // 尽力补 emit(listener 就绪时同步 pending 内存视图,缩短 pending_notifications
133
+ // 工具列表的不一致窗口;失败无害——appendEntry 已是权威路径)
134
+ try {
135
+ pi.events.emit("pending:unregister", { id, reason: pendingReason });
136
+ } catch (err) {
137
+ logger.debug("reconcile best-effort emit failed (harmless)", {
138
+ detail: { id, err: err instanceof Error ? err.message : String(err) },
139
+ });
140
+ }
141
+ result.reconciled++;
142
+ }
143
+ if (result.reconciled > 0) {
144
+ logger.debug("pending reconcile settled zombie registers", {
145
+ detail: { reconciled: result.reconciled, skipped: result.skipped.length },
146
+ });
147
+ }
148
+ return result;
149
+ }
150
+
151
+ /** 终态判据(§3.5 接入细则 4 原文):registry 终态,或 active 状态但 pid 已判死。 */
152
+ function isTerminalByRegistry(entry: RegistryEntry): boolean {
153
+ if (isTerminalState(entry.state)) return true;
154
+ return isActiveState(entry.state) && !isPidAlive(entry.pid);
155
+ }
156
+
157
+ /**
158
+ * 收尾 reason/status 映射:
159
+ * - exited:按条目 reason/exitCode 走 toPendingReason(与 exit 边沿 emit 同一映射,
160
+ * 两路径写出的 entry 语义一致);reason 缺失按 pending mapReasonToStatus 的
161
+ * default=completed 语义处理(防御分支,正常路径 finalize 必写 reason)
162
+ * - orphaned / running+判死:cancelled(任务非自身成败地终止/消失)
163
+ */
164
+ function settledPendingReason(entry: RegistryEntry): "completed" | "failed" | "time_limited" | "cancelled" {
165
+ if (entry.state === "exited" && entry.reason !== undefined) {
166
+ return toPendingReason(entry.reason, entry.exitCode ?? null);
167
+ }
168
+ return "cancelled";
169
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 模块级轮询器单例(D17:exit 感知靠轮询不靠 ChildProcess 闭包)。
3
+ *
4
+ * 为什么轮询:同进程 session 替换(fork/switch/new)会重建 eventBus 并重新 load
5
+ * extension,ChildProcess exit 闭包监听里的 bus/pi 引用全部 stale——完成通知在
6
+ * 新 session 不可达。kill(pid,0) 轮询 + 模块级单例跨替换免疫,2s 延迟对分钟级
7
+ * 任务无感。
8
+ *
9
+ * 惰性启停:无 running/killing 条目时清定时器(防空转泄漏),新任务登记时重启。
10
+ *
11
+ * 已知竞态(设计文档 §3.5 原样登记,不修):同进程 session 替换 dispose → 新实例
12
+ * load 间毫秒窗口任务恰好完成时通知可能落旧 bus——窗口极窄且后果可恢复(bash_output
13
+ * 可查、对账可补),不加同步握手。
14
+ */
15
+
16
+ import { isPidAlive } from "../kill-tree.ts";
17
+ import { readTailSummary } from "./output-tail.ts";
18
+ import { taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
19
+ import { finalizeTask, getActiveTasks } from "./task-store.ts";
20
+ import type { BackgroundTask } from "./types.ts";
21
+
22
+ /** 轮询间隔(设计文档 §3.5:约 2s)。 */
23
+ export const POLL_INTERVAL_MS = 2000;
24
+
25
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
26
+
27
+ /**
28
+ * M3 通知接入点(本单元 no-op 占位):exit 边沿收尾(单例表 + registry 终态写完)
29
+ * 之后同步回调。M3 在这里接 pending:unregister emit + sendMessage steer。
30
+ * 不在 M2 实现任何通知行为。
31
+ */
32
+ let onTaskExitCallback: ((task: BackgroundTask) => void) | undefined;
33
+
34
+ export function setOnTaskExit(callback: ((task: BackgroundTask) => void) | undefined): void {
35
+ onTaskExitCallback = callback;
36
+ }
37
+
38
+ /** 惰性启动:有活跃条目才跑定时器(spawn 登记与 kill/timeout 标记后调用)。 */
39
+ export function ensurePollerRunning(): void {
40
+ if (pollTimer !== undefined) return;
41
+ pollTimer = setInterval(pollTick, POLL_INTERVAL_MS);
42
+ // 不阻止进程退出(收殓路径 stopPoller 统一清理)
43
+ pollTimer.unref?.();
44
+ }
45
+
46
+ export function stopPoller(): void {
47
+ if (pollTimer !== undefined) {
48
+ clearInterval(pollTimer);
49
+ pollTimer = undefined;
50
+ }
51
+ }
52
+
53
+ export function isPollerRunning(): boolean {
54
+ return pollTimer !== undefined;
55
+ }
56
+
57
+ function pollTick(): void {
58
+ const active = getActiveTasks();
59
+ if (active.length === 0) {
60
+ stopPoller();
61
+ return;
62
+ }
63
+ for (const task of active) {
64
+ // libuv 自动 reap 后 kill(pid,0) 报 ESRCH(判死),ChildProcess.exitCode 仍可读
65
+ if (!isPidAlive(task.pid)) {
66
+ finalizeExitedTask(task);
67
+ }
68
+ }
69
+ }
70
+
71
+ /** 测试专用:手动跑一轮轮询(不依赖 2s 定时器)。 */
72
+ export function pollTickForTest(): void {
73
+ pollTick();
74
+ }
75
+
76
+ /**
77
+ * exit 边沿收尾(单一终态归属):读 exitCode → 组装 tail 摘要 → reason 判定
78
+ * (intent.killed → "killed"、intent.timeout → "timeout"、无 intent → "natural";
79
+ * "process-exit" 由收殓路径直接调 finalizeTask,不经这里)→ 单例表 + registry
80
+ * 两侧写终态 → 触发 onTaskExit 回调(M3 接入点)。
81
+ */
82
+ function finalizeExitedTask(task: BackgroundTask): void {
83
+ const exitCode = task.child?.exitCode ?? null;
84
+ const reason = task.intent?.reason ?? "natural";
85
+ const endedAt = Date.now();
86
+ const tailSummary = readTailSummary(task.outputFile);
87
+ const finalized = finalizeTask(task.taskId, { exitCode, reason, endedAt, tailSummary });
88
+ if (finalized === undefined) return;
89
+ writeRegistryEntry(finalized.registryPath, taskToRegistryEntry(finalized));
90
+ onTaskExitCallback?.(finalized);
91
+ }