pi-skill-evolve 0.1.10 → 0.1.11

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.
@@ -51,7 +51,7 @@ export type DispatchOutcome =
51
51
  | { status: "dispatched"; run: DispatchedRun }
52
52
  | {
53
53
  status: "skipped";
54
- why: "disabled" | "cap" | "lock" | "lock-error" | "selftest-failed" | "busy";
54
+ why: "disabled" | "cap" | "lock" | "lock-error" | "selftest-failed" | "busy" | "pi-not-found";
55
55
  ownerPid: number | null;
56
56
  }
57
57
  | { status: "superseded"; rounds: number }
@@ -16,7 +16,7 @@ import { acquireLock, releaseLock, writeOwnerSync } from "./lock.ts";
16
16
  import { buildPrompt, readPromptTemplateSync } from "./prompt.ts";
17
17
  import { writeRunRecord } from "./runs.ts";
18
18
  import { writeSessionIndex } from "./session-index.ts";
19
- import { CHILD_LOG, resolvePiBin, spawnDetached, uniqueRunId, sessionHashOf } from "./spawn-plan.ts";
19
+ import { CHILD_LOG, isExecutableFile, resolvePiBin, spawnDetached, uniqueRunId, sessionHashOf } from "./spawn-plan.ts";
20
20
  import { mergePendingRounds } from "./pending.ts";
21
21
  import { watchRunChild } from "./run-watch.ts";
22
22
  import { spawnReviewSupervisor } from "./review-spawn.ts";
@@ -160,6 +160,23 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
160
160
  lockHeld = true;
161
161
 
162
162
  try {
163
+ // 先验 pi 真的可执行:spawn 的 ENOENT 是**异步** emit 的,早于收尸 listener 挂载,
164
+ // 会把整个 pi 进程 uncaughtException 打死(2026-11-09 写死路径事故)。宁可不派,也不炸宿主。
165
+ // 注入了 spawnFn = 单测/嵌入式宿主,路径由调用方自负,这里不做磁盘校验。
166
+ const piBin = resolvePiBin(env);
167
+ if (!deps.spawnFn && !isExecutableFile(piBin)) {
168
+ log(`[skill-evolve] 未派生:找不到可执行的 pi(${piBin})。设 SKILL_EVOLVE_PI_BIN 或先装好 pi`);
169
+ deps.telemetry.append({
170
+ kind: "skipped",
171
+ sessionId: input.sessionId,
172
+ why: "pi-not-found",
173
+ ownerPid: lock.owner?.pid ?? null,
174
+ trigger,
175
+ });
176
+ await releaseOnce();
177
+ return { status: "skipped", why: "pi-not-found", ownerPid: lock.owner?.pid ?? null };
178
+ }
179
+
163
180
  const indexFile = path.join(runDir, "00-session", "index.json");
164
181
  const sessionFile = input.sessionFile ?? carried?.sessionFile;
165
182
  let evidenceMode: RunRecord["evidenceMode"] = "telemetry-only";
@@ -199,7 +216,6 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
199
216
  dispatchedAt: at,
200
217
  coverage,
201
218
  });
202
- const piBin = resolvePiBin(env);
203
219
  const spawnFn: SpawnLike = deps.spawnFn ?? (spawn as unknown as SpawnLike);
204
220
  const child = spawnDetached(spawnFn, piBin, ["-p", "--no-session", "-ns", prompt], {
205
221
  cwd: deps.cwd,
@@ -207,6 +223,11 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
207
223
  // stdout+stderr 落到 run 目录:子进程怎么死的,只有这份日志答得出来
208
224
  logFile: path.join(runDir, CHILD_LOG),
209
225
  });
226
+ // 兜底 listener:spawn 仍可能失败(权限、架构不匹配),没 listener 的 error 事件在 Node 里
227
+ // 会直接抛成 uncaughtException。真正的收尸仍由 watchRunChild 负责,这里只保证不炸宿主。
228
+ child.once("error", (error) => {
229
+ log(`[skill-evolve] 复盘子进程启动失败:${String(error)}`);
230
+ });
210
231
 
