@zhushanwen/pi-base-tool-enhance 0.3.1 → 0.4.1

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.
@@ -4,23 +4,33 @@
4
4
  * 两层存储分工:
5
5
  * - 单例任务表(task-store.ts,模块级 Map)= 运行时权威;条目唯一来源 = 本进程
6
6
  * execute 的 spawn——他进程 / 历史 session 的 running 条目永不进表(零恢复零接管)
7
- * - registry.json(registry.ts,per-sessionId 目录)= 持久化权威;M5 reaper 的
8
- * 孤儿发现源,条目记 ownerPiPid(M2 只负责写入)
7
+ * - registry.json(registry.ts,per-sessionId 目录)= 持久化权威;孤儿发现源,
8
+ * 条目记 ownerPiPid(M2 只负责写入)
9
+ *
10
+ * 契约改引(u-bte-remove):registry 持久化形状(RegistryEntry / 状态机 / reason
11
+ * 枚举 / active·terminal 判定)与 @xyz-agent/extension-protocol background-task.ts
12
+ * 契约逐字段一致(契约文档头声明基线即本文件 + registry.ts),本地副本已删除、
13
+ * 此处 re-export 并保留包内短名——消费方 import 路径与字段语义零变化。运行时
14
+ * 对象类型(BackgroundTask / KillingIntent)不在契约内(剥离非持久字段前的形状),
15
+ * 仍本地定义。
9
16
  */
10
17
 
11
18
  import type { ChildProcess } from "node:child_process";
19
+ import type {
20
+ BackgroundTaskEndReason as ContractEndReason,
21
+ BackgroundTaskState as ContractState,
22
+ } from "@xyz-agent/extension-protocol";
12
23
 
13
- /** 任务状态机:running → killing(intent 瞬态)→ exited;orphaned 由 M5 reaper 写入。 */
14
- export type BackgroundTaskState = "running" | "killing" | "exited" | "orphaned";
15
-
16
- /**
17
- * 终态 reason 枚举(§3.5 bash_output 规格):
18
- * - natural 进程自然退出(含外力终止——观测上不可区分,§3.5 两层存储分工节)
19
- * - timeout 后台显式 timeout 定时器触发
20
- * - killed bash_kill 发令
21
- * - process-exit pi 进程退出收殓(D12)
22
- */
23
- export type BackgroundTaskEndReason = "natural" | "timeout" | "killed" | "process-exit";
24
+ /** 状态机与 reason 枚举 / 持久化条目 / active·terminal 判定:契约 SSOT re-export。 */
25
+ export type {
26
+ BackgroundTaskRegistryEntry as RegistryEntry,
27
+ BackgroundTaskState,
28
+ BackgroundTaskEndReason,
29
+ } from "@xyz-agent/extension-protocol";
30
+ export {
31
+ isActiveBackgroundTaskState as isActiveState,
32
+ isTerminalBackgroundTaskState as isTerminalState,
33
+ } from "@xyz-agent/extension-protocol";
24
34
 
25
35
  /**
26
36
  * killing intent:bash_kill / 后台 timeout 已发令、轮询器 exit 边沿未确认的瞬态标记。
@@ -28,7 +38,7 @@ export type BackgroundTaskEndReason = "natural" | "timeout" | "killed" | "proces
28
38
  * 轮询边沿据此决定终态 reason,消费后清除。
29
39
  */
30
40
  export interface KillingIntent {
31
- reason: Extract<BackgroundTaskEndReason, "killed" | "timeout">;
41
+ reason: Extract<ContractEndReason, "killed" | "timeout">;
32
42
  at: number;
33
43
  }
34
44
 
