pi-skill-evolve 0.1.10 → 0.1.12
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/CHANGELOG.md +12 -0
- package/README.md +16 -0
- package/extensions/skill-evolve/index.ts +10 -0
- 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/evolution.ts +36 -0
- package/extensions/skill-evolve/lib/pipeline.ts +206 -0
- package/extensions/skill-evolve/lib/recover.ts +182 -0
- package/extensions/skill-evolve/lib/review-spawn.ts +18 -1
- package/extensions/skill-evolve/lib/review-supervisor.ts +18 -2
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +25 -2
- package/extensions/skill-evolve/lib/spawn-plan.ts +47 -4
- package/extensions/skill-evolve/lib/supervisor-runtime.ts +127 -0
- package/extensions/skill-evolve/lib/web/pending.ts +13 -0
- package/extensions/skill-evolve/lib/web/render-run.ts +37 -0
- package/extensions/skill-evolve/lib/web/run-detail.ts +20 -0
- package/extensions/skill-evolve/lib/web/style.ts +18 -0
- package/extensions/skill-evolve/test/dispatch.test.ts +11 -0
- package/extensions/skill-evolve/test/pipeline.test.ts +141 -0
- package/extensions/skill-evolve/test/recover.test.ts +153 -0
- package/extensions/skill-evolve/test/review-spawn.test.ts +30 -2
- package/extensions/skill-evolve/test/supervisor-runtime.test.ts +107 -0
- package/extensions/skill-evolve/test/web-data.test.ts +2 -0
- package/extensions/skill-evolve/test/web-render-run.test.ts +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.12] - 2026-11-09
|
|
4
|
+
|
|
5
|
+
按 npm 包安装时「评审 → 采纳」整条链原本一次都跑不起来,这个版本修掉它,并把每轮复盘画成五段流水线。
|
|
6
|
+
|
|
7
|
+
- 修复(事故):`review-supervisor` 在 `node_modules` 里被 `node --experimental-strip-types` 拒绝(`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`),督导一起就崩。新增 `lib/supervisor-runtime.ts`:把督导依赖闭包先复制到 `.pi/evolution/runtime/supervisor/<版本-指纹>/`(排除 `*.test.*`、`.ready` 标记、升级清旧目录),搬不动回退原路径只记日志
|
|
8
|
+
- 修复:督导「起来但什么都不做,退出码 0」——`import.meta.url` 与 `process.argv[1]` 直接比字符串,macOS `/tmp` 软链(`/private/tmp`)会判成「不是直接运行」。改 `isDirectInvocation()` 用 `realpathSync` 双向比对
|
|
9
|
+
- 新增:`lib/pipeline.ts` 五段流水线(派发 → 分析 → 提案 → 评审 → 采纳),每段状态取 `done/running/waiting/blocked/failed/skipped`,只看磁盘产物与 pid,不猜进度;卡住那段直接写「下次会话起点自动重挂」之类的接手方式
|
|
10
|
+
- 面板:run 详情页顶部新增「流水线」一排格子(状态色 + 明细 + 时刻 + 一句话 hint),排在概要之前;待处理页的「正在跑」条目多一行 `流水线:派发✓ 分析… 提案· 评审· 采纳·`
|
|
11
|
+
- 新增:`lib/recover.ts` 重启 / `/reload` 后自动接手 —— 分析产物在而状态还是 `dispatched` 就补成 `analyzed`;有提案但督导已死就重挂督导;复盘子进程还活着则不打扰;**绝不重跑复盘**
|
|
12
|
+
- 会话起点(`session_start`)会调 `recover()`,有动作时用一行通知说明补了什么
|
|
13
|
+
- 测试:664 → 685 条(新增 `supervisor-runtime`、`recover`、`pipeline` 三组用例)
|
|
14
|
+
|
|
3
15
|
## [0.1.0] - 2026-11-08
|
|
4
16
|
|
|
5
17
|
首个可用版本。
|
package/README.md
CHANGED
|
@@ -36,6 +36,22 @@ pi install git:github.com/suveng/pi-skill-evolve
|
|
|
36
36
|
|
|
37
37
|
只绑 `127.0.0.1`、零鉴权、写操作带 CSRF 与同源校验;远程看用 `ssh -L 7788:127.0.0.1:7788`。
|
|
38
38
|
|
|
39
|
+
### 流水线
|
|
40
|
+
|
|
41
|
+
每条 run 都走五段,run 详情页顶部会画出这五格(状态词 + 明细 + 时刻 + 一句话 hint):
|
|
42
|
+
|
|
43
|
+
| 段 | 完成的标志 | 卡住时谁接手 |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| 派发 | `run.json` | — |
|
|
46
|
+
| 分析 | `01-findings.json` | 子进程退出没产物 → 卡死判定收尾 |
|
|
47
|
+
| 提案 | `02-proposal.json` | 分析已结束却没提案 = 判定「不该改」,正常结束 |
|
|
48
|
+
| 评审 | `03-review.json` | 提案在、督导不在 → 下次会话起点自动重挂 |
|
|
49
|
+
| 采纳 | `04-decision.json` | 自动采纳关着 → 面板待审批页批准 |
|
|
50
|
+
|
|
51
|
+
### 重启恢复
|
|
52
|
+
|
|
53
|
+
`/reload` 或重启会丢掉内存里的收尸逻辑(`watchRunChild` 的回调),所以 `session_start` 会跑一次恢复:补 `analyzed` 状态、重挂被带走的督导、其余不动。**永不重跑复盘**(花 token 且会覆盖已有产物),有动作时用一行通知说明补了什么。
|
|
54
|
+
|
|
39
55
|
## 配置
|
|
40
56
|
|
|
41
57
|
`.pi/evolution/config.json`(建议 gitignore,开关是本机偏好):
|
|
@@ -16,6 +16,7 @@ import { createEvolution, type Evolution } from "./lib/evolution.js";
|
|
|
16
16
|
import { logLine } from "./lib/logfile.js";
|
|
17
17
|
import { deliverNotifications } from "./lib/notify.js";
|
|
18
18
|
import { entryLines, type PanelEntry, type PanelState } from "./lib/panel.js";
|
|
19
|
+
import { renderRecovery } from "./lib/recover.js";
|
|
19
20
|
import { renderPanel, writeEntry, type UiPanelHost } from "./lib/ui-gate.js";
|
|
20
21
|
import { startWebPanel } from "./lib/web/server.js";
|
|
21
22
|
import { createWebLifecycle } from "./lib/web/autostart.js";
|
|
@@ -233,6 +234,15 @@ export default function skillEvolve(pi: ExtensionAPI): void {
|
|
|
233
234
|
pi.on("session_start", async (_event, ctx) => {
|
|
234
235
|
guarded(ctx, "session_start", (evo) => {
|
|
235
236
|
evo.apply.sessionStart();
|
|
237
|
+
// `/reload` 与重启会丢掉内存里的收尸逻辑:这里补状态、重挂被带走的督导(不重跑复盘)
|
|
238
|
+
void evo.recover().then((actions) => {
|
|
239
|
+
if (actions.length > 0) {
|
|
240
|
+
// 通知只有一行:把多行恢复报告压成 ` / ` 分隔
|
|
241
|
+
const brief = renderRecovery(actions).replace(/\n\s*/g, " / ");
|
|
242
|
+
ctx.ui.notify(brief, "info");
|
|
243
|
+
}
|
|
244
|
+
evo.observe.refresh();
|
|
245
|
+
});
|
|
236
246
|
evo.observe.refresh();
|
|
237
247
|
// 上一轮没送出去的失败/待批,这里补发(notified.json 保证不重复)
|
|
238
248
|
notifyEvents(ctx.cwd);
|
|
@@ -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,
|
|
@@ -14,6 +14,7 @@ import { createDispatcher } from "./dispatch.ts";
|
|
|
14
14
|
import { createApplyTools, type ApplyTools } from "./evolution-apply.ts";
|
|
15
15
|
import { createModelTools, type ModelTools } from "./evolution-model.ts";
|
|
16
16
|
import { createObserver, type Observer, type PanelDeps } from "./evolution-observe.ts";
|
|
17
|
+
import { recoverRuns, type RecoveryAction } from "./recover.ts";
|
|
17
18
|
import { createViews } from "./evolution-views.ts";
|
|
18
19
|
import { flagStateText, FLAG_LABEL } from "./flags.ts";
|
|
19
20
|
import type { GitRunner } from "./gate0.ts";
|
|
@@ -92,6 +93,13 @@ export interface Evolution {
|
|
|
92
93
|
apply: ApplyTools;
|
|
93
94
|
/** M6:面板刷新(状态变迁转 /tree 条目)与周报 + 指标落盘 */
|
|
94
95
|
observe: Observer;
|
|
96
|
+
/**
|
|
97
|
+
* 重启 / `/reload` 后的接手:补状态、重挂督导师(详见 lib/recover.ts)。
|
|
98
|
+
* 返回本次做了什么,面板与日志都靠它告诉用户「后台任务没丢」。
|
|
99
|
+
*/
|
|
100
|
+
recover(): Promise<RecoveryAction[]>;
|
|
101
|
+
/** 最近一次恢复的结果(面板可视化用;本进程内有效) */
|
|
102
|
+
lastRecovery(): RecoveryAction[];
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
export function createEvolution(deps: EvolutionDeps): Evolution {
|
|
@@ -270,6 +278,32 @@ export function createEvolution(deps: EvolutionDeps): Evolution {
|
|
|
270
278
|
return `${FLAG_LABEL[field]}已${value ? "开启" : "关闭"}:${configFile(evolutionDir)}(当前会话立即生效)\n${flagStateText(config)}`;
|
|
271
279
|
};
|
|
272
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 重启 / 重载后的接手:只补状态与重挂督导师,绝不重跑复盘。
|
|
283
|
+
* 同步抛错也在这里吞:会话起点不能被恢复流程搞挂。
|
|
284
|
+
*/
|
|
285
|
+
let lastActions: RecoveryAction[] = [];
|
|
286
|
+
const recover = async (): Promise<RecoveryAction[]> => {
|
|
287
|
+
try {
|
|
288
|
+
lastActions = await recoverRuns({
|
|
289
|
+
evolutionDir,
|
|
290
|
+
cwd: deps.cwd,
|
|
291
|
+
queue: deps.queue,
|
|
292
|
+
now,
|
|
293
|
+
env,
|
|
294
|
+
autoReview: () => config.autoReview,
|
|
295
|
+
reviewModel: modelTools.reviewModel,
|
|
296
|
+
...(deps.alive ? { alive: deps.alive } : {}),
|
|
297
|
+
...(deps.supervisorSpawnFn ? { spawnSupervisor: deps.supervisorSpawnFn } : {}),
|
|
298
|
+
log,
|
|
299
|
+
});
|
|
300
|
+
} catch (error) {
|
|
301
|
+
log(`[skill-evolve] 重启恢复失败:${String(error)}`);
|
|
302
|
+
lastActions = [];
|
|
303
|
+
}
|
|
304
|
+
return lastActions;
|
|
305
|
+
};
|
|
306
|
+
|
|
273
307
|
return {
|
|
274
308
|
recordAdvertised,
|
|
275
309
|
noteToolCall,
|
|
@@ -288,5 +322,7 @@ export function createEvolution(deps: EvolutionDeps): Evolution {
|
|
|
288
322
|
model: modelTools,
|
|
289
323
|
apply: applyTools,
|
|
290
324
|
observe: observer,
|
|
325
|
+
recover,
|
|
326
|
+
lastRecovery: () => lastActions,
|
|
291
327
|
};
|
|
292
328
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 五段流水线:派发 → 分析 → 提案 → 评审 → 采纳。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单列一层:用户看「已分析」四个字永远不知道后台卡在哪一步(2026-11-09 真实困惑:
|
|
5
|
+
* 面板写「已分析」,提案却永远不出现,其实督导崩在 import 阶段)。这里把「一步做什么、
|
|
6
|
+
* 现在到哪一步、卡住时下一步谁来接」摊平成五个格子,面板与 `/tree` 都读同一份结论。
|
|
7
|
+
*
|
|
8
|
+
* 纪律:只看磁盘产物与 pid 存活,**不猜进度**。没有的产物就是没有,缺文件写缺哪个。
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
|
|
13
|
+
/** done=已完成 / running=正在跑 / waiting=等上一步 / blocked=卡住等人或等重启 / failed=失败 / skipped=按配置跳过 */
|
|
14
|
+
export type StageState = "done" | "running" | "waiting" | "blocked" | "failed" | "skipped";
|
|
15
|
+
|
|
16
|
+
export type StageKey = "dispatch" | "analyze" | "propose" | "review" | "apply";
|
|
17
|
+
|
|
18
|
+
export interface PipelineStage {
|
|
19
|
+
key: StageKey;
|
|
20
|
+
label: string;
|
|
21
|
+
state: StageState;
|
|
22
|
+
/** 一句话说清「这一步现在是什么情况」:谁在跑、缺哪个文件、等谁 */
|
|
23
|
+
detail: string;
|
|
24
|
+
/** 该步完成/开始的时刻:没有就 null(不编时间) */
|
|
25
|
+
at: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Pipeline {
|
|
29
|
+
stages: PipelineStage[];
|
|
30
|
+
/** 第一处不是 done 的说明 + 下一步动作;全 done 时给结论 */
|
|
31
|
+
hint: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PipelineInput {
|
|
35
|
+
runDir: string;
|
|
36
|
+
/** run.json 的 status */
|
|
37
|
+
status: string;
|
|
38
|
+
pid: number | null;
|
|
39
|
+
supervisorPid: number | null;
|
|
40
|
+
dispatchedAt: number | null;
|
|
41
|
+
/** 采纳队列里这条 run 的状态(pending / applied / rejected …);不在队列为 null */
|
|
42
|
+
queued?: string | null;
|
|
43
|
+
/** 自动评审 / 自动采纳开关;null = 面板不知道(CLI 上下文)就不下结论 */
|
|
44
|
+
autoReview?: boolean | null;
|
|
45
|
+
autoApply?: boolean | null;
|
|
46
|
+
alive?: (pid: number) => boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LABELS: Record<StageKey, string> = {
|
|
50
|
+
dispatch: "派发",
|
|
51
|
+
analyze: "分析",
|
|
52
|
+
propose: "提案",
|
|
53
|
+
review: "评审",
|
|
54
|
+
apply: "采纳",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** 终态里不该再往后走的那些 */
|
|
58
|
+
const DEAD_STATUS = new Set(["stale", "abandoned", "failed", "unauthorized_write"]);
|
|
59
|
+
|
|
60
|
+
function statOf(file: string): { at: number } | null {
|
|
61
|
+
try {
|
|
62
|
+
const stat = fs.statSync(file);
|
|
63
|
+
return stat.isFile() ? { at: stat.mtimeMs } : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonOf(file: string): Record<string, unknown> | null {
|
|
70
|
+
try {
|
|
71
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
|
|
72
|
+
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function textOf(value: unknown, fallback = ""): string {
|
|
79
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 五段状态。同一份产物只读一次:这里的判断全在文件系统上,多一次 stat 只是浪费。
|
|
84
|
+
*/
|
|
85
|
+
export function buildPipeline(input: PipelineInput): Pipeline {
|
|
86
|
+
const alive = input.alive ?? (() => false);
|
|
87
|
+
const dir = input.runDir;
|
|
88
|
+
const findings = statOf(path.join(dir, "01-findings.json"));
|
|
89
|
+
const proposalFile = statOf(path.join(dir, "02-proposal.json"));
|
|
90
|
+
const reviewFile = statOf(path.join(dir, "03-review.json"));
|
|
91
|
+
const decision = statOf(path.join(dir, "04-decision.json"));
|
|
92
|
+
const verify = statOf(path.join(dir, "06-verify.json"));
|
|
93
|
+
const proposal = proposalFile ? jsonOf(path.join(dir, "02-proposal.json")) : null;
|
|
94
|
+
const review = reviewFile ? jsonOf(path.join(dir, "03-review.json")) : null;
|
|
95
|
+
const dead = DEAD_STATUS.has(input.status);
|
|
96
|
+
const childAlive = input.pid !== null && alive(input.pid);
|
|
97
|
+
const supervisorAlive = input.supervisorPid !== null && alive(input.supervisorPid);
|
|
98
|
+
|
|
99
|
+
const stages: PipelineStage[] = [];
|
|
100
|
+
|
|
101
|
+
// 1. 派发:run.json 存在就算完成(时间取 dispatchedAt,没有就 null)
|
|
102
|
+
stages.push({
|
|
103
|
+
key: "dispatch",
|
|
104
|
+
label: LABELS.dispatch,
|
|
105
|
+
state: "done",
|
|
106
|
+
detail: input.pid === null ? "已派发(pid 未记)" : childAlive ? `复盘子进程 pid ${input.pid} 在跑` : `复盘子进程 pid ${input.pid} 已退出`,
|
|
107
|
+
at: input.dispatchedAt,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// 2. 分析:产物是 01-findings.json
|
|
111
|
+
const analyze: PipelineStage = findings
|
|
112
|
+
? { key: "analyze", label: LABELS.analyze, state: "done", detail: "01-findings.json 已落盘", at: findings.at }
|
|
113
|
+
: dead
|
|
114
|
+
? { key: "analyze", label: LABELS.analyze, state: "failed", detail: `分析没出结论就结束了(${input.status})`, at: null }
|
|
115
|
+
: childAlive
|
|
116
|
+
? { key: "analyze", label: LABELS.analyze, state: "running", detail: `子进程 pid ${input.pid} 正在读会话`, at: null }
|
|
117
|
+
: { key: "analyze", label: LABELS.analyze, state: "waiting", detail: "子进程已退出但没写 01-findings.json(等卡死判定收尾)", at: null };
|
|
118
|
+
stages.push(analyze);
|
|
119
|
+
|
|
120
|
+
// 3. 提案:产物是 02-proposal.json(候选 diff 由同一步写入 candidate/)
|
|
121
|
+
const propose: PipelineStage = proposalFile
|
|
122
|
+
? {
|
|
123
|
+
key: "propose",
|
|
124
|
+
label: LABELS.propose,
|
|
125
|
+
state: "done",
|
|
126
|
+
detail: `${textOf(proposal?.type, "提案")} → ${textOf(proposal?.skill, "未写技能")}`,
|
|
127
|
+
at: proposalFile.at,
|
|
128
|
+
}
|
|
129
|
+
: dead
|
|
130
|
+
? { key: "propose", label: LABELS.propose, state: "waiting", detail: "分析没走完,没到提案", at: null }
|
|
131
|
+
: childAlive
|
|
132
|
+
? { key: "propose", label: LABELS.propose, state: "running", detail: "分析在跑,提案随后写", at: null }
|
|
133
|
+
: findings
|
|
134
|
+
? { key: "propose", label: LABELS.propose, state: "waiting", detail: "分析已结束但没写提案(多半是判定「不该改」)", at: null }
|
|
135
|
+
: { key: "propose", label: LABELS.propose, state: "waiting", detail: "等分析结论", at: null };
|
|
136
|
+
stages.push(propose);
|
|
137
|
+
|
|
138
|
+
// 4. 评审:产物是 03-review.json;有提案但督导不在 = 需要重启恢复接手
|
|
139
|
+
const reviewStatus = review ? textOf(review.status, "未知") : null;
|
|
140
|
+
let reviewStage: PipelineStage;
|
|
141
|
+
if (reviewFile) {
|
|
142
|
+
const failed = reviewStatus === "review_failed" || reviewStatus === "error";
|
|
143
|
+
reviewStage = {
|
|
144
|
+
key: "review",
|
|
145
|
+
label: LABELS.review,
|
|
146
|
+
state: failed ? "failed" : "done",
|
|
147
|
+
detail: `${reviewStatus}${textOf(review?.verdict) ? ` / ${textOf(review?.verdict)}` : ""}`,
|
|
148
|
+
at: reviewFile.at,
|
|
149
|
+
};
|
|
150
|
+
} else if (proposalFile) {
|
|
151
|
+
reviewStage = supervisorAlive
|
|
152
|
+
? { key: "review", label: LABELS.review, state: "running", detail: `督导 pid ${input.supervisorPid} 正在评审`, at: null }
|
|
153
|
+
: input.autoReview === false
|
|
154
|
+
? { key: "review", label: LABELS.review, state: "skipped", detail: "自动评审关着:等人工 /skill-evolve approve", at: null }
|
|
155
|
+
: { key: "review", label: LABELS.review, state: "blocked", detail: "提案在但督导不在:下次会话起点自动重挂", at: null };
|
|
156
|
+
} else {
|
|
157
|
+
reviewStage = { key: "review", label: LABELS.review, state: "waiting", detail: "等提案出来再评审", at: null };
|
|
158
|
+
}
|
|
159
|
+
stages.push(reviewStage);
|
|
160
|
+
|
|
161
|
+
// 5. 采纳:需要 04-decision.json(队列 applied / 五闸);rejected 的到此为止
|
|
162
|
+
let applyStage: PipelineStage;
|
|
163
|
+
if (decision) {
|
|
164
|
+
applyStage = { key: "apply", label: LABELS.apply, state: "done", detail: "04-decision.json 已写(技能文件已改)", at: decision.at };
|
|
165
|
+
} else if (reviewStatus === "rejected" || input.status === "rejected") {
|
|
166
|
+
applyStage = { key: "apply", label: LABELS.apply, state: "skipped", detail: "评审驳回,不会采纳", at: null };
|
|
167
|
+
} else if (reviewStatus === "approved" || reviewStatus === "needs_human") {
|
|
168
|
+
applyStage = input.queued === "pending" && input.autoApply !== false
|
|
169
|
+
? { key: "apply", label: LABELS.apply, state: "running", detail: "队列 pending,自动采纳正在走五闸", at: null }
|
|
170
|
+
: { key: "apply", label: LABELS.apply, state: "blocked", detail: input.autoApply === false ? "自动采纳关着:等面板批准" : "已入队 pending,等采纳扫描", at: null };
|
|
171
|
+
} else if (verify) {
|
|
172
|
+
applyStage = { key: "apply", label: LABELS.apply, state: "blocked", detail: "五闸跑过但没写决定:看 06-verify.json", at: verify.at };
|
|
173
|
+
} else {
|
|
174
|
+
applyStage = { key: "apply", label: LABELS.apply, state: "waiting", detail: "等评审通过", at: null };
|
|
175
|
+
}
|
|
176
|
+
stages.push(applyStage);
|
|
177
|
+
|
|
178
|
+
return { stages, hint: hintOf(stages) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 「下一步」的挑选顺序:先看**真在跑 / 真卡住 / 真失败**的那一步(用户能立刻行动),
|
|
183
|
+
* 再退回第一处「等上一步」;全 done 时明说结束。只按顺序找第一处会让「分析等收尾」
|
|
184
|
+
* 盖掉「提案在、督导死了」这种更该被看见的状态。
|
|
185
|
+
*/
|
|
186
|
+
function hintOf(stages: PipelineStage[]): string {
|
|
187
|
+
const first =
|
|
188
|
+
stages.find((stage) => stage.state === "running" || stage.state === "blocked" || stage.state === "failed") ??
|
|
189
|
+
stages.find((stage) => stage.state === "waiting");
|
|
190
|
+
if (!first) {
|
|
191
|
+
const skipped = stages.find((stage) => stage.state === "skipped");
|
|
192
|
+
return skipped ? `流程结束:卡在「${skipped.label}」— ${skipped.detail}` : "流程走完:五段全部完成";
|
|
193
|
+
}
|
|
194
|
+
return `下一步「${first.label}」— ${first.detail}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 面板用的一句话:`派发✓ 分析✓ 提案✓ 评审… 采纳·`,一眼看出卡在哪 */
|
|
198
|
+
export function renderPipelineLine(pipeline: Pipeline): string {
|
|
199
|
+
const mark: Record<StageState, string> = { done: "✓", running: "…", waiting: "·", blocked: "!", failed: "✗", skipped: "–" };
|
|
200
|
+
return pipeline.stages.map((stage) => `${stage.label}${mark[stage.state]}`).join(" ");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 把 stages 折成「进度 3/5」这种数字,列表列用 */
|
|
204
|
+
export function pipelineProgress(pipeline: Pipeline): { done: number; total: number } {
|
|
205
|
+
return { done: pipeline.stages.filter((stage) => stage.state === "done").length, total: pipeline.stages.length };
|
|
206
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 重启 / `/reload` 之后的自动恢复。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:派发出去的两个子进程都是 detached 的,但**收尸逻辑在内存里**。
|
|
5
|
+
* 主进程一重启,`watchRunChild` 的 exit 回调就没了;督导若也被带走了(机器重启、
|
|
6
|
+
* 端口没放开被旧实例挡掉、面板手动清理),run 会永久停在 `dispatched` / `analyzed`,
|
|
7
|
+
* 用户看到的就是「已分析」但提案永远不出 —— 2026-11-09 的事故现场。
|
|
8
|
+
*
|
|
9
|
+
* 恢复只做三件保守的事,绝不重跑复盘(花 token 且会覆盖已有产物):
|
|
10
|
+
* 1. 有 `01-findings.json` 却还写着 `dispatched` → 补成 `analyzed`(分析其实已经跑完)
|
|
11
|
+
* 2. 有 `02-proposal.json` 但没 `03-review.json`、督导进程已死 → 重挂一个督导接着评审
|
|
12
|
+
* 3. 其余情况只报告,不动手(复盘子进程还在跑就别打扰它)
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { spawnReviewSupervisor, type SpawnSupervisorOptions } from "./review-spawn.ts";
|
|
17
|
+
import { listRuns, updateRunStatus, type RunRecord } from "./runs.ts";
|
|
18
|
+
import type { MutationQueue } from "./telemetry.ts";
|
|
19
|
+
|
|
20
|
+
/** 只恢复最近这么多小时内派发的 run:更老的多半是用户已经不管的历史 */
|
|
21
|
+
export const RECOVER_WINDOW_MS = 6 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/** 还在「半路」的状态:出终态的不该被碰 */
|
|
24
|
+
const RESUMABLE = new Set(["dispatched", "analyzed", "proposed"]);
|
|
25
|
+
|
|
26
|
+
export type RecoveryKind =
|
|
27
|
+
/** 分析产物在、状态落后:只补状态 */
|
|
28
|
+
| "advanced-analyzed"
|
|
29
|
+
/** 提案在、督导不在:重挂督导 */
|
|
30
|
+
| "supervisor-respawned"
|
|
31
|
+
/** 提案在、督导还在跑:不打扰 */
|
|
32
|
+
| "waiting-review"
|
|
33
|
+
/** 还没出提案,复盘子进程还活着 */
|
|
34
|
+
| "waiting-proposal"
|
|
35
|
+
/** 还没出提案,复盘子进程也没了:不重跑,等 stale 收尾 */
|
|
36
|
+
| "interrupted"
|
|
37
|
+
/** 已有评审结论:交给会话起点的入队 / 自动采纳 */
|
|
38
|
+
| "review-done"
|
|
39
|
+
/** 重挂被挡下(单飞闸占满 / 关着自动评审 / 起不来) */
|
|
40
|
+
| "respawn-blocked";
|
|
41
|
+
|
|
42
|
+
export interface RecoveryAction {
|
|
43
|
+
runId: string;
|
|
44
|
+
kind: RecoveryKind;
|
|
45
|
+
detail: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RecoverDeps {
|
|
49
|
+
evolutionDir: string;
|
|
50
|
+
cwd: string;
|
|
51
|
+
queue: MutationQueue;
|
|
52
|
+
now?: () => number;
|
|
53
|
+
env?: NodeJS.ProcessEnv;
|
|
54
|
+
alive?: (pid: number) => boolean;
|
|
55
|
+
/** 现读配置:自动评审关着就不重挂督导(用户手动关的不该被重启偷偷打开) */
|
|
56
|
+
autoReview?: () => boolean;
|
|
57
|
+
reviewModel?: () => string;
|
|
58
|
+
spawnSupervisor?: (options: SpawnSupervisorOptions) => number | null;
|
|
59
|
+
log?: (msg: string) => void;
|
|
60
|
+
windowMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function exists(runDir: string, name: string): boolean {
|
|
64
|
+
try {
|
|
65
|
+
return fs.statSync(path.join(runDir, name)).isFile();
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 生产里就是读 run.json:坏数据当「没有」处理,宁可不动手 */
|
|
72
|
+
function readRecord(runDir: string): RunRecord | null {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(fs.readFileSync(path.join(runDir, "run.json"), "utf8")) as RunRecord;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function recoverRuns(deps: RecoverDeps): Promise<RecoveryAction[]> {
|
|
81
|
+
const log = deps.log ?? (() => {});
|
|
82
|
+
const now = deps.now ?? (() => Date.now());
|
|
83
|
+
const alive = deps.alive ?? ((pid: number) => {
|
|
84
|
+
try {
|
|
85
|
+
process.kill(pid, 0);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
const windowMs = deps.windowMs ?? RECOVER_WINDOW_MS;
|
|
92
|
+
const spawnSupervisor = deps.spawnSupervisor ?? spawnReviewSupervisor;
|
|
93
|
+
const actions: RecoveryAction[] = [];
|
|
94
|
+
const runsRoot = path.join(deps.evolutionDir, "runs");
|
|
95
|
+
let entries: string[];
|
|
96
|
+
try {
|
|
97
|
+
entries = fs.readdirSync(runsRoot);
|
|
98
|
+
} catch {
|
|
99
|
+
return actions;
|
|
100
|
+
}
|
|
101
|
+
const cutoff = now() - windowMs;
|
|
102
|
+
for (const runId of entries.sort()) {
|
|
103
|
+
const runDir = path.join(runsRoot, runId);
|
|
104
|
+
const record = readRecord(runDir);
|
|
105
|
+
if (!record) continue;
|
|
106
|
+
if (!RESUMABLE.has(record.status ?? "")) continue;
|
|
107
|
+
if (typeof record.dispatchedAt === "number" && record.dispatchedAt < cutoff) continue;
|
|
108
|
+
|
|
109
|
+
const hasFindings = exists(runDir, "01-findings.json");
|
|
110
|
+
const hasProposal = exists(runDir, "02-proposal.json");
|
|
111
|
+
const hasReview = exists(runDir, "03-review.json");
|
|
112
|
+
const childAlive = typeof record.pid === "number" && alive(record.pid);
|
|
113
|
+
const supervisorAlive = typeof record.supervisorPid === "number" && alive(record.supervisorPid);
|
|
114
|
+
|
|
115
|
+
if (hasReview) {
|
|
116
|
+
actions.push({ runId, kind: "review-done", detail: "评审已落盘,等会话起点入队 / 自动采纳" });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 1. 分析跑完了但状态没跟上:补状态(不重跑、不花 token)
|
|
121
|
+
if (record.status === "dispatched" && hasFindings) {
|
|
122
|
+
const ok = await updateRunStatus(deps.evolutionDir, runId, "analyzed", deps.queue);
|
|
123
|
+
actions.push({
|
|
124
|
+
runId,
|
|
125
|
+
kind: ok ? "advanced-analyzed" : "interrupted",
|
|
126
|
+
detail: ok ? "01-findings.json 已在,状态补成 analyzed(未重跑)" : "补状态失败:run.json 写不进去",
|
|
127
|
+
});
|
|
128
|
+
if (!ok) continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!hasProposal) {
|
|
132
|
+
if (childAlive) {
|
|
133
|
+
actions.push({ runId, kind: "waiting-proposal", detail: `复盘子进程 pid ${record.pid} 还活着,不打扰` });
|
|
134
|
+
} else {
|
|
135
|
+
actions.push({ runId, kind: "interrupted", detail: "还没出提案,复盘子进程已不在:不自动重跑,等 stale 收尾" });
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 2. 提案在、评审没做:重挂督导(这才是「后台任务丢了」的正解)
|
|
141
|
+
if (supervisorAlive) {
|
|
142
|
+
actions.push({ runId, kind: "waiting-review", detail: `督导 pid ${record.supervisorPid} 还在跑,等它出结论` });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (deps.autoReview && !deps.autoReview()) {
|
|
146
|
+
actions.push({ runId, kind: "respawn-blocked", detail: "自动评审关着:停在待人工 /skill-evolve approve" });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const pid = spawnSupervisor({
|
|
150
|
+
runDir,
|
|
151
|
+
cwd: deps.cwd,
|
|
152
|
+
autoReview: true,
|
|
153
|
+
reviewModel: deps.reviewModel?.() ?? "inherit",
|
|
154
|
+
...(deps.env ? { env: deps.env } : {}),
|
|
155
|
+
...(deps.alive ? { alive: deps.alive } : {}),
|
|
156
|
+
log,
|
|
157
|
+
});
|
|
158
|
+
actions.push(
|
|
159
|
+
pid === null
|
|
160
|
+
? { runId, kind: "respawn-blocked", detail: "督导没起来(单飞闸占满或 spawn 失败),下次会话起点还会再试" }
|
|
161
|
+
: { runId, kind: "supervisor-respawned", detail: `提案已在、督导原本不在:重挂 pid ${pid}` },
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return actions;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 面板文案:一句话说清「重启后补了什么」 */
|
|
168
|
+
export function renderRecovery(actions: RecoveryAction[]): string {
|
|
169
|
+
if (actions.length === 0) return "重启恢复:没有需要接手的 run";
|
|
170
|
+
const label: Record<RecoveryKind, string> = {
|
|
171
|
+
"advanced-analyzed": "补状态",
|
|
172
|
+
"supervisor-respawned": "重挂督导",
|
|
173
|
+
"waiting-review": "等评审",
|
|
174
|
+
"waiting-proposal": "等提案",
|
|
175
|
+
interrupted: "已中断",
|
|
176
|
+
"review-done": "评审完成",
|
|
177
|
+
"respawn-blocked": "未能重挂",
|
|
178
|
+
};
|
|
179
|
+
const lines = [`重启恢复:${actions.length} 条 run 被检查`];
|
|
180
|
+
for (const action of actions) lines.push(` · ${action.runId} ${label[action.kind]} — ${action.detail}`);
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import { patchRunRecordSync } from "./runs.ts";
|
|
14
14
|
import { guardBusy } from "./single-flight.ts";
|
|
15
15
|
import { spawnDetached } from "./spawn-plan.ts";
|
|
16
|
+
import { ensureSupervisorRuntime, type RuntimeOptions } from "./supervisor-runtime.ts";
|
|
16
17
|
import type { TelemetryWriter } from "./telemetry.ts";
|
|
17
18
|
|
|
18
19
|
/** 督导脚本绝对路径:dispatch 与 shutdown-dispatch 两条路径共用 */
|
|
@@ -50,6 +51,13 @@ export interface SpawnSupervisorOptions {
|
|
|
50
51
|
alive?: (pid: number) => boolean;
|
|
51
52
|
/** 退出补发路径:主进程马上退,遥测只能同步写 */
|
|
52
53
|
sync?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* 督导入口解析:默认走 supervisor-runtime 的「node_modules 里先搬家」逻辑。
|
|
56
|
+
* 单测注入它,避免真去拷源码目录。
|
|
57
|
+
*/
|
|
58
|
+
resolveScript?: (options: RuntimeOptions) => string;
|
|
59
|
+
/** 包版本:参与运行目录的缓存键 */
|
|
60
|
+
version?: string;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/** 返回 pid;被单飞闸挡住或起不来返回 null(只记日志,绝不抛到主会话) */
|
|
@@ -74,7 +82,16 @@ export function spawnReviewSupervisor(options: SpawnSupervisorOptions): number |
|
|
|
74
82
|
const env = { ...(options.env ?? process.env) };
|
|
75
83
|
// 督导自己不是 pi 子代理:把「别再采集」的标记去掉,避免它起评审子进程时语义混乱
|
|
76
84
|
delete env.SKILL_EVOLVE_CHILD;
|
|
77
|
-
|
|
85
|
+
// node_modules 里的 .ts 不能直接跑(ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING):先搬到工作区的
|
|
86
|
+
// runtime 目录再起进程,否则按 npm 包安装时督导**每次**都崩在 import 阶段(2026-11-09 事故)
|
|
87
|
+
const resolveScript = options.resolveScript ?? ensureSupervisorRuntime;
|
|
88
|
+
const script = resolveScript({
|
|
89
|
+
evolutionDir: path.dirname(path.dirname(options.runDir)),
|
|
90
|
+
scriptPath: SUPERVISOR_SCRIPT,
|
|
91
|
+
...(options.version ? { version: options.version } : {}),
|
|
92
|
+
...(options.log ? { log: options.log } : {}),
|
|
93
|
+
});
|
|
94
|
+
const args = ["--experimental-strip-types", script, options.runDir];
|
|
78
95
|
if (options.reviewModel && options.reviewModel !== "inherit") args.push(options.reviewModel);
|
|
79
96
|
const spawnFn = options.spawnFn ?? (nodeSpawn as unknown as SupervisorSpawn);
|
|
80
97
|
try {
|