@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.
- package/README.md +31 -0
- package/index.ts +1 -0
- package/package.json +54 -0
- package/skills/base-tool-enhance-ext-config/SKILL.md +76 -0
- package/src/__tests__/background-lifecycle.test.ts +634 -0
- package/src/__tests__/bash-tool.test.ts +573 -0
- package/src/__tests__/config.test.ts +193 -0
- package/src/__tests__/force-patterns.test.ts +230 -0
- package/src/__tests__/index.test.ts +133 -0
- package/src/__tests__/kill-tree.test.ts +76 -0
- package/src/__tests__/notify.test.ts +335 -0
- package/src/__tests__/pending-reconcile.test.ts +237 -0
- package/src/__tests__/reaper.test.ts +373 -0
- package/src/__tests__/registry.test.ts +149 -0
- package/src/__tests__/task-store.test.ts +156 -0
- package/src/__tests__/tool-error-audit.test.ts +92 -0
- package/src/background/notify.ts +218 -0
- package/src/background/output-tail.ts +84 -0
- package/src/background/pending-reconcile.ts +169 -0
- package/src/background/poller.ts +91 -0
- package/src/background/process-exit-guard.ts +106 -0
- package/src/background/registry.ts +203 -0
- package/src/background/spawn-background.ts +275 -0
- package/src/background/subagent-guard.ts +21 -0
- package/src/background/task-store.ts +125 -0
- package/src/background/types.ts +103 -0
- package/src/bash-kill-tool.ts +144 -0
- package/src/bash-output-tool.ts +131 -0
- package/src/bash-tool.ts +226 -0
- package/src/config.ts +167 -0
- package/src/force-patterns.ts +236 -0
- package/src/index.ts +90 -0
- package/src/kill-tree.ts +100 -0
- package/src/reaper.ts +313 -0
- package/src/tool-error-audit.ts +78 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi 进程退出收殓(D12:后台任务生命周期绑定 pi 进程,不绑定 session)。
|
|
3
|
+
*
|
|
4
|
+
* 绝不能挂在 extension dispose / session_shutdown 上——session 替换(fork/switch/new)
|
|
5
|
+
* 也触发它们,那会误杀全部后台任务(任务跨同进程 session 替换继续运行)。收殓只认
|
|
6
|
+
* process 级信号与进程退出:
|
|
7
|
+
* - SIGTERM handler:只收殓不 exit(pi rpc-mode 自有 SIGTERM → shutdown 流程,抢
|
|
8
|
+
* exit 会打断其 tracked-pid 清理与日志 flush),随后 process.exit 触发下方兜底
|
|
9
|
+
* - process.on("exit"):同步兜底路径——kill-tree 同步 kill + registry 同步原子写
|
|
10
|
+
* - 幂等:cleaned flag 保证信号 handler 先跑收殓后,exit 兜底再跑一次无害
|
|
11
|
+
*
|
|
12
|
+
* 不挂 SIGINT handler(pi 0.84.1 实装核实,2026-08 review 轮修复):
|
|
13
|
+
* - TUI 常态 raw mode,Ctrl+C 是按键不走信号;唯一收到 SIGINT 的 TUI 场景是挂起态
|
|
14
|
+
* (cooked mode),而 pi 挂起态刻意注册 ignoreSigint 免疫 Ctrl+C
|
|
15
|
+
* (interactive-mode.js「Ignore SIGINT while suspended」)。Node 对同一信号调用
|
|
16
|
+
* 全部 listener,本包任何 SIGINT listener(含 once)都会在此刻被触发——cleanup
|
|
17
|
+
* 杀光后台任务(违背 D15「interrupt 不传播后台任务」)再 exit 掐死本应存活的
|
|
18
|
+
* 挂起进程,pi 的 ignore 语义完全失效
|
|
19
|
+
* - rpc/print 模式 Ctrl+C:pi 无 SIGINT listener(dist 全量 grep 唯一注册点在 TUI
|
|
20
|
+
* 挂起态)→ 默认处置进程即死。探针实测:无 listener 的信号默认终止**不触发
|
|
21
|
+
* exit 事件**,同步收殓在该路径本就不可达;孤儿由 M5 reaper 属主判定收殓
|
|
22
|
+
* (与 SIGKILL 强杀同类,设计内兜底)
|
|
23
|
+
* - cleanup-only listener 更不可取:注册即抑制默认终止,rpc 前台 Ctrl+C 后 pi 变
|
|
24
|
+
* 成杀不死的僵尸进程
|
|
25
|
+
*
|
|
26
|
+
* 子进程死后 registry 目录无人再扫(reaper 是 M5)——本单元不管。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
30
|
+
|
|
31
|
+
import { killProcessTree } from "../kill-tree.ts";
|
|
32
|
+
import { emitPendingUnregister } from "./notify.ts";
|
|
33
|
+
import { stopPoller } from "./poller.ts";
|
|
34
|
+
import { taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
|
|
35
|
+
import { finalizeTask, getActiveTasks } from "./task-store.ts";
|
|
36
|
+
|
|
37
|
+
const logger = getLogger("base-tool-enhance");
|
|
38
|
+
|
|
39
|
+
let installed = false;
|
|
40
|
+
let cleaned = false;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 安装进程级收殓(幂等,extension load 时调用一次;同进程 session 替换重新 load
|
|
44
|
+
* 不会重复挂 handler——模块级 installed flag)。
|
|
45
|
+
*/
|
|
46
|
+
export function installProcessExitGuard(): void {
|
|
47
|
+
if (installed) return;
|
|
48
|
+
installed = true;
|
|
49
|
+
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
if (cleaned) return;
|
|
52
|
+
cleaned = true;
|
|
53
|
+
reapBackgroundTasksNow();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// SIGTERM:只收殓不 exit——pi rpc-mode 的 SIGTERM handler(先注册先跑)走自己的
|
|
57
|
+
// shutdown 流程,最终 process.exit 触发下方 exit 兜底(幂等跳过)。
|
|
58
|
+
// SIGINT 刻意不挂 handler——会破坏 pi TUI 挂起态的 ignoreSigint 语义,且 rpc
|
|
59
|
+
// 默认终止路径 exit 事件本就不触发、孤儿归 M5 reaper 兜底(完整论证见文件头
|
|
60
|
+
// 「不挂 SIGINT handler」专段)
|
|
61
|
+
process.once("SIGTERM", cleanup);
|
|
62
|
+
// 同步兜底:exit handler 里只能做同步收殓(kill-tree 同步 + registry 同步原子写)
|
|
63
|
+
process.on("exit", cleanup);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 收殓动作(导出供测试直接调用,不真杀 pi 进程):
|
|
68
|
+
* 轮询器停止 → 遍历**单例表**活跃条目 kill-tree(单例表 = 本进程任务全集,天然
|
|
69
|
+
* 不含他进程条目——不遍历 registry,否则 ephemeral 附着进程退出会误杀属主进程的
|
|
70
|
+
* 任务)→ registry 写终态 exited(reason:"process-exit")。
|
|
71
|
+
*
|
|
72
|
+
* 终态与轮询器边沿共用 finalizeTask(单一终态归属)。
|
|
73
|
+
*/
|
|
74
|
+
export function reapBackgroundTasksNow(): void {
|
|
75
|
+
stopPoller();
|
|
76
|
+
for (const task of getActiveTasks()) {
|
|
77
|
+
try {
|
|
78
|
+
killProcessTree(task.pid);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// 单条 kill 失败不阻断其余条目收殓;进程将退出,残余由 M5 reaper 兜底
|
|
81
|
+
logger.warn("kill-tree failed during process-exit reap", {
|
|
82
|
+
detail: { taskId: task.taskId, pid: task.pid, err: err instanceof Error ? err.message : String(err) },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const finalized = finalizeTask(task.taskId, {
|
|
86
|
+
exitCode: task.child?.exitCode ?? null,
|
|
87
|
+
reason: "process-exit",
|
|
88
|
+
endedAt: Date.now(),
|
|
89
|
+
});
|
|
90
|
+
if (finalized !== undefined) {
|
|
91
|
+
writeRegistryEntry(finalized.registryPath, taskToRegistryEntry(finalized));
|
|
92
|
+
// 尽力补 emit pending:unregister(§3.5「pi API 若仍可用」):SIGTERM
|
|
93
|
+
// 信号路径 pi 引用尚活(emit 经模块级 notify 引用,bus 未 dispose 时送达
|
|
94
|
+
// pending listener 落盘);process.on("exit") 同步路径 bus 可能已 dispose,
|
|
95
|
+
// emit throw 被 notify 内部捕获降级——残留由 session_start 对账兜底。
|
|
96
|
+
// **不 sendMessage**:进程都退了,无投递目标。
|
|
97
|
+
emitPendingUnregister(finalized.taskId, "process-exit", finalized.exitCode ?? null);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 测试专用:复位幂等 flag(clearTaskStoreForTest 配套)。 */
|
|
103
|
+
export function resetProcessExitGuardForTest(): void {
|
|
104
|
+
installed = false;
|
|
105
|
+
cleaned = false;
|
|
106
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry.json 持久化读写(per-sessionId 目录,D8)。
|
|
3
|
+
*
|
|
4
|
+
* 路径:<dataDir>/base-tool-enhance/<sessionId>/registry.json(dataDir = pi
|
|
5
|
+
* getAgentDir() 同源路径,由调用方解析传入)。条目记 ownerPiPid——M5 reaper 属主
|
|
6
|
+
* 判定依据,M2 只负责写入。
|
|
7
|
+
*
|
|
8
|
+
* 写入协议:
|
|
9
|
+
* - 原子写 temp+rename(tmp 名带 pid+随机段防并发碰撞,llm-shared saveConfig 范式)
|
|
10
|
+
* - 锁内 RMW(@zhushanwen/pi-file-lock withFileLockSync)——同 sessionId 目录可能
|
|
11
|
+
* 被桌面端 ephemeral 附着进程与发起进程并发写,跨进程互斥只依赖同一 lockfile
|
|
12
|
+
* - 锁获取失败不降级无锁写:返回 {success:false},条目停留 running 由 M5 reaper
|
|
13
|
+
* 兜底(§3.5「registry/entry 写不进则条目停留 running」)
|
|
14
|
+
* - 损坏读取防御(§3.6):解析失败/形状非法 → 重命名 .corrupt 保留现场 + 按空表
|
|
15
|
+
* 重建 + warn 日志
|
|
16
|
+
* - 终态条目 LRU 上限 50(与单例表对称)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
23
|
+
import { withFileLockSync } from "@zhushanwen/pi-file-lock";
|
|
24
|
+
|
|
25
|
+
import { isTerminalState, type BackgroundTask, type RegistryEntry } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
const logger = getLogger("base-tool-enhance");
|
|
28
|
+
|
|
29
|
+
/** registry 文件格式版本(未来结构变更时迁移判据)。 */
|
|
30
|
+
const REGISTRY_VERSION = 1;
|
|
31
|
+
/** 终态条目 LRU 上限(与 task-store MAX_TERMINAL_TASKS 对称,§3.5)。 */
|
|
32
|
+
export const MAX_TERMINAL_REGISTRY_ENTRIES = 50;
|
|
33
|
+
const JSON_INDENT = 2;
|
|
34
|
+
// tmp 随机段参数(llm-shared uniqueTmpPath 同款:36 进制随机串,跳过 "0." 前缀)
|
|
35
|
+
const TMP_RADIX = 36;
|
|
36
|
+
const TMP_SLICE_START = 2;
|
|
37
|
+
const TMP_SLICE_END = 10;
|
|
38
|
+
|
|
39
|
+
export function getBaseToolEnhanceDir(dataDir: string): string {
|
|
40
|
+
return join(dataDir, "base-tool-enhance");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getRegistryPath(dataDir: string, sessionId: string): string {
|
|
44
|
+
return join(getBaseToolEnhanceDir(dataDir), sessionId, "registry.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface RegistryFileShape {
|
|
48
|
+
version: number;
|
|
49
|
+
entries: RegistryEntry[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 最低限度形状校验:核心标识字段缺失即整条丢弃(不因单条脏数据报废全表)。
|
|
54
|
+
*/
|
|
55
|
+
function isValidRegistryEntry(item: unknown): item is RegistryEntry {
|
|
56
|
+
if (typeof item !== "object" || item === null) return false;
|
|
57
|
+
const e = item as Record<string, unknown>;
|
|
58
|
+
return (
|
|
59
|
+
typeof e.taskId === "string" &&
|
|
60
|
+
typeof e.pid === "number" &&
|
|
61
|
+
typeof e.command === "string" &&
|
|
62
|
+
typeof e.outputFile === "string" &&
|
|
63
|
+
typeof e.startedAt === "number" &&
|
|
64
|
+
typeof e.state === "string" &&
|
|
65
|
+
typeof e.ownerPiPid === "number" &&
|
|
66
|
+
typeof e.sessionId === "string"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 校验并归一化 registry 文件内容;形状非法返回 undefined(走 corrupt 路径)。 */
|
|
71
|
+
function parseRegistryContent(raw: string): RegistryFileShape | undefined {
|
|
72
|
+
let parsed: unknown;
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(raw);
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
79
|
+
const { version, entries } = parsed as Record<string, unknown>;
|
|
80
|
+
if (version !== REGISTRY_VERSION || !Array.isArray(entries)) return undefined;
|
|
81
|
+
const valid: RegistryEntry[] = [];
|
|
82
|
+
for (const item of entries) {
|
|
83
|
+
if (isValidRegistryEntry(item)) valid.push(item);
|
|
84
|
+
}
|
|
85
|
+
return { version: REGISTRY_VERSION, entries: valid };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** .corrupt 落点:固定名优先;已存在则带时间戳,不覆盖前一份现场。 */
|
|
89
|
+
function corruptPathFor(registryPath: string): string {
|
|
90
|
+
const base = `${registryPath}.corrupt`;
|
|
91
|
+
return existsSync(base) ? `${base}-${Date.now()}` : base;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 读取 registry 全量条目。文件不存在 / 读失败 / 解析失败均返回空表(工具面不因
|
|
96
|
+
* registry 问题崩溃);解析失败时重命名 .corrupt 保留现场 + warn(§3.6)。
|
|
97
|
+
*/
|
|
98
|
+
export function readRegistry(registryPath: string): Map<string, RegistryEntry> {
|
|
99
|
+
if (!existsSync(registryPath)) return new Map();
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = readFileSync(registryPath, "utf8");
|
|
103
|
+
} catch (err) {
|
|
104
|
+
logger.warn("registry read failed, treating as empty", {
|
|
105
|
+
detail: { path: registryPath, err: err instanceof Error ? err.message : String(err) },
|
|
106
|
+
});
|
|
107
|
+
return new Map();
|
|
108
|
+
}
|
|
109
|
+
const parsed = parseRegistryContent(raw);
|
|
110
|
+
if (parsed === undefined) {
|
|
111
|
+
const corruptPath = corruptPathFor(registryPath);
|
|
112
|
+
try {
|
|
113
|
+
renameSync(registryPath, corruptPath);
|
|
114
|
+
logger.warn("registry corrupted, renamed to preserve scene and rebuilt empty", {
|
|
115
|
+
detail: { path: registryPath, corruptPath },
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
logger.warn("registry corrupted and rename failed, rebuilding empty in place", {
|
|
119
|
+
detail: { path: registryPath, err: err instanceof Error ? err.message : String(err) },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return new Map();
|
|
123
|
+
}
|
|
124
|
+
return new Map(parsed.entries.map((e) => [e.taskId, e]));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** BackgroundTask → RegistryEntry(剥离运行时字段 intent/timeoutTimer/child/registryPath)。 */
|
|
128
|
+
export function taskToRegistryEntry(task: BackgroundTask): RegistryEntry {
|
|
129
|
+
return {
|
|
130
|
+
taskId: task.taskId,
|
|
131
|
+
pid: task.pid,
|
|
132
|
+
command: task.command,
|
|
133
|
+
outputFile: task.outputFile,
|
|
134
|
+
startedAt: task.startedAt,
|
|
135
|
+
state: task.state,
|
|
136
|
+
ownerPiPid: task.ownerPiPid,
|
|
137
|
+
sessionId: task.sessionId,
|
|
138
|
+
...(task.exitCode !== undefined ? { exitCode: task.exitCode } : {}),
|
|
139
|
+
...(task.reason !== undefined ? { reason: task.reason } : {}),
|
|
140
|
+
...(task.endedAt !== undefined ? { endedAt: task.endedAt } : {}),
|
|
141
|
+
...(task.durationMs !== undefined ? { durationMs: task.durationMs } : {}),
|
|
142
|
+
...(task.tailSummary !== undefined ? { tailSummary: task.tailSummary } : {}),
|
|
143
|
+
...(task.pidStartTime !== undefined ? { pidStartTime: task.pidStartTime } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function serializeRegistry(entries: RegistryEntry[]): string {
|
|
148
|
+
const shape: RegistryFileShape = { version: REGISTRY_VERSION, entries };
|
|
149
|
+
return `${JSON.stringify(shape, null, JSON_INDENT)}\n`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 原子写:tmp(pid+随机段唯一化)+ rename(POSIX/Windows 均原子);失败清理 tmp。 */
|
|
153
|
+
function atomicWriteRegistry(registryPath: string, content: string): void {
|
|
154
|
+
mkdirSync(dirname(registryPath), { recursive: true });
|
|
155
|
+
const tmpPath = `${registryPath}.tmp_${process.pid}_${Math.random().toString(TMP_RADIX).slice(TMP_SLICE_START, TMP_SLICE_END)}`;
|
|
156
|
+
try {
|
|
157
|
+
writeFileSync(tmpPath, content, "utf8");
|
|
158
|
+
renameSync(tmpPath, registryPath);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
try {
|
|
161
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
162
|
+
} catch (cleanupErr) {
|
|
163
|
+
// tmp 清理失败不掩盖原错误,仅留诊断
|
|
164
|
+
logger.warn("registry tmp cleanup failed", {
|
|
165
|
+
detail: { tmpPath, err: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr) },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 写入/更新单条 registry 条目(锁内 RMW:读全量 → 合并同 id 覆盖 → 终态 LRU 50 →
|
|
174
|
+
* 原子写)。任务登记(running)、killing intent、终态三条路径共用。
|
|
175
|
+
* 失败返回 {success:false}——调用方按「写不进则条目停留 running,M5 reaper 兜底」
|
|
176
|
+
* 处理(M2 只 warn,不重试不阻断主流程)。
|
|
177
|
+
*/
|
|
178
|
+
export function writeRegistryEntry(
|
|
179
|
+
registryPath: string,
|
|
180
|
+
entry: RegistryEntry,
|
|
181
|
+
): { success: boolean; error?: string } {
|
|
182
|
+
const writeMerged = (): void => {
|
|
183
|
+
const merged = readRegistry(registryPath);
|
|
184
|
+
merged.set(entry.taskId, entry);
|
|
185
|
+
const all = [...merged.values()];
|
|
186
|
+
const terminal = all
|
|
187
|
+
.filter((e) => isTerminalState(e.state))
|
|
188
|
+
.sort((a, b) => (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt));
|
|
189
|
+
const excess = terminal.length - MAX_TERMINAL_REGISTRY_ENTRIES;
|
|
190
|
+
for (let i = 0; i < excess; i++) merged.delete(terminal[i].taskId);
|
|
191
|
+
atomicWriteRegistry(registryPath, serializeRegistry([...merged.values()]));
|
|
192
|
+
};
|
|
193
|
+
try {
|
|
194
|
+
withFileLockSync(registryPath, writeMerged);
|
|
195
|
+
return { success: true };
|
|
196
|
+
} catch (err) {
|
|
197
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
198
|
+
logger.warn("registry write failed; entry stays as-is (M5 reaper will reconcile)", {
|
|
199
|
+
detail: { path: registryPath, taskId: entry.taskId, err: message },
|
|
200
|
+
});
|
|
201
|
+
return { success: false, error: message };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后台任务 spawn(D7/D10/D12/D15,设计文档 §3.5 后台任务生命周期数据流 ③④)。
|
|
3
|
+
*
|
|
4
|
+
* spawn 细节:detached(自成进程组,进程树 kill 的语义基础)+ stdio 直接落
|
|
5
|
+
* <dataDir>/base-tool-enhance/<sessionId>/<task_id>.log(append fd,不占内存、
|
|
6
|
+
* 无 pipe backpressure)+ child.unref()(pi 不等它)。abort/interrupt 不传播到
|
|
7
|
+
* 后台任务(D15)——execute 立即返回,本模块不接触 signal。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { mkdirSync, openSync, closeSync, accessSync, constants } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
16
|
+
|
|
17
|
+
import { killProcessTree } from "../kill-tree.ts";
|
|
18
|
+
import { getProcessStartTimeSec, pidStartMatchesRegistered } from "../reaper.ts";
|
|
19
|
+
import { emitPendingRegister } from "./notify.ts";
|
|
20
|
+
import { ensurePollerRunning } from "./poller.ts";
|
|
21
|
+
import { getRegistryPath, taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
|
|
22
|
+
import { countActiveTasks, markKillingIntent, oldestActiveTask, registerSpawnedTask } from "./task-store.ts";
|
|
23
|
+
import type { BackgroundTask } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
/** task_id 随机段熵源字节数(crypto 大数采样,8 字节 = 64 bit)。 */
|
|
26
|
+
const TASK_ID_ENTROPY_BYTES = 8;
|
|
27
|
+
/** 秒 → 毫秒。 */
|
|
28
|
+
const MS_PER_SECOND = 1000;
|
|
29
|
+
|
|
30
|
+
const logger = getLogger("base-tool-enhance");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 并发上限默认值(D10)。M4:取值来源已配置化——bash-tool execute 每次读
|
|
34
|
+
* maxConcurrentBackground 配置经 opts.maxConcurrent 传入;本常量仅在调用方未传时
|
|
35
|
+
* 兜底(与 config.ts DEFAULT_BASE_TOOL_ENHANCE_CONFIG.maxConcurrentBackground 同源)。
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_MAX_CONCURRENT_BACKGROUND = 8;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* task_id 随机段长度(base36 小写)。6 字符 ≈ 31 bit 熵:同毫秒内数百次 spawn 的
|
|
41
|
+
* 生日碰撞概率 ~1e-7(4 字符实测在 500 次内可撞——唯一性是 pending 差集前提,
|
|
42
|
+
* §2.3,熵按此约束取值)。
|
|
43
|
+
*/
|
|
44
|
+
const TASK_ID_RAND_LENGTH = 6;
|
|
45
|
+
const TASK_ID_RADIX = 36;
|
|
46
|
+
const TASK_ID_RAND_SPACE = TASK_ID_RADIX ** TASK_ID_RAND_LENGTH;
|
|
47
|
+
|
|
48
|
+
/** 命令在错误文案中的展示长度上限。 */
|
|
49
|
+
const COMMAND_DISPLAY_LIMIT = 80;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* task_id 生成:`bt-<ts>-<rand>`(前缀 bt- 刻意区别于 subagent-workflow 的
|
|
53
|
+
* bg-/run-,§2.3)。**禁止进程内自增序列**——pi 重启后撞旧 id 会破坏 pending
|
|
54
|
+
* 差集前提(register 被幂等忽略 / 旧 unregister 误消新任务)。
|
|
55
|
+
*/
|
|
56
|
+
export function generateTaskId(now: number = Date.now()): string {
|
|
57
|
+
return `bt-${now}-${randomRandSegment()}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 均匀 base36 随机段:crypto 随机大数取模,无 base64 字符集替换导致的采样偏置。 */
|
|
61
|
+
function randomRandSegment(): string {
|
|
62
|
+
const value = randomBytes(TASK_ID_ENTROPY_BYTES).readBigUInt64BE() % BigInt(TASK_ID_RAND_SPACE);
|
|
63
|
+
return value.toString(TASK_ID_RADIX).padStart(TASK_ID_RAND_LENGTH, "0");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 后台 timeout 解析(秒),优先级 = LLM 显式值 > 配置默认值 > 不限(§3.5):
|
|
68
|
+
* - 显式值有效 → 显式值;显式值无效(非有限/≤0)→ 沿用 pi 内置文案抛错
|
|
69
|
+
* - 显式值缺省 && 配置默认提供 → 配置默认(M4 注入接缝:调用侧传
|
|
70
|
+
* backgroundTimeoutSeconds;已 normalize,必为正有限数)
|
|
71
|
+
* - 双缺省 → undefined(不限)
|
|
72
|
+
* D13 例外由调用侧实现:白名单强转后台时传 explicitSec=undefined(显式值整体忽略)。
|
|
73
|
+
*/
|
|
74
|
+
export function resolveBackgroundTimeoutSec(
|
|
75
|
+
explicitSec: number | undefined,
|
|
76
|
+
defaultSec?: number,
|
|
77
|
+
): number | undefined {
|
|
78
|
+
if (explicitSec === undefined) return defaultSec;
|
|
79
|
+
if (!Number.isFinite(explicitSec) || explicitSec <= 0) {
|
|
80
|
+
throw new Error("Invalid timeout: must be a finite number of seconds");
|
|
81
|
+
}
|
|
82
|
+
return explicitSec;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SpawnBackgroundOptions {
|
|
86
|
+
command: string;
|
|
87
|
+
/** execute ctx.cwd(权威 cwd) */
|
|
88
|
+
cwd: string;
|
|
89
|
+
/** pi getAgentDir() 同源 dataDir */
|
|
90
|
+
dataDir: string;
|
|
91
|
+
sessionId: string;
|
|
92
|
+
/** LLM 显式 timeout(秒);undefined 时若配置默认存在由调用侧注入(M4)。 */
|
|
93
|
+
timeoutSec?: number;
|
|
94
|
+
/** 并发上限(M4:bash-tool 从 maxConcurrentBackground 配置传入;缺省走默认常量)。 */
|
|
95
|
+
maxConcurrent?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type SpawnBackgroundResult =
|
|
99
|
+
| { ok: true; task: BackgroundTask }
|
|
100
|
+
| { ok: false; error: string };
|
|
101
|
+
|
|
102
|
+
function shellForPlatform(): { shell: string; args: string[] } {
|
|
103
|
+
if (process.platform === "win32") {
|
|
104
|
+
return { shell: process.env.ComSpec || "cmd.exe", args: ["/c"] };
|
|
105
|
+
}
|
|
106
|
+
return { shell: process.env.SHELL || "/bin/sh", args: ["-c"] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 启动后台任务:并发检查 → spawn(输出重定向 .log)→ 单例表登记 + registry 写
|
|
111
|
+
* running 条目 → 显式 timeout 定时器 → 启动轮询器。
|
|
112
|
+
* 立即返回,不等待命令退出(turn 不被占用,G1)。
|
|
113
|
+
*/
|
|
114
|
+
export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgroundResult {
|
|
115
|
+
const maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_BACKGROUND;
|
|
116
|
+
if (countActiveTasks() >= maxConcurrent) {
|
|
117
|
+
const oldest = oldestActiveTask();
|
|
118
|
+
if (oldest === undefined) {
|
|
119
|
+
// 防御缺口闭合:上限已满但找不出最老活跃任务 = maxConcurrent <= 0 且 0 活跃
|
|
120
|
+
// ——同样拒绝,否则上限静默失效。config normalize 保证 >= 1,此处兜底
|
|
121
|
+
// 绕过配置直传非法值的调用方(错误指向配置键,可操作)
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
error:
|
|
125
|
+
`Background task concurrency limit configuration invalid (maxConcurrent=${maxConcurrent}, must be >= 1). ` +
|
|
126
|
+
"Fix maxConcurrentBackground in the base-tool-enhance config.",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const cmd = truncateCommand(oldest.command);
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error:
|
|
133
|
+
`Background task limit reached (max ${maxConcurrent} concurrent). ` +
|
|
134
|
+
`Oldest task: ${oldest.taskId} (${cmd}). ` +
|
|
135
|
+
`Kill it with bash_kill {task_id:"${oldest.taskId}"} or wait for it to finish.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// cwd 校验对齐 pi 内置文案(bash.js createLocalBashOperations)
|
|
140
|
+
try {
|
|
141
|
+
accessOrThrow(opts.cwd);
|
|
142
|
+
} catch {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: `Working directory does not exist: ${opts.cwd}\nCannot execute bash commands.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const taskId = generateTaskId();
|
|
150
|
+
const registryPath = getRegistryPath(opts.dataDir, opts.sessionId);
|
|
151
|
+
const outputFile = join(dirname(registryPath), `${taskId}.log`);
|
|
152
|
+
mkdirSync(dirname(outputFile), { recursive: true });
|
|
153
|
+
|
|
154
|
+
const { shell, args } = shellForPlatform();
|
|
155
|
+
let outputFd: number | undefined;
|
|
156
|
+
let child;
|
|
157
|
+
try {
|
|
158
|
+
outputFd = openSync(outputFile, "a");
|
|
159
|
+
child = spawn(shell, [...args, opts.command], {
|
|
160
|
+
cwd: opts.cwd,
|
|
161
|
+
detached: true,
|
|
162
|
+
stdio: ["ignore", outputFd, outputFd],
|
|
163
|
+
});
|
|
164
|
+
// 唯一的事件监听例外:no-op error listener 防 spawn 异步失败 emit error 无监听
|
|
165
|
+
// 导致进程崩溃(EventEmitter 语义)。不做任何状态推进——exit 感知归轮询器(D17)。
|
|
166
|
+
child.on("error", () => {});
|
|
167
|
+
child.unref();
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
170
|
+
return { ok: false, error: `Failed to start background command: ${message}` };
|
|
171
|
+
} finally {
|
|
172
|
+
// 子进程已继承 fd 副本(含 spawn 同步失败路径:fd 未被子进程持有),父进程侧
|
|
173
|
+
// 描述符统一在此关闭防句柄泄漏
|
|
174
|
+
if (outputFd !== undefined) {
|
|
175
|
+
try {
|
|
176
|
+
closeSync(outputFd);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// 已关闭/不可关闭均不掩盖主流程,仅留诊断
|
|
179
|
+
logger.debug("background spawn output fd close failed", {
|
|
180
|
+
detail: { outputFile, err: err instanceof Error ? err.message : String(err) },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (child.pid === undefined) {
|
|
187
|
+
return { ok: false, error: "Failed to start background command: no pid acquired" };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// M3 补写 M5 预告字段:spawn 后立即读子进程 start time(epoch 秒),供 reaper
|
|
191
|
+
// 精确比较防 pid 复用误杀。读取失败省略(undefined 不进条目)——reaper 降级走
|
|
192
|
+
// startedAt 秒级校验兜底,不报错不阻断 spawn
|
|
193
|
+
const pidStartTime = getProcessStartTimeSec(child.pid);
|
|
194
|
+
|
|
195
|
+
const task: BackgroundTask = {
|
|
196
|
+
taskId,
|
|
197
|
+
pid: child.pid,
|
|
198
|
+
command: opts.command,
|
|
199
|
+
outputFile,
|
|
200
|
+
registryPath,
|
|
201
|
+
startedAt: Date.now(),
|
|
202
|
+
state: "running",
|
|
203
|
+
ownerPiPid: process.pid,
|
|
204
|
+
sessionId: opts.sessionId,
|
|
205
|
+
...(pidStartTime !== undefined ? { pidStartTime } : {}),
|
|
206
|
+
child,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
if (opts.timeoutSec !== undefined) {
|
|
210
|
+
armBackgroundTimeout(task, opts.timeoutSec);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 登记顺序(§3.5 数据流 ③④⑤):单例表(运行时权威)→ registry(持久化)→
|
|
214
|
+
// pending:register emit。registry 写失败不阻断(条目停留 running 由 M5 reaper
|
|
215
|
+
// 兜底,§3.5);emit 失败同样无害(peer 未加载/引用未注入时无 listener,通知
|
|
216
|
+
// 链路缺失不影响任务本体)
|
|
217
|
+
registerSpawnedTask(task);
|
|
218
|
+
writeRegistryEntry(registryPath, taskToRegistryEntry(task));
|
|
219
|
+
emitPendingRegister(task);
|
|
220
|
+
ensurePollerRunning();
|
|
221
|
+
return { ok: true, task };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 后台显式 timeout(D6:任务寿命可由使用者显式约束)。到点:pid 身份校验通过则
|
|
226
|
+
* kill-tree + 两侧标 killing intent(reason 候选 timeout)——实际终态由轮询器边沿
|
|
227
|
+
* 收尾写(单一终态归属),此处不写终态。
|
|
228
|
+
*/
|
|
229
|
+
function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
|
|
230
|
+
const timer = setTimeout(() => {
|
|
231
|
+
// pid 复用防御(§3.6,同 reaper reapEntrySync / bash_kill 范式,宁不杀勿误杀):
|
|
232
|
+
// 任务早已退出(exit 边沿未被轮询器收尾或竞态未及)且 pid 在到点前被系统复用
|
|
233
|
+
// 时,直接 killProcessTree 会杀掉复用 pid 上的无辜进程(整进程组 SIGKILL)。
|
|
234
|
+
if (!isRecordedPidStillOriginal(task)) {
|
|
235
|
+
logger.warn("background timeout: pid identity unverified (reuse suspected or start time unreadable), skipping kill", {
|
|
236
|
+
detail: {
|
|
237
|
+
taskId: task.taskId,
|
|
238
|
+
pid: task.pid,
|
|
239
|
+
pidStartTime: task.pidStartTime,
|
|
240
|
+
startedAt: task.startedAt,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
} else {
|
|
244
|
+
killProcessTree(task.pid);
|
|
245
|
+
}
|
|
246
|
+
const marked = markKillingIntent(task.taskId, "timeout");
|
|
247
|
+
if (marked === undefined) return;
|
|
248
|
+
writeRegistryEntry(marked.registryPath, taskToRegistryEntry(marked));
|
|
249
|
+
ensurePollerRunning();
|
|
250
|
+
}, timeoutSec * MS_PER_SECOND);
|
|
251
|
+
timer.unref?.();
|
|
252
|
+
task.timeoutTimer = timer;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 到点 pid 身份校验:true = 登记时的原进程仍占用该 pid(可安全 kill-tree)。
|
|
257
|
+
* 判据(精确比较 / startedAt 秒级降级 / 读不到保守 false)单点定义于 reaper
|
|
258
|
+
* 的 pidStartMatchesRegistered,与 reapEntrySync 孤儿补杀共用同一份。
|
|
259
|
+
*/
|
|
260
|
+
function isRecordedPidStillOriginal(task: BackgroundTask): boolean {
|
|
261
|
+
const actualStartSec = getProcessStartTimeSec(task.pid);
|
|
262
|
+
if (actualStartSec === undefined) return false;
|
|
263
|
+
return pidStartMatchesRegistered(actualStartSec, task.pidStartTime, task.startedAt);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function accessOrThrow(cwd: string): void {
|
|
267
|
+
// 独立小函数:保持与内置 fsAccess(constants.F_OK) 语义一致的同步版本
|
|
268
|
+
accessSync(cwd, constants.F_OK);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function truncateCommand(command: string): string {
|
|
272
|
+
return command.length > COMMAND_DISPLAY_LIMIT
|
|
273
|
+
? `${command.slice(0, COMMAND_DISPLAY_LIMIT)}…`
|
|
274
|
+
: command;
|
|
275
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent 进程识别(D14 降级判据,探针 P5)。
|
|
3
|
+
*
|
|
4
|
+
* subagent-workflow 的 buildChildEnv(execution/session-runner.ts)对每个子 agent
|
|
5
|
+
* 进程**无条件**注入身份贯穿 env(PI_SUBAGENT_ROOT_SESSION_ID / SELF_RECORD_ID /
|
|
6
|
+
* DEPTH / ROOT_CWD,见其源码注释「无条件注入每个 subagent」)。任一存在 = 当前
|
|
7
|
+
* pi 进程是 subagent 子进程 → 本扩展降级:background:true 被忽略走前台同步语义。
|
|
8
|
+
*
|
|
9
|
+
* 为什么降级:子 agent 内后台化会破坏 workflow 结构化输出契约(预算耗尽时测试未
|
|
10
|
+
* 回);且子进程死后其 registry 目录永远不会再有 session 启动,孤儿无人 reap。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** subagent-workflow 注入的身份贯穿 env 前缀(跨包契约,P5 探针确认)。 */
|
|
14
|
+
const SUBAGENT_ENV_KEYS = [
|
|
15
|
+
"PI_SUBAGENT_ROOT_SESSION_ID",
|
|
16
|
+
"PI_SUBAGENT_SELF_RECORD_ID",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export function isSubagentProcess(): boolean {
|
|
20
|
+
return SUBAGENT_ENV_KEYS.some((key) => process.env[key] !== undefined);
|
|
21
|
+
}
|