@zhushanwen/pi-base-tool-enhance 0.5.2 → 0.5.3
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 +6 -5
- package/src/__tests__/background-lifecycle.test.ts +10 -6
- package/src/__tests__/index.test.ts +3 -5
- package/src/__tests__/maintenance-once.test.ts +2 -2
- package/src/__tests__/notify.test.ts +1 -1
- package/src/__tests__/pending-reconcile.test.ts +98 -33
- package/src/__tests__/subagent-guard.test.ts +29 -0
- package/src/background/notify.ts +4 -3
- package/src/background/output-tail.ts +9 -65
- package/src/background/pending-reconcile.ts +33 -69
- package/src/background/poller.ts +2 -1
- package/src/background/process-exit-guard.ts +9 -3
- package/src/background/registry.ts +36 -124
- package/src/background/spawn-background.ts +18 -6
- package/src/background/subagent-guard.ts +11 -15
- package/src/background/task-store.ts +12 -7
- package/src/bash-kill-tool.ts +19 -4
- package/src/bash-output-tool.ts +7 -3
- package/src/bash-tool.ts +3 -0
- package/src/config.ts +3 -6
- package/src/index.ts +2 -1
- package/src/tool-error-audit.ts +3 -2
- package/src/__tests__/kill-tree.test.ts +0 -161
- package/src/kill-tree.ts +0 -151
|
@@ -27,9 +27,10 @@
|
|
|
27
27
|
* 本单元不管。
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
+
import { killProcessTree } from "@xyz-agent/extension-protocol/background-task";
|
|
31
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
30
32
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
31
33
|
|
|
32
|
-
import { killProcessTree } from "../kill-tree.ts";
|
|
33
34
|
import { emitPendingUnregister } from "./notify.ts";
|
|
34
35
|
import { stopPoller } from "./poller.ts";
|
|
35
36
|
import { taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
|
|
@@ -76,11 +77,16 @@ export function reapBackgroundTasksNow(): void {
|
|
|
76
77
|
stopPoller();
|
|
77
78
|
for (const task of getActiveTasks()) {
|
|
78
79
|
try {
|
|
79
|
-
|
|
80
|
+
// 回退路径诊断经 onFallback 注入 logger 适配(ext-simplify-13 D2)
|
|
81
|
+
killProcessTree(task.pid, (step, err) =>
|
|
82
|
+
logger.debug(step, {
|
|
83
|
+
detail: { pid: task.pid, err: toErrorMessage(err) },
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
80
86
|
} catch (err) {
|
|
81
87
|
// 单条 kill 失败不阻断其余条目收殓;进程将退出,残余由 runtime 收殓兜底
|
|
82
88
|
logger.warn("kill-tree failed during process-exit reap", {
|
|
83
|
-
detail: { taskId: task.taskId, pid: task.pid, err:
|
|
89
|
+
detail: { taskId: task.taskId, pid: task.pid, err: toErrorMessage(err) },
|
|
84
90
|
});
|
|
85
91
|
}
|
|
86
92
|
const finalized = finalizeTask(task.taskId, {
|
|
@@ -6,41 +6,44 @@
|
|
|
6
6
|
* 判定依据(u-bte-remove 后 = xyz-agent runtime background-task-reaper),M2 只负责
|
|
7
7
|
* 写入。
|
|
8
8
|
*
|
|
9
|
+
* 行为原语单点(ext-simplify-13):解析防御 / corrupt 隔离 / 原子写 / 序列化 /
|
|
10
|
+
* 终态 LRU 裁剪自 @xyz-agent/extension-protocol background-task 子出口引入——此前
|
|
11
|
+
* 本地实现与 runtime 侧逐字同构、靠注释对齐;本模块只保留 bte 形态薄壳:Map 投影
|
|
12
|
+
* readRegistry 与锁内 RMW 写壳。
|
|
13
|
+
*
|
|
9
14
|
* 写入协议:
|
|
10
|
-
* - 原子写 temp+rename
|
|
15
|
+
* - 原子写 temp+rename、损坏读取防御(§3.6 重命名 .corrupt 保留现场 + 按空表
|
|
16
|
+
* 重建)、终态条目 LRU 上限 50 均由 protocol 原语承担;诊断日志(corrupt 隔离
|
|
17
|
+
* warn 等)经 onLog 注入本包 logger,落盘通道不变
|
|
11
18
|
* - 锁内 RMW(@zhushanwen/pi-file-lock withFileLockSync)——同 sessionId 目录可能
|
|
12
19
|
* 被桌面端 ephemeral 附着进程与发起进程并发写,跨进程互斥只依赖同一 lockfile
|
|
13
20
|
* (runtime 收殓器写终态用 xyz-agent 统一锁,与该 lockfile 互斥)
|
|
14
21
|
* - 锁获取失败不降级无锁写:返回 {success:false},条目停留 running 由 runtime
|
|
15
22
|
* 收殓兜底(§3.5「registry/entry 写不进则条目停留 running」)
|
|
16
|
-
* - 损坏读取防御(§3.6):解析失败/形状非法 → 重命名 .corrupt 保留现场 + 按空表
|
|
17
|
-
* 重建 + warn 日志
|
|
18
|
-
* - 终态条目 LRU 上限 50(与单例表对称)
|
|
19
|
-
*
|
|
20
|
-
* 契约改引(u-bte-remove):版本常量 / LRU 上限 / 文件形状自
|
|
21
|
-
* @xyz-agent/extension-protocol background-task.ts 契约引入(此前本地重复定义)。
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
|
-
import {
|
|
25
|
-
import { dirname, join } from "node:path";
|
|
25
|
+
import { join } from "node:path";
|
|
26
26
|
|
|
27
|
+
// 契约常量(LRU 上限)走 index 出口(background-task.ts 契约面);行为原语走
|
|
28
|
+
// background-task 子出口(含 node 内建依赖,不进 index——renderer/core 零触达)
|
|
29
|
+
import { MAX_TERMINAL_REGISTRY_ENTRIES } from "@xyz-agent/extension-protocol";
|
|
27
30
|
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
atomicWriteRegistry,
|
|
32
|
+
readRegistry as readRegistryEntries,
|
|
33
|
+
serializeRegistryFile,
|
|
34
|
+
trimTerminalEntries,
|
|
35
|
+
type RegistryFileLogFn,
|
|
36
|
+
} from "@xyz-agent/extension-protocol/background-task";
|
|
37
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
32
38
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
33
39
|
import { withFileLockSync } from "@zhushanwen/pi-file-lock";
|
|
34
40
|
|
|
35
|
-
import {
|
|
41
|
+
import type { BackgroundTask, RegistryEntry } from "./types.ts";
|
|
36
42
|
|
|
37
43
|
const logger = getLogger("base-tool-enhance");
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const TMP_RADIX = 36;
|
|
42
|
-
const TMP_SLICE_START = 2;
|
|
43
|
-
const TMP_SLICE_END = 10;
|
|
45
|
+
/** protocol registry 原语 → 本包 logger 适配(corrupt 隔离 warn 是排障生命线)。 */
|
|
46
|
+
const registryFileLog: RegistryFileLogFn = (level, event, detail) => logger[level](event, { detail });
|
|
44
47
|
|
|
45
48
|
export function getBaseToolEnhanceDir(dataDir: string): string {
|
|
46
49
|
return join(dataDir, "base-tool-enhance");
|
|
@@ -51,78 +54,13 @@ export function getRegistryPath(dataDir: string, sessionId: string): string {
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
/**
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (typeof item !== "object" || item === null) return false;
|
|
58
|
-
const e = item as Record<string, unknown>;
|
|
59
|
-
return (
|
|
60
|
-
typeof e.taskId === "string" &&
|
|
61
|
-
typeof e.pid === "number" &&
|
|
62
|
-
typeof e.command === "string" &&
|
|
63
|
-
typeof e.outputFile === "string" &&
|
|
64
|
-
typeof e.startedAt === "number" &&
|
|
65
|
-
typeof e.state === "string" &&
|
|
66
|
-
typeof e.ownerPiPid === "number" &&
|
|
67
|
-
typeof e.sessionId === "string"
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** 校验并归一化 registry 文件内容;形状非法返回 undefined(走 corrupt 路径)。 */
|
|
72
|
-
function parseRegistryContent(raw: string): BackgroundTaskRegistryFile | undefined {
|
|
73
|
-
let parsed: unknown;
|
|
74
|
-
try {
|
|
75
|
-
parsed = JSON.parse(raw);
|
|
76
|
-
} catch {
|
|
77
|
-
return undefined;
|
|
78
|
-
}
|
|
79
|
-
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
80
|
-
const { version, entries } = parsed as Record<string, unknown>;
|
|
81
|
-
if (version !== BACKGROUND_TASK_REGISTRY_VERSION || !Array.isArray(entries)) return undefined;
|
|
82
|
-
const valid: RegistryEntry[] = [];
|
|
83
|
-
for (const item of entries) {
|
|
84
|
-
if (isValidRegistryEntry(item)) valid.push(item);
|
|
85
|
-
}
|
|
86
|
-
return { version: BACKGROUND_TASK_REGISTRY_VERSION, entries: valid };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** .corrupt 落点:固定名优先;已存在则带时间戳,不覆盖前一份现场。 */
|
|
90
|
-
function corruptPathFor(registryPath: string): string {
|
|
91
|
-
const base = `${registryPath}.corrupt`;
|
|
92
|
-
return existsSync(base) ? `${base}-${Date.now()}` : base;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* 读取 registry 全量条目。文件不存在 / 读失败 / 解析失败均返回空表(工具面不因
|
|
97
|
-
* registry 问题崩溃);解析失败时重命名 .corrupt 保留现场 + warn(§3.6)。
|
|
57
|
+
* 读取 registry 全量条目(Map 投影薄壳)。文件不存在 / 读失败 / 解析失败均返回
|
|
58
|
+
* 空表(工具面不因 registry 问题崩溃);解析失败由 protocol 原语重命名 .corrupt
|
|
59
|
+
* 保留现场 + warn(§3.6)。
|
|
98
60
|
*/
|
|
99
61
|
export function readRegistry(registryPath: string): Map<string, RegistryEntry> {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
raw = readFileSync(registryPath, "utf8");
|
|
104
|
-
} catch (err) {
|
|
105
|
-
logger.warn("registry read failed, treating as empty", {
|
|
106
|
-
detail: { path: registryPath, err: err instanceof Error ? err.message : String(err) },
|
|
107
|
-
});
|
|
108
|
-
return new Map();
|
|
109
|
-
}
|
|
110
|
-
const parsed = parseRegistryContent(raw);
|
|
111
|
-
if (parsed === undefined) {
|
|
112
|
-
const corruptPath = corruptPathFor(registryPath);
|
|
113
|
-
try {
|
|
114
|
-
renameSync(registryPath, corruptPath);
|
|
115
|
-
logger.warn("registry corrupted, renamed to preserve scene and rebuilt empty", {
|
|
116
|
-
detail: { path: registryPath, corruptPath },
|
|
117
|
-
});
|
|
118
|
-
} catch (err) {
|
|
119
|
-
logger.warn("registry corrupted and rename failed, rebuilding empty in place", {
|
|
120
|
-
detail: { path: registryPath, err: err instanceof Error ? err.message : String(err) },
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
return new Map();
|
|
124
|
-
}
|
|
125
|
-
return new Map(parsed.entries.map((e) => [e.taskId, e]));
|
|
62
|
+
const { entries } = readRegistryEntries(registryPath, registryFileLog);
|
|
63
|
+
return new Map(entries.map((e) => [e.taskId, e] as const));
|
|
126
64
|
}
|
|
127
65
|
|
|
128
66
|
/** BackgroundTask → RegistryEntry(剥离运行时字段 intent/timeoutTimer/child/registryPath)。 */
|
|
@@ -145,34 +83,10 @@ export function taskToRegistryEntry(task: BackgroundTask): RegistryEntry {
|
|
|
145
83
|
};
|
|
146
84
|
}
|
|
147
85
|
|
|
148
|
-
function serializeRegistry(entries: RegistryEntry[]): string {
|
|
149
|
-
const shape: BackgroundTaskRegistryFile = { version: BACKGROUND_TASK_REGISTRY_VERSION, entries };
|
|
150
|
-
return `${JSON.stringify(shape, null, JSON_INDENT)}\n`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** 原子写:tmp(pid+随机段唯一化)+ rename(POSIX/Windows 均原子);失败清理 tmp。 */
|
|
154
|
-
function atomicWriteRegistry(registryPath: string, content: string): void {
|
|
155
|
-
mkdirSync(dirname(registryPath), { recursive: true });
|
|
156
|
-
const tmpPath = `${registryPath}.tmp_${process.pid}_${Math.random().toString(TMP_RADIX).slice(TMP_SLICE_START, TMP_SLICE_END)}`;
|
|
157
|
-
try {
|
|
158
|
-
writeFileSync(tmpPath, content, "utf8");
|
|
159
|
-
renameSync(tmpPath, registryPath);
|
|
160
|
-
} catch (err) {
|
|
161
|
-
try {
|
|
162
|
-
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
163
|
-
} catch (cleanupErr) {
|
|
164
|
-
// tmp 清理失败不掩盖原错误,仅留诊断
|
|
165
|
-
logger.warn("registry tmp cleanup failed", {
|
|
166
|
-
detail: { tmpPath, err: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr) },
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
throw err;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
86
|
/**
|
|
174
87
|
* 写入/更新单条 registry 条目(锁内 RMW:读全量 → 合并同 id 覆盖 → 终态 LRU 50 →
|
|
175
|
-
*
|
|
88
|
+
* 原子写;读 / 裁剪 / 序列化 / 原子写由 protocol 原语承担)。任务登记(running)、
|
|
89
|
+
* killing intent、终态三条路径共用。
|
|
176
90
|
* 失败返回 {success:false}——调用方按「写不进则条目停留 running,runtime 收殓
|
|
177
91
|
* 兜底」处理(M2 只 warn,不重试不阻断主流程)。
|
|
178
92
|
*/
|
|
@@ -181,21 +95,19 @@ export function writeRegistryEntry(
|
|
|
181
95
|
entry: RegistryEntry,
|
|
182
96
|
): { success: boolean; error?: string } {
|
|
183
97
|
const writeMerged = (): void => {
|
|
184
|
-
const
|
|
98
|
+
const { entries } = readRegistryEntries(registryPath, registryFileLog);
|
|
99
|
+
const merged = new Map(entries.map((e) => [e.taskId, e] as const));
|
|
185
100
|
merged.set(entry.taskId, entry);
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
.filter((e) => isTerminalState(e.state))
|
|
189
|
-
.sort((a, b) => (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt));
|
|
190
|
-
const excess = terminal.length - MAX_TERMINAL_REGISTRY_ENTRIES;
|
|
191
|
-
for (let i = 0; i < excess; i++) merged.delete(terminal[i].taskId);
|
|
192
|
-
atomicWriteRegistry(registryPath, serializeRegistry([...merged.values()]));
|
|
101
|
+
const trimmed = trimTerminalEntries([...merged.values()], MAX_TERMINAL_REGISTRY_ENTRIES);
|
|
102
|
+
atomicWriteRegistry(registryPath, serializeRegistryFile(trimmed), registryFileLog);
|
|
193
103
|
};
|
|
194
104
|
try {
|
|
195
105
|
withFileLockSync(registryPath, writeMerged);
|
|
196
106
|
return { success: true };
|
|
197
107
|
} catch (err) {
|
|
198
|
-
|
|
108
|
+
// 锁壳层写失败 warn 是本模块职责(protocol 原语不发该日志——写失败时的
|
|
109
|
+
// 「条目停留 running」降级决策在锁壳)
|
|
110
|
+
const message = toErrorMessage(err);
|
|
199
111
|
logger.warn("registry write failed; entry stays as-is (runtime reaper will collect the orphan)", {
|
|
200
112
|
detail: { path: registryPath, taskId: entry.taskId, err: message },
|
|
201
113
|
});
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* <dataDir>/base-tool-enhance/<sessionId>/<task_id>.log(append fd,不占内存、
|
|
6
6
|
* 无 pipe backpressure)+ child.unref()(pi 不等它)。abort/interrupt 不传播到
|
|
7
7
|
* 后台任务(D15)——execute 立即返回,本模块不接触 signal。
|
|
8
|
+
*
|
|
9
|
+
* D6:本包不强加任务寿命上限(无 TTL/墙钟)——vitest watch、dev server 这类任务
|
|
10
|
+
* 的合法寿命就是「跑到用户叫停」,强加上限会把长任务误判死、完成通知链路随之失效。
|
|
11
|
+
* 寿命出口只有三个:自然退出 / 显式 timeout(LLM 传值或配置 backgroundTimeoutSeconds)
|
|
12
|
+
* / bash_kill。
|
|
8
13
|
*/
|
|
9
14
|
|
|
10
15
|
import { spawn } from "node:child_process";
|
|
@@ -12,9 +17,10 @@ import { mkdirSync, openSync, closeSync, accessSync, constants } from "node:fs";
|
|
|
12
17
|
import { dirname, join } from "node:path";
|
|
13
18
|
import { randomBytes } from "node:crypto";
|
|
14
19
|
|
|
20
|
+
import { getProcessStartTimeSec, killProcessTree, pidStartMatchesRegistered } from "@xyz-agent/extension-protocol/background-task";
|
|
21
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
15
22
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
16
23
|
|
|
17
|
-
import { getProcessStartTimeSec, killProcessTree, pidStartMatchesRegistered } from "../kill-tree.ts";
|
|
18
24
|
import { emitPendingRegister } from "./notify.ts";
|
|
19
25
|
import { ensurePollerRunning } from "./poller.ts";
|
|
20
26
|
import { getRegistryPath, taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
|
|
@@ -165,7 +171,7 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
|
|
|
165
171
|
child.on("error", () => {});
|
|
166
172
|
child.unref();
|
|
167
173
|
} catch (err) {
|
|
168
|
-
const message =
|
|
174
|
+
const message = toErrorMessage(err);
|
|
169
175
|
return { ok: false, error: `Failed to start background command: ${message}` };
|
|
170
176
|
} finally {
|
|
171
177
|
// 子进程已继承 fd 副本(含 spawn 同步失败路径:fd 未被子进程持有),父进程侧
|
|
@@ -176,7 +182,7 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
|
|
|
176
182
|
} catch (err) {
|
|
177
183
|
// 已关闭/不可关闭均不掩盖主流程,仅留诊断
|
|
178
184
|
logger.debug("background spawn output fd close failed", {
|
|
179
|
-
detail: { outputFile, err:
|
|
185
|
+
detail: { outputFile, err: toErrorMessage(err) },
|
|
180
186
|
});
|
|
181
187
|
}
|
|
182
188
|
}
|
|
@@ -253,7 +259,12 @@ function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
|
|
|
253
259
|
// 收尾不受影响。
|
|
254
260
|
return;
|
|
255
261
|
}
|
|
256
|
-
|
|
262
|
+
// 回退路径诊断经 onFallback 注入 logger 适配(ext-simplify-13 D2)
|
|
263
|
+
killProcessTree(task.pid, (step, err) =>
|
|
264
|
+
logger.debug(step, {
|
|
265
|
+
detail: { pid: task.pid, err: toErrorMessage(err) },
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
257
268
|
const marked = markKillingIntent(task.taskId, "timeout");
|
|
258
269
|
if (marked === undefined) return;
|
|
259
270
|
writeRegistryEntry(marked.registryPath, taskToRegistryEntry(marked));
|
|
@@ -265,8 +276,9 @@ function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
|
|
|
265
276
|
|
|
266
277
|
/**
|
|
267
278
|
* 到点 pid 身份校验:true = 登记时的原进程仍占用该 pid(可安全 kill-tree)。
|
|
268
|
-
* 判据(精确比较 / startedAt 秒级降级 / 读不到保守 false)单点定义于
|
|
269
|
-
* 的 pidStartMatchesRegistered(原 reaper.ts
|
|
279
|
+
* 判据(精确比较 / startedAt 秒级降级 / 读不到保守 false)单点定义于
|
|
280
|
+
* extension-protocol 的 pidStartMatchesRegistered(原 reaper.ts 单点,跨端原语
|
|
281
|
+
* 归一后经 background-task 子出口消费——ext-simplify-13)。
|
|
270
282
|
*/
|
|
271
283
|
function isRecordedPidStillOriginal(task: BackgroundTask): boolean {
|
|
272
284
|
const actualStartSec = getProcessStartTimeSec(task.pid);
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* subagent 进程识别(D14
|
|
2
|
+
* subagent 进程识别(bte background 降级判据——生命周期设计 D14 项,commit 8599abd0c)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 判据收敛于 ext-guards isSubagentProcess(`XYZ_AGENT_SUBAGENT === "1"`——引擎
|
|
5
|
+
* spawn 链恒注入的统一标记,ext-simplify-17 D4 重锚定)。命中 = 当前 pi 进程是
|
|
6
|
+
* 引擎链上的真 subagent → 本扩展降级:background:true 被忽略走前台同步语义。
|
|
7
|
+
*
|
|
8
|
+
* [HISTORICAL] 旧判据查 PI_SUBAGENT_* 身份键两枚(任一存在即命中)——引擎协议化
|
|
9
|
+
* 后该键族无写入方,判据失效态(降级实际从未生效),20260914 真机探针双断言证实
|
|
10
|
+
* 后随 D4 重锚。现行事实与口径详见 ext-guards isSubagentProcess JSDoc、设计
|
|
11
|
+
* docs/architecture/ext-simplify-17-shared-extraction.md §3.1 D4、探针报告
|
|
12
|
+
* .tmp/dev-flow/ext-simplify-17-d4-probe.md。
|
|
8
13
|
*
|
|
9
14
|
* 为什么降级:子 agent 内后台化会破坏 workflow 结构化输出契约(预算耗尽时测试未
|
|
10
15
|
* 回);且子进程死后其 registry 目录永远不会再有 session 启动,孤儿无人 reap。
|
|
11
16
|
*/
|
|
12
|
-
|
|
13
|
-
/** subagent-core(session-runner)注入的身份贯穿 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
|
-
}
|
|
17
|
+
export { isSubagentProcess } from "@zhushanwen/pi-ext-guards";
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* 未来新增写路径(如 maintenance 类)必须保持该顺序,违反即 intent 可丢失。
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { trimTerminalEntries } from "@xyz-agent/extension-protocol/background-task";
|
|
32
|
+
|
|
31
33
|
import {
|
|
32
34
|
isActiveState,
|
|
33
35
|
isTerminalState,
|
|
@@ -120,14 +122,17 @@ export function finalizeTask(taskId: string, outcome: FinalizeOutcome): Backgrou
|
|
|
120
122
|
return task;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* 终态条目超上限时按 endedAt 升序淘汰最老(LRU;endedAt 缺失退 startedAt)。
|
|
127
|
+
* 裁剪纯函数单点在 protocol trimTerminalEntries(ext-simplify-13:与 registry 写侧、
|
|
128
|
+
* runtime registry-write 共用同一实现,本地上限经 MAX_TERMINAL_TASKS 传参)。
|
|
129
|
+
*/
|
|
124
130
|
function evictTerminalOverflow(): void {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
taskTable.delete(terminal[i].taskId);
|
|
131
|
+
const kept = trimTerminalEntries(getAllTasks(), MAX_TERMINAL_TASKS);
|
|
132
|
+
if (kept.length === taskTable.size) return;
|
|
133
|
+
const keptIds = new Set(kept.map((t) => t.taskId));
|
|
134
|
+
for (const id of [...taskTable.keys()]) {
|
|
135
|
+
if (!keptIds.has(id)) taskTable.delete(id);
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
|
package/src/bash-kill-tool.ts
CHANGED
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { getProcessStartTimeSec, isPidAlive, killProcessTree } from "@xyz-agent/extension-protocol/background-task";
|
|
21
22
|
import { Type } from "typebox";
|
|
22
23
|
|
|
23
|
-
import {
|
|
24
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
25
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
26
|
+
|
|
24
27
|
import { ensurePollerRunning } from "./background/poller.ts";
|
|
25
28
|
import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "./background/registry.ts";
|
|
26
29
|
import { getAllTasks, markKillingIntent } from "./background/task-store.ts";
|
|
@@ -38,6 +41,8 @@ const BASH_KILL_DESCRIPTION = [
|
|
|
38
41
|
/** JSON 输出缩进(registry.ts 同款)。 */
|
|
39
42
|
const JSON_INDENT = 2;
|
|
40
43
|
|
|
44
|
+
const logger = getLogger("base-tool-enhance");
|
|
45
|
+
|
|
41
46
|
function textResult(text: string): AgentToolResult<unknown> {
|
|
42
47
|
return { content: [{ type: "text", text }], details: undefined };
|
|
43
48
|
}
|
|
@@ -77,11 +82,15 @@ export function createBashKillToolDefinition() {
|
|
|
77
82
|
}
|
|
78
83
|
// registry-only 活跃条目 = 他进程任务(本进程活跃任务必在单例表)——跨进程
|
|
79
84
|
// 不可 kill:处置权归发起进程;属主若已死,孤儿由 xyz-agent runtime 在
|
|
80
|
-
// session 销毁时或启动期兜底扫描时收殓(属主判定,u-bte-remove
|
|
85
|
+
// session 销毁时或启动期兜底扫描时收殓(属主判定,u-bte-remove 下沉;
|
|
86
|
+
// 纯 CLI 独立安装无 runtime 收殓兜底,孤儿自然退出或手动杀——E11 已
|
|
87
|
+
// 接受边界,hint 按形态分流,不写死 runtime 承诺)
|
|
81
88
|
return killedFalse(
|
|
82
89
|
"cross-process running task owned by another pi process",
|
|
83
90
|
"the task is managed by the pi process that started it (bash_kill from that session); " +
|
|
84
|
-
"if that process is gone, the
|
|
91
|
+
"if that process is gone, the orphan keeps running until it exits on its own; " +
|
|
92
|
+
"inside the xyz-agent runtime it is collected when the owner's session ends or on app restart; " +
|
|
93
|
+
"standalone pi: kill the process group manually (kill -- -<pgid>; the registered task pid is the pgid) if it lingers",
|
|
85
94
|
);
|
|
86
95
|
}
|
|
87
96
|
if (isTerminalState(fromStore.state)) {
|
|
@@ -125,7 +134,13 @@ export function createBashKillToolDefinition() {
|
|
|
125
134
|
if (marked !== undefined) {
|
|
126
135
|
writeRegistryEntry(marked.registryPath, taskToRegistryEntry(marked));
|
|
127
136
|
}
|
|
128
|
-
|
|
137
|
+
// 回退路径诊断经 onFallback 注入 logger 适配(ext-simplify-13 D2:进程原语
|
|
138
|
+
// 零日志依赖,协议侧 step 标识符 + 本包落盘通道)
|
|
139
|
+
killProcessTree(fromStore.pid, (step, err) =>
|
|
140
|
+
logger.debug(step, {
|
|
141
|
+
detail: { pid: fromStore.pid, err: toErrorMessage(err) },
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
129
144
|
// 轮询器确保在跑:边沿收尾(写终态)依赖它
|
|
130
145
|
ensurePollerRunning();
|
|
131
146
|
return textResult(
|
package/src/bash-output-tool.ts
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
|
|
15
15
|
import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { readOutputTail } from "@xyz-agent/extension-protocol/background-task";
|
|
17
18
|
import { Type } from "typebox";
|
|
18
19
|
|
|
19
|
-
import {
|
|
20
|
+
import { TAIL_MAX_BYTES, TAIL_MAX_LINES } from "./background/output-tail.ts";
|
|
20
21
|
import { getRegistryPath, readRegistry } from "./background/registry.ts";
|
|
21
22
|
import { truncateCommand } from "./background/spawn-background.ts";
|
|
22
23
|
import { getAllTasks } from "./background/task-store.ts";
|
|
@@ -88,7 +89,10 @@ function findTask(
|
|
|
88
89
|
|
|
89
90
|
/** 详情视图:tail 输出(丢失时 "<lost>",§3.6)+ 终态字段按存在性展开。 */
|
|
90
91
|
function taskDetailText(task: BackgroundTask | RegistryEntry): string {
|
|
91
|
-
|
|
92
|
+
// protocol 原语返回 {text, truncated};50KB/2000 行口径(本包产品决策)在此传参。
|
|
93
|
+
// 返回 JSON 的 `output` 字段名是 LLM 可见契约(工具 schema/描述零变化),仅在
|
|
94
|
+
// 此处从原语的 text 投影,ext-simplify-13 D2 有意内部变化
|
|
95
|
+
const tail = readOutputTail(task.outputFile, { maxBytes: TAIL_MAX_BYTES, maxLines: TAIL_MAX_LINES });
|
|
92
96
|
const detail = {
|
|
93
97
|
task_id: task.taskId,
|
|
94
98
|
state: task.state,
|
|
@@ -96,7 +100,7 @@ function taskDetailText(task: BackgroundTask | RegistryEntry): string {
|
|
|
96
100
|
...(task.reason !== undefined ? { reason: task.reason } : {}),
|
|
97
101
|
startedAt: task.startedAt,
|
|
98
102
|
...(task.durationMs !== undefined ? { durationMs: task.durationMs } : {}),
|
|
99
|
-
output: tail?.
|
|
103
|
+
output: tail?.text ?? "<lost>",
|
|
100
104
|
truncated: tail?.truncated ?? false,
|
|
101
105
|
outputFile: task.outputFile,
|
|
102
106
|
};
|
package/src/bash-tool.ts
CHANGED
|
@@ -148,6 +148,9 @@ export function createBashOverrideToolDefinition() {
|
|
|
148
148
|
// (label/promptSnippet/promptGuidelines/renderCall/renderResult 等)全部随
|
|
149
149
|
// delegate 透传,cwd 重建 delegate 时静态闭包引用不变(render 闭包与 cwd 无关)
|
|
150
150
|
...initial,
|
|
151
|
+
// D2(禁止改名):桌面侧六处按工具 key 'bash' 硬编码(truncate-tool-output、
|
|
152
|
+
// useToolMeta、block-icon、pi-preset BUILTIN_TOOLS 与 deniedTools、permission
|
|
153
|
+
// extractCommand)——改名后这些消费面全部失配,审批退化为逐次弹窗、渲染错位
|
|
151
154
|
name: initial.name, // "bash":同名覆盖内置工具(agent-session _refreshToolRegistry 后注册者胜)
|
|
152
155
|
description: ENHANCED_BASH_DESCRIPTION,
|
|
153
156
|
parameters: enhancedBashSchema,
|
package/src/config.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { getConfigPath as getLlmSharedConfigPath, loadConfig } from "@zhushanwen/pi-llm-shared";
|
|
14
|
+
import { isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
14
15
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
15
16
|
|
|
16
17
|
import { DEFAULT_MAX_CONCURRENT_BACKGROUND } from "./background/spawn-background.ts";
|
|
@@ -51,10 +52,6 @@ export function getConfigFilePath(): string {
|
|
|
51
52
|
return getLlmSharedConfigPath(CONFIG_PKG);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
55
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
55
|
function describeValue(value: unknown): string {
|
|
59
56
|
return typeof value === "string" ? JSON.stringify(value) : String(value);
|
|
60
57
|
}
|
|
@@ -109,7 +106,7 @@ function normalizeForceBackgroundPatterns(raw: unknown): string[] {
|
|
|
109
106
|
} catch (err) {
|
|
110
107
|
logger.warn(
|
|
111
108
|
`Config key 'forceBackgroundPatterns[${index}]' is not a valid regex (${item}), dropped: ` +
|
|
112
|
-
`${
|
|
109
|
+
`${toErrorMessage(err)} (${getLlmSharedConfigPath(CONFIG_PKG)}).`,
|
|
113
110
|
);
|
|
114
111
|
return;
|
|
115
112
|
}
|
|
@@ -138,7 +135,7 @@ function normalizeMaxConcurrentBackground(raw: unknown): number {
|
|
|
138
135
|
* 问题在这里就地消化)。未知键忽略(前向兼容)。
|
|
139
136
|
*/
|
|
140
137
|
export function normalizeBaseToolEnhanceConfig(raw: unknown): BaseToolEnhanceConfig {
|
|
141
|
-
if (!
|
|
138
|
+
if (!isRecord(raw)) {
|
|
142
139
|
if (raw !== undefined && raw !== null) {
|
|
143
140
|
logger.warn(
|
|
144
141
|
`Config root is not a JSON object (${describeValue(raw)}), using all defaults ` +
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
SessionStartEvent,
|
|
21
21
|
} from "@earendil-works/pi-coding-agent";
|
|
22
22
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
23
24
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
24
25
|
|
|
25
26
|
import { createBashOutputToolDefinition } from "./bash-output-tool.ts";
|
|
@@ -90,7 +91,7 @@ function runSessionStartMaintenance(
|
|
|
90
91
|
} catch (err) {
|
|
91
92
|
// 对账失败无害:僵尸 register 停留差集,下一 session_start 幂等重查
|
|
92
93
|
logger.warn("session_start pending reconcile failed; zombies retried next session start", {
|
|
93
|
-
detail: { err:
|
|
94
|
+
detail: { err: toErrorMessage(err) },
|
|
94
95
|
});
|
|
95
96
|
}
|
|
96
97
|
}
|
package/src/tool-error-audit.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* 迁移约定(与原实现逐字段一致,M1 验收点):
|
|
5
5
|
* - 事件名 = "tool_execution_end"(pi 0.84.1 实装无 "tool_error" 事件,工具报错以
|
|
6
6
|
* ToolExecutionEndEvent.isError=true 表达——以 dist types.d.ts 为准)
|
|
7
|
-
* - customType = "unified-hooks:tool-error"
|
|
8
|
-
*
|
|
7
|
+
* - customType = "unified-hooks:tool-error"(保持原值:无代码消费方;保持旧名是
|
|
8
|
+
* 历史 entry 可查询性承诺(01 号设计 P-protocol / README 承接表),废弃包名残留
|
|
9
|
+
* 为已接受代价——ext-simplify-13 D4)
|
|
9
10
|
* - entry 形态 = { timestamp, toolName, toolCallId, errorText },errorText 取不到时 null
|
|
10
11
|
*
|
|
11
12
|
* 原实现的设计决策一并继承:不调 ctx.ui.notify——tool error 已在对话流里
|