@@ -49,13 +59,13 @@ export interface BackgroundTask {
49
59
  /** 本条目 registry.json 路径(收殓同步写终态用;运行时字段,不序列化) */
50
60
  registryPath: string;
51
61
  startedAt: number;
52
- state: BackgroundTaskState;
53
- /** 发起任务的 pi 进程 pid(M5 reaper 属主判定依据;M2 只写入) */
62
+ state: ContractState;
63
+ /** 发起任务的 pi 进程 pid(收殓属主判定依据;M2 只写入——孤儿处置归 runtime) */
54
64
  ownerPiPid: number;
55
65
  /** 发起 session(registry 目录归属) */
56
66
  sessionId: string;
57
67
  exitCode?: number | null;
58
- reason?: BackgroundTaskEndReason;
68
+ reason?: ContractEndReason;
59
69
  endedAt?: number;
60
70
  durationMs?: number;
61
71
  /** exit 边沿组装的输出尾部摘要(M3 通知用;M2 存条目不消费) */
@@ -65,39 +75,10 @@ export interface BackgroundTask {
65
75
  /** 后台显式 timeout 定时器(到点 kill-tree + 标 intent timeout);终态化时清除 */
66
76
  timeoutTimer?: ReturnType<typeof setTimeout>;
67
77
  /**
68
- * 子进程 start time(epoch 秒,M3 补写 M5 预告字段):spawn 后立即读取,供 reaper
69
- * 精确比较防 pid 复用误杀;读取失败省略(reaper 降级走 startedAt 秒级校验兜底)。
78
+ * 子进程 start time(epoch 秒,M3 补写字段):spawn 后立即读取,供 kill 侧
79
+ * 精确比较防 pid 复用误杀;读取失败省略(降级走 startedAt 秒级校验兜底)。
70
80
  */
71
81
  pidStartTime?: number;
72
82
  /** spawn 返回的 ChildProcess 引用:仅读 exitCode/signalCode(D17,见接口注释) */
73
83
  child?: ChildProcess;
74
84
  }
75
-
76
- /** registry.json 持久化条目(BackgroundTask 剥离运行时字段后的形状)。 */
77
- export interface RegistryEntry {
78
- taskId: string;
79
- pid: number;
80
- command: string;
81
- outputFile: string;
82
- startedAt: number;
83
- state: BackgroundTaskState;
84
- ownerPiPid: number;
85
- sessionId: string;
86
- exitCode?: number | null;
87
- reason?: BackgroundTaskEndReason;
88
- endedAt?: number;
89
- durationMs?: number;
90
- tailSummary?: string;
91
- /** 子进程 start time(epoch 秒,reaper pid 复用防御精确比较用;缺失走降级校验)。 */
92
- pidStartTime?: number;
93
- }
94
-
95
- /** 任务是否处于活跃态(轮询器监护对象)。 */
96
- export function isActiveState(state: BackgroundTaskState): boolean {
97
- return state === "running" || state === "killing";
98
- }
99
-
100
- /** 任务是否处于终态(LRU 淘汰对象;orphaned 由 M5 写入,同属终态)。 */
101
- export function isTerminalState(state: BackgroundTaskState): boolean {
102
- return state === "exited" || state === "orphaned";
103
- }
@@ -5,21 +5,22 @@
5
5
  * (查询面立即可见——kill 返回后 bash_output 即显示 killing,无「已 kill 仍 running」
6
6
  * 倒挂窗口)。实际终态(exited, reason:"killed")由轮询器 exit 边沿收尾写——
7
7
  * bash_kill 不直接写终态(单一终态归属),也不 sendMessage(kill 调用方就在当前
8
- * turn 内等结果,再发 steer 通知是双发噪音——那是 M3 的规则,此处先不实现通知)。
8
+ * turn 内等结果,再发 steer 通知是双发噪音——按 notify.ts 的单点归属规则,kill
9
+ * 路径不 sendMessage,reason:"killed" 只 emit unregister,终态由 handleTaskExit 收尾)。
9
10
  *
10
11
  * kill 目标归属(§3.5):**限定本进程单例表条目**;registry 回落限定终态条目
11
12
  * (exited/orphaned → already exited)——registry 中他进程的 running/killing 条目
