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.
- package/extensions/skill-evolve/lib/dispatch-types.ts +1 -1
- package/extensions/skill-evolve/lib/dispatch.ts +25 -2
- package/extensions/skill-evolve/lib/drain-pending.ts +3 -0
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +25 -2
- package/extensions/skill-evolve/lib/spawn-plan.ts +47 -4
- package/extensions/skill-evolve/test/dispatch.test.ts +11 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
-
/**
|
|
12
|
-
|
|
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
|
|
24
|
-
|
|
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);
|