211
232
  record.pid = child.pid ?? null;
212
233
  await writeRunRecord(runDir, record, deps.queue);
@@ -258,6 +279,8 @@ export function createDispatcher(deps: DispatchDeps): Dispatcher {
258
279
  ...(deps.reviewModel ? { reviewModel: deps.reviewModel } : {}),
259
280
  ...(deps.supervisorSpawnFn ? { supervisorSpawnFn: deps.supervisorSpawnFn } : {}),
260
281
  ...(deps.alive ? { alive: deps.alive } : {}),
282
+ // 注入了 spawnFn = 单测/嵌入式宿主:路径由调用方自负,退出补发也不做磁盘校验
283
+ ...(deps.spawnFn ? { piBinExists: () => true } : {}),
261
284
  log,
262
285
  },
263
286
  at,
@@ -26,6 +26,8 @@ export interface DrainPendingOptions {
26
26
  reviewModel?: () => string;
27
27
  supervisorSpawnFn?: SupervisorSpawn;
28
28
  alive?: (pid: number) => boolean;
29
+ /** pi 可执行文件判定(dispatch 注入假 spawnFn 时直接传「都在」,单测不碰真磁盘) */
30
+ piBinExists?: (candidate: string) => boolean;
29
31
  log: (msg: string) => void;
30
32
  }
31
33
 
@@ -49,6 +51,7 @@ export function drainPending(pending: Map<string, PendingRounds>, options: Drain
49
51
  ...(options.reviewModel ? { reviewModel: options.reviewModel() } : {}),
50
52
  ...(options.supervisorSpawnFn ? { supervisorSpawnFn: options.supervisorSpawnFn } : {}),
51
53
  ...(options.alive ? { alive: options.alive } : {}),
54
+ ...(options.piBinExists ? { piBinExists: options.piBinExists } : {}),
52
55
  log: options.log,
53
56
  },
54
57
  at,
@@ -13,7 +13,7 @@ import { acquireLockSync, releaseLockSync, writeOwnerSync } from "./lock.ts";
13
13
  import { buildPrompt, readPromptTemplateSync } from "./prompt.ts";
14
14
  import { spawnReviewSupervisor, type SupervisorSpawn } from "./review-spawn.ts";
15
15
  import { guardBusy } from "./single-flight.ts";
16
- import { CHILD_LOG, uniqueRunId, sessionHashOf, resolvePiBin, spawnDetached, type SpawnLike } from "./spawn-plan.ts";
16
+ import { CHILD_LOG, isExecutableFile, uniqueRunId, sessionHashOf, resolvePiBin, spawnDetached, type SpawnLike } from "./spawn-plan.ts";
17
17
  import { writeRunRecordSync, type RunRecord } from "./runs.ts";
18
18
  import { writeSessionIndexSync } from "./session-index.ts";
19
19
  import type { TelemetryWriter } from "./telemetry.ts";
@@ -46,6 +46,8 @@ export interface ShutdownDeps {
46
46
  supervisorSpawnFn?: SupervisorSpawn;
47
47
  /** pid 存活判定(全局单飞闸用;单测注入,测试里不起真子进程) */
48
48
  alive?: (pid: number) => boolean;
49
+ /** pi 可执行文件判定(默认查真磁盘;单测注入避开机器差异) */
50
+ piBinExists?: (candidate: string) => boolean;
49
51
  log: (msg: string) => void;
50
52
  }
51
53
 
@@ -88,6 +90,23 @@ export function flushPendingSync(item: PendingRounds, deps: ShutdownDeps, now: n
88
90
  });
89
91
  if (occupant) return "skipped";
90
92
 