12
- * **不可 kill**(跨进程边界:处置权归发起进程,孤儿由 reaper 属主判定收殓)。
13
+ * **不可 kill**(跨进程边界:处置权归发起进程;孤儿收殓已下沉 xyz-agent runtime,
14
+ * u-bte-remove 后由 background-task-reaper 按 ownerPiPid 属主判定处置)。
13
15
  * kill 前校验 pid 判活 + start time 匹配(防陈旧条目遇 pid 复用误杀无关进程,
14
- * 宁不杀勿误杀,同 reaper 原则 §3.6)。
16
+ * 宁不杀勿误杀,§3.6 收殓侧同原则)。
15
17
  */
16
18
 
17
19
  import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
20
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
21
  import { Type } from "typebox";
20
22
 
21
- import { isPidAlive, killProcessTree } from "./kill-tree.ts";
22
- import { getProcessStartTimeSec } from "./reaper.ts";
23
+ import { getProcessStartTimeSec, isPidAlive, killProcessTree } from "./kill-tree.ts";
23
24
  import { ensurePollerRunning } from "./background/poller.ts";
24
25
  import { getRegistryPath, readRegistry, taskToRegistryEntry, writeRegistryEntry } from "./background/registry.ts";
25
26
  import { getAllTasks, markKillingIntent } from "./background/task-store.ts";
@@ -75,12 +76,12 @@ export function createBashKillToolDefinition() {
75
76
  );
76
77
  }
77
78
  // registry-only 活跃条目 = 他进程任务(本进程活跃任务必在单例表)——跨进程
78
- // 不可 kill:处置权归发起进程;属主若已死,孤儿由 reaper 在下次 session
79
- // 启动时收殓(属主判定)
79
+ // 不可 kill:处置权归发起进程;属主若已死,孤儿由 xyz-agent runtime 在
80
+ // session 销毁时或启动期兜底扫描时收殓(属主判定,u-bte-remove 下沉)
80
81
  return killedFalse(
81
82
  "cross-process running task owned by another pi process",
82
83
  "the task is managed by the pi process that started it (bash_kill from that session); " +
83
- "if that process is gone, the reaper will collect the orphan at the next session start",
84
+ "if that process is gone, the owning xyz-agent runtime will collect the orphan when its session ends or on app restart",
84
85
  );
85
86
  }
86
87
  if (isTerminalState(fromStore.state)) {
@@ -89,7 +90,7 @@ export function createBashKillToolDefinition() {
89
90
  );
90
91
  }
91
92
 
92
- // pid 复用防御(§3.6 同 reaper 原则,宁不杀勿误杀):
93
+ // pid 复用防御(§3.6 宁不杀勿误杀):
93
94
  // - pid 已死 → already exited 风格返回,不发 kill(终态由轮询边沿收尾)
94
95
  // - 有 pidStartTime 字段 → 校验实际进程 start time 匹配;不匹配/读不到 =
95
96
  // 复用嫌疑,拒绝 kill 并说明
@@ -32,7 +32,7 @@ const JSON_INDENT = 2;
32
32
  const BASH_OUTPUT_DESCRIPTION = [
33
33
  "Fetch output and status of a background bash task started with bash {background:true}.",
34
34
  "Provide task_id to get task detail: state (running|killing|exited|orphaned), exitCode, reason, duration and tail output (last 2000 lines / 50KB).",
35
- "Omit task_id to list all background tasks of this session.",
35
+ "Omit task_id to list known background tasks (process-wide; may include tasks started under earlier sessions).",
36
36
  ].join("\n");
37
37
 
38
38
  /** list 视图条目(§3.5:command 前 80 字符)。 */
