@zhushanwen/pi-base-tool-enhance 0.3.0 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zhushanwen/pi-base-tool-enhance
2
2
 
3
- 同名 override pi 内置 bash 工具的增强层:前台行为 100% 委托 pi 官方工厂(`createBashToolDefinition`),增量提供 background 模式、强制后台白名单与双模式可配置超时。承接已废弃 unified-hooks 的全部能力(测试类拦截 → force-test 白名单;网络类挂死保护可配置前台默认超时弱承接;工具报错审计 → tool_error 审计 hook,entry customType 保持 `unified-hooks:tool-error` 历史连续)。
3
+ 同名 override pi 内置 bash 工具的增强层:前台行为 100% 委托 pi 官方工厂(`createBashToolDefinition`),增量提供 background 模式、强制后台白名单与双模式可配置超时。承接已废弃 unified-hooks 的核心能力(测试类拦截式 guard → force-test 白名单命中自动转后台,匹配语义较原 guard 有调整、登记于 force-patterns.ts;网络类挂死保护 可配置前台默认超时,弱承接;工具报错审计 → tool_error 审计 hook,entry customType 保持 `unified-hooks:tool-error` 历史连续)。
4
4
 
5
5
  设计文档(SSOT):`docs/design/base-tool-enhance.md`。
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-base-tool-enhance",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Base tool enhancement - override builtin bash tool: foreground delegates to pi official factory, incremental background mode (design: docs/design/base-tool-enhance.md)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -28,18 +28,19 @@
28
28
  "skills/"
29
29
  ],
30
30
  "dependencies": {
31
- "@zhushanwen/pi-extension-logger": "0.3.0",
32
- "@zhushanwen/pi-file-lock": "0.1.2",
33
- "@zhushanwen/pi-llm-shared": "0.4.1"
31
+ "@xyz-agent/extension-protocol": "0.8.0",
32
+ "@zhushanwen/pi-extension-logger": "0.4.0",
33
+ "@zhushanwen/pi-file-lock": "0.2.0",
34
+ "@zhushanwen/pi-llm-shared": "0.5.0"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@vitest/coverage-v8": "^4.1.9",
37
38
  "vitest": "^4.1.8"
38
39
  },
39
40
  "peerDependencies": {
40
- "@earendil-works/pi-coding-agent": "^0.84.1",
41
+ "@earendil-works/pi-coding-agent": "^0.84.4",
41
42
  "typebox": "*",
42
- "@zhushanwen/pi-pending-notifications": "0.5.0"
43
+ "@zhushanwen/pi-pending-notifications": "0.5.2"
43
44
  },