93
+ // 退出补发同样先验 pi 可执行:spawn 的 ENOENT 是异步 emit,没有 listener 就直接 uncaughtException,
94
+ // 那会把「正常退出」变成「崩溃退出」(2026-11-09 写死路径事故就是这条路径炸的)
95
+ const piBin = resolvePiBin(deps.env);
96
+ if (!(deps.piBinExists ?? isExecutableFile)(piBin)) {
97
+ deps.telemetry.appendSync({
98
+ kind: "skipped",
99
+ ts: now,
100
+ sessionId: item.sessionId,
101
+ why: "pi-not-found",
102
+ trigger: "shutdown",
103
+ ownerPid: null,
104
+ rounds: item.rounds,
105
+ });
106
+ deps.log(`[skill-evolve] 退出补发放弃:找不到可执行的 pi(${piBin})`);
107
+ return "skipped";
108
+ }
109
+
91
110
  const runId = uniqueRunId(deps.evolutionDir, item.sessionId, now);
92
111
  const runDir = path.join(deps.evolutionDir, "runs", runId);
93
112
  const lock = acquireLockSync({
@@ -150,12 +169,16 @@ export function flushPendingSync(item: PendingRounds, deps: ShutdownDeps, now: n
150
169
  dispatchedAt: now,
151
170
  coverage: record.coverage,
152
171
  });
153
- const child = spawnDetached(deps.spawnFn, resolvePiBin(deps.env), ["-p", "--no-session", "-ns", prompt], {
172
+ const child = spawnDetached(deps.spawnFn, piBin, ["-p", "--no-session", "-ns", prompt], {
154
173
  cwd: deps.cwd,
155
174
  env: { ...deps.env, SKILL_EVOLVE_CHILD: "1" },
156
175
  // 退出前补发这条路径最容易“静默失败”:日志落盘才知道子进程到底起没起来
157
176
  logFile: path.join(runDir, CHILD_LOG),
158
177
  });
178
+ // 异步 emit 的 error 没 listener 会直接炸进程;主进程马上要退出,这里只求不把退出变成崩溃
179
+ child.once("error", (error) => {
180
+ deps.log(`[skill-evolve] 退出补发子进程启动失败:${String(error)}`);
181
+ });
159
182
  childPid = child.pid ?? null;
160
183
 
161
184
  record.pid = childPid;
@@ -6,10 +6,38 @@
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
8
  import * as fs from "node:fs";
9
+ import * as os from "node:os";
9
10
  import * as path from "node:path";
10
11
 
11
- /** pi 不能靠 PATH 找:GUI / 非交互环境里 PATH 常常没有它 */
12
- export const DEFAULT_PI_BIN = "/home/suveng/.pi/agent/bin/pi";
12
+ /**
13
+ * 兜底 pi 路径:按**当前用户 home** 拼,绝不写死某个人的家目录。
14
+ *
15
+ * 2026-11-09 事故:v0.1.10 把 Linux 家目录 `/home/suveng/.pi/agent/bin/pi` 写成常量,
16
+ * 换到 macOS 上 spawn 直接 ENOENT;而 spawn 的 error 事件是**异步** emit 的,
17
+ * 那一刻还没人挂 listener,于是 uncaughtException 把整个 pi 进程打死。
18
+ */
19
+ export const DEFAULT_PI_BIN = path.join(os.homedir(), ".pi", "agent", "bin", "pi");
20
+
21
+ /** 可执行文件判定:不存在 / 无执行权限 / 是目录都算「不可用」,一律不抛 */
22
+ export function isExecutableFile(candidate: string): boolean {
23
+ try {
24
+ if (!fs.statSync(candidate).isFile()) return false;
25
+ fs.accessSync(candidate, fs.constants.X_OK);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /** 在 PATH 里找 pi:GUI / 非交互启动的 pi 常常带着不完整的 PATH,所以只作第二选择 */
33
+ function findOnPath(name: string, env: NodeJS.ProcessEnv, exists: (candidate: string) => boolean): string | null {
34
+ const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
35
+ for (const dir of dirs) {
36
+ const candidate = path.join(dir, name);
37
+ if (exists(candidate)) return candidate;
38
+ }
39
+ return null;
40
+ }
13
41
 
14
42
  export interface SpawnedChild {
15
43
  pid?: number;
@@ -20,8 +48,23 @@ export interface SpawnedChild {
20
48
 
21
49
  export type SpawnLike = (command: string, args: string[], options: Record<string, unknown>) => SpawnedChild;
22
50
 
23
- export function resolvePiBin(env: NodeJS.ProcessEnv = process.env): string {
24
- return env.SKILL_EVOLVE_PI_BIN || env.PI_SUBAGENT_PI_BINARY || DEFAULT_PI_BIN;
51
+ export interface ResolvePiBinOptions {
52
+ /** 单测注入的存在性判定(默认查真磁盘) */
53
+ exists?: (candidate: string) => boolean;
54
+ /** 可执行文件名(Windows 上是 pi.cmd / pi.exe,默认 "pi") */
55
+ name?: string;
56
+ }
57
+
58
+ /**
59
+ * pi 路径解析顺序:显式环境变量 → home 下的 `.pi/agent/bin/pi` → PATH 里的 `pi` → 兜底常量。
60
+ * 返回的路径**不保证存在**(调用方要用 isExecutableFile 先验一次,别直接 spawn)。
61
+ */
62
+ export function resolvePiBin(env: NodeJS.ProcessEnv = process.env, options: ResolvePiBinOptions = {}): string {
63
+ const explicit = env.SKILL_EVOLVE_PI_BIN || env.PI_SUBAGENT_PI_BINARY;
64
+ if (explicit) return explicit;
65
+ const exists = options.exists ?? isExecutableFile;
66
+ if (exists(DEFAULT_PI_BIN)) return DEFAULT_PI_BIN;
67
+ return findOnPath(options.name ?? "pi", env, exists) ?? DEFAULT_PI_BIN;
25
68
  }
26
69
 
27
70
  /** 复盘子进程的日志文件名(落在 run 目录下,与 run.json 同级) */
@@ -132,6 +132,17 @@ test("纯函数:pi 路径优先级、runId 形态、去抖判据", () => {
132
132
  assert.equal(shouldDebounce(NOW - 90_000, NOW, 90_000), false);
133
133
  });
134
134
 
135
+ test("纯函数:pi 路径在陌生机器上也解得出来(不写死别人的家目录)", () => {
136
+ // 2026-11-09 事故回归:常量必须跟当前用户 home 走,且 home 不存在时退到 PATH
137
+ assert.equal(DEFAULT_PI_BIN, path.join(os.homedir(), ".pi", "agent", "bin", "pi"));
138
+ const none = (): boolean => false;
139
+ assert.equal(resolvePiBin({}, { exists: none }), DEFAULT_PI_BIN);
140
+ const onPath = path.join("/opt/node/bin", "pi");
141
+ assert.equal(resolvePiBin({ PATH: ["/x", path.dirname(onPath)].join(path.delimiter) }, { exists: (p) => p === onPath }), onPath);
142
+ // home 下的优先于 PATH
143
+ assert.equal(resolvePiBin({ PATH: path.dirname(onPath) }, { exists: () => true }), DEFAULT_PI_BIN);
144
+ });
145
+
135
146
  test("buildPrompt:带会话原文/索引/run 目录,并写清覆盖轮数", () => {
136
147
  const full = buildPrompt({ template: "模板", sessionFile: "/s.jsonl", indexFile: "/i.json", runDir: "/r", sessionId: "s1", skills: ["a", "b"], dispatchedAt: NOW });
137
148
  assert.equal(full.includes("会话原文:/s.jsonl"), true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-evolve",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "技能进化:采集会话中的技能曝光/加载,异步复盘、生成改进提案并独立评审,附本机 Web 管理面板",
5
5
  "keywords": [
6
6
  "pi-package",