@@ -122,7 +122,7 @@ export function createBashOutputToolDefinition() {
122
122
  const task = findTask(args.task_id, registry);
123
123
  if (task === undefined) {
124
124
  throw new Error(
125
- `No such task: ${args.task_id}. Use bash_output without task_id to list all background tasks.`,
125
+ `No such task: ${args.task_id}. Use bash_output without task_id to list known background tasks.`,
126
126
  );
127
127
  }
128
128
  return textResult(taskDetailText(task));
package/src/bash-tool.ts CHANGED
@@ -37,7 +37,7 @@ import { compileForcePatterns, describeForceMatch, matchForceBackground } from "
37
37
  const enhancedBashSchema = Type.Object({
38
38
  command: Type.String({ description: "Bash command to execute" }),
39
39
  timeout: Type.Optional(
40
- Type.Number({ description: "Timeout in seconds (optional, no default timeout)" }),
40
+ Type.Number({ description: "Timeout in seconds (optional; if omitted, the configured default timeout applies when set)" }),
41
41
  ),
42
42
  background: Type.Optional(
43
43
  Type.Boolean({
@@ -60,10 +60,10 @@ const enhancedBashSchema = Type.Object({
60
60
  const ENHANCED_BASH_DESCRIPTION = [
61
61
  "Execute a bash command in the current working directory. Returns stdout and stderr.",
62
62
  "Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file.",
63
- "Optionally provide a timeout in seconds; an explicit timeout is respected, except when a whitelisted long-running command is force-routed to background.",
63
+ "Optionally provide a timeout in seconds; an explicit timeout is respected, except when a whitelisted long-running command is force-routed to background. If no timeout is given, a configured default timeout applies when set.",
64
64
  "",
65
65
  "Background mode: set background: true to start the command without waiting for it. The tool returns immediately with a task_id, the pid, and an output file path, and you can continue other work while it runs.",
66
- "Poll progress and fetch output with bash_output {task_id} (omit task_id to list all background tasks); terminate a task with bash_kill {task_id}.",
66
+ "Poll progress and fetch output with bash_output {task_id} (omit task_id to list known background tasks); terminate a task with bash_kill {task_id}.",
67
67
  "",
68
68
  "Commands matching the force-background whitelist (test suites, dev servers, watch jobs and similar long-running commands) are automatically routed to background even when background was not requested; in that case the result carries a task_id to poll.",
69
69
  ].join("\n");
package/src/index.ts CHANGED
@@ -6,11 +6,19 @@
6
6
  * 轮询器单例任务表)、bash_output / bash_kill 工具、进程退出收殓、subagent 降级。
7
7
  * M3:pending-notifications 通知接入——load 时刷新轮询器通知通路的 pi 引用(D17
8
8
  * session 替换接管)+ 挂 exit 边沿通知回调(unregister emit + sendMessage steer)+
9
- * session_start 对账(reaper 先、对账后,appendEntry 权威路径兜底 pending 收尾)。
9
+ * session_start 对账(appendEntry 权威路径兜底 pending 收尾)。
10
+ * 收殓下沉(u-bte-remove):M5 孤儿收殓已移交 xyz-agent runtime(background-task-
11
+ * reaper 双触发面——session 销毁时 + 启动期兜底扫描,设计 file-lock-unification-
12
+ * and-reaper-sink.md §3.2 D2),extension 不再做全局扫描/全局锁;session_start
13
+ * 维护链仅剩 M3 对账(session 级豁免类,每 session_start 执行,D3 粒度段)。
10
14
  * 白名单与配置体系(M4 已交付)经 bash-tool execute 读时加载接入。
11
15
  */
12
16
 
13
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import type {
18
+ ExtensionAPI,
19
+ ExtensionContext,
20
+ SessionStartEvent,
21
+ } from "@earendil-works/pi-coding-agent";
14
22
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
15
23
  import { getLogger } from "@zhushanwen/pi-extension-logger";
16
24
 
@@ -21,7 +29,6 @@ import { handleTaskExit, refreshPiReference } from "./background/notify.ts";
21
29
  import { installProcessExitGuard } from "./background/process-exit-guard.ts";
22
30
  import { reconcilePendingEntries } from "./background/pending-reconcile.ts";
23
31
  import { setOnTaskExit } from "./background/poller.ts";
24
- import { reapOrphanedTasks } from "./reaper.ts";
25
32
  import { setupToolErrorAudit } from "./tool-error-audit.ts";
26
33
 
27
34
  const logger = getLogger("base-tool-enhance");
@@ -43,44 +50,43 @@ export default function baseToolEnhanceExtension(pi: ExtensionAPI): void {
43
50
  installProcessExitGuard();
44
51
  // unified-hooks 退役承接:工具报错审计(D11 落点)
45
52
  setupToolErrorAudit(pi);
46
- // 孤儿收殓(M5)+ pending 对账(M3):任意 session 启动触发(startup/reload/new/resume/fork 全 reason)
47
- pi.on("session_start", (_event, ctx: ExtensionContext) => {
48
- void runSessionStartMaintenance(pi, ctx);
53
+ // pending 对账(M3):任意 session 启动触发(startup/reload/new/resume/fork 全
54
+ // reason)。孤儿收殓已下沉 runtime(见文件头),本链无全局扫描
55
+ pi.on("session_start", (event: SessionStartEvent, ctx: ExtensionContext) => {
56
+ runSessionStartMaintenance(pi, ctx, event.reason);
49
57
  });
50
58
  }
51
59
 
52
60
  /**
53
- * session_start 维护链(M5 reaper + M3 对账,reaper 先、对账后)。
61
+ * session_start 维护链(仅剩 M3 对账——收殓下沉 runtime 后,u-bte-remove)。
54
62
  *
55
- * 执行形态:fire-and-forget 而非 await——pi extension runner 对 session_start
56
- * handler 是顺序 await(runner.js emit:逐 handler await),若本 handler await
57
- * reaper,多进程锁竞争(reaper.lock 等待 + registry 写 busy-wait)会把秒级延迟
58
- * 累计进 session 启动链。reaper 是兜底机制,不要求启动时序内完成,扔后台跑、
59
- * 错误吞掉记 warn(孤儿保持原状,下一 session_start 幂等重试)。
63
+ * 执行形态:同步直跑不 await——对账毫秒级(readRegistry + kill(pid,0) +
64
+ * appendEntry),不构成 session 启动链延迟;错误吞掉记 warn(对账失败无害:僵尸
65
+ * register 停留差集,下一 session_start 幂等重查)。
60
66
  *
61
- * 对账同步毫秒级(readRegistry + kill(pid,0) + appendEntry),排在 reaper await
62
- * 之后同一 async 函数体内顺序执行——先处置孤儿/补写 registry 终态,对账随后读到
63
- * 正确终态;顺序颠倒也无静默错误(对账先见 running+pid 活则不动作,下一
64
- * session_start 兜底),按设计约定维持 reaper 先行。
67
+ * 频率语义(D3 守卫粒度):对账是 session 级操作(读当前 session 的 pi entries +
68
+ * 当前 session 的 registry,appendEntry 幂等),属豁免类不挂进程级 once flag,
69
+ * 每 session_start 都执行。桌面端每次激活是 startup+resume 双派发(factory 二调
70
+ * 下 handler 还会累积),对账多次执行幂等无害;反之若挂进程级 flag,startup 消费
71
+ * flag 后目标 session 的对账将永远被跳过(M3 对账在主链路被禁用,此处是唯一执行点)。
65
72
  *
66
- * 不节流(同进程多次 session_start——CLI /fork /switch 均触发,每次都扫):
67
- * 幂等性由三分支判定构造性保证(终态条目跳过、属主活跳过——二次扫描对已处置
68
- * 孤儿天然 no-op);无孤儿时扫描成本 = 目录枚举 + 每目录一次 JSON 读 + 每
69
- * running 条目一次 kill(pid,0),毫秒级且零子进程开销(ps 只在「属主死 + pid 活」
70
- * 的孤儿判定时才调用)。节流反而引入「最近扫描后的新孤儿延迟收殓」窗口,不抵。
73
+ * 入口无条件 debug 日志(S6 观测通道):每次 handler 派发都打,含 reason——
74
+ * XYZ_AGENT_DEBUG=1 时落 extension 日志(默认 no-op),供「factory 二调下维护链
75
+ * 行为」验收观测派发次数。变更登记(u-bte-remove):reap 下沉 runtime 后本链
76
+ * 不再含全局扫描/全局锁,原 reapSkipped 字段随 reap 调用一并移除——对应 S6
77
+ * 场景「批 2 后 reap 类操作不再执行」。
71
78
  */
72
- async function runSessionStartMaintenance(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
73
- const dataDir = getAgentDir();
74
- try {
75
- await reapOrphanedTasks(dataDir);
76
- } catch (err) {
77
- logger.warn("session_start reaper failed; orphans stay until next session start", {
78
- detail: { err: err instanceof Error ? err.message : String(err) },
79
- });
80
- }
79
+ function runSessionStartMaintenance(
80
+ pi: ExtensionAPI,
81
+ ctx: ExtensionContext,
82
+ reason: SessionStartEvent["reason"],
83
+ ): void {
84
+ logger.debug("session_start maintenance dispatch", {
85
+ detail: { reason },
86
+ });
81
87
  try {
82
88
  const sessionId = ctx.sessionManager.getSessionId();
83
- reconcilePendingEntries(pi, dataDir, sessionId, ctx.sessionManager.getEntries());
89
+ reconcilePendingEntries(pi, getAgentDir(), sessionId, ctx.sessionManager.getEntries());
84
90
  } catch (err) {
85
91
  // 对账失败无害:僵尸 register 停留差集,下一 session_start 幂等重查
86
92
  logger.warn("session_start pending reconcile failed; zombies retried next session start", {
package/src/kill-tree.ts CHANGED
@@ -1,19 +1,28 @@
1
1
  /**
2
- * 自实现进程树 kill 与 pid 判活(设计文档 §3.5 bash_kill)。
2
+ * 自实现进程树 kill 与 pid 判活/身份判据(设计文档 §3.5 bash_kill)。
3
3
  *
4
4
  * 为什么不用 pi 的 killProcessTree:它未从主入口导出(pi package.json exports 仅
5
5
  * `.`/`./rpc-entry`/`./client`,定义在 dist/utils/shell.d.ts 但子路径不暴露),不可
6
6
  * import。分支语义与 pi 实装对齐:Windows `taskkill /F /T`、POSIX 杀进程组
7
7
  * `kill -- -<pgid>`(本包后台任务 detached spawn 自成进程组,pgid = pid);进程组
8
8
  * 杀不到时回退单 pid + `pgrep -P` 递归杀残留子进程。
9
+ *
10
+ * pid 身份判据(getProcessStartTimeSec / pidStartMatchesRegistered):原定义于
11
+ * reaper.ts,孤儿收殓下沉 runtime 后随 reaper.ts 删除平移至此(u-bte-remove)
12
+ * ——消费方是 bash_kill 前校验与后台 timeout 到点校验(进程内自防御,与收殓无关)。
9
13
  */
10
14
 
11
- import { spawnSync } from "node:child_process";
15
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
12
16
 
13
17
  import { getLogger } from "@zhushanwen/pi-extension-logger";
14
18
 
15
19
  const logger = getLogger("base-tool-enhance");
16
20
 
21
+ /** ps 调用超时:卡死的 ps 不拖垮调用方(超时按取不到处理 → 保守跳过)。 */
22
+ const PS_TIMEOUT_MS = 5000;
23
+ /** 毫秒 → 秒(epoch 秒换算,spawn-background.ts 同名常量先例)。 */
24
+ const MS_PER_SECOND = 1000;
25
+
17
26
  /**
18
27
  * pid 判活:kill(pid, 0) 不发信号只做权限校验。
19
28
  * ESRCH = 已死(含 libuv 自动 reap 后);EPERM = 进程存在但属其他用户,仍视为活。
@@ -98,3 +107,45 @@ function killDescendantsRecursive(pid: number): void {
98
107
  }
99
108
  }
100
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
+ }