@zhushanwen/pi-base-tool-enhance 0.5.2 → 0.5.4

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.
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * kill 目标归属(§3.5):**限定本进程单例表条目**;registry 回落限定终态条目
12
12
  * (exited/orphaned → already exited)——registry 中他进程的 running/killing 条目
13
- * **不可 kill**(跨进程边界:处置权归发起进程;孤儿收殓已下沉 xyz-agent runtime,
13
+ * **不可 kill**(跨进程边界:处置权归发起进程;孤儿收殓已下沉 taiji runtime,
14
14
  * u-bte-remove 后由 background-task-reaper 按 ownerPiPid 属主判定处置)。
15
15
  * kill 前校验 pid 判活 + start time 匹配(防陈旧条目遇 pid 复用误杀无关进程,
16
16
  * 宁不杀勿误杀,§3.6 收殓侧同原则)。
@@ -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 "@taiji/extension-protocol/background-task";
21
22
  import { Type } from "typebox";
22
23
 
23
- import { getProcessStartTimeSec, isPidAlive, killProcessTree } from "./kill-tree.ts";
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
  }
@@ -76,12 +81,16 @@ export function createBashKillToolDefinition() {
76
81
  );
77
82
  }
78
83
  // registry-only 活跃条目 = 他进程任务(本进程活跃任务必在单例表)——跨进程
79
- // 不可 kill:处置权归发起进程;属主若已死,孤儿由 xyz-agent runtime 在
80
- // session 销毁时或启动期兜底扫描时收殓(属主判定,u-bte-remove 下沉)
84
+ // 不可 kill:处置权归发起进程;属主若已死,孤儿由 taiji runtime 在
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 owning xyz-agent runtime will collect the orphan when its session ends or on app restart",
91
+ "if that process is gone, the orphan keeps running until it exits on its own; " +
92
+ "inside the taiji 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
- killProcessTree(fromStore.pid);
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(
@@ -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 "@taiji/extension-protocol/background-task";
17
18
  import { Type } from "typebox";
18
19
 
19
- import { readOutputTail } from "./background/output-tail.ts";
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
- const tail = readOutputTail(task.outputFile);
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?.output ?? "<lost>",
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
- `${err instanceof Error ? err.message : String(err)} (${getLlmSharedConfigPath(CONFIG_PKG)}).`,
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 (!isPlainObject(raw)) {
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
@@ -7,7 +7,7 @@
7
7
  * M3:pending-notifications 通知接入——load 时刷新轮询器通知通路的 pi 引用(D17
8
8
  * session 替换接管)+ 挂 exit 边沿通知回调(unregister emit + sendMessage steer)+
9
9
  * session_start 对账(appendEntry 权威路径兜底 pending 收尾)。
10
- * 收殓下沉(u-bte-remove):M5 孤儿收殓已移交 xyz-agent runtime(background-task-
10
+ * 收殓下沉(u-bte-remove):M5 孤儿收殓已移交 taiji runtime(background-task-
11
11
  * reaper 双触发面——session 销毁时 + 启动期兜底扫描,设计 file-lock-unification-
12
12
  * and-reaper-sink.md §3.2 D2),extension 不再做全局扫描/全局锁;session_start
13
13
  * 维护链仅剩 M3 对账(session 级豁免类,每 session_start 执行,D3 粒度段)。
@@ -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";
@@ -71,7 +72,7 @@ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
71
72
  * flag 后目标 session 的对账将永远被跳过(M3 对账在主链路被禁用,此处是唯一执行点)。
72
73
  *
73
74
  * 入口无条件 debug 日志(S6 观测通道):每次 handler 派发都打,含 reason——
74
- * XYZ_AGENT_DEBUG=1 时落 extension 日志(默认 no-op),供「factory 二调下维护链
75
+ * TAIJI_AGENT_DEBUG=1 时落 extension 日志(默认 no-op),供「factory 二调下维护链
75
76
  * 行为」验收观测派发次数。变更登记(u-bte-remove):reap 下沉 runtime 后本链
76
77
  * 不再含全局扫描/全局锁,原 reapSkipped 字段随 reap 调用一并移除——对应 S6
77
78
  * 场景「批 2 后 reap 类操作不再执行」。
@@ -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: err instanceof Error ? err.message : String(err) },
94
+ detail: { err: toErrorMessage(err) },
94
95
  });