44
45
  "peerDependenciesMeta": {
45
46
  "@zhushanwen/pi-pending-notifications": {
@@ -177,7 +177,7 @@ describe("real spawn lifecycle (poll edge finalization)", () => {
177
177
  expect(seen[0]).toBe(`${spawned.task.taskId}:exited:natural`);
178
178
  });
179
179
 
180
- it("spawned entry carries pidStartTime for reaper precise comparison (M5→M3)", async () => {
180
+ it("spawned entry carries pidStartTime for kill-side precise comparison (M5→M3)", async () => {
181
181
  const spawned = spawnBg("sleep 30");
182
182
  if (!spawned.ok) throw new Error(spawned.error);
183
183
  const { task } = spawned;
@@ -232,7 +232,7 @@ describe("bash_output tool", () => {
232
232
 
233
233
  it("P0-1: list merges only TERMINAL registry entries — running entries stay invisible (§3.5)", async () => {
234
234
  // 独立 session 目录:单例表空 + registry 含 running 与 exited 两条他进程条目
235
- // (模拟 resume 被强杀 session:reaper 转终态前的窗口里 registry 残留 running)
235
+ // (模拟 resume 被强杀 session:runtime 收殓转终态前的窗口里 registry 残留 running)
236
236
  const sid = "sess-p0-list";
237
237
  writeRegistryEntry(getRegistryPath(DATA_DIR, sid), makeRegistryEntry({ taskId: "bt-xrun", state: "running" }));
238
238
  writeRegistryEntry(
@@ -361,7 +361,8 @@ describe("bash_kill tool (killing intent, single-point finalization)", () => {
361
361
  };
362
362
  expect(result.killed).toBe(false);
363
363
  expect(result.reason).toBe("cross-process running task owned by another pi process");
364
- expect(result.hint).toContain("reaper");
364
+ // 收殓已下沉 runtime(u-bte-remove):hint 指向 runtime 收殓而非 reaper
365
+ expect(result.hint).toContain("xyz-agent runtime");
365
366
  expect(killTreeCalls).not.toContain(foreignPid); // 未发 kill 信号
366
367
  });
367
368
 
@@ -63,7 +63,7 @@ describe("baseToolEnhanceExtension entry", () => {
63
63
  expect(pi.on).toHaveBeenCalledWith("tool_execution_end", expect.any(Function));
64
64
  });
65
65
 
66
- it("registers a session_start handler (reaper + reconcile chain, M5/M3)", () => {
66
+ it("registers a session_start handler (reconcile chain, M3)", () => {
67
67
  setupOfficialFactory();
68
68
  const pi = createMockPi();
69
69
 
@@ -73,10 +73,10 @@ describe("baseToolEnhanceExtension entry", () => {
73
73
  });
74
74
  });
75
75
 
76
- describe("session_start chain: reaper first, reconcile after (M3)", () => {
77
- it("settles a zombie register via appendEntry on the SAME pi reference after reaper", async () => {
76
+ describe("session_start chain: reconcile (M3, reap sunk into runtime by u-bte-remove)", () => {
77
+ it("settles a zombie register via appendEntry on the SAME pi reference", async () => {
78
78
  setupOfficialFactory();
79
- // 独立临时 dataDir:reaper 对空目录 no-op,registry 预置终态僵尸条目
79
+ // 独立临时 dataDir(维护链只读不扫:收殓已下沉 runtime),registry 预置终态僵尸条目
80
80
  const dataDir = mkdtempSync(join(tmpdir(), "bte-index-"));
81
81
  dataDirRef.dir = dataDir;
82
82
  try {
@@ -99,7 +99,9 @@ describe("session_start chain: reaper first, reconcile after (M3)", () => {
99
99
  ctx: { sessionManager: { getSessionId: () => string; getEntries: () => unknown[] } },
100
100
  ) => void;
101
101
  expect(handler).toBeDefined();
102
- handler(undefined, {
102
+ // pi runner emit session_start 必带事件(SessionStartEvent.reason 必填,
103
+ // pi 0.84.4 实装核实)——维护链入口日志消费 reason
104
+ handler({ type: "session_start", reason: "resume" }, {
103
105
  sessionManager: {
104
106
  getSessionId: () => sessionId,
105
107
  getEntries: () => [
@@ -111,7 +113,7 @@ describe("session_start chain: reaper first, reconcile after (M3)", () => {
111
113
  },
112
114
  });
113
115
 
114
- // handler 是 fire-and-forget(内部 await reaper 后同步对账)——等待落定
116
+ // handler 同步直跑对账(u-bte-remove 后无 reap await)——等待落定
115
117
  await vi.waitFor(() =>
116
118
  expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
117
119
  id: "bt-1700000000-idx001",
@@ -1,11 +1,18 @@
1
1
  // src/__tests__/kill-tree.test.ts —— 进程树 kill 真实进程验证(POSIX 进程组语义)
2
+ // + pid 身份判据(getProcessStartTimeSec / pidStartMatchesRegistered——原 reaper.test.ts
3
+ // 覆盖平移:收殓下沉 runtime 后 helper 移入本文件,kill 侧自防御仍消费)
2
4
  import { spawn } from "node:child_process";
3
5
 
4
6
  import { describe, expect, it, vi } from "vitest";
5
7
 
6
8
  vi.setConfig({ testTimeout: 20000 });
7
9
 
8
- import { isPidAlive, killProcessTree } from "../kill-tree.ts";
10
+ import {
11
+ getProcessStartTimeSec,
12
+ isPidAlive,
13
+ killProcessTree,
14
+ pidStartMatchesRegistered,
15
+ } from "../kill-tree.ts";
9
16
 
10
17
  function sleep(ms: number): Promise<void> {
11
18
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -74,3 +81,44 @@ describe("killProcessTree (process group semantics)", () => {
74
81
  expect(() => killProcessTree(pid)).not.toThrow();
75
82
  });
76
83
  });
84
+
85
+ describe("getProcessStartTimeSec (platform probe sanity)", () => {
86
+ it("returns a sane epoch-seconds value for a live process", () => {
87
+ const sec = getProcessStartTimeSec(process.pid);
88
+ expect(sec).toBeDefined();
89
+ const nowSec = Math.floor(Date.now() / 1000);
90
+ // 本进程必然启动于 [now - 10min, now + 5s](时钟容差)
91
+ expect(sec!).toBeGreaterThan(nowSec - 600);
92
+ expect(sec!).toBeLessThan(nowSec + 5);
93
+ });
94
+
95
+ it("returns undefined for a dead pid", async () => {
96
+ const child = spawnDetached("sleep 0.1");
97
+ const pid = child.pid;
98
+ if (pid === undefined) throw new Error("no pid");
99
+ await sleep(500); // 等退出 + libuv reap
100
+ expect(getProcessStartTimeSec(pid)).toBeUndefined();
101
+ });
102
+ });
103
+
104
+ describe("pidStartMatchesRegistered (pid identity predicate, §3.6)", () => {
105
+ it("exact match on registered pidStartTime authorizes (precise path)", () => {
106
+ expect(pidStartMatchesRegistered(1000, 1000, 1_700_000_000_000)).toBe(true);
107
+ });
108
+
109
+ it("registered mismatch (pid reuse suspicion) rejects", () => {
110
+ expect(pidStartMatchesRegistered(2000, 1000, 1_700_000_000_000)).toBe(false);
111
+ });
112
+
113
+ it("missing registration degrades to startedAt-seconds bound: original process authorizes", () => {
114
+ // 登记发生在 spawn 之后 → 原进程 start time 必然 ≤ floor(startedAt/1000)
115
+ const startedAtMs = 1_700_000_123_456; // floor = 1_700_000_123
116
+ expect(pidStartMatchesRegistered(1_700_000_123, undefined, startedAtMs)).toBe(true);
117
+ expect(pidStartMatchesRegistered(1_700_000_122, undefined, startedAtMs)).toBe(true);
118
+ });
119
+
120
+ it("missing registration degrades: newer-than-registration start time rejects (reuse)", () => {
121
+ const startedAtMs = 1_700_000_123_456;
122
+ expect(pidStartMatchesRegistered(1_700_000_124, undefined, startedAtMs)).toBe(false);
123
+ });
124
+ });
@@ -0,0 +1,164 @@
1
+ // src/__tests__/maintenance-once.test.ts —— 收殓下沉后的 session_start 维护链
2
+ // 语义(实施单元 u-bte-remove,设计 docs/design/file-lock-unification-and-reaper-
3
+ // sink.md §3.2 D2「extension 删 session_start reaper」/ §3.3 D3 粒度段 / §4 S6
4
+ // 「批 2 后 reap 类操作不再执行」):
5
+ // - reconcilePendingEntries 是 session 级豁免类,每 session_start 派发都执行
6
+ // (startup/resume/new 多派发 ×N——含 factory 二调 handler 累积的真实形态)
7
+ // - 触发面消失:reaper.ts 模块文件已删除(文件级断言,见首条用例)+ 维护链不再触发
8
+ // 全局文件锁(withFileLock——原 reapOrphanedTasks 的 reaper.lock 路径)
9
+ // - 入口无条件 debug 日志按派发次数出现,detail 仅含 reason(reapSkipped 字段
10
+ // 随 reap 调用移除,S6 观测通道语义更新)
11
+ // 断言方式:reconcile / logger / file-lock 全部间谍注入,观察调用次数与参数。
12
+ import { existsSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ import { describe, expect, it, vi, beforeEach } from "vitest";
17
+
18
+ const { reconcileMock, loggerMock, officialBashFactoryMock, dataDirRef, withFileLockMock } =
19
+ vi.hoisted(() => ({
20
+ reconcileMock: vi.fn(),
21
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
22
+ officialBashFactoryMock: vi.fn(),
23
+ dataDirRef: { dir: "/tmp/bte-fake-agent-dir" },
24
+ withFileLockMock: vi.fn(),
25
+ }));
26
+
27
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
28
+ createBashToolDefinition: officialBashFactoryMock,
29
+ getAgentDir: () => dataDirRef.dir,
30
+ }));
31
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
32
+ getLogger: () => loggerMock,
33
+ }));
34
+ // 全局扫描探针:withFileLock 是原 reapOrphanedTasks 持 reaper.lock 的唯一锁路径
35
+ // (registry 写走的是 withFileLockSync)——维护链触发它 = 全局扫描回流
36
+ vi.mock("@zhushanwen/pi-file-lock", () => ({
37
+ withFileLock: withFileLockMock,
38
+ withFileLockSync: vi.fn(() => {
39
+ throw new Error("unexpected registry write in maintenance chain");
40
+ }),
41
+ }));
42
+ vi.mock("../background/pending-reconcile.ts", () => ({
43
+ reconcilePendingEntries: reconcileMock,
44
+ }));
45
+
46
+ import type { ExtensionAPI, SessionStartEvent } from "@earendil-works/pi-coding-agent";
47
+
48
+ function createMockPi() {
49
+ return {
50
+ registerTool: vi.fn(),
51
+ on: vi.fn(),
52
+ appendEntry: vi.fn(),
53
+ events: { emit: vi.fn(), on: vi.fn() },
54
+ sendMessage: vi.fn(),
55
+ };
56
+ }
57
+
58
+ function getSessionStartHandler(pi: ReturnType<typeof createMockPi>) {
59
+ const handler = pi.on.mock.calls.find((call) => call[0] === "session_start")?.[1];
60
+ expect(handler).toBeTypeOf("function");
61
+ return handler as (event: SessionStartEvent, ctx: unknown) => void;
62
+ }
63
+
64
+ function makeEvent(reason: SessionStartEvent["reason"]): SessionStartEvent {
65
+ return { type: "session_start", reason };
66
+ }
67
+
68
+ function makeCtx() {
69
+ return {
70
+ sessionManager: { getSessionId: () => "sid-once", getEntries: () => [] },
71
+ };
72
+ }
73
+
74
+ async function loadExtension() {
75
+ return import("../index.ts");
76
+ }
77
+
78
+ beforeEach(() => {
79
+ reconcileMock.mockReset();
80
+ // 官方 bash 工厂返回最小工具定义(override 定义 spread initial.name)
81
+ officialBashFactoryMock.mockReset();
82
+ officialBashFactoryMock.mockReturnValue({
83
+ name: "bash",
84
+ label: "bash",
85
+ description: "official",
86
+ parameters: {},
87
+ execute: vi.fn(),
88
+ });
89
+ withFileLockMock.mockReset();
90
+ loggerMock.debug.mockClear();
91
+ loggerMock.warn.mockClear();
92
+ loggerMock.error.mockClear();
93
+ });
94
+
95
+ describe("session_start maintenance chain after reap sink (u-bte-remove)", () => {
96
+ it("no longer ships a reaper: the module is gone from the extension (trigger surface removed)", () => {
97
+ // 等价改写(fallow unresolved-import):原断言动态 import 已删除模块期待
98
+ // rejects——静态分析无法理解故意性。模块文件不存在 ⟹ import 必 rejects,
99
+ // 蕴含成立,断言价值不变(触发面消失),文件级判定更直接。路径用 join 逐段
100
+ // 拼装:fallow 会把 ../reaper.ts 形态的相对路径字符串按 import specifier 扫描
101
+ const reaperModulePath = join(dirname(fileURLToPath(import.meta.url)), "..", "reaper.ts");
102
+ expect(existsSync(reaperModulePath)).toBe(false);
103
+ });
104
+
105
+ it("runs reconcilePendingEntries on EVERY session_start dispatch (session-scoped exempt, D3)", async () => {
106
+ const mod = await loadExtension();
107
+ const pi = createMockPi();
108
+ mod.default(pi as unknown as ExtensionAPI);
109
+ const handler = getSessionStartHandler(pi);
110
+ const ctx = makeCtx();
111
+
112
+ // 多派发覆盖真实形态:startup + resume 双发(桌面端每次激活)+ new;
113
+ // factory 二调 handler 累积时同一事件被多组 handler 各跑一次,同理多次执行
114
+ handler(makeEvent("startup"), ctx);
115
+ handler(makeEvent("resume"), ctx);
116
+ handler(makeEvent("new"), ctx);
117
+
118
+ expect(reconcileMock).toHaveBeenCalledTimes(3);
119
+ expect(reconcileMock).toHaveBeenNthCalledWith(1, expect.anything(), dataDirRef.dir, "sid-once", []);
120
+ expect(reconcileMock).toHaveBeenLastCalledWith(expect.anything(), dataDirRef.dir, "sid-once", []);
121
+ // 无全局扫描:维护链不触发原 reaper 的跨进程锁路径,也无告警
122
+ expect(withFileLockMock).not.toHaveBeenCalled();
123
+ expect(loggerMock.warn).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it("emits the unconditional entry debug log per dispatch with reason only (S6 observation channel)", async () => {
127
+ const mod = await loadExtension();
128
+ const pi = createMockPi();
129
+ mod.default(pi as unknown as ExtensionAPI);
130
+ const handler = getSessionStartHandler(pi);
131
+ const ctx = makeCtx();
132
+
133
+ handler(makeEvent("startup"), ctx);
134
+ handler(makeEvent("resume"), ctx);
135
+
136
+ // 每次派发恰好一条入口日志(无条件 = 不以任何条件短路),reason 取自事件;
137
+ // reapSkipped 字段已随 reap 下沉移除(u-bte-remove 变更登记,S6「批 2 后
138
+ // reap 类操作不再执行」)
139
+ expect(loggerMock.debug).toHaveBeenCalledTimes(2);
140
+ expect(loggerMock.debug).toHaveBeenNthCalledWith(1, "session_start maintenance dispatch", {
141
+ detail: { reason: "startup" },
142
+ });
143
+ expect(loggerMock.debug).toHaveBeenNthCalledWith(2, "session_start maintenance dispatch", {
144
+ detail: { reason: "resume" },
145
+ });
146
+ });
147
+
148
+ it("keeps reconcile failures non-fatal: warn logged, dispatch chain not throwing", async () => {
149
+ reconcileMock.mockImplementation(() => {
150
+ throw new Error("reconcile boom");
151
+ });
152
+ const mod = await loadExtension();
153
+ const pi = createMockPi();
154
+ mod.default(pi as unknown as ExtensionAPI);
155
+ const handler = getSessionStartHandler(pi);
156
+
157
+ // 对账抛错被维护链吞掉记 warn(僵尸停留差集,下一 session_start 幂等重查)
158
+ expect(() => handler(makeEvent("resume"), makeCtx())).not.toThrow();
159
+ expect(loggerMock.warn).toHaveBeenCalledWith(
160
+ "session_start pending reconcile failed; zombies retried next session start",
161
+ expect.objectContaining({ detail: expect.objectContaining({ err: "reconcile boom" }) }),
162
+ );
163
+ });
164
+ });
@@ -135,7 +135,7 @@ describe("reconcile scenario ①: graceful-exit leftover (registry exited, entry
135
135
  });
136
136
  });
137
137
 
138
- describe("reconcile scenario ②: reaper-only orphan (registry orphaned, session file untouched)", () => {
138
+ describe("reconcile scenario ②: collector-only orphan (registry orphaned, session file untouched)", () => {
139
139
  it("appends unregister with cancelled for orphaned entries", () => {
140
140
  const entry = makeRegistryEntry({ state: "orphaned" });
141
141
  writeRegistryEntry(getRegistryPath(DATA_DIR, SESSION_ID), entry);
@@ -5,9 +5,10 @@ import { join } from "node:path";
5
5
 
6
6
  import { afterEach, describe, expect, it } from "vitest";
7
7
 
8
+ import { MAX_TERMINAL_REGISTRY_ENTRIES } from "@xyz-agent/extension-protocol";
9
+
8
10
  import {
9
11
  getRegistryPath,
10
- MAX_TERMINAL_REGISTRY_ENTRIES,
11
12
  readRegistry,
12
13
  taskToRegistryEntry,
13
14
  writeRegistryEntry,
@@ -4,7 +4,7 @@
4
4
  * 职责:对「session entries 差集显示 active、但任务已终态」的 bt- 任务补写
5
5
  * pending:unregister entry。覆盖三类 otherwise 悬空场景(设计原文):
6
6
  * ① 进程 graceful 退出收殓时 pi API 已不可用,unregister 没写成 entry;
7
- * ② 强杀后 reaper 只改 registry(标 orphaned),碰不了 session 文件;
7
+ * ② 强杀后收殓侧只改 registry(标 orphaned),碰不了 session 文件;
8
8
  * ③ fork 后任务完成通知写进新 session,旧 session 文件的 register 成僵尸。
9
9
  *
10
10
  * 收尾写法(权威路径):直接 pi.appendEntry("pending:unregister", {id, reason,
@@ -17,11 +17,12 @@
17
17
  *
18
18
  * 终态判据:registry state ∈ {exited, orphaned},或(state=running/killing 且
19
19
  * kill(pid,0) 判死:收殓/写盘失败遗留的 running 条目按事实终态处理)。
20
- * 不改 registry——终态写入归 reaper/轮询器(单点归属),对账只清 pending 侧。
20
+ * 不改 registry——终态写入归收殓侧(runtime reaper)/轮询器(单点归属),对账只清
21
+ * pending 侧。
21
22
  *
22
- * 执行顺序(index.ts session_start 链内):reaper 先、对账后——先按属主判定处置
23
- * 孤儿/补写 registry 终态,对账随后读到正确终态;即使颠倒也无静默错误(对账先见
24
- * running+pid 活则不动作,下一 session_start 兜底)。
23
+ * 执行链(index.ts session_start 链内):收殓下沉 runtime 后(u-bte-remove)本链
24
+ * 仅剩对账——「对账见 running+pid 活则不动作,下一 session_start 兜底」的幂等
25
+ * 语义覆盖孤儿终态由 runtime 异步写入的时序窗口。
25
26
  */
26
27
 
27
28
  import { getLogger } from "@zhushanwen/pi-extension-logger";
@@ -50,10 +50,6 @@ export function stopPoller(): void {
50
50
  }
51
51
  }
52
52
 
53
- export function isPollerRunning(): boolean {
54
- return pollTimer !== undefined;
55
- }
56
-
57
53
  function pollTick(): void {
58
54
  const active = getActiveTasks();
59
55
  if (active.length === 0) {
@@ -18,12 +18,13 @@
18
18
  * 挂起进程,pi 的 ignore 语义完全失效
19
19
  * - rpc/print 模式 Ctrl+C:pi 无 SIGINT listener(dist 全量 grep 唯一注册点在 TUI
20
20
  * 挂起态)→ 默认处置进程即死。探针实测:无 listener 的信号默认终止**不触发
21
- * exit 事件**,同步收殓在该路径本就不可达;孤儿由 M5 reaper 属主判定收殓
22
- * (与 SIGKILL 强杀同类,设计内兜底)
21
+ * exit 事件**,同步收殓在该路径本就不可达;孤儿由 xyz-agent runtime 收殓器按
22
+ * 属主判定收殓(u-bte-remove 下沉后;与 SIGKILL 强杀同类,设计内兜底)
23
23
  * - cleanup-only listener 更不可取:注册即抑制默认终止,rpc 前台 Ctrl+C 后 pi 变
24
24
  * 成杀不死的僵尸进程
25
25
  *
26
- * 子进程死后 registry 目录无人再扫(reaper M5)——本单元不管。
26
+ * 子进程死后 registry 目录由 xyz-agent runtime 启动期兜底扫描覆盖(收殓下沉)——
27
+ * 本单元不管。
27
28
  */
28
29
 
29
30
  import { getLogger } from "@zhushanwen/pi-extension-logger";
@@ -56,7 +57,7 @@ export function installProcessExitGuard(): void {
56
57
  // SIGTERM:只收殓不 exit——pi rpc-mode 的 SIGTERM handler(先注册先跑)走自己的
57
58
  // shutdown 流程,最终 process.exit 触发下方 exit 兜底(幂等跳过)。
58
59
  // SIGINT 刻意不挂 handler——会破坏 pi TUI 挂起态的 ignoreSigint 语义,且 rpc
59
- // 默认终止路径 exit 事件本就不触发、孤儿归 M5 reaper 兜底(完整论证见文件头
60
+ // 默认终止路径 exit 事件本就不触发、孤儿归 runtime 收殓兜底(完整论证见文件头
60
61
  // 「不挂 SIGINT handler」专段)
61
62
  process.once("SIGTERM", cleanup);
62
63
  // 同步兜底:exit handler 里只能做同步收殓(kill-tree 同步 + registry 同步原子写)
@@ -77,7 +78,7 @@ export function reapBackgroundTasksNow(): void {
77
78
  try {
78
79
  killProcessTree(task.pid);
79
80
  } catch (err) {
80
- // 单条 kill 失败不阻断其余条目收殓;进程将退出,残余由 M5 reaper 兜底
81
+ // 单条 kill 失败不阻断其余条目收殓;进程将退出,残余由 runtime 收殓兜底
81
82
  logger.warn("kill-tree failed during process-exit reap", {
82
83
  detail: { taskId: task.taskId, pid: task.pid, err: err instanceof Error ? err.message : String(err) },
83
84
  });
@@ -2,23 +2,33 @@
2
2
  * registry.json 持久化读写(per-sessionId 目录,D8)。
3
3
  *
4
4
  * 路径:<dataDir>/base-tool-enhance/<sessionId>/registry.json(dataDir = pi
5
- * getAgentDir() 同源路径,由调用方解析传入)。条目记 ownerPiPid——M5 reaper 属主
6
- * 判定依据,M2 只负责写入。
5
+ * getAgentDir() 同源路径,由调用方解析传入)。条目记 ownerPiPid——收殓侧属主
6
+ * 判定依据(u-bte-remove 后 = xyz-agent runtime background-task-reaper),M2 只负责
7
+ * 写入。
7
8
  *
8
9
  * 写入协议:
9
10
  * - 原子写 temp+rename(tmp 名带 pid+随机段防并发碰撞,llm-shared saveConfig 范式)
10
11
  * - 锁内 RMW(@zhushanwen/pi-file-lock withFileLockSync)——同 sessionId 目录可能
11
12
  * 被桌面端 ephemeral 附着进程与发起进程并发写,跨进程互斥只依赖同一 lockfile
12
- * - 锁获取失败不降级无锁写:返回 {success:false},条目停留 running M5 reaper
13
- * 兜底(§3.5「registry/entry 写不进则条目停留 running」)
13
+ * (runtime 收殓器写终态用 xyz-agent 统一锁,与该 lockfile 互斥)
14
+ * - 锁获取失败不降级无锁写:返回 {success:false},条目停留 running 由 runtime
15
+ * 收殓兜底(§3.5「registry/entry 写不进则条目停留 running」)
14
16
  * - 损坏读取防御(§3.6):解析失败/形状非法 → 重命名 .corrupt 保留现场 + 按空表
15
17
  * 重建 + warn 日志
16
18
  * - 终态条目 LRU 上限 50(与单例表对称)
19
+ *
20
+ * 契约改引(u-bte-remove):版本常量 / LRU 上限 / 文件形状自
21
+ * @xyz-agent/extension-protocol background-task.ts 契约引入(此前本地重复定义)。
17
22
  */
18
23
 
19
24
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
20
25
  import { dirname, join } from "node:path";
21
26
 
27
+ import {
28
+ BACKGROUND_TASK_REGISTRY_VERSION,
29
+ type BackgroundTaskRegistryFile,
30
+ MAX_TERMINAL_REGISTRY_ENTRIES,
31
+ } from "@xyz-agent/extension-protocol";
22
32
  import { getLogger } from "@zhushanwen/pi-extension-logger";
23
33
  import { withFileLockSync } from "@zhushanwen/pi-file-lock";
24
34
 
@@ -26,10 +36,6 @@ import { isTerminalState, type BackgroundTask, type RegistryEntry } from "./type
26
36
 
27
37
  const logger = getLogger("base-tool-enhance");
28
38
 
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
39
  const JSON_INDENT = 2;
34
40
  // tmp 随机段参数(llm-shared uniqueTmpPath 同款:36 进制随机串,跳过 "0." 前缀)
35
41
  const TMP_RADIX = 36;
@@ -44,11 +50,6 @@ export function getRegistryPath(dataDir: string, sessionId: string): string {
44
50
  return join(getBaseToolEnhanceDir(dataDir), sessionId, "registry.json");
45
51
  }
46
52
 
47
- interface RegistryFileShape {
48
- version: number;
49
- entries: RegistryEntry[];
50
- }
51
-
52
53
  /**
53
54
  * 最低限度形状校验:核心标识字段缺失即整条丢弃(不因单条脏数据报废全表)。
54
55
  */
@@ -68,7 +69,7 @@ function isValidRegistryEntry(item: unknown): item is RegistryEntry {
68
69
  }
69
70
 
70
71
  /** 校验并归一化 registry 文件内容;形状非法返回 undefined(走 corrupt 路径)。 */
71
- function parseRegistryContent(raw: string): RegistryFileShape | undefined {
72
+ function parseRegistryContent(raw: string): BackgroundTaskRegistryFile | undefined {
72
73
  let parsed: unknown;
73
74
  try {
74
75
  parsed = JSON.parse(raw);
@@ -77,12 +78,12 @@ function parseRegistryContent(raw: string): RegistryFileShape | undefined {
77
78
  }
78
79
  if (typeof parsed !== "object" || parsed === null) return undefined;
79
80
  const { version, entries } = parsed as Record<string, unknown>;
80
- if (version !== REGISTRY_VERSION || !Array.isArray(entries)) return undefined;
81
+ if (version !== BACKGROUND_TASK_REGISTRY_VERSION || !Array.isArray(entries)) return undefined;
81
82
  const valid: RegistryEntry[] = [];
82
83
  for (const item of entries) {
83
84
  if (isValidRegistryEntry(item)) valid.push(item);
84
85
  }
85
- return { version: REGISTRY_VERSION, entries: valid };
86
+ return { version: BACKGROUND_TASK_REGISTRY_VERSION, entries: valid };
86
87
  }
87
88
 
88
89
  /** .corrupt 落点:固定名优先;已存在则带时间戳,不覆盖前一份现场。 */
@@ -145,7 +146,7 @@ export function taskToRegistryEntry(task: BackgroundTask): RegistryEntry {
145
146
  }
146
147
 
147
148
  function serializeRegistry(entries: RegistryEntry[]): string {
148
- const shape: RegistryFileShape = { version: REGISTRY_VERSION, entries };
149
+ const shape: BackgroundTaskRegistryFile = { version: BACKGROUND_TASK_REGISTRY_VERSION, entries };
149
150
  return `${JSON.stringify(shape, null, JSON_INDENT)}\n`;
150
151
  }
151
152
 
@@ -172,8 +173,8 @@ function atomicWriteRegistry(registryPath: string, content: string): void {
172
173
  /**
173
174
  * 写入/更新单条 registry 条目(锁内 RMW:读全量 → 合并同 id 覆盖 → 终态 LRU 50 →
174
175
  * 原子写)。任务登记(running)、killing intent、终态三条路径共用。
175
- * 失败返回 {success:false}——调用方按「写不进则条目停留 running,M5 reaper 兜底」
176
- * 处理(M2 只 warn,不重试不阻断主流程)。
176
+ * 失败返回 {success:false}——调用方按「写不进则条目停留 running,runtime 收殓
177
+ * 兜底」处理(M2 只 warn,不重试不阻断主流程)。
177
178
  */
178
179
  export function writeRegistryEntry(
179
180
  registryPath: string,
@@ -195,7 +196,7 @@ export function writeRegistryEntry(
195
196
  return { success: true };
196
197
  } catch (err) {
197
198
  const message = err instanceof Error ? err.message : String(err);
198
- logger.warn("registry write failed; entry stays as-is (M5 reaper will reconcile)", {
199
+ logger.warn("registry write failed; entry stays as-is (runtime reaper will collect the orphan)", {
199
200
  detail: { path: registryPath, taskId: entry.taskId, err: message },
200
201
  });
201
202
  return { success: false, error: message };
@@ -14,8 +14,7 @@ import { randomBytes } from "node:crypto";
14
14
 
15
15
  import { getLogger } from "@zhushanwen/pi-extension-logger";
16
16
 
17
- import { killProcessTree } from "../kill-tree.ts";
18
- import { getProcessStartTimeSec, pidStartMatchesRegistered } from "../reaper.ts";
17
+ import { getProcessStartTimeSec, killProcessTree, pidStartMatchesRegistered } from "../kill-tree.ts";
19
18
  import { emitPendingRegister } from "./notify.ts";
20
19
  import { ensurePollerRunning } from "./poller.ts";
21
20
  import { getRegistryPath, taskToRegistryEntry, writeRegistryEntry } from "./registry.ts";
@@ -187,8 +186,8 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
187
186
  return { ok: false, error: "Failed to start background command: no pid acquired" };
188
187
  }
189
188
 
190
- // M3 补写 M5 预告字段:spawn 后立即读子进程 start time(epoch 秒),供 reaper
191
- // 精确比较防 pid 复用误杀。读取失败省略(undefined 不进条目)——reaper 降级走
189
+ // M3 补写字段:spawn 后立即读子进程 start time(epoch 秒),供收殓/kill
190
+ // 精确比较防 pid 复用误杀。读取失败省略(undefined 不进条目)——消费方降级走
192
191
  // startedAt 秒级校验兜底,不报错不阻断 spawn
193
192
  const pidStartTime = getProcessStartTimeSec(child.pid);
194
193
 
@@ -211,8 +210,9 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
211
210
  }
212
211
 
213
212
  // 登记顺序(§3.5 数据流 ③④⑤):单例表(运行时权威)→ registry(持久化)→
214
- // pending:register emit。registry 写失败不阻断(条目停留 running 由 M5 reaper
215
- // 兜底,§3.5);emit 失败同样无害(peer 未加载/引用未注入时无 listener,通知
213
+ // pending:register emit。registry 写失败不阻断(条目停留 running 由 runtime
214
+ // 收殓器兜底——孤儿判定按 ownerPiPid,§3.5;u-bte-remove 下沉后不再依赖本包
215
+ // 扫描);emit 失败同样无害(peer 未加载/引用未注入时无 listener,通知
216
216
  // 链路缺失不影响任务本体)
217
217
  registerSpawnedTask(task);
218
218
  writeRegistryEntry(registryPath, taskToRegistryEntry(task));
@@ -228,7 +228,7 @@ export function spawnBackgroundTask(opts: SpawnBackgroundOptions): SpawnBackgrou
228
228
  */
229
229
  function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
230
230
  const timer = setTimeout(() => {
231
- // pid 复用防御(§3.6,同 reaper reapEntrySync / bash_kill 范式,宁不杀勿误杀):
231
+ // pid 复用防御(§3.6,同 bash_kill 范式,宁不杀勿误杀):
232
232
  // 任务早已退出(exit 边沿未被轮询器收尾或竞态未及)且 pid 在到点前被系统复用
233
233
  // 时,直接 killProcessTree 会杀掉复用 pid 上的无辜进程(整进程组 SIGKILL)。
234
234
  if (!isRecordedPidStillOriginal(task)) {
@@ -254,8 +254,8 @@ function armBackgroundTimeout(task: BackgroundTask, timeoutSec: number): void {
254
254
 
255
255
  /**
256
256
  * 到点 pid 身份校验:true = 登记时的原进程仍占用该 pid(可安全 kill-tree)。
257
- * 判据(精确比较 / startedAt 秒级降级 / 读不到保守 false)单点定义于 reaper
258
- * 的 pidStartMatchesRegistered,与 reapEntrySync 孤儿补杀共用同一份。
257
+ * 判据(精确比较 / startedAt 秒级降级 / 读不到保守 false)单点定义于 kill-tree.ts
258
+ * 的 pidStartMatchesRegistered(原 reaper.ts 单点,收殓下沉后随删除平移)。
259
259
  */
260
260
  function isRecordedPidStillOriginal(task: BackgroundTask): boolean {
261
261
  const actualStartSec = getProcessStartTimeSec(task.pid);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * subagent 进程识别(D14 降级判据,探针 P5)。
3
3
  *
4
- * subagent-workflow 的 buildChildEnv(execution/session-runner.ts)对每个子 agent
5
- * 进程**无条件**注入身份贯穿 env(PI_SUBAGENT_ROOT_SESSION_ID / SELF_RECORD_ID /
4
+ * subagent-core 的 buildChildEnv(packages/subagent-core/src/execution/session-runner.ts
5
+ * 对每个子 agent 进程**无条件**注入身份贯穿 env(PI_SUBAGENT_ROOT_SESSION_ID / SELF_RECORD_ID /
6
6
  * DEPTH / ROOT_CWD,见其源码注释「无条件注入每个 subagent」)。任一存在 = 当前
7
7
  * pi 进程是 subagent 子进程 → 本扩展降级:background:true 被忽略走前台同步语义。
8
8
  *
@@ -10,7 +10,7 @@
10
10
  * 回);且子进程死后其 registry 目录永远不会再有 session 启动,孤儿无人 reap。
11
11
  */
12
12
 
13
- /** subagent-workflow 注入的身份贯穿 env 前缀(跨包契约,P5 探针确认)。 */
13
+ /** subagent-core(session-runner)注入的身份贯穿 env 前缀(跨包契约,P5 探针确认)。 */
14
14
  const SUBAGENT_ENV_KEYS = [
15
15
  "PI_SUBAGENT_ROOT_SESSION_ID",
16
16
  "PI_SUBAGENT_SELF_RECORD_ID",
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * 条目唯一来源 = 本进程 execute 的 spawn(registerSpawnedTask 全仓唯一调用点 =
9
9
  * spawn-background.ts)。他进程 / 历史 session 的 running 条目**永不进表**——
10
- * 零恢复零接管,处置权统一归 M5 reaper 属主裁决;终态条目可从 registry 读,
11
- * 仅供 bash_output 查历史。
10
+ * 零恢复零接管,孤儿处置权统一归收殓侧(u-bte-remove = xyz-agent runtime)
11
+ * 按属主裁决;终态条目可从 registry 读,仅供 bash_output 查历史。
12
12
  *
13
13
  * 终态条目 LRU 上限 MAX_TERMINAL_ENTRIES(与 registry 对称),淘汰后 bash_output
14
14
  * 回落 registry 查询。