95
96
  }
96
97
  }
@@ -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"(保持原值,等价迁移不断链;unified-hooks
8
- * 整包废弃后该 entry 由本包继续产出,M6 摘除旧包时消费方无感)
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 已在对话流里
@@ -1,161 +0,0 @@
1
- // src/__tests__/kill-tree.test.ts —— 进程树 kill 真实进程验证(POSIX 进程组语义)
2
- // + pid 身份判据(getProcessStartTimeSec / pidStartMatchesRegistered——原 reaper.test.ts
3
- // 覆盖平移:收殓下沉 runtime 后 helper 移入本文件,kill 侧自防御仍消费)
4
- import { spawn, spawnSync } from "node:child_process";
5
-
6
- import { describe, expect, it, vi } from "vitest";
7
-
8
- vi.setConfig({ testTimeout: 20000 });
9
-
10
- import {
11
- getProcessStartTimeSec,
12
- isPidAlive,
13
- killProcessTree,
14
- pidStartMatchesRegistered,
15
- } from "../kill-tree.ts";
16
-
17
- function sleep(ms: number): Promise<void> {
18
- return new Promise((resolve) => setTimeout(resolve, ms));
19
- }
20
-
21
- /**
22
- * 轮询直到期望状态出现或超时:真实进程事件(退出 → libuv reap → pid 释放)在满载
23
- * 下延迟不可预估,固定 sleep 后单次断言是满载 flake 源——等状态而非猜时刻。
24
- */
25
- async function pollUntil(check: () => boolean, deadlineMs: number, what: string): Promise<void> {
26
- const deadline = Date.now() + deadlineMs;
27
- while (!check()) {
28
- if (Date.now() > deadline) {
29
- throw new Error(`timed out after ${deadlineMs}ms waiting for ${what}`);
30
- }
31
- await sleep(25);
32
- }
33
- }
34
-
35
- /** pgrep -P 组内枚举:列组长直接子 pid(仅本进程组内,非全机扫描)。 */
36
- function listChildPids(pid: number): number[] {
37
- const result = spawnSync("/usr/bin/pgrep", ["-P", String(pid)], { encoding: "utf8" });
38
- // exit 1 = 无匹配(空表正常态);解析失败按空表处理,由轮询重试兜住 spawn 竞态
39
- return (result.stdout ?? "")
40
- .split("\n")
41
- .map((line) => Number.parseInt(line.trim(), 10))
42
- .filter((n) => Number.isInteger(n) && n > 0);
43
- }
44
-
45
- /** 真实 detached spawn(与本包后台任务同款形态:自成进程组)。 */
46
- function spawnDetached(command: string) {
47
- const child = spawn("/bin/sh", ["-c", command], {
48
- detached: true,
49
- stdio: "ignore",
50
- });
51
- child.on("error", () => {});
52
- child.unref();
53
- return child;
54
- }
55
-
56
- describe("isPidAlive", () => {
57
- it("current process is alive", () => {
58
- expect(isPidAlive(process.pid)).toBe(true);
59
- });
60
-
61
- it("exited process is dead (pid freed after reap)", async () => {
62
- const child = spawnDetached("true");
63
- const pid = child.pid;
64
- if (pid === undefined) throw new Error("no pid");
65
- // 轮询等退出 + libuv reap(SIGCHLD → waitpid 后 pid 释放)
66
- await pollUntil(() => !isPidAlive(pid), 5000, "pid to be freed after reap");
67
- expect(isPidAlive(pid)).toBe(false);
68
- });
69
-
70
- it("invalid pid (0/negative) is treated as dead", () => {
71
- expect(isPidAlive(0)).toBe(false);
72
- expect(isPidAlive(-1)).toBe(false);
73
- });
74
- });
75
-
76
- describe("killProcessTree (process group semantics)", () => {
77
- it("kills the whole detached process group including grandchildren", async () => {
78
- // 组长 sh + 两个子 sleep:进程组 kill 必须全部覆盖
79
- const child = spawnDetached("sleep 30 & sleep 30 & wait");
80
- const pid = child.pid;
81
- if (pid === undefined) throw new Error("no pid");
82
- // 轮询等两个孙子 sleep 真正 fork 出来,并在 kill 之前用组内枚举(pgrep -P)
83
- // 记录孙子 pid 快照——kill 后只对这些记录过的 pid 断言,禁止全机 pgrep -f
84
- // 扫描(满载并行时其他包测试会真实 spawn 同名命令,跨包互踩)
85
- let grandchildPids: number[] = [];
86
- await pollUntil(
87
- () => {
88
- grandchildPids = listChildPids(pid);
89
- return isPidAlive(pid) && grandchildPids.length >= 2;
90
- },
91
- 5000,
92
- "grandchildren to spawn inside the group",
93
- );
94
- expect(isPidAlive(pid)).toBe(true);
95
-
96
- killProcessTree(pid);
97
- // 轮询组长死(等 libuv reap,deadline 5s)
98
- await pollUntil(() => !isPidAlive(pid), 5000, "group leader to die");
99
-
100
- // 记录过的孙子 pid 也不得残留。覆盖面说明:killProcessTree 是进程组 kill
101
- // (POSIX kill -- -<pgid>),孙子与组长同组、组长死后 reparent 不换组,
102
- // 故「组 kill + 组内 pid 记录」已覆盖「reparent 后孙子也能被杀」的原始意图;
103
- // pid 复用概率在 5s 轮询窗口内可忽略
104
- await pollUntil(
105
- () => grandchildPids.every((gpid) => !isPidAlive(gpid)),
106
- 5000,
107
- "recorded grandchildren to die",
108
- );
109
- });
110
-
111
- it("killing an already-dead pid is a silent no-op", async () => {
112
- const child = spawnDetached("true");
113
- const pid = child.pid;
114
- if (pid === undefined) throw new Error("no pid");
115
- // 轮询等死透再 kill:断言语义是「对已死 pid 发令为无害 no-op」
116
- await pollUntil(() => !isPidAlive(pid), 5000, "pid to die");
117
- expect(() => killProcessTree(pid)).not.toThrow();
118
- });
119
- });
120
-
121
- describe("getProcessStartTimeSec (platform probe sanity)", () => {
122
- it("returns a sane epoch-seconds value for a live process", () => {
123
- const sec = getProcessStartTimeSec(process.pid);
124
- expect(sec).toBeDefined();
125
- const nowSec = Math.floor(Date.now() / 1000);
126
- // 本进程必然启动于 [now - 10min, now + 5s](时钟容差)
127
- expect(sec!).toBeGreaterThan(nowSec - 600);
128
- expect(sec!).toBeLessThan(nowSec + 5);
129
- });
130
-
131
- it("returns undefined for a dead pid", async () => {
132
- const child = spawnDetached("sleep 0.1");
133
- const pid = child.pid;
134
- if (pid === undefined) throw new Error("no pid");
135
- // 轮询等退出 + libuv reap(满载下延迟不可预估)
136
- await pollUntil(() => getProcessStartTimeSec(pid) === undefined, 5000, "ps record to vanish");
137
- expect(getProcessStartTimeSec(pid)).toBeUndefined();
138
- });
139
- });
140
-
141
- describe("pidStartMatchesRegistered (pid identity predicate, §3.6)", () => {
142
- it("exact match on registered pidStartTime authorizes (precise path)", () => {
143
- expect(pidStartMatchesRegistered(1000, 1000, 1_700_000_000_000)).toBe(true);
144
- });
145
-
146
- it("registered mismatch (pid reuse suspicion) rejects", () => {
147
- expect(pidStartMatchesRegistered(2000, 1000, 1_700_000_000_000)).toBe(false);
148
- });
149
-
150
- it("missing registration degrades to startedAt-seconds bound: original process authorizes", () => {
151
- // 登记发生在 spawn 之后 → 原进程 start time 必然 ≤ floor(startedAt/1000)
152
- const startedAtMs = 1_700_000_123_456; // floor = 1_700_000_123
153
- expect(pidStartMatchesRegistered(1_700_000_123, undefined, startedAtMs)).toBe(true);
154
- expect(pidStartMatchesRegistered(1_700_000_122, undefined, startedAtMs)).toBe(true);
155
- });
156
-
157
- it("missing registration degrades: newer-than-registration start time rejects (reuse)", () => {
158
- const startedAtMs = 1_700_000_123_456;
159
- expect(pidStartMatchesRegistered(1_700_000_124, undefined, startedAtMs)).toBe(false);
160
- });
161
- });
package/src/kill-tree.ts DELETED
@@ -1,151 +0,0 @@
1
- /**
2
- * 自实现进程树 kill 与 pid 判活/身份判据(设计文档 §3.5 bash_kill)。
3
- *
4
- * 为什么不用 pi 的 killProcessTree:它未从主入口导出(pi package.json exports 仅
5
- * `.`/`./rpc-entry`/`./client`,定义在 dist/utils/shell.d.ts 但子路径不暴露),不可
6
- * import。分支语义与 pi 实装对齐:Windows `taskkill /F /T`、POSIX 杀进程组
7
- * `kill -- -<pgid>`(本包后台任务 detached spawn 自成进程组,pgid = pid);进程组
8
- * 杀不到时回退单 pid + `pgrep -P` 递归杀残留子进程。
9
- *
10
- * pid 身份判据(getProcessStartTimeSec / pidStartMatchesRegistered):原定义于
11
- * reaper.ts,孤儿收殓下沉 runtime 后随 reaper.ts 删除平移至此(u-bte-remove)
12
- * ——消费方是 bash_kill 前校验与后台 timeout 到点校验(进程内自防御,与收殓无关)。
13
- */
14
-
15
- import { spawnSync, type SpawnSyncReturns } from "node:child_process";
16
-
17
- import { getLogger } from "@zhushanwen/pi-extension-logger";
18
-
19
- const logger = getLogger("base-tool-enhance");
20
-
21
- /** ps 调用超时:卡死的 ps 不拖垮调用方(超时按取不到处理 → 保守跳过)。 */
22
- const PS_TIMEOUT_MS = 5000;
23
- /** 毫秒 → 秒(epoch 秒换算,spawn-background.ts 同名常量先例)。 */
24
- const MS_PER_SECOND = 1000;
25
-
26
- /**
27
- * pid 判活:kill(pid, 0) 不发信号只做权限校验。
28
- * ESRCH = 已死(含 libuv 自动 reap 后);EPERM = 进程存在但属其他用户,仍视为活。
29
- */
30
- export function isPidAlive(pid: number): boolean {
31
- if (!Number.isInteger(pid) || pid <= 0) return false;
32
- try {
33
- process.kill(pid, 0);
34
- return true;
35
- } catch (err) {
36
- return (err as NodeJS.ErrnoException).code === "EPERM";
37
- }
38
- }
39
-
40
- /**
41
- * 杀整棵进程树(同步;收殓路径在 process.on("exit") 里跑,必须同步)。
42
- * 幂等:目标已死时静默成功。
43
- */
44
- export function killProcessTree(pid: number): void {
45
- if (!Number.isInteger(pid) || pid <= 0) return;
46
- if (process.platform === "win32") {
47
- killProcessTreeWindows(pid);
48
- return;
49
- }
50
- // POSIX:detached spawn 自成进程组(pgid = pid),杀进程组一次性覆盖全部子孙
51
- try {
52
- process.kill(-pid, "SIGKILL");
53
- return;
54
- } catch (err) {
55
- logger.debug("process group kill missed, falling back to single pid + descendants", {
56
- detail: { pid, err: err instanceof Error ? err.message : String(err) },
57
- });
58
- }
59
- // 回退:组长已死(进程组不复存在)时单杀 pid + pgrep -P 递归清理残留子进程
60
- try {
61
- process.kill(pid, "SIGKILL");
62
- } catch (err) {
63
- // 目标已死:kill 幂等语义,仅留诊断
64
- logger.debug("single pid kill missed (already dead?)", {
65
- detail: { pid, err: err instanceof Error ? err.message : String(err) },
66
- });
67
- }
68
- killDescendantsRecursive(pid);
69
- }
70
-
71
- function killProcessTreeWindows(pid: number): void {
72
- try {
73
- const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], {
74
- stdio: "ignore",
75
- windowsHide: true,
76
- });
77
- if (result.error) throw result.error;
78
- } catch (err) {
79
- logger.debug("taskkill failed", {
80
- detail: { pid, err: err instanceof Error ? err.message : String(err) },
81
- });
82
- }
83
- }
84
-
85
- /** pgrep -P 递归:先杀孙辈再杀子辈(防孙辈在父死后被 reparent 逃逸枚举)。 */
86
- function killDescendantsRecursive(pid: number): void {
87
- let stdout: string;
88
- try {
89
- const result = spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" });
90
- if (result.error || result.status !== 0 || !result.stdout) return;
91
- stdout = result.stdout;
92
- } catch {
93
- return;
94
- }
95
- for (const line of stdout.split("\n")) {
96
- const childPid = Number.parseInt(line.trim(), 10);
97
- if (Number.isInteger(childPid) && childPid > 0) {
98
- killDescendantsRecursive(childPid);
99
- try {
100
- process.kill(childPid, "SIGKILL");
101
- } catch (err) {
102
- // 已死:kill 幂等语义,仅留诊断
103
- logger.debug("descendant kill missed (already dead?)", {
104
- detail: { pid: childPid, err: err instanceof Error ? err.message : String(err) },
105
- });
106
- }
107
- }
108
- }
109
- }
110
-
111
- /**
112
- * 取进程 start time(epoch 秒)。ps -o lstart= 跨 macOS/Linux(Linux /proc/
113
- * <pid>/stat 精度更高但 macOS 无 /proc,统一 ps 保跨平台一致);= 号去表头。
114
- * 返回 undefined:进程不存在 / ps 不可用 / 输出不可解析——调用方一律按
115
- * 「无法校验 → 保守跳过」处理。
116
- */
117
- export function getProcessStartTimeSec(pid: number): number | undefined {
118
- let result: SpawnSyncReturns<string>;
119
- try {
120
- result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
121
- encoding: "utf8",
122
- timeout: PS_TIMEOUT_MS,
123
- });
124
- } catch {
125
- return undefined;
126
- }
127
- if (result.error || result.status !== 0 || !result.stdout) return undefined;
128
- // lstart 形如 "Mon Aug 25 14:23:45 2026"(本地时区),Date.parse 按本地时区解释
129
- const ms = Date.parse(result.stdout.trim());
130
- return Number.isNaN(ms) ? undefined : Math.floor(ms / MS_PER_SECOND);
131
- }
132
-
133
- /**
134
- * pid 身份判据(§3.6「宁不杀勿误杀」的唯一定义点;原 reaper.ts 单点定义,收殓
135
- * 下沉后由进程内自防御消费——bash_kill kill 前校验与 background timeout 到点
136
- * kill 校验共用)。true = 当前占用该 pid 的进程 start time 与登记值匹配,可安全
137
- * kill。
138
- * - 有登记 start time(spawn 时 ps 读取成功)→ 精确比较(同单位 epoch 秒)
139
- * - 缺登记 start time(M3 前登记的存量条目 / ps 不可用平台)→ startedAtMs 秒级
140
- * 降级:登记发生在 spawn 之后(进程先启动、条目后登记),原进程 start time
141
- * 必然 ≤ floor(startedAtMs/1000)
142
- */
143
- export function pidStartMatchesRegistered(
144
- actualStartSec: number,
145
- registeredStartSec: number | undefined,
146
- startedAtMs: number,
147
- ): boolean {
148
- return registeredStartSec !== undefined
149
- ? actualStartSec === registeredStartSec
150
- : actualStartSec <= Math.floor(startedAtMs / MS_PER_SECOND);
